diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..3bd7ad371f Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env index 1d44286e25..8fda25a5d8 100644 --- a/.env +++ b/.env @@ -11,16 +11,16 @@ FRONTEND_HOST=http://localhost:5173 # FRONTEND_HOST=https://dashboard.example.com # Environment: local, staging, production -ENVIRONMENT=local +ENVIRONMENT=production -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project +PROJECT_NAME="KeToanAuto" +STACK_NAME=ketoanauto # 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 +BACKEND_CORS_ORIGINS="http://localhost:5173,https://localhost:5173,http://localhost:3000,https://localhost:3000" +SECRET_KEY=Ti100600@12131231 +FIRST_SUPERUSER=nguyenvantien0620@gmail.com +FIRST_SUPERUSER_PASSWORD=Ti100600@ # Emails SMTP_HOST= @@ -34,12 +34,32 @@ SMTP_PORT=587 # Postgres POSTGRES_SERVER=localhost POSTGRES_PORT=5432 -POSTGRES_DB=app +POSTGRES_DB=KeToanAuto POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_PASSWORD=Ti100600@ SENTRY_DSN= # Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend +DOCKER_IMAGE_BACKEND=nguyentien0620/backend +DOCKER_IMAGE_FRONTEND=nguyentien0620/frontend + + +# AWS S3 (Cloudflare R2) credentials +R2_ACCOUNT_ID="46252d78a71b1e948cca93580f21d6c8" +R2_ACCESS_KEY="fc94974446d24f787fc2c065211dd4b5" +R2_SECRET_KEY="7e2612409a61feb12a18d87cf960389e165b541680d660c86725de1e7cbbc753" +R2_BUCKET_NAME="ketoanauto" + +# OCR API +OCR_API_URL="https://nas3fbh253sfifna.aistudio-app.com/layout-parsing" +OCR_API_TOKEN="24f39b195ccd25b584dd4d3edac1179d1688b1c3" +OCR_JOB_URL="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" +OCR_JOB_POLLING_INTERVAL=5 # in seconds +OCR_MODEL="PaddleOCR-VL-1.5" + +# VNPAY credentials (for testing, use the provided demo credentials or set your own in the .env file) +VNPAY_TMN_CODE="36PBP850" # Replace with your actual +VNPAY_HASH_SECRET="Q6NRDOTHBWMJ5KWUMAZUNRT4MNYLHR2E" # Replace +VNPAY_RETURN_URL="https://localhost:5173/payment/return" # Update if your backend URL is different +VNP_URL="https://sandbox.vnpayment.vn/paymentv2/vpcpay.html" # VNPAY sandbox URL diff --git a/.env.local b/.env.local new file mode 100644 index 0000000000..a7619e4dc2 --- /dev/null +++ b/.env.local @@ -0,0 +1,59 @@ +# Domain +# This would be set to the production domain with an env var on deployment +# used by Traefik to transmit traffic and aqcuire TLS certificates +DOMAIN=localhost +# To test the local Traefik config +# DOMAIN=localhost.tiangolo.com + +# Used by the backend to generate links in emails to the frontend +FRONTEND_HOST=http://localhost:5173 +# In staging and production, set this env var to the frontend host, e.g. +# FRONTEND_HOST=https://dashboard.example.com + +# Environment: local, staging, production +ENVIRONMENT=local + +PROJECT_NAME="KeToanAuto" +STACK_NAME=ketoanauto + +# Backend +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" +SECRET_KEY=Ti100600@12131231 +FIRST_SUPERUSER=nguyenvantien0620@gmail.com +FIRST_SUPERUSER_PASSWORD=Ti100600@ + +# 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=KeToanAuto +POSTGRES_USER=postgres +POSTGRES_PASSWORD=Ti100600@ + +SENTRY_DSN= + +# Configure these with your own Docker registry images +DOCKER_IMAGE_BACKEND=backend +DOCKER_IMAGE_FRONTEND=frontend + + +# AWS S3 (Cloudflare R2) credentials +R2_ACCOUNT_ID="46252d78a71b1e948cca93580f21d6c8" +R2_ACCESS_KEY="fc94974446d24f787fc2c065211dd4b5" +R2_SECRET_KEY="7e2612409a61feb12a18d87cf960389e165b541680d660c86725de1e7cbbc753" +R2_BUCKET_NAME="ketoanauto" + +# OCR API +OCR_API_URL="https://nas3fbh253sfifna.aistudio-app.com/layout-parsing" +OCR_API_TOKEN="24f39b195ccd25b584dd4d3edac1179d1688b1c3" +OCR_JOB_URL="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" +OCR_JOB_POLLING_INTERVAL=5 # in seconds +OCR_MODEL="PaddleOCR-VL" \ No newline at end of file diff --git a/backend/app/alembic/versions/16a9754259d0_add_topup_tables.py b/backend/app/alembic/versions/16a9754259d0_add_topup_tables.py new file mode 100644 index 0000000000..2be3783f28 --- /dev/null +++ b/backend/app/alembic/versions/16a9754259d0_add_topup_tables.py @@ -0,0 +1,55 @@ +"""add topup tables + +Revision ID: 16a9754259d0 +Revises: e2f3a4b5c6d7 +Create Date: 2026-04-25 01:40:28.720543 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '16a9754259d0' +down_revision = 'e2f3a4b5c6d7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('topup_transactions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('txn_ref', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('type', sa.Enum('CREDIT', 'DEBIT', name='topuptype'), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'SUCCESS', 'FAILED', name='topupstatus'), nullable=False), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_topup_transactions_txn_ref'), 'topup_transactions', ['txn_ref'], unique=False) + op.create_index(op.f('ix_topup_transactions_user_id'), 'topup_transactions', ['user_id'], unique=False) + op.create_table('user_balances', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('balance', sa.Float(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_balances_user_id'), 'user_balances', ['user_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_balances_user_id'), table_name='user_balances') + op.drop_table('user_balances') + op.drop_index(op.f('ix_topup_transactions_user_id'), table_name='topup_transactions') + op.drop_index(op.f('ix_topup_transactions_txn_ref'), table_name='topup_transactions') + op.drop_table('topup_transactions') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py deleted file mode 100644 index 10e47a1456..0000000000 --- a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Add cascade delete relationships - -Revision ID: 1a31ce608336 -Revises: d98dd8ec85a3 -Create Date: 2024-07-31 22:24:34.447891 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = '1a31ce608336' -down_revision = 'd98dd8ec85a3' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('item', 'owner_id', - existing_type=sa.UUID(), - nullable=False) - op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') - op.create_foreign_key(None, 'item', 'user', ['owner_id'], ['id'], ondelete='CASCADE') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'item', type_='foreignkey') - op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) - op.alter_column('item', 'owner_id', - existing_type=sa.UUID(), - nullable=True) - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py b/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py deleted file mode 100755 index 78a41773b9..0000000000 --- a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Add max length for string(varchar) fields in User and Items models - -Revision ID: 9c0a54914c78 -Revises: e2412789c190 -Create Date: 2024-06-17 14:42:44.639457 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = '9c0a54914c78' -down_revision = 'e2412789c190' -branch_labels = None -depends_on = None - - -def upgrade(): - # Adjust the length of the email field in the User table - op.alter_column('user', 'email', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=False) - - # Adjust the length of the full_name field in the User table - op.alter_column('user', 'full_name', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=True) - - # Adjust the length of the title field in the Item table - op.alter_column('item', 'title', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=False) - - # Adjust the length of the description field in the Item table - op.alter_column('item', 'description', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=True) - - -def downgrade(): - # Revert the length of the email field in the User table - op.alter_column('user', 'email', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=False) - - # Revert the length of the full_name field in the User table - op.alter_column('user', 'full_name', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=True) - - # Revert the length of the title field in the Item table - op.alter_column('item', 'title', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=False) - - # Revert the length of the description field in the Item table - op.alter_column('item', 'description', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=True) diff --git a/backend/app/alembic/versions/a24416477b07_create_api_keys_table.py b/backend/app/alembic/versions/a24416477b07_create_api_keys_table.py new file mode 100644 index 0000000000..bc0bb6d5af --- /dev/null +++ b/backend/app/alembic/versions/a24416477b07_create_api_keys_table.py @@ -0,0 +1,39 @@ +"""create_api_keys_table + +Revision ID: a24416477b07 +Revises: c4f659581040 +Create Date: 2026-04-12 15:14:24.777302 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'a24416477b07' +down_revision = 'c4f659581040' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('api_keys', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys') + op.drop_table('api_keys') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/a7b8c9d0e1f2_add_user_type_to_users.py b/backend/app/alembic/versions/a7b8c9d0e1f2_add_user_type_to_users.py new file mode 100644 index 0000000000..8c4b67de80 --- /dev/null +++ b/backend/app/alembic/versions/a7b8c9d0e1f2_add_user_type_to_users.py @@ -0,0 +1,36 @@ +"""add user_type to users + +Revision ID: a7b8c9d0e1f2 +Revises: f1a2b3c4d5e6 +Create Date: 2026-06-12 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a7b8c9d0e1f2' +down_revision = 'f1a2b3c4d5e6' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'users', + sa.Column( + 'user_type', + sa.String(length=20), + nullable=False, + server_default='normal', + ), + ) + # Existing superusers become admins + op.execute("UPDATE users SET user_type = 'admin' WHERE is_superuser") + op.create_index(op.f('ix_users_user_type'), 'users', ['user_type']) + + +def downgrade(): + op.drop_index(op.f('ix_users_user_type'), table_name='users') + op.drop_column('users', 'user_type') diff --git a/backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py b/backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py new file mode 100644 index 0000000000..7736152ae7 --- /dev/null +++ b/backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py @@ -0,0 +1,27 @@ +"""add model to file_jobs + +Revision ID: b1c2d3e4f5a6 +Revises: a7b8c9d0e1f2 +Create Date: 2026-06-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b1c2d3e4f5a6' +down_revision = 'a7b8c9d0e1f2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'file_jobs', + sa.Column('model', sa.String(length=100), nullable=True), + ) + + +def downgrade(): + op.drop_column('file_jobs', 'model') diff --git a/backend/app/alembic/versions/b27c541d6090_edit_model_6.py b/backend/app/alembic/versions/b27c541d6090_edit_model_6.py new file mode 100644 index 0000000000..9b8939f002 --- /dev/null +++ b/backend/app/alembic/versions/b27c541d6090_edit_model_6.py @@ -0,0 +1,83 @@ +"""edit-model-6 + +Revision ID: b27c541d6090 +Revises: +Create Date: 2026-03-29 18:26:30.701531 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'b27c541d6090' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('files', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('size', sa.Integer(), nullable=True), + sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('job_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('job_status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column('err_msg', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('bank', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_files_job_id'), 'files', ['job_id'], unique=False) + op.create_table('items', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_storage_stats', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('file_count', sa.Integer(), nullable=False), + sa.Column('total_size', sa.Integer(), nullable=False), + sa.Column('total_cost', sa.Float(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('total_transactions', sa.Integer(), nullable=True), + sa.Column('total_pages', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_storage_stats_user_id'), 'user_storage_stats', ['user_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_storage_stats_user_id'), table_name='user_storage_stats') + op.drop_table('user_storage_stats') + op.drop_table('items') + op.drop_index(op.f('ix_files_job_id'), table_name='files') + op.drop_table('files') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/c4f659581040_add_api_key_model.py b/backend/app/alembic/versions/c4f659581040_add_api_key_model.py new file mode 100644 index 0000000000..8cdd1109d2 --- /dev/null +++ b/backend/app/alembic/versions/c4f659581040_add_api_key_model.py @@ -0,0 +1,39 @@ +"""Add api key model + +Revision ID: c4f659581040 +Revises: b27c541d6090 +Create Date: 2026-04-12 13:44:09.876423 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'c4f659581040' +down_revision = 'b27c541d6090' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('files', 'size', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('user_storage_stats', 'total_pages', + existing_type=sa.INTEGER(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user_storage_stats', 'total_pages', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('files', 'size', + existing_type=sa.INTEGER(), + nullable=True) + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py b/backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py new file mode 100644 index 0000000000..473723bf3c --- /dev/null +++ b/backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py @@ -0,0 +1,42 @@ +"""create_file_jobs_table + +Revision ID: d1e2f3a4b5c6 +Revises: a24416477b07 +Create Date: 2026-04-25 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'd1e2f3a4b5c6' +down_revision = 'a24416477b07' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'file_jobs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('job_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('file_id', sa.Uuid(), nullable=False), + sa.Column('state', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('total_pages', sa.Integer(), nullable=True), + sa.Column('extracted_pages', sa.Integer(), nullable=True), + sa.Column('start_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('json_url', sqlmodel.sql.sqltypes.AutoString(length=4000), nullable=True), + sa.Column('markdown_url', sqlmodel.sql.sqltypes.AutoString(length=4000), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['file_id'], ['files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_file_jobs_job_id'), 'file_jobs', ['job_id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_file_jobs_job_id'), table_name='file_jobs') + op.drop_table('file_jobs') diff --git a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py b/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py deleted file mode 100755 index 37af1fa215..0000000000 --- a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Edit replace id integers in all models to use UUID instead - -Revision ID: d98dd8ec85a3 -Revises: 9c0a54914c78 -Create Date: 2024-07-19 04:08:04.000976 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes -from sqlalchemy.dialects import postgresql - - -# revision identifiers, used by Alembic. -revision = 'd98dd8ec85a3' -down_revision = '9c0a54914c78' -branch_labels = None -depends_on = None - - -def upgrade(): - # Ensure uuid-ossp extension is available - op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') - - # Create a new UUID column with a default UUID value - op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) - op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) - op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True)) - - # Populate the new columns with UUIDs - op.execute('UPDATE "user" SET new_id = uuid_generate_v4()') - op.execute('UPDATE item SET new_id = uuid_generate_v4()') - op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)') - - # Set the new_id as not nullable - op.alter_column('user', 'new_id', nullable=False) - op.alter_column('item', 'new_id', nullable=False) - - # Drop old columns and rename new columns - op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') - op.drop_column('item', 'owner_id') - op.alter_column('item', 'new_owner_id', new_column_name='owner_id') - - op.drop_column('user', 'id') - op.alter_column('user', 'new_id', new_column_name='id') - - op.drop_column('item', 'id') - op.alter_column('item', 'new_id', new_column_name='id') - - # Create primary key constraint - op.create_primary_key('user_pkey', 'user', ['id']) - op.create_primary_key('item_pkey', 'item', ['id']) - - # Recreate foreign key constraint - op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) - -def downgrade(): - # Reverse the upgrade process - op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True)) - op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True)) - op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True)) - - # Populate the old columns with default values - # Generate sequences for the integer IDs if not exist - op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id') - op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id') - - op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)') - op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)') - - op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')') - op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)') - - # Drop new columns and rename old columns back - op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') - op.drop_column('item', 'owner_id') - op.alter_column('item', 'old_owner_id', new_column_name='owner_id') - - op.drop_column('user', 'id') - op.alter_column('user', 'old_id', new_column_name='id') - - op.drop_column('item', 'id') - op.alter_column('item', 'old_id', new_column_name='id') - - # Create primary key constraint - op.create_primary_key('user_pkey', 'user', ['id']) - op.create_primary_key('item_pkey', 'item', ['id']) - - # Recreate foreign key constraint - op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) diff --git a/backend/app/alembic/versions/e2412789c190_initialize_models.py b/backend/app/alembic/versions/e2412789c190_initialize_models.py deleted file mode 100644 index 7529ea91fa..0000000000 --- a/backend/app/alembic/versions/e2412789c190_initialize_models.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Initialize models - -Revision ID: e2412789c190 -Revises: -Create Date: 2023-11-24 22:55:43.195942 - -""" -import sqlalchemy as sa -import sqlmodel.sql.sqltypes -from alembic import op - -# revision identifiers, used by Alembic. -revision = "e2412789c190" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "user", - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) - op.create_table( - "item", - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("owner_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["owner_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("item") - op.drop_index(op.f("ix_user_email"), table_name="user") - op.drop_table("user") - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py b/backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py new file mode 100644 index 0000000000..3ae981aadf --- /dev/null +++ b/backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py @@ -0,0 +1,42 @@ +"""drop_job_fields_from_files_add_err_msg_to_file_jobs + +Revision ID: e2f3a4b5c6d7 +Revises: d1e2f3a4b5c6 +Create Date: 2026-04-25 00:01:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'e2f3a4b5c6d7' +down_revision = 'd1e2f3a4b5c6' +branch_labels = None +depends_on = None + + +def upgrade(): + # Drop job-tracking columns from files table + op.drop_index('ix_files_job_id', table_name='files') + op.drop_column('files', 'job_id') + op.drop_column('files', 'job_status') + op.drop_column('files', 'err_msg') + + # Add err_msg to file_jobs table + op.add_column( + 'file_jobs', + sa.Column('err_msg', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + ) + + +def downgrade(): + # Restore columns on files table + op.add_column('files', sa.Column('err_msg', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('files', sa.Column('job_status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True)) + op.add_column('files', sa.Column('job_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.create_index('ix_files_job_id', 'files', ['job_id'], unique=False) + + # Drop err_msg from file_jobs + op.drop_column('file_jobs', 'err_msg') diff --git a/backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py b/backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py new file mode 100644 index 0000000000..a710ac94f1 --- /dev/null +++ b/backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py @@ -0,0 +1,39 @@ +"""change txn_ref to varchar + +Revision ID: f1a2b3c4d5e6 +Revises: 16a9754259d0 +Create Date: 2026-04-25 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e6' +down_revision = '16a9754259d0' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + 'topup_transactions', + 'txn_ref', + existing_type=sa.Integer(), + type_=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_nullable=True, + postgresql_using="txn_ref::varchar(100)", + ) + + +def downgrade(): + op.alter_column( + 'topup_transactions', + 'txn_ref', + existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.Integer(), + existing_nullable=True, + postgresql_using="txn_ref::integer", + ) diff --git a/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py b/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py deleted file mode 100644 index 3e15754825..0000000000 --- a/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Add created_at to User and Item - -Revision ID: fe56fa70289e -Revises: 1a31ce608336 -Create Date: 2026-01-23 15:50:37.171462 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = 'fe56fa70289e' -down_revision = '1a31ce608336' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('item', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) - op.add_column('user', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('user', 'created_at') - op.drop_column('item', 'created_at') - # ### end Alembic commands ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..1edba26cfe 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,14 +1,26 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes.utils import router as utils_router +from app.api_keys.router import router as api_keys_router +from app.auth.router import router as login_router from app.core.config import settings +from app.files.router import router as files_router +from app.items.router import router as items_router +from app.storages.router import router as storages_router +from app.topup.router import router as topup_router +from app.users.router import router as users_router 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(login_router) +api_router.include_router(users_router) +api_router.include_router(utils_router) +api_router.include_router(items_router) +api_router.include_router(files_router) +api_router.include_router(storages_router) +api_router.include_router(api_keys_router) +api_router.include_router(topup_router) if settings.ENVIRONMENT == "local": - api_router.include_router(private.router) + from app.api.routes.private import router as private_router + + api_router.include_router(private_router) diff --git a/backend/app/api/routes/files.py b/backend/app/api/routes/files.py new file mode 100644 index 0000000000..7b84ef6d2b --- /dev/null +++ b/backend/app/api/routes/files.py @@ -0,0 +1,2 @@ +# Backwards-compatibility shim – router now lives in app.files.router +from app.files.router import router # noqa: F401 diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index f1929e5836..0a153b0e1e 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,112 +1,2 @@ -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() - - return ItemsPublic(data=items, 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") +# Backwards-compatibility shim – router now lives in app.items.router +from app.items.router import router # noqa: F401 diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 58441e37e9..2c5da51e57 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -1,123 +1,2 @@ -from datetime import timedelta -from typing import Annotated, Any - -from fastapi import APIRouter, Depends, HTTPException -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.utils import ( - generate_password_reset_token, - generate_reset_password_email, - send_email, - verify_password_reset_token, -) - -router = APIRouter(tags=["login"]) - - -@router.post("/login/access-token") -def login_access_token( - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -) -> Token: - """ - OAuth2 compatible token login, get an access token for future requests - """ - user = crud.authenticate( - session=session, email=form_data.username, password=form_data.password - ) - if not user: - raise HTTPException(status_code=400, detail="Incorrect email or password") - elif not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return Token( - access_token=security.create_access_token( - user.id, expires_delta=access_token_expires - ) - ) - - -@router.post("/login/test-token", response_model=UserPublic) -def test_token(current_user: CurrentUser) -> Any: - """ - Test access token - """ - return current_user - - -@router.post("/password-recovery/{email}") -def recover_password(email: str, session: SessionDep) -> Message: - """ - Password Recovery - """ - user = crud.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 - if user: - password_reset_token = generate_password_reset_token(email=email) - email_data = generate_reset_password_email( - email_to=user.email, email=email, token=password_reset_token - ) - send_email( - email_to=user.email, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return Message( - message="If that email is registered, we sent a password recovery link" - ) - - -@router.post("/reset-password/") -def reset_password(session: SessionDep, body: NewPassword) -> Message: - """ - Reset password - """ - 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) - 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( - session=session, - db_user=user, - user_in=user_in_update, - ) - return Message(message="Password updated successfully") - - -@router.post( - "/password-recovery-html-content/{email}", - dependencies=[Depends(get_current_active_superuser)], - response_class=HTMLResponse, -) -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) - - if not user: - raise HTTPException( - status_code=404, - detail="The user with this username does not exist in the system.", - ) - password_reset_token = generate_password_reset_token(email=email) - email_data = generate_reset_password_email( - email_to=user.email, email=email, token=password_reset_token - ) - - return HTMLResponse( - content=email_data.html_content, headers={"subject:": email_data.subject} - ) +# Backwards-compatibility shim – router now lives in app.auth.router +from app.auth.router import router # noqa: F401 diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py index 9f33ef1900..c36eb222b3 100644 --- a/backend/app/api/routes/private.py +++ b/backend/app/api/routes/private.py @@ -3,12 +3,10 @@ from fastapi import APIRouter from pydantic import BaseModel -from app.api.deps import SessionDep +from app.auth.dependencies import SessionDep from app.core.security import get_password_hash -from app.models import ( - User, - UserPublic, -) +from app.users.models import User +from app.users.schemas import UserPublic router = APIRouter(tags=["private"], prefix="/private") @@ -25,14 +23,11 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: """ Create a new user. """ - user = User( email=user_in.email, 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/storage.py b/backend/app/api/routes/storage.py new file mode 100644 index 0000000000..9146909c34 --- /dev/null +++ b/backend/app/api/routes/storage.py @@ -0,0 +1,2 @@ +# Backwards-compatibility shim – presign endpoint now lives in app.files.router +from app.files.router import router # noqa: F401 diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 35f64b626e..76880f88f6 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,231 +1,2 @@ -import uuid -from typing import Any - -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, - UsersPublic, - UserUpdate, - UserUpdateMe, -) -from app.utils import generate_new_account_email, send_email - -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.get( - "/", - dependencies=[Depends(get_current_active_superuser)], - response_model=UsersPublic, -) -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() - - return UsersPublic(data=users, count=count) - - -@router.post( - "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic -) -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.", - ) - - 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 - ) - send_email( - email_to=user_in.email, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return user - - -@router.patch("/me", response_model=UserPublic) -def update_user_me( - *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser -) -> Any: - """ - 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 - - -@router.patch("/me/password", response_model=Message) -def update_password_me( - *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser -) -> Any: - """ - 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" - ) - hashed_password = get_password_hash(body.new_password) - current_user.hashed_password = hashed_password - session.add(current_user) - session.commit() - return Message(message="Password updated successfully") - - -@router.get("/me", response_model=UserPublic) -def read_user_me(current_user: CurrentUser) -> Any: - """ - Get current user. - """ - return current_user - - -@router.delete("/me", response_model=Message) -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() - return Message(message="User deleted successfully") - - -@router.post("/signup", response_model=UserPublic) -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 - - -@router.get("/{user_id}", response_model=UserPublic) -def read_user_by_id( - user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser -) -> Any: - """ - 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", - ) - if user is None: - raise HTTPException(status_code=404, detail="User not found") - return user - - -@router.patch( - "/{user_id}", - dependencies=[Depends(get_current_active_superuser)], - response_model=UserPublic, -) -def update_user( - *, - session: SessionDep, - user_id: uuid.UUID, - user_in: UserUpdate, -) -> Any: - """ - 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", - ) - 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 - - -@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) -def delete_user( - session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID -) -> Message: - """ - 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" - ) - statement = delete(Item).where(col(Item.owner_id) == user_id) - session.exec(statement) - session.delete(user) - session.commit() - return Message(message="User deleted successfully") +# Backwards-compatibility shim – router now lives in app.users.router +from app.users.router import router # noqa: F401 diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..6b7f4692b4 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -1,9 +1,14 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic.networks import EmailStr +from sqlalchemy import delete +from sqlmodel import Session -from app.api.deps import get_current_active_superuser -from app.models import Message -from app.utils import generate_test_email, send_email +from app.aws.client import get_s3_client +from app.aws.config import aws_settings +from app.auth.dependencies import get_current_active_superuser, get_db +from app.files.models import File +from app.users.schemas import Message +from app.users.utils import generate_test_email, send_email router = APIRouter(prefix="/utils", tags=["utils"]) @@ -29,3 +34,55 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True + + +@router.post( + "/clear-files/", + dependencies=[Depends(get_current_active_superuser)], + status_code=200, +) +def clear_all_files(session: Session = Depends(get_db)) -> Message: + """Remove all objects from the configured R2 bucket and delete File rows. + + Requires superuser. This is a destructive operation. + """ + bucket = aws_settings.R2_BUCKET_NAME + if not bucket: + raise HTTPException(status_code=500, detail="R2 bucket not configured") + + client = get_s3_client() + + # List all objects in the bucket and delete them in batches of up to 1000 + try: + paginator_args = {"Bucket": bucket} + keys_to_delete: list[dict[str, str]] = [] + resp = client.list_objects_v2(**paginator_args) + while True: + contents = resp.get("Contents") or [] + for obj in contents: + keys_to_delete.append({"Key": obj["Key"]}) + + # If we have 1000 keys, delete them now + if len(keys_to_delete) >= 1000: + client.delete_objects(Bucket=bucket, Delete={"Objects": keys_to_delete}) + keys_to_delete = [] + + if not resp.get("IsTruncated"): + break + resp = client.list_objects_v2(Bucket=bucket, ContinuationToken=resp.get("NextContinuationToken")) + + if keys_to_delete: + client.delete_objects(Bucket=bucket, Delete={"Objects": keys_to_delete}) + except Exception as exc: # pragma: no cover - depends on external service + raise HTTPException(status_code=500, detail=f"Failed to clear R2 bucket: {exc}") + + # Delete all File rows in the database + try: + statement = delete(File) + session.exec(statement) + session.commit() + except Exception as exc: # pragma: no cover - db issue + raise HTTPException(status_code=500, detail=f"Failed to delete File records: {exc}") + + return Message(message="Cleared all objects from R2 bucket and deleted File records") + diff --git a/backend/app/api_keys/__init__.py b/backend/app/api_keys/__init__.py new file mode 100644 index 0000000000..055bdc2e90 --- /dev/null +++ b/backend/app/api_keys/__init__.py @@ -0,0 +1,6 @@ +"""API Keys package + +Provides models, schemas, service and router for storing user API keys. +""" + +__all__ = ["models", "schemas", "service", "router"] diff --git a/backend/app/api_keys/crud.py b/backend/app/api_keys/crud.py new file mode 100644 index 0000000000..0f7a08f3ba --- /dev/null +++ b/backend/app/api_keys/crud.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from app.backend_pre_start import logger + +import uuid +from typing import List + +from sqlmodel import Session, select + +from app.api_keys.models import ApiKey +from app.api_keys.schemas import ApiKeyCreate + + +def create_api_key(session: Session, user_id: uuid.UUID, api_key_in: ApiKeyCreate) -> ApiKey: + api_key = ApiKey(user_id=user_id, name=api_key_in.name, key=api_key_in.key) + session.add(api_key) + session.commit() + session.refresh(api_key) + return api_key + + +def get_api_keys_for_user(session: Session, user_id: uuid.UUID) -> List[ApiKey]: + statement = select(ApiKey).where(ApiKey.user_id == user_id) + return list(session.exec(statement).all()) + + +def get_api_key(session: Session, api_key_id: uuid.UUID) -> ApiKey | None: + return session.get(ApiKey, api_key_id) + + +def delete_api_key(session: Session, api_key_id: uuid.UUID) -> None: + api_key = session.get(ApiKey, api_key_id) + if api_key: + session.delete(api_key) + session.commit() + +def get_api_key_by_user(session: Session, user_id: uuid.UUID) -> ApiKey: + statement = select(ApiKey).where(ApiKey.user_id == user_id) + api_key = ApiKey( + key='AIzaSyBzqezPY0EVJZfMGPfkG5TpHRtUZeeu_rE' + ) + + if not api_key: + logger.error(f"No API key found for user_id: {user_id}") + raise ValueError("No API key found! You need to create one Google Gemini API key to use this function.") + + return api_key \ No newline at end of file diff --git a/backend/app/api_keys/models.py b/backend/app/api_keys/models.py new file mode 100644 index 0000000000..e4616f8f4f --- /dev/null +++ b/backend/app/api_keys/models.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlmodel import Field, SQLModel + +from app.utils import get_datetime_utc + + +class ApiKeyBase(SQLModel): + name: str | None = None + + +class ApiKey(ApiKeyBase, table=True): + __tablename__ = "api_keys" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="users.id", index=True) + # NOTE: consider encrypting this column in production + key: str + created_at: datetime | None = Field(default_factory=get_datetime_utc) diff --git a/backend/app/api_keys/router.py b/backend/app/api_keys/router.py new file mode 100644 index 0000000000..e5a9bb1f10 --- /dev/null +++ b/backend/app/api_keys/router.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import uuid +from typing import List + +from fastapi import APIRouter, HTTPException + +from app.api_keys import crud as api_keys_crud +from app.api_keys.schemas import ApiKeyCreate, ApiKeyPublic, ApiKeysList +from app.auth.dependencies import CurrentUser, SessionDep + +router = APIRouter(prefix="/api-keys", tags=["api-keys"]) + + +@router.post("/", response_model=ApiKeyPublic) +def create_api_key( + api_key_in: ApiKeyCreate, session: SessionDep, current_user: CurrentUser +): + """Upload an API key for the current user.""" + api_key = api_keys_crud.create_api_key(session=session, user_id=current_user.id, api_key_in=api_key_in) + return ApiKeyPublic(id=api_key.id, name=api_key.name, created_at=api_key.created_at) + + +@router.get("/", response_model=ApiKeysList) +def list_api_keys(session: SessionDep, current_user: CurrentUser): + """List API keys for the current user (does not include the secret key itself).""" + keys = api_keys_crud.get_api_keys_for_user(session=session, user_id=current_user.id) + public = [ApiKeyPublic(id=k.id, name=k.name, created_at=k.created_at) for k in keys] + return ApiKeysList(data=public, count=len(public)) + + +@router.delete("/{api_key_id}") +def delete_api_key(api_key_id: uuid.UUID, session: SessionDep, current_user: CurrentUser): + api_key = api_keys_crud.get_api_key(session=session, api_key_id=api_key_id) + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + if api_key.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to delete this API key") + api_keys_crud.delete_api_key(session=session, api_key_id=api_key_id) + return {"detail": "deleted"} diff --git a/backend/app/api_keys/schemas.py b/backend/app/api_keys/schemas.py new file mode 100644 index 0000000000..00af12f179 --- /dev/null +++ b/backend/app/api_keys/schemas.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime + +from sqlmodel import SQLModel + + +class ApiKeyCreate(SQLModel): + name: str | None = None + key: str + + +class ApiKeyPublic(SQLModel): + id: uuid.UUID + name: str | None = None + created_at: datetime | None = None + # we intentionally do not return the key itself in public schema + + +class ApiKeysList(SQLModel): + data: list[ApiKeyPublic] + count: int diff --git a/backend/app/auth/constants.py b/backend/app/auth/constants.py new file mode 100644 index 0000000000..2e4806e733 --- /dev/null +++ b/backend/app/auth/constants.py @@ -0,0 +1 @@ +ACCESS_TOKEN_TYPE = "bearer" diff --git a/backend/app/api/deps.py b/backend/app/auth/dependencies.py similarity index 74% rename from backend/app/api/deps.py rename to backend/app/auth/dependencies.py index c2b83c841d..6f80bd271e 100644 --- a/backend/app/api/deps.py +++ b/backend/app/auth/dependencies.py @@ -1,3 +1,6 @@ +from __future__ import annotations +from app.users.models import User + from collections.abc import Generator from typing import Annotated @@ -8,10 +11,10 @@ from pydantic import ValidationError from sqlmodel import Session +from app.auth.schemas import TokenPayload from app.core import security from app.core.config import settings from app.core.db import engine -from app.models import TokenPayload, User reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" @@ -27,7 +30,9 @@ def get_db() -> Generator[Session, None, None]: TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: TokenDep) -> User: +def get_current_user(session: SessionDep, token: TokenDep): + from app.users.models import User + try: payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] @@ -46,12 +51,14 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: return user -CurrentUser = Annotated[User, Depends(get_current_user)] - - -def get_current_active_superuser(current_user: CurrentUser) -> User: - if not current_user.is_superuser: +def get_current_active_superuser(current_user: Annotated[object, Depends(get_current_user)]): + if not getattr(current_user, "is_superuser", False): raise HTTPException( status_code=403, detail="The user doesn't have enough privileges" ) return current_user + + +# Runtime type alias – using object avoids circular import at module level. +# The actual return type of get_current_user is app.users.models.User. +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/auth/exceptions.py b/backend/app/auth/exceptions.py new file mode 100644 index 0000000000..7cd5f0f52b --- /dev/null +++ b/backend/app/auth/exceptions.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, status + +CredentialsException = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", +) + +InactiveUserException = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user", +) + +InvalidTokenException = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token", +) diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py new file mode 100644 index 0000000000..cde931a809 --- /dev/null +++ b/backend/app/auth/models.py @@ -0,0 +1 @@ +# db models for auth (JWT tokens are stateless; no DB models required currently) diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py new file mode 100644 index 0000000000..118c1c9ced --- /dev/null +++ b/backend/app/auth/router.py @@ -0,0 +1,118 @@ +from datetime import timedelta +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2PasswordRequestForm + +from app.auth.dependencies import CurrentUser, SessionDep, get_current_active_superuser +from app.auth.schemas import NewPassword, Token +from app.auth.service import authenticate +from app.auth.utils import generate_password_reset_token, verify_password_reset_token +from app.core import security +from app.core.config import settings +from app.users.schemas import Message, UserPublic, UserUpdate +from app.users.service import get_user_by_email, update_user +from app.users.utils import generate_reset_password_email, send_email + +router = APIRouter(tags=["login"]) + + +@router.post("/login/access-token") +def login_access_token( + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = authenticate( + session=session, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user_id=user.id, expires_delta=access_token_expires, user_type=user.user_type + ) + ) + + +@router.post("/login/test-token", response_model=UserPublic) +def test_token(current_user: CurrentUser) -> Any: + """ + Test access token + """ + return current_user + + +@router.post("/password-recovery/{email}") +def recover_password(email: str, session: SessionDep) -> Message: + """ + Password Recovery + """ + user = get_user_by_email(session=session, email=email) + + if user: + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + send_email( + email_to=user.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message( + message="If that email is registered, we sent a password recovery link" + ) + + +@router.post("/reset-password/") +def reset_password(session: SessionDep, body: NewPassword) -> Message: + """ + Reset password + """ + email = verify_password_reset_token(token=body.token) + if not email: + raise HTTPException(status_code=400, detail="Invalid token") + user = get_user_by_email(session=session, email=email) + if not user: + 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) + update_user( + session=session, + db_user=user, + user_in=user_in_update, + ) + return Message(message="Password updated successfully") + + +@router.post( + "/password-recovery-html-content/{email}", + dependencies=[Depends(get_current_active_superuser)], + response_class=HTMLResponse, +) +def recover_password_html_content(email: str, session: SessionDep) -> Any: + """ + HTML Content for Password Recovery + """ + user = get_user_by_email(session=session, email=email) + + if not user: + raise HTTPException( + status_code=404, + detail="The user with this username does not exist in the system.", + ) + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + + return HTMLResponse( + content=email_data.html_content, headers={"subject:": email_data.subject} + ) diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000000..17f9e6cc99 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,17 @@ +from sqlmodel import Field, SQLModel + + +# JSON payload containing access token +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +# Contents of JWT token +class TokenPayload(SQLModel): + sub: str | None = None + + +class NewPassword(SQLModel): + token: str + new_password: str = Field(min_length=8, max_length=128) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000000..2041523bbb --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,26 @@ +from sqlmodel import Session + +from app.core.security import verify_password +from app.users.models import User +from app.users.service import get_user_by_email + +# 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: + 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 + 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 diff --git a/backend/app/auth/utils.py b/backend/app/auth/utils.py new file mode 100644 index 0000000000..7f34da3232 --- /dev/null +++ b/backend/app/auth/utils.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from jwt.exceptions import InvalidTokenError + +from app.core import security +from app.core.config import settings + + +def generate_password_reset_token(email: str) -> str: + delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) + now = datetime.now(timezone.utc) + expires = now + delta + exp = expires.timestamp() + encoded_jwt = jwt.encode( + {"exp": exp, "nbf": now, "sub": email}, + settings.SECRET_KEY, + algorithm=security.ALGORITHM, + ) + return encoded_jwt + + +def verify_password_reset_token(token: str) -> str | None: + try: + decoded_token = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + return str(decoded_token["sub"]) + except InvalidTokenError: + return None diff --git a/backend/app/alembic/versions/.keep b/backend/app/aws/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from backend/app/alembic/versions/.keep rename to backend/app/aws/__init__.py diff --git a/backend/app/aws/client.py b/backend/app/aws/client.py new file mode 100644 index 0000000000..36f4842eb5 --- /dev/null +++ b/backend/app/aws/client.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import boto3 +from botocore.client import Config + +from app.aws.config import aws_settings +from app.backend_pre_start import logger + + +def get_s3_client(): + """Return a boto3 S3 client configured for S3-compatible endpoints (e.g., Cloudflare R2).""" + kwargs: dict = {} + if aws_settings.R2_ACCESS_KEY: + kwargs["aws_access_key_id"] = aws_settings.R2_ACCESS_KEY + if aws_settings.R2_SECRET_KEY: + kwargs["aws_secret_access_key"] = aws_settings.R2_SECRET_KEY + + endpoint = f"https://{aws_settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + client_config = Config(signature_version="s3v4") + + return boto3.client("s3", endpoint_url=endpoint, config=client_config, **kwargs) + + +def generate_presigned_put_url(key: str, bucket: str | None = None, expiration: int = 60*60*24*7) -> str: + bucket = bucket or aws_settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured") + + client = get_s3_client() + params = {"Bucket": bucket, "Key": key} + + url = client.generate_presigned_url( + ClientMethod="get_object", Params=params, ExpiresIn=expiration + ) + + return url + + +def upload_file_to_r2(key: str, data: bytes, content_type: str | None = None, presign: bool = False) -> dict: + bucket = aws_settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured") + + client = get_s3_client() + extra_args: dict = {} + if content_type: + extra_args["ContentType"] = content_type + + resp = client.put_object(Bucket=bucket, Key=key, Body=data, **extra_args) + resp["IsSuccess"] = resp.get("ResponseMetadata", {}).get("HTTPStatusCode", 0) == 200 + if presign: + resp["PresignedURL"] = generate_presigned_put_url(key=key, bucket=bucket) + return resp + +def download_file_from_r2(key: str, bucket: str | None = None) -> bytes: + bucket = bucket or aws_settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured") + + client = get_s3_client() + response = client.get_object(Bucket=bucket, Key=key) + return response["Body"].read() \ No newline at end of file diff --git a/backend/app/aws/config.py b/backend/app/aws/config.py new file mode 100644 index 0000000000..5ebf5f62ae --- /dev/null +++ b/backend/app/aws/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AWSSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file="../.env", + env_ignore_empty=True, + extra="ignore", + ) + + R2_ACCESS_KEY: str | None = None + R2_SECRET_KEY: str | None = None + R2_BUCKET_NAME: str | None = None + R2_ACCOUNT_ID: str | None = None + + +aws_settings = AWSSettings() # type: ignore[call-arg] diff --git a/backend/app/aws/constants.py b/backend/app/aws/constants.py new file mode 100644 index 0000000000..0564a291b1 --- /dev/null +++ b/backend/app/aws/constants.py @@ -0,0 +1 @@ +DEFAULT_PRESIGN_EXPIRATION_SECONDS = 3600 diff --git a/backend/app/aws/exceptions.py b/backend/app/aws/exceptions.py new file mode 100644 index 0000000000..3ac668931e --- /dev/null +++ b/backend/app/aws/exceptions.py @@ -0,0 +1,6 @@ +from fastapi import HTTPException, status + +S3BucketNotConfiguredException = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="S3 bucket not configured", +) diff --git a/backend/app/aws/schemas.py b/backend/app/aws/schemas.py new file mode 100644 index 0000000000..e455e9fa59 --- /dev/null +++ b/backend/app/aws/schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class PresignRequest(BaseModel): + filename: str + content_type: str | None = None + bucket: str | None = None + + +class PresignResponse(BaseModel): + url: str + key: str diff --git a/backend/app/aws/utils.py b/backend/app/aws/utils.py new file mode 100644 index 0000000000..3540bdc231 --- /dev/null +++ b/backend/app/aws/utils.py @@ -0,0 +1 @@ +# AWS/S3 utility functions diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py index c2f8e29ae1..e378a3c66e 100644 --- a/backend/app/backend_pre_start.py +++ b/backend/app/backend_pre_start.py @@ -6,7 +6,10 @@ from app.core.db import engine -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", +) logger = logging.getLogger(__name__) max_tries = 60 * 5 # 5 minutes diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..c2dd7b0ca4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] - @computed_field # type: ignore[prop-decorator] + @computed_field @property def all_cors_origins(self) -> list[str]: return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ @@ -56,7 +56,7 @@ def all_cors_origins(self) -> list[str]: POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" - @computed_field # type: ignore[prop-decorator] + @computed_field @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: return PostgresDsn.build( @@ -85,7 +85,7 @@ def _set_default_emails_from(self) -> Self: EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 - @computed_field # type: ignore[prop-decorator] + @computed_field @property def emails_enabled(self) -> bool: return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) @@ -94,6 +94,14 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str + # S3 / R2 settings (optional). If using Cloudflare R2, set S3_ENDPOINT_URL to your + # R2 endpoint and provide access keys. These are intentionally optional so local + # dev continues to work without them. + R2_ACCESS_KEY: str | None = None + R2_SECRET_KEY: str | None = None + R2_BUCKET_NAME: str | None = None + R2_ACCOUNT_ID: str | None = None + def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( @@ -114,6 +122,15 @@ def _enforce_non_default_secrets(self) -> Self: ) return self - + + OCR_API_URL: HttpUrl | None = None + OCR_API_TOKEN: str | None = None + OCR_JOB_URL: HttpUrl | None = None + OCR_JOB_POLLING_INTERVAL: int = 5 # in seconds + OCR_MODEL: str = "PaddleOCR-VL" + + VNPAY_TMN_CODE: str | None = None + VNPAY_HASH_SECRET: str | None = None + VNPAY_RETURN_URL: str = "https://localhost:5173/payment/return" settings = Settings() # type: ignore diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..45c1a9c5d2 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,33 +1,31 @@ from sqlmodel import Session, create_engine, select -from app import crud from app.core.config import settings -from app.models import User, UserCreate engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) - -# make sure all SQLModel models are imported (app.models) before initializing DB +# make sure all SQLModel models are imported before initializing DB # otherwise, SQLModel might fail to initialize relationships properly -# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 - - def init_db(session: Session) -> None: - # Tables should be created with Alembic migrations - # But if you don't want to use migrations, create - # the tables un-commenting the next lines - # from sqlmodel import SQLModel - - # This works because the models are already imported and registered from app.models - # SQLModel.metadata.create_all(engine) + # Import all models so SQLModel registers them + from app.files.models import File # noqa: F401 + from app.items.models import Item # noqa: F401 + from app.users.models import User + # ensure api_keys model is imported so SQLModel registers the table + from app.api_keys.models import ApiKey # noqa: F401 + from app.users.schemas import UserCreate + from app.users.service import create_user user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) ).first() if not user: + from app.users.constants import UserType + user_in = UserCreate( email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, + user_type=UserType.ADMIN, ) - user = crud.create_user(session=session, user_create=user_in) + create_user(session=session, user_create=user_in) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 1e49ebc1fe..f14a69046d 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -5,7 +5,6 @@ from pwdlib import PasswordHash from pwdlib.hashers.argon2 import Argon2Hasher from pwdlib.hashers.bcrypt import BcryptHasher - from app.core.config import settings password_hash = PasswordHash( @@ -19,9 +18,9 @@ ALGORITHM = "HS256" -def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: +def create_access_token(user_id: any, expires_delta: timedelta, user_type: str) -> str: expire = datetime.now(timezone.utc) + expires_delta - to_encode = {"exp": expire, "sub": str(subject)} + to_encode = {"exp": expire, "sub": str(user_id), "user_type": user_type} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt diff --git a/backend/app/crud.py b/backend/app/crud.py deleted file mode 100644 index a8ceba6444..0000000000 --- a/backend/app/crud.py +++ /dev/null @@ -1,68 +0,0 @@ -import uuid -from typing import Any - -from sqlmodel import Session, select - -from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate - - -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 - - -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 - - -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" - - -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 - - -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 diff --git a/backend/app/exceptions/OcrJobException.py b/backend/app/exceptions/OcrJobException.py new file mode 100644 index 0000000000..c34a700163 --- /dev/null +++ b/backend/app/exceptions/OcrJobException.py @@ -0,0 +1,265 @@ +"""OCR provider exception types and mapping utilities. + +This module defines a base `OcrJobException` and common subclasses that represent +error conditions returned by external OCR providers (Baidu OCR, etc.). It also +provides a small factory `from_baidu_response` which can translate a typical +Baidu OCR error payload (e.g. containing ``error_code`` and ``error_msg``) into a +rich Python exception subclass. + +The goal is to centralize mapping provider error payloads to typed exceptions +so callers (workers, API handlers) can decide whether an error is retryable, +transient, or permanent. + +Reference: https://ai.baidu.com/ai-doc/AISTUDIO/Mml7n69e7 (provider error shapes) +""" + +from __future__ import annotations + +from typing import Any + + +class OcrJobException(Exception): + """Base exception for OCR job related errors. + + Attributes + ---------- + code: int | None - provider-specific numeric error code when available + message: str - human-readable message + http_status: int | None - optional HTTP status associated with the error + meta: dict - optional extra data (raw provider payload) + """ + + def __init__(self, message: str | None = None, *, code: int | None = None, + http_status: int | None = None, meta: dict | None = None) -> None: + super().__init__(message or "OCR job error") + self.code = code + self.message = message or "OCR job error" + self.http_status = http_status + self.meta = meta or {} + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.__class__.__name__, + "code": self.code, + "message": self.message, + "http_status": self.http_status, + "meta": self.meta, + } + + def __str__(self) -> str: # pragma: no cover - trivial + return f"{self.__class__.__name__}(code={self.code}, message={self.message})" + + +# --- Specific exception subclasses --- + + +class BadRequestError(OcrJobException): + """400-like error: invalid request or parameters.""" + + +class AuthenticationError(OcrJobException): + """Authentication failed (invalid/expired access token).""" + + +class AuthorizationError(OcrJobException): + """Permission denied for the requested resource or action.""" + + +class NotFoundError(OcrJobException): + """Requested resource not found.""" + + +class RateLimitError(OcrJobException): + """Rate limit exceeded (throttling).""" + + +class QuotaExceededError(OcrJobException): + """Account or project quota exhausted.""" + + +class UnsupportedFileTypeError(OcrJobException): + """Uploaded file type is not supported by the OCR provider.""" + + +class FileTooLargeError(OcrJobException): + """Uploaded file exceeds provider/max size limits.""" + + +class ProviderTimeoutError(OcrJobException): + """The OCR provider timed out while processing the request.""" + + +class ServiceUnavailableError(OcrJobException): + """Provider service temporarily unavailable (5xx or maintenance).""" + + +class InternalServerError(OcrJobException): + """Unexpected provider-side error.""" + + +class ConflictError(OcrJobException): + """Conflict (e.g., duplicate resource)""" + + +class NetworkError(OcrJobException): + """Network-level failure when calling the provider.""" + + +class InvalidArgumentError(BadRequestError): + """Invalid argument or malformed request payload.""" + + +# Generic provider error fallback + + +class ProviderError(OcrJobException): + """Generic OCR provider error when no better mapping exists.""" + + +# Mapping helpers +_DEFAULT_MAPPING: dict[str, type[OcrJobException]] = { + # textual heuristics + "access token": AuthenticationError, + "access_token": AuthenticationError, + "permission": AuthorizationError, + "permission denied": AuthorizationError, + "quota": QuotaExceededError, + "limit": RateLimitError, + "rate limit": RateLimitError, + "file too large": FileTooLargeError, + "size limit": FileTooLargeError, + "unsupported": UnsupportedFileTypeError, + "format": UnsupportedFileTypeError, + "timeout": ProviderTimeoutError, + "service unavailable": ServiceUnavailableError, + "internal error": InternalServerError, + "internal server error": InternalServerError, +} + + +def _guess_from_message(msg: str) -> type[OcrJobException]: + if not msg: + return ProviderError + lowered = msg.lower() + for key, exc in _DEFAULT_MAPPING.items(): + if key in lowered: + return exc + # fallback by heuristics + if "unauthorized" in lowered or "invalid token" in lowered: + return AuthenticationError + if "forbidden" in lowered: + return AuthorizationError + if "not found" in lowered: + return NotFoundError + if "429" in lowered or "rate" in lowered: + return RateLimitError + return ProviderError + + +def from_baidu_response(payload: dict[str, Any], http_status: int | None = None) -> OcrJobException: + """Create an OcrJobException from a Baidu OCR provider response payload. + + Expected payload shapes (examples): + - {"error_code": 110, "error_msg": "Invalid access token"} + - {"error": "...", "error_description": "..."} + + This function attempts to detect common keys and returns a concrete + subclass when possible. If no mapping is found it returns ``ProviderError``. + """ + + # Normalized extraction + code: int | None = None + message: str | None = None + if not payload: + return ProviderError("empty response from provider", code=None, http_status=http_status, meta={}) + + # Safely parse numeric codes (they may be strings or ints) + ec = payload.get("error_code") + if ec is None: + ec = payload.get("errno") + try: + if ec is not None: + code = int(ec) + except Exception: + code = None + + message = payload.get("error_msg") or payload.get("error_description") or payload.get("error") or payload.get("message") + + # Some providers return nested data; keep raw payload for debugging + meta = {"provider_payload": payload} + + # Map specific numeric codes if we know them (extendable) + if code is not None: + # Common mapping by numeric code (examples / placeholders). + # Extend this mapping with concrete Baidu codes if known. + if code in (110, 111, 112): # token / auth related (example) + return AuthenticationError(message or "authentication failed", code=code, http_status=http_status, meta=meta) + if code in (17, 18, 19): # quota/limit examples + return QuotaExceededError(message or "quota exceeded", code=code, http_status=http_status, meta=meta) + if 400 <= code < 500: + return BadRequestError(message or "client error", code=code, http_status=http_status, meta=meta) + if 500 <= code < 600: + return ServiceUnavailableError(message or "provider server error", code=code, http_status=http_status, meta=meta) + + # If no numeric mapping, try to guess from message text + exc_cls = _guess_from_message(message or "") + return exc_cls(message or "provider error", code=code, http_status=http_status, meta=meta) + + +def raise_for_baidu_response(payload: dict[str, Any], http_status: int | None = None) -> None: + """Convenience helper: raise an OcrJobException if the payload represents an error. + + This inspects the payload for common error keys and raises a mapped exception. + Callers can catch ``OcrJobException`` or specific subclasses to handle retries. + """ + if not payload: + return + + # Heuristic: if provider included an explicit error key + error_present = any(k in payload for k in ("error", "error_msg", "error_code", "errno", "message")) + if not error_present: + return + + # If payload contains numeric `error_code` or textual `error_msg` treat as error + # NOTE: sometimes providers embed success metadata alongside errors; adjust as needed. + is_error = False + if payload.get("error"): + is_error = True + if payload.get("error_msg"): + is_error = True + if payload.get("error_code") is not None: + # treat non-zero numeric error codes as error + try: + ec = int(payload.get("error_code")) + if ec != 0: + is_error = True + except Exception: + is_error = True + + if not is_error: + return + + exc = from_baidu_response(payload, http_status=http_status) + raise exc + + +__all__ = [ + "OcrJobException", + "BadRequestError", + "AuthenticationError", + "AuthorizationError", + "NotFoundError", + "RateLimitError", + "QuotaExceededError", + "UnsupportedFileTypeError", + "FileTooLargeError", + "ProviderTimeoutError", + "ServiceUnavailableError", + "InternalServerError", + "ConflictError", + "NetworkError", + "InvalidArgumentError", + "ProviderError", + "from_baidu_response", + "raise_for_baidu_response", +] diff --git a/backend/app/files/__init__.py b/backend/app/files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/files/constants.py b/backend/app/files/constants.py new file mode 100644 index 0000000000..da595d14e7 --- /dev/null +++ b/backend/app/files/constants.py @@ -0,0 +1 @@ +MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB diff --git a/backend/app/files/crud.py b/backend/app/files/crud.py new file mode 100644 index 0000000000..60841367d9 --- /dev/null +++ b/backend/app/files/crud.py @@ -0,0 +1,71 @@ +import uuid + +from sqlmodel import Session, select + +from app.files.models import File, FileJob +from app.files.schemas import FileCreate, FileJobCreate + + +def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: + db_file = File.model_validate(file_in, update={"user_id": user_id}) + session.add(db_file) + session.commit() + session.refresh(db_file) + return db_file + + +def delete_file(*, session: Session, file_id: uuid.UUID) -> None: + db_file = session.get(File, file_id) + if db_file: + session.delete(db_file) + session.commit() + + +# --------------------------------------------------------------------------- +# FileJob CRUD +# --------------------------------------------------------------------------- + +def create_file_job(*, session: Session, file_job_in: FileJobCreate) -> FileJob: + db_file_job = FileJob.model_validate(file_job_in) + session.add(db_file_job) + session.commit() + session.refresh(db_file_job) + return db_file_job + + +def get_file_job_by_file_id(*, session: Session, file_id: uuid.UUID) -> FileJob | None: + statement = select(FileJob).where(FileJob.file_id == file_id) + return session.exec(statement).first() + + +def get_file_job_by_job_id(*, session: Session, job_id: str) -> FileJob | None: + statement = select(FileJob).where(FileJob.job_id == job_id) + return session.exec(statement).first() + + +def update_file_job( + *, + session: Session, + file_job: FileJob, + state: str, + total_pages: int | None = None, + extracted_pages: int | None = None, + json_url: str | None = None, + markdown_url: str | None = None, + err_msg: str | None = None, +) -> FileJob: + file_job.state = state + if total_pages is not None: + file_job.total_pages = total_pages + if extracted_pages is not None: + file_job.extracted_pages = extracted_pages + if json_url is not None: + file_job.json_url = json_url + if markdown_url is not None: + file_job.markdown_url = markdown_url + if err_msg is not None: + file_job.err_msg = err_msg + session.add(file_job) + session.commit() + session.refresh(file_job) + return file_job diff --git a/backend/app/files/dependencies.py b/backend/app/files/dependencies.py new file mode 100644 index 0000000000..96419242cb --- /dev/null +++ b/backend/app/files/dependencies.py @@ -0,0 +1,6 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) diff --git a/backend/app/files/exceptions.py b/backend/app/files/exceptions.py new file mode 100644 index 0000000000..818349e177 --- /dev/null +++ b/backend/app/files/exceptions.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, status + +FileNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", +) + +FileTooLargeException = HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="File too large", +) + +NoTableFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No table found in the file", +) \ No newline at end of file diff --git a/backend/app/files/models.py b/backend/app/files/models.py new file mode 100644 index 0000000000..54535d1005 --- /dev/null +++ b/backend/app/files/models.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime +from sqlmodel import Field, SQLModel + +from app.ocrs.constants import OcrJobStatus +from app.utils import get_datetime_utc + + +class File(SQLModel, table=True): + __tablename__ = "files" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + filename: str = Field(min_length=1, max_length=255) + content_type: str = Field(min_length=1, max_length=255) + size: int + url: str | None = None + bank: str | None = Field(default=None, max_length=255) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # ty:ignore[invalid-argument-type] + ) + user_id: uuid.UUID = Field( + foreign_key="users.id", nullable=False, ondelete="CASCADE" + ) + +class FileJob(SQLModel, table=True): + __tablename__ = "file_jobs" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_id: str = Field(max_length=255, index=True) + file_id: uuid.UUID = Field(foreign_key="files.id", nullable=False, ondelete="CASCADE") + state: str = Field(default=OcrJobStatus.PENDING, max_length=50) + model: str | None = Field(default=None, max_length=100) + total_pages: int | None = None + extracted_pages: int | None = None + start_time: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) # ty:ignore[invalid-argument-type] + end_time: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) # ty:ignore[invalid-argument-type] + json_url: str | None = Field(default=None, max_length=4000) + markdown_url: str | None = Field(default=None, max_length=4000) + err_msg: str | None = Field(default=None, max_length=500) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # ty:ignore[invalid-argument-type] + ) diff --git a/backend/app/files/router.py b/backend/app/files/router.py new file mode 100644 index 0000000000..a5c5f6fb44 --- /dev/null +++ b/backend/app/files/router.py @@ -0,0 +1,292 @@ +import uuid + +from fastapi import APIRouter, Form, HTTPException, Response, UploadFile +from sqlalchemy import desc +from sqlmodel import select + +from app.aws.client import upload_file_to_r2 +from app.backend_pre_start import logger +from app.files.crud import ( + create_file, + delete_file, + get_file_job_by_file_id, + update_file_job, +) +from app.files.dependencies import CurrentUser, SessionDep +from app.files.models import File, FileJob +from app.files.schemas import ( + FileCreate, + FileJobPublic, + FilePreviewResponse, + FilePublic, + FilesStatusRequest, + FileWithJobPublic, +) +from app.files.service import ( + download_file, + download_file_with_accounting_code, + get_preview_data +) +from app.ocrs.constants import OcrJobStatus, OcrModel +from app.ocrs.service import ( + fetch_ocr_table_pages, + get_ocr_job_status, + post_ocr_jobs, +) + +router = APIRouter(prefix="/files", tags=["files"]) + +@router.get("/models", response_model=list[str]) +def list_ocr_models(): + """List the OCR models a user can choose from when parsing a document.""" + return sorted(OcrModel.ALL) + +@router.post("/", response_model=FilePublic) +def upload_file_endpoint( + session: SessionDep, + user: CurrentUser, + file: UploadFile, # noqa: B008 + model: str | None = Form(default=None), +): + """ + Upload a file to R2/S3 storage. + + `model` selects which PaddleOCR model parses the document. When omitted, + the configured default (settings.OCR_MODEL) is used. + """ + if model is not None and model not in OcrModel.ALL: + raise HTTPException( + status_code=422, + detail=f"Unsupported model '{model}'. Allowed: {sorted(OcrModel.ALL)}", + ) + + file_bytes = file.file.read() + file_name = file.filename or "upload" + file_type = file.content_type or "application/octet-stream" + file_create = FileCreate(filename=file_name, content_type=file_type, size=len(file_bytes), url="") + file_result = create_file(session=session, file_in=file_create, user_id=user.id) + key = user.email + "/" + str(file_result.id) + "/" + file_name + + try: + # upload to r2 + r2_result = upload_file_to_r2( + key=key, # Use DB record ID for unique key + data=file_bytes, + content_type=file.content_type, + presign=True + ) + + # enqueue OCR job + if not r2_result.get("IsSuccess"): + delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + raise HTTPException(status_code=500, detail="Failed to upload file to R2") + + logger.info(f"File {file_result.id} uploaded to R2 successfully, URL: {r2_result['PresignedURL']}") + post_ocr_jobs(session=session, file=file_result, file_url=r2_result["PresignedURL"], model=model) + + return file_result + except Exception as exc: + delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + logger.error(f"Error handling uploaded file {file_name}: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) + +@router.put("/{file_id}", response_model=FileJobPublic) +def update_file_job_status_endpoint( + file_id: uuid.UUID, + job_status: str, + session: SessionDep, +): + """ + Update the job status for a file based on OCR job updates. + """ + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job: + raise HTTPException(status_code=404, detail="FileJob not found") + updated = update_file_job(session=session, file_job=file_job, state=job_status) + return updated + +@router.get("/{file_id}/status", response_model=FileJobPublic) +def get_file_status(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Get the current OCR job status for a file by polling the OCR API. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + + get_ocr_job_status(file=file, session=session, user=user) # Poll & persist latest state + + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job: + raise HTTPException(status_code=404, detail="No job found for this file") + return file_job + +@router.get("/{file_id}/job", response_model=FileJobPublic) +def get_file_job(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Get the FileJob record for a given file, containing detailed OCR progress info. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") + + file_job: FileJob | None = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job: + raise HTTPException(status_code=404, detail="No job found for this file") + return file_job + + +@router.get('/', response_model=list[FileWithJobPublic]) +def list_files(session: SessionDep, user: CurrentUser, skip: int = 0, limit: int = 0): + """ + List all files uploaded by the current user, each enriched with its FileJob. + """ + user_id = user.id + if limit <= 0: + statement = select(File).where(File.user_id == user_id).order_by(desc(File.created_at)) # ty:ignore[invalid-argument-type] + else: + statement = select(File).where(File.user_id == user_id).order_by(desc(File.created_at)).offset(skip).limit(limit) # ty:ignore[invalid-argument-type] + + files = session.exec(statement).all() + + result: list[FileWithJobPublic] = [] + for f in files: + file_job = get_file_job_by_file_id(session=session, file_id=f.id) + + # Refresh any still-processing job by polling the OCR API via its job_id. + if file_job and file_job.state in (OcrJobStatus.PENDING, OcrJobStatus.RUNNING): + try: + get_ocr_job_status(file=f, session=session, user=user) + file_job = get_file_job_by_file_id(session=session, file_id=f.id) + except Exception as exc: + logger.error(f"Error refreshing OCR status for file {f.id}: {exc}") + + job_public: FileJobPublic | None = FileJobPublic.model_validate(file_job) if file_job else None + result.append(FileWithJobPublic.model_validate(f, update={"job": job_public})) + + return result + +@router.post("/{file_id}/download", response_class=Response) +def download_table_excel_file(file_id: uuid.UUID, type: str, session: SessionDep, user: CurrentUser,): + """ + Stream an Excel file built from the OCR result JSON stored in R2. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") + + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + + if not file_job or file_job.state != "done": + raise HTTPException(status_code=400, detail="OCR job is not done yet") + + logger.info(f"Preparing to stream file {file_id} for user {user.email} with requested type {type}") + excel_bytes, content_disposition = download_file(session=session, file=file, type=type) + media_type = { + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "csv": "text/csv", + "json": "application/json", + "html": "text/html", + }.get(type, "application/octet-stream") + + return Response( + content=excel_bytes, + media_type=media_type, + headers={"Content-Disposition": content_disposition}, + ) + + +@router.post("/{file_id}/download/new", response_class=Response) +def download_new_version_excel(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Generate and return a new version of the Excel file created by the standard + download endpoint. This will fetch the existing generated Excel bytes, write + them to a temporary file, call `get_gemini_response_for_file` to produce a + modified xlsx, and stream that back to the client. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job or file_job.state != "done": + raise HTTPException(status_code=400, detail="OCR job is not done yet") + try: + ex_bytes, content_disposition = download_file_with_accounting_code(session=session, file=file, user=user) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return Response( + content=ex_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": content_disposition}, + ) + +@router.post("/batch/status", response_model=list[FileJobPublic]) +def get_files_batch_status( + body: FilesStatusRequest, + session: SessionDep, + user: CurrentUser, +): + """ + Accept a list of file IDs, refresh each file's OCR job status via the OCR API, + and return the updated list of FileJob records. + """ + file_jobs: list[FileJob] = [] + for file_id in body.file_ids: + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail=f"Not authorized to access file {file_id}") + + try: + get_ocr_job_status(file=file, session=session, user=user) + except Exception as exc: + logger.error(f"Error refreshing OCR status for file {file_id}: {exc}") + + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if file_job: + file_jobs.append(file_job) + + return file_jobs + +@router.get("/{file_id}/preview", response_model=FilePreviewResponse) +def preview_file_result(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Fetch the parsed OCR result for a file from its stored ``json_url`` and + return the extracted table as JSON (``columns`` + ``rows``), ready to render + in the front end. This is the same table data the download endpoint exports. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") + + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job or file_job.state != OcrJobStatus.DONE: + raise HTTPException(status_code=400, detail="OCR job is not done yet") + if not file_job.json_url: + raise HTTPException(status_code=400, detail="No result data available for this file") + + try: + columns, rows = get_preview_data(file_job) + except Exception as exc: + logger.error("Failed to fetch OCR preview for file %s: %s", file_id, exc) + raise HTTPException(status_code=502, detail="Failed to load result data") from exc + + return FilePreviewResponse( + file_id=file.id, + filename=file.filename, + columns=columns, + rows=rows, + row_count=len(rows), + markdown_url=file_job.markdown_url, + ) diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py new file mode 100644 index 0000000000..7664499ff1 --- /dev/null +++ b/backend/app/files/schemas.py @@ -0,0 +1,81 @@ +import uuid +from datetime import datetime +from typing import Any + +from sqlmodel import Field, SQLModel + + +class FileBase(SQLModel): + filename: str = Field(min_length=1, max_length=255) + content_type: str = Field(min_length=1, max_length=255) + size: int | None = None + +class FileCreate(FileBase): + url: str | None = None + +class FilePublic(FileBase): + id: uuid.UUID + created_at: datetime | None = None + user_id: uuid.UUID + +class FilesPublic(SQLModel): + data: list[FilePublic] + count: int + + +class FilesStatusRequest(SQLModel): + file_ids: list[uuid.UUID] + + +# --------------------------------------------------------------------------- +# FileJob schemas +# --------------------------------------------------------------------------- + +class FileJobCreate(SQLModel): + job_id: str = Field(max_length=255) + file_id: uuid.UUID + state: str = Field(max_length=50) + model: str | None = Field(default=None, max_length=100) + total_pages: int | None = None + extracted_pages: int | None = None + json_url: str | None = Field(default=None, max_length=4000) + markdown_url: str | None = Field(default=None, max_length=4000) + err_msg: str | None = Field(default=None, max_length=500) + + +class FileJobPublic(SQLModel): + id: uuid.UUID + job_id: str + file_id: uuid.UUID + state: str + model: str | None = None + total_pages: int | None = None + extracted_pages: int | None = None + json_url: str | None = None + markdown_url: str | None = None + err_msg: str | None = None + created_at: datetime | None = None + + +class FileWithJobPublic(FilePublic): + """FilePublic enriched with its associated FileJob (if any).""" + job: FileJobPublic | None = None + + +# --------------------------------------------------------------------------- +# Result preview schemas +# --------------------------------------------------------------------------- + +class FilePreviewResponse(SQLModel): + """Parsed OCR result table for a file, ready to render in the front end. + + ``columns`` is the ordered list of column headers and ``rows`` is the table + content as a list of ``{column: value}`` records — the same data the JSON + download exports, returned inline for previewing. + """ + file_id: uuid.UUID + filename: str + columns: list[str] + rows: list[dict[str, Any]] + row_count: int + markdown_url: str | None = None diff --git a/backend/app/files/service.py b/backend/app/files/service.py new file mode 100644 index 0000000000..e2aabd3677 --- /dev/null +++ b/backend/app/files/service.py @@ -0,0 +1,162 @@ +import io +import json +from io import StringIO +from typing import Any + +import pandas as pd +from google import genai +from pandas.core.frame import DataFrame +from sqlmodel import Session + +from app.api_keys.crud import get_api_key_by_user +from app.files.crud import get_file_job_by_file_id +from app.files.dependencies import CurrentUser +from app.files.models import File, FileJob +from app.files.strategies import DOWNLOAD_STRATEGIES +from app.files.utils import get_df_from_result_json + +model = "gemini-3-flash-preview" + +user_instruction = ( + "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung để xác định giao dịch (Bạn phải tự xác định cột chứa nội dung giao dịch)" + "này thuộc mã tài khoản kế toán nào (mã này được lấy từ thị trường Việt Nam). Sau đó trả ra " + "cho tôi file mới có thêm cột mã tk, và tên tk ở cuối. Nếu nội dung chuyển khoản không chắc chắn, hãy bỏ trống\n\n" + "Dưới đây là nội dung file (nguyên văn). Hãy trả lại CHÍNH XÁC NỘI DUNG file mới dưới dạng CSV hoặc plain text, " + "không thêm giải thích, chú thích hay văn bản khác. Chỉ output nội dung file mới.\n\n" +) + +def download_file(session: Session, file: File, type: str = "xlsx") -> tuple[bytes, str]: + """ + Given a File record, download the file content from its URL and return bytes + and a Content-Disposition header for the requested format. + + The format-specific conversion is delegated to a :class:`DownloadStrategy` + looked up from ``DOWNLOAD_STRATEGIES``. + + Supported types: "xlsx", "csv", "json", "html". + """ + strategy = DOWNLOAD_STRATEGIES.get(type) + if strategy is None: + raise ValueError(f"Unsupported file type requested: {type}") + + file_job = get_file_job_by_file_id(session=session, file_id=file.id) + if not file_job or not file_job.json_url: + raise ValueError("No OCR result available for this file yet.") + + df: DataFrame | None = get_df_from_result_json(file_job.json_url) + if df is None: + df = pd.DataFrame() + + safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename + data_bytes, content_disposition = strategy.convert(df, safe_name) + + return (data_bytes, content_disposition) + +def get_preview_data(file_job: FileJob) -> tuple[list[str], list[dict[str, Any]]]: + """Build the OCR result table for previewing. + + Fetches the parsed OCR result from the job's ``json_url`` and returns it as a + ``(columns, rows)`` tuple: ``columns`` is the ordered list of headers and + ``rows`` is the table content as JSON-serialisable ``{column: value}`` + records (``NaN`` values are normalised to ``None``). This is the same table + the JSON download exports, returned inline rather than as a file. + """ + df: DataFrame | None = get_df_from_result_json(file_job.json_url) + if df is None: + return [], [] + + # Drop the internal page-tracking column used only for debugging exports. + df = df.drop(columns=["__page__"], errors="ignore") + + columns = [str(col) for col in df.columns] + # to_json normalises NaN/NaT to null and keeps unicode intact. + rows = json.loads(df.to_json(orient="records", force_ascii=False) or "[]") + + return columns, rows + + +def get_gemini_response_for_file(input_path: str, output_path: str, *, model: str | None = None) -> None: + """Read a local file (CSV or XLSX), send its contents to Gemini with the Vietnamese + prompt, and write the model's returned contents into `output_path` as XLSX when + the output filename ends with .xlsx. + + Behavior: + - If input is .xlsx or .xls, read with pandas and convert to CSV text for the prompt. + - Otherwise, read the file as text and include it verbatim. + - The prompt explicitly asks Gemini to return only the new file contents (CSV/plain text). + - If the output is requested as .xlsx, the function will attempt to parse the model's + CSV/plain-text response back into a DataFrame and write it to Excel. + If parsing fails, the raw response will be put into a single-cell Excel sheet. + + Prompt used (Vietnamese): + "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung trong cột thứ 2 để xác định giao dịch này thuộc mã tài khoản kế toán nào (mã này được lấy từ thị trường Việt Nam). Sau đó trả ra cho tôi file mới có thêm cột mã tk, và tên tk ở cuối" + + Note: The GEMINI_API_KEY must be set in the environment for `genai.Client()` to authenticate. + """ + + client = genai.Client() + + if model is None: + model = "gemini-3-flash-preview" + + # Load input file. If Excel, convert to CSV text to include in the prompt. + file_ext = input_path.lower().rsplit('.', 1)[-1] if '.' in input_path else '' + if file_ext in ("xlsx", "xls"): + df_in = pd.read_excel(input_path) + file_text = df_in.to_csv(index=False) + else: + # For non-excel files, read as text + with open(input_path, encoding="utf-8") as f: + file_text = f.read() + + # Build prompt that asks the model to return only the file contents (CSV/plain text) + + full_prompt = user_instruction + "---FILE-BEGIN---\n" + file_text + "\n---FILE-END---\n" + + # Send to Gemini + response = client.models.generate_content(model=model, contents=full_prompt) + resp_text = response.text or "" + + # If output path requests xlsx, try to parse response as CSV/plain text and write to Excel + out_ext = output_path.lower().rsplit('.', 1)[-1] if '.' in output_path else '' + if out_ext in ("xlsx", "xls"): + try: + df_out = pd.read_csv(StringIO(resp_text)) + df_out.to_excel(output_path, index=False, engine='openpyxl') + except Exception: + # Fallback: write the raw response into a single-cell sheet + fallback_df = pd.DataFrame({"result": [resp_text]}) + fallback_df.to_excel(output_path, index=False, engine='openpyxl') + else: + # For non-xlsx output, write raw text + with open(output_path, "w", encoding="utf-8") as out_f: + out_f.write(resp_text) + +def download_file_with_accounting_code(session: Session, file: File, user: CurrentUser) -> tuple[bytes, str]: + """ + This is a placeholder for a future function that would download the file with an additional account code column. + The implementation would likely involve calling `get_gemini_response_for_file` to get the modified file content, + then returning the bytes and content disposition for that modified file. + """ + api_key = get_api_key_by_user(session=session, user_id=user.id) # type: ignore[call-arg] + client = genai.Client(api_key=api_key.key) + file_job = get_file_job_by_file_id(session=session, file_id=file.id) + + if not file_job or not file_job.json_url: + raise ValueError("No OCR result available for this file yet.") + + df: DataFrame | None = get_df_from_result_json(file_job.json_url) + if df is None: + raise ValueError("No tables found in OCR result.") + file_text = df.to_csv(index=False) + + full_prompt = user_instruction + "---FILE-BEGIN---\n" + file_text + "\n---FILE-END---\n" + response = client.models.generate_content(model=model, contents=full_prompt) + resp_text = response.text or "" + + df_out = pd.read_csv(StringIO(resp_text)) + output = io.BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] # ty:ignore[invalid-argument-type] + df_out.to_excel(writer, index=False, sheet_name="OCR Tables with Account Codes") + + return output.getvalue(), DOWNLOAD_STRATEGIES["xlsx"].get_content_disposition(f"{file.filename.rsplit('.', 1)[0]}_with_account_codes.xlsx") \ No newline at end of file diff --git a/backend/app/files/strategies.py b/backend/app/files/strategies.py new file mode 100644 index 0000000000..1ad5b29eb9 --- /dev/null +++ b/backend/app/files/strategies.py @@ -0,0 +1,96 @@ +""" +Download format strategies for the Strategy pattern used in `download_file`. + +Each strategy encapsulates the logic for converting a pandas DataFrame into bytes +for a specific file format, along with the appropriate filename suffix. +""" + +import io +from abc import ABC, abstractmethod +from io import StringIO + +import pandas as pd +from pandas.core.frame import DataFrame + + +class DownloadStrategy(ABC): + """Abstract base class for file download format strategies.""" + + @abstractmethod + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + """ + Convert a DataFrame to bytes in the target format. + + Args: + df: The pandas DataFrame to convert. + safe_name: The base filename (without extension) to use. + + Returns: + A tuple of (file_bytes, filename). + """ + ... + + def encode_filename(self, filename: str) -> str: + """Helper method to percent-encode a UTF-8 filename for Content-Disposition.""" + from urllib.parse import quote + return quote(filename, safe="") + + def get_content_disposition(self, filename: str) -> str: + """Generate a Content-Disposition header value with both filename and filename*. + + The legacy ``filename`` parameter is restricted to ASCII/latin-1 so that + HTTP headers remain valid regardless of the server encoding. The full + Unicode filename is carried by the RFC 5987 ``filename*`` parameter. + """ + encoded_filename = self.encode_filename(filename) + # Strip / replace non-ASCII chars for the legacy filename= field so the + # header value never triggers a UnicodeEncodeError in latin-1. + ascii_filename = filename.encode("ascii", errors="replace").decode("ascii") + return ( + f"attachment; filename=\"{ascii_filename}\"; " + f"filename*=UTF-8''{encoded_filename}" + ) + + +class XlsxDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as an Excel (.xlsx) file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + output = io.BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] # ty:ignore[invalid-argument-type] + df.to_excel(writer, index=False, sheet_name="OCR Tables") + return output.getvalue(), self.get_content_disposition(f"{safe_name}_tables.xlsx") + + +class CsvDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as a CSV file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + output = StringIO() + df.to_csv(output, index=False) + return output.getvalue().encode("utf-8"), self.get_content_disposition(f"{safe_name}_tables.csv") + + +class JsonDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as a JSON file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + text = df.to_json(orient="records", force_ascii=False) or "" + return text.encode("utf-8"), self.get_content_disposition(f"{safe_name}_tables.json") + + +class HtmlDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as an HTML table file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + text = df.to_html(index=False) or "" + return text.encode("utf-8"), self.get_content_disposition(f"{safe_name}_tables.html") + + +# Registry mapping type strings to strategy instances +DOWNLOAD_STRATEGIES: dict[str, DownloadStrategy] = { + "xlsx": XlsxDownloadStrategy(), + "csv": CsvDownloadStrategy(), + "json": JsonDownloadStrategy(), + "html": HtmlDownloadStrategy(), +} diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py new file mode 100644 index 0000000000..7f4731268b --- /dev/null +++ b/backend/app/files/utils.py @@ -0,0 +1,61 @@ +import json +from io import StringIO + +import pandas as pd +import requests +from pandas import DataFrame + + +def extract_tables_from_ocr(data) -> pd.DataFrame: + all_dfs: list[DataFrame] = [] + + pages = data["result"]["layoutParsingResults"] + + for page_idx, page in enumerate(pages): + blocks = page.get("prunedResult", {}).get("parsing_res_list", []) + + for block in blocks: + if block.get("block_label") == "table": + html = block.get("block_content") + + try: + dfs = pd.read_html(StringIO(html)) + for df in dfs: + df["__page__"] = page_idx + 1 # debug tracking + all_dfs.append(df) + except Exception as e: + pass + + merged = pd.concat(all_dfs, ignore_index=True) + + return merged + + +def _extract_tables_from_ndjson(text: str) -> DataFrame | None: + """Parse NDJSON (one JSON object per line) and extract all tables.""" + all_dfs: list[DataFrame] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + data = json.loads(line) + try: + df = extract_tables_from_ocr(data) + all_dfs.append(df) + except Exception: + pass + if all_dfs: + return pd.concat(all_dfs, ignore_index=True) + + return None + + +def get_df_from_result_json(url) -> DataFrame | None: + res = requests.get(url) + res.raise_for_status() + return _extract_tables_from_ndjson(res.text) + + +def get_df_from_json_bytes(json_bytes: bytes) -> DataFrame | None: + text = json_bytes.decode("utf-8") + return _extract_tables_from_ndjson(text) diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/helpers/s3.py b/backend/app/helpers/s3.py new file mode 100644 index 0000000000..8469c92af5 --- /dev/null +++ b/backend/app/helpers/s3.py @@ -0,0 +1,2 @@ +# Backwards-compatibility shim – S3 client now lives in app.aws.client +from app.aws.client import generate_presigned_put_url, get_s3_client, upload_file_to_r2 as upload_r2_file # noqa: F401 diff --git a/backend/app/items/__init__.py b/backend/app/items/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/items/constants.py b/backend/app/items/constants.py new file mode 100644 index 0000000000..c13e8e12c3 --- /dev/null +++ b/backend/app/items/constants.py @@ -0,0 +1 @@ +MAX_ITEMS_PER_PAGE = 100 diff --git a/backend/app/items/dependencies.py b/backend/app/items/dependencies.py new file mode 100644 index 0000000000..96419242cb --- /dev/null +++ b/backend/app/items/dependencies.py @@ -0,0 +1,6 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) diff --git a/backend/app/items/exceptions.py b/backend/app/items/exceptions.py new file mode 100644 index 0000000000..8a008012b7 --- /dev/null +++ b/backend/app/items/exceptions.py @@ -0,0 +1,11 @@ +from fastapi import HTTPException, status + +ItemNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Item not found", +) + +InsufficientPermissionsException = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", +) diff --git a/backend/app/items/models.py b/backend/app/items/models.py new file mode 100644 index 0000000000..13a8e3e1b3 --- /dev/null +++ b/backend/app/items/models.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from app.utils import get_datetime_utc +import uuid +from datetime import datetime +from sqlalchemy import DateTime +from sqlmodel import Field, SQLModel + +class Item(SQLModel, table=True): + __tablename__ = "items" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + user_id: uuid.UUID = Field( + foreign_key="users.id", nullable=False, ondelete="CASCADE" + ) + # owner: User | None = Relationship(back_populates="items") diff --git a/backend/app/items/router.py b/backend/app/items/router.py new file mode 100644 index 0000000000..c5c9cbcb8f --- /dev/null +++ b/backend/app/items/router.py @@ -0,0 +1,108 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import col, func, select + +from app.auth.dependencies import CurrentUser, SessionDep +from app.items.models import Item +from app.items.schemas import ItemCreate, ItemPublic, ItemsPublic, ItemUpdate +from app.items.service import create_item, update_item +from app.users.schemas import 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 = 1#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() + pass + + return ItemsPublic(data=[], count=1) # ty:ignore[invalid-argument-type] + + +@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.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + return item + + +@router.post("/", response_model=ItemPublic) +def create_item_endpoint( + *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate +) -> Any: + """ + Create new item. + """ + item = create_item(session=session, item_in=item_in, user_id=current_user.id) + return item + + +@router.put("/{id}", response_model=ItemPublic) +def update_item_endpoint( + *, + 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.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + item = update_item(session=session, db_item=item, item_in=item_in) + 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.user_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/items/schemas.py b/backend/app/items/schemas.py new file mode 100644 index 0000000000..72d1e3d1d3 --- /dev/null +++ b/backend/app/items/schemas.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlmodel import Field, SQLModel + + +# Shared properties +class ItemBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + + +# Properties to receive on item creation +class ItemCreate(ItemBase): + pass + + +# Properties to receive on item update +class ItemUpdate(ItemBase): + title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore[assignment] + + +# Properties to return via API, id is always required +class ItemPublic(ItemBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime | None = None + + +class ItemsPublic(SQLModel): + data: list[ItemPublic] + count: int diff --git a/backend/app/items/service.py b/backend/app/items/service.py new file mode 100644 index 0000000000..545822c3d7 --- /dev/null +++ b/backend/app/items/service.py @@ -0,0 +1,23 @@ +import uuid + +from sqlmodel import Session + +from app.items.models import Item +from app.items.schemas import ItemCreate, ItemUpdate + + +def create_item(*, session: Session, item_in: ItemCreate, user_id: uuid.UUID) -> Item: + db_item = Item.model_validate(item_in, update={"user_id": user_id}) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item + + +def update_item(*, session: Session, db_item: Item, item_in: ItemUpdate) -> Item: + update_dict = item_in.model_dump(exclude_unset=True) + db_item.sqlmodel_update(update_dict) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item diff --git a/backend/app/items/utils.py b/backend/app/items/utils.py new file mode 100644 index 0000000000..528bfb7b68 --- /dev/null +++ b/backend/app/items/utils.py @@ -0,0 +1 @@ +# Item-related utility functions diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..5caacd8c32 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +from typing import Any, cast + import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute @@ -10,7 +12,6 @@ def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" - if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) @@ -20,14 +21,14 @@ def custom_generate_unique_id(route: APIRoute) -> str: generate_unique_id_function=custom_generate_unique_id, ) -# Set all CORS enabled origins -if settings.all_cors_origins: - app.add_middleware( - CORSMiddleware, - allow_origins=settings.all_cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) +# Allow all CORS origins (useful for local development). If you want to restrict +# origins in production, change this to use `settings.all_cors_origins` instead. +app.add_middleware( + cast(Any, CORSMiddleware), + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..79473e9662 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,129 +1,49 @@ -import uuid -from datetime import datetime, timezone - -from pydantic import EmailStr -from sqlalchemy import DateTime -from sqlmodel import Field, Relationship, SQLModel - - -def get_datetime_utc() -> datetime: - return datetime.now(timezone.utc) - - -# Shared properties -class UserBase(SQLModel): - email: EmailStr = Field(unique=True, index=True, max_length=255) - is_active: bool = True - is_superuser: bool = False - full_name: str | None = Field(default=None, max_length=255) - - -# Properties to receive via API on creation -class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=128) - - -class UserRegister(SQLModel): - email: EmailStr = Field(max_length=255) - password: str = Field(min_length=8, max_length=128) - full_name: str | None = Field(default=None, max_length=255) - - -# Properties to receive via API on update, all are optional -class UserUpdate(UserBase): - email: EmailStr | None = Field(default=None, max_length=255) # type: ignore - password: str | None = Field(default=None, min_length=8, max_length=128) - - -class UserUpdateMe(SQLModel): - full_name: str | None = Field(default=None, max_length=255) - email: EmailStr | None = Field(default=None, max_length=255) - - -class UpdatePassword(SQLModel): - current_password: str = Field(min_length=8, max_length=128) - new_password: str = Field(min_length=8, max_length=128) - - -# Database model, database table inferred from class name -class User(UserBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - hashed_password: str - created_at: datetime | None = Field( - default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore - ) - items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) - - -# Properties to return via API, id is always required -class UserPublic(UserBase): - id: uuid.UUID - created_at: datetime | None = None - - -class UsersPublic(SQLModel): - data: list[UserPublic] - count: int - - -# Shared properties -class ItemBase(SQLModel): - title: str = Field(min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) - - -# Properties to receive on item creation -class ItemCreate(ItemBase): - pass - - -# Properties to receive on item update -class ItemUpdate(ItemBase): - title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore - - -# Database model, database table inferred from class name -class Item(ItemBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - created_at: datetime | None = Field( - default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore - ) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" - ) - owner: User | None = Relationship(back_populates="items") - - -# Properties to return via API, id is always required -class ItemPublic(ItemBase): - id: uuid.UUID - owner_id: uuid.UUID - created_at: datetime | None = None - - -class ItemsPublic(SQLModel): - data: list[ItemPublic] - count: int - - -# Generic message -class Message(SQLModel): - message: str - - -# JSON payload containing access token -class Token(SQLModel): - access_token: str - token_type: str = "bearer" - - -# Contents of JWT token -class TokenPayload(SQLModel): - sub: str | None = None - - -class NewPassword(SQLModel): - token: str - new_password: str = Field(min_length=8, max_length=128) +""" +Backwards-compatibility shim. +All models now live in the per-domain model modules: + - app.users.models / app.users.schemas + - app.items.models / app.items.schemas + - app.files.models / app.files.schemas + - app.storages.models / app.storages.schemas + - app.auth.schemas +""" +from sqlmodel import SQLModel # noqa: F401 + +from app.api_keys.models import ApiKey # noqa: F401 +from app.auth.schemas import NewPassword, Token, TokenPayload # noqa: F401 +from app.files.models import File, FileJob # noqa: F401 +from app.files.schemas import ( # noqa: F401 + FileBase, + FileCreate, + FilePublic, + FilesPublic, +) +from app.items.models import Item # noqa: F401 +from app.items.schemas import ( # noqa: F401 + ItemBase, + ItemCreate, + ItemPublic, + ItemsPublic, + ItemUpdate, +) +from app.storages.models import UserStorageStat # noqa: F401 +from app.storages.schemas import ( # noqa: F401 + UserStorageStatPublic, + UserStorageStatUpdate, +) +from app.topup.models import TopupTransaction, UserBalance # noqa: F401 +from app.topup.schemas import ( # noqa: F401 + TopupTransactionPublic, + UserBalancePublic, +) +from app.users.models import User # noqa: F401 +from app.users.schemas import ( # noqa: F401 + Message, + UpdatePassword, + UserCreate, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) diff --git a/backend/app/ocrs/constants.py b/backend/app/ocrs/constants.py new file mode 100644 index 0000000000..6a1b7587b7 --- /dev/null +++ b/backend/app/ocrs/constants.py @@ -0,0 +1,23 @@ +class OcrJobStatus: + PENDING = "pending" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +class OcrModel: + """Supported PaddleOCR parsing models (values sent to the OCR API).""" + + PADDLEOCR_VL_1_6 = "PaddleOCR-VL-1.6" + PADDLEOCR_VL_1_5 = "PaddleOCR-VL-1.5" + PP_OCRV6 = "PP-OCRv6" + PP_OCRV5 = "PP-OCRv5" + PP_STRUCTURE_V3 = "PP-StructureV3" + + ALL: set[str] = { + PADDLEOCR_VL_1_6, + PADDLEOCR_VL_1_5, + PP_OCRV6, + PP_OCRV5, + PP_STRUCTURE_V3, + } diff --git a/backend/app/ocrs/dependencies.py b/backend/app/ocrs/dependencies.py new file mode 100644 index 0000000000..ecac90d4ce --- /dev/null +++ b/backend/app/ocrs/dependencies.py @@ -0,0 +1,4 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, +) diff --git a/backend/app/ocrs/schemas.py b/backend/app/ocrs/schemas.py new file mode 100644 index 0000000000..e24e9384fb --- /dev/null +++ b/backend/app/ocrs/schemas.py @@ -0,0 +1,82 @@ +from pydantic import BaseModel + + +# --------------------------------------------------------------------------- +# Nested models +# --------------------------------------------------------------------------- + +class ExtractProgress(BaseModel): + totalPages: int | None = None + extractedPages: int + startTime: str | None = None + endTime: str | None = None + + +class ResultUrl(BaseModel): + jsonUrl: str | None = None + markdownUrl: str | None = None + + +# --------------------------------------------------------------------------- +# Submit-job response (POST /jobs) +# --------------------------------------------------------------------------- + +class OcrSubmitData(BaseModel): + jobId: str + + +class OcrSubmitResponse(BaseModel): + traceId: str | None = None + code: int | None = None + msg: str | None = None + data: OcrSubmitData + + def is_success(self) -> bool: + return self.code == 0 + + +# --------------------------------------------------------------------------- +# Job-level response (GET /jobs/{jobId}) +# --------------------------------------------------------------------------- + +class OcrJobData(BaseModel): + jobId: str + state: str # OcrJobStatus values: pending | running | done | failed + extractProgress: ExtractProgress | None = None + resultUrl: ResultUrl | None = None + errorMsg: str | None = None + + +class OcrJobResponse(BaseModel): + traceId: str | None = None + code: int | None = None + msg: str | None = None + data: OcrJobData + + def is_success(self) -> bool: + return self.code == 0 + + +# --------------------------------------------------------------------------- +# Batch-job response (GET /jobs/batch/{batchId}) +# --------------------------------------------------------------------------- + +class OcrBatchExtractResult(BaseModel): + jobId: str + state: str # pending | running | done | failed + extractProgress: ExtractProgress | None = None + resultUrl: ResultUrl | None = None + errorMsg: str | None = None + + +class OcrBatchData(BaseModel): + batchId: str + extractResult: list[OcrBatchExtractResult] + + +class OcrBatchResponse(BaseModel): + traceId: str | None = None + code: int | None = None + msg: str | None = None + data: OcrBatchData + diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py new file mode 100644 index 0000000000..08a74ed445 --- /dev/null +++ b/backend/app/ocrs/service.py @@ -0,0 +1,221 @@ +import json +import logging +from typing import Any + +import requests +from sqlmodel import Session + +from app.aws.client import upload_file_to_r2 +from app.core.config import settings +from app.files.crud import ( + create_file_job, + get_file_job_by_file_id, + update_file_job, +) +from app.files.models import File, FileJob +from app.files.schemas import FileJobCreate +from app.ocrs.constants import OcrJobStatus +from app.ocrs.dependencies import CurrentUser, SessionDep +from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse +from app.storages.service import increment_storage_stat +from app.utils import get_bytes_from_file_url + +logger = logging.getLogger(__name__) + +headers = { + "Authorization": f"bearer {settings.OCR_API_TOKEN}", + "Content-Type": "application/json", +} + +optional_payload = { + "useDocOrientationClassify": False, + "useDocUnwarping": False, + "useChartRecognition": False, +} + +def post_ocr_jobs( + session: Session, file: File, file_url: str, model: str | None = None +) -> tuple[bool, str | None]: + """ + Submit an OCR job for the given file URL and create a FileJob record. + Only posts the job — polling is handled separately. + """ + + selected_model = model or settings.OCR_MODEL + payload = { + "fileUrl": file_url, + "model": selected_model, + "optionalPayload": optional_payload, + } + + raw = requests.post(str(settings.OCR_JOB_URL), json=payload, headers=headers) + raw.raise_for_status() + logger.info("Submitted OCR job for file %s, response: %s", file.id, raw.text) + submit_response = OcrSubmitResponse.model_validate(raw.json()) + is_success = submit_response.is_success() + if not is_success: + create_file_job( + session=session, + file_job_in=FileJobCreate( + file_id=file.id, + state=OcrJobStatus.FAILED, + model=selected_model, + err_msg=submit_response.msg, + ), + ) + logger.error("Failed to submit OCR job for file %s: %s - %s", file.id, submit_response.code, submit_response.msg) + return (False, None) + + job_id = submit_response.data.jobId + logger.info("OCR job submitted successfully for file %s, job_id: %s", file.id, job_id) + + # Create a FileJob record to track this job + create_file_job( + session=session, + file_job_in=FileJobCreate( + job_id=job_id, + file_id=file.id, + state=OcrJobStatus.RUNNING, + model=selected_model, + ), + ) + + return (is_success, job_id) + + +def get_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> str | None: + """ + Poll the OCR API for job results. Reads job_id/state from the FileJob record. + """ + file_job: FileJob | None = get_file_job_by_file_id(session=session, file_id=file.id) + + if not file_job: + logger.error("File %s has no FileJob record", file.id) + raise Exception("No FileJob record for this file") + + if file_job.state in (OcrJobStatus.DONE, OcrJobStatus.FAILED): + return file_job.state + + req_headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} + raw = requests.get(f"{settings.OCR_JOB_URL}/{file_job.job_id}", headers=req_headers) + assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" + + result: OcrJobResponse = OcrJobResponse.model_validate(raw.json()) + if not result.is_success(): + logger.error("Error fetching OCR job status for job_id %s: %s", file_job.job_id, result.msg) + update_file_job(session=session, file_job=file_job, state=OcrJobStatus.FAILED, err_msg=result.msg) + raise Exception(f"OCR API error: {result.msg}") + + state = result.data.state + + if state == OcrJobStatus.RUNNING and file_job.state == OcrJobStatus.PENDING: + update_file_job(session=session, file_job=file_job, state=OcrJobStatus.RUNNING) + + elif state == OcrJobStatus.DONE: + logger.info("OCR job %s completed successfully.", file_job.job_id) + extract = result.data.extractProgress + result_url = result.data.resultUrl + update_file_job( + session=session, + file_job=file_job, + state=OcrJobStatus.DONE, + total_pages=extract.totalPages if extract else None, + extracted_pages=extract.extractedPages if extract else None, + json_url=result_url.jsonUrl if result_url else None, + markdown_url=result_url.markdownUrl if result_url else None, + ) + upload_ocr_job_result(user=user, file=file, result=result, session=session) + + elif state == OcrJobStatus.FAILED: + logger.error("OCR job %s failed: %s", file_job.job_id, result.data.errorMsg) + update_file_job(session=session, file_job=file_job, state=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) + + return state + + +def fetch_ocr_table_pages(json_url: str) -> list[str]: + """ + Fetch the parsed OCR result from its data URL (``json_url``) and return the + extracted table(s) for each page as HTML — the same table data the download + endpoint turns into a spreadsheet (see ``app.files.utils``). + + The result file is JSON Lines — one JSON record per page. Tables live under + ``result.layoutParsingResults[*].prunedResult.parsing_res_list`` as blocks + whose ``block_label`` is ``"table"`` and whose ``block_content`` is HTML. + """ + raw = get_bytes_from_file_url(json_url).decode("utf-8") + pages: list[str] = [] + for record in _parse_result_records(raw): + html = _extract_tables(record) + if html.strip(): + pages.append(html) + return pages + + +def _parse_result_records(raw: str) -> list[Any]: + """Parse the result payload as a single JSON value or as JSON Lines.""" + text = raw.strip() + if not text: + return [] + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, list) else [parsed] + except json.JSONDecodeError: + pass + + records: list[Any] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning("Skipping malformed OCR result line") + return records + + +def _extract_tables(record: Any) -> str: + """Pull the table block(s) out of one page record as HTML. + + Mirrors the table lookup in ``app.files.utils.extract_tables_from_ocr``: each + record wraps its payload under ``result``, whose ``layoutParsingResults`` hold + ``prunedResult.parsing_res_list`` blocks; table blocks carry their HTML in + ``block_content``. + """ + if not isinstance(record, dict): + return "" + if isinstance(record.get("result"), dict): + record = record["result"] + results = record.get("layoutParsingResults") or record.get("ocrResults") or [record] + if not isinstance(results, list): + results = [results] + + tables: list[str] = [] + for item in results: + if not isinstance(item, dict): + continue + pruned = item.get("prunedResult") if isinstance(item.get("prunedResult"), dict) else {} + for block in pruned.get("parsing_res_list", []): + if not isinstance(block, dict) or block.get("block_label") != "table": + continue + content = block.get("block_content") + if isinstance(content, str) and content.strip(): + tables.append(content) + return "\n".join(tables) + + +def upload_ocr_job_result(user: CurrentUser, file: File, result: OcrJobResponse, session: SessionDep): + key = f"{user.email}/{file.id}/result.json" + (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) + logger.info(f"Uploading OCR job result for file {file.id} to R2, json_url: {json_url}, md_url: {md_url}") + if json_url: + upload_file_to_r2(key=key, data=get_bytes_from_file_url(json_url), content_type="application/json") + + increment_storage_stat( + session=session, + user_id=user.id, + size_delta=file.size, + total_pages_delta=result.data.extractProgress.extractedPages, # ty:ignore[unresolved-attribute] + file_count_delta=1 + ) diff --git a/backend/app/storages/__init__.py b/backend/app/storages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/storages/dependencies.py b/backend/app/storages/dependencies.py new file mode 100644 index 0000000000..96419242cb --- /dev/null +++ b/backend/app/storages/dependencies.py @@ -0,0 +1,6 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) diff --git a/backend/app/storages/exceptions.py b/backend/app/storages/exceptions.py new file mode 100644 index 0000000000..719837ee62 --- /dev/null +++ b/backend/app/storages/exceptions.py @@ -0,0 +1,6 @@ +from fastapi import HTTPException, status + +StorageStatNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Storage stat not found", +) diff --git a/backend/app/storages/models.py b/backend/app/storages/models.py new file mode 100644 index 0000000000..08aa6331c0 --- /dev/null +++ b/backend/app/storages/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime +from sqlmodel import Field, SQLModel + +from app.utils import get_datetime_utc + + +class UserStorageStat(SQLModel, table=True): + """Tracks per-user file usage statistics.""" + + __tablename__ = "user_storage_stats" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="users.id", + nullable=False, + ondelete="CASCADE", + unique=True, + index=True, + ) + file_count: int = Field(default=0, ge=0) + total_size: int = Field( + default=0, ge=0, description="Total size of all files in bytes" + ) + total_cost: float = Field( + default=0.0, ge=0.0, description="Accumulated cost in USD" + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + total_transactions: int | None = Field(default=0, ge=0, description="Total number of transactions") + total_pages: int = Field(default=0, ge=0, description="Total number of pages processed") \ No newline at end of file diff --git a/backend/app/storages/router.py b/backend/app/storages/router.py new file mode 100644 index 0000000000..22d48d6e68 --- /dev/null +++ b/backend/app/storages/router.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from app.storages.dependencies import CurrentUser, SessionDep +from app.storages.schemas import UserStorageStatPublic +from app.storages.service import get_or_create_storage_stat +from app.topup.service import get_balance + +router = APIRouter(prefix="/storages", tags=["storages"]) + + +@router.get("/me", response_model=UserStorageStatPublic) +def get_my_storage_stat(session: SessionDep, current_user: CurrentUser): + """Return the storage statistics for the current user.""" + stat = get_or_create_storage_stat(session=session, user_id=current_user.id) + user_balance = get_balance(session=session, user_id=current_user.id) + + session.refresh(stat) + + return {**stat.model_dump(), "balance": user_balance.balance} diff --git a/backend/app/storages/schemas.py b/backend/app/storages/schemas.py new file mode 100644 index 0000000000..a1c5801ffa --- /dev/null +++ b/backend/app/storages/schemas.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime + +from sqlmodel import SQLModel + + +class UserStorageStatPublic(SQLModel): + id: uuid.UUID | None = None + user_id: uuid.UUID | None + file_count: int | None = None + total_size: int | None = None + total_cost: float | None = None + updated_at: datetime | None = None + total_transactions: int | None = None + total_pages: int | None = None + balance: float = 0.0 + +class UserStorageStatUpdate(SQLModel): + file_count: int | None = None + total_size: int | None = None + total_cost: float | None = None + total_transactions: int | None = None + total_pages: int | None = None diff --git a/backend/app/storages/service.py b/backend/app/storages/service.py new file mode 100644 index 0000000000..b69bbcdf5e --- /dev/null +++ b/backend/app/storages/service.py @@ -0,0 +1,73 @@ +import uuid + +from sqlmodel import Session, select + +from app.storages.models import UserStorageStat +from app.storages.schemas import UserStorageStatUpdate +from app.utils import get_datetime_utc + + +def get_storage_stat(*, session: Session, user_id: uuid.UUID) -> UserStorageStat | None: + return session.exec( + select(UserStorageStat).where(UserStorageStat.user_id == user_id) + ).first() + + +def get_or_create_storage_stat( + *, session: Session, user_id: uuid.UUID +) -> UserStorageStat: + stat = get_storage_stat(session=session, user_id=user_id) + if not stat: + stat = UserStorageStat(user_id=user_id) + session.add(stat) + session.commit() + session.refresh(stat) + return stat + + +def update_storage_stat( + *, session: Session, user_id: uuid.UUID, stat_in: UserStorageStatUpdate +) -> UserStorageStat: + stat = get_or_create_storage_stat(session=session, user_id=user_id) + update_data = stat_in.model_dump(exclude_unset=True) + update_data.setdefault("file_count", stat.file_count + 1) + stat.sqlmodel_update(update_data) + stat.updated_at = get_datetime_utc() + session.add(stat) + session.commit() + session.refresh(stat) + return stat + + +def increment_storage_stat( + *, session: Session, + user_id: uuid.UUID, + size_delta: int = 0, + cost_delta: float = 0.0, + total_pages_delta: int = 0, + file_count_delta: int = 0, +) -> UserStorageStat: + stat = get_or_create_storage_stat(session=session, user_id=user_id) + stat.file_count += file_count_delta + stat.total_size += size_delta + stat.total_cost += cost_delta + stat.total_pages += total_pages_delta + stat.updated_at = get_datetime_utc() + session.add(stat) + session.commit() + session.refresh(stat) + return stat + + +def decrement_storage_stat( + *, session: Session, user_id: uuid.UUID, size_delta: int = 0, cost_delta: float = 0.0 +) -> UserStorageStat: + stat = get_or_create_storage_stat(session=session, user_id=user_id) + stat.file_count = max(0, stat.file_count - 1) + stat.total_size = max(0, stat.total_size - size_delta) + stat.total_cost = max(0.0, stat.total_cost - cost_delta) + stat.updated_at = get_datetime_utc() + session.add(stat) + session.commit() + session.refresh(stat) + return stat diff --git a/backend/app/storages/utils.py b/backend/app/storages/utils.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/topup/__init__.py b/backend/app/topup/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/topup/constants.py b/backend/app/topup/constants.py new file mode 100644 index 0000000000..5483c319a0 --- /dev/null +++ b/backend/app/topup/constants.py @@ -0,0 +1,22 @@ +from typing import TypedDict + + +class TopupPackageDict(TypedDict): + id: str + amount: int + label: str + + +TOPUP_PACKAGES: list[TopupPackageDict] = [ + {"id": "20k", "amount": 20_000, "label": "20,000 VND"}, + {"id": "50k", "amount": 50_000, "label": "50,000 VND"}, + {"id": "100k", "amount": 100_000, "label": "100,000 VND"}, + {"id": "200k", "amount": 200_000, "label": "200,000 VND"}, + {"id": "500k", "amount": 500_000, "label": "500,000 VND"}, + {"id": "1000k", "amount": 1_000_000, "label": "1,000,000 VND"}, + {"id": "2000k", "amount": 2_000_000, "label": "2,000,000 VND"}, + {"id": "5000k", "amount": 5_000_000, "label": "5,000,000 VND"}, + {"id": "10000k", "amount":10_000_000, "label":"10,000,000 VND"}, +] + +ALLOWED_AMOUNTS: frozenset[int] = frozenset(p["amount"] for p in TOPUP_PACKAGES) diff --git a/backend/app/topup/crud.py b/backend/app/topup/crud.py new file mode 100644 index 0000000000..7794bb95db --- /dev/null +++ b/backend/app/topup/crud.py @@ -0,0 +1,105 @@ +"""CRUD helpers for topup transactions and user balance.""" +from __future__ import annotations + +import uuid + +from sqlmodel import Session, select + +from app.topup.models import TopupStatus, TopupTransaction, TopupType, UserBalance +from app.utils import get_datetime_utc + +# --------------------------------------------------------------------------- +# UserBalance +# --------------------------------------------------------------------------- + + +def get_or_create_balance(session: Session, user_id: uuid.UUID) -> UserBalance: + """Return the UserBalance row for *user_id*, creating it if absent.""" + balance = session.exec( + select(UserBalance).where(UserBalance.user_id == user_id) + ).first() + if balance is None: + balance = UserBalance(user_id=user_id, balance=0.0) + session.add(balance) + session.flush() + return balance + + +# --------------------------------------------------------------------------- +# TopupTransaction +# --------------------------------------------------------------------------- + + +def create_transaction( + session: Session, + *, + user_id: uuid.UUID, + amount: float, + type: TopupType, + txn_ref: str | None = None, + note: str | None = None, + status: TopupStatus = TopupStatus.PENDING, +) -> TopupTransaction: + txn = TopupTransaction( + user_id=user_id, + amount=amount, + type=type, + txn_ref=txn_ref, + note=note, + status=status, + ) + session.add(txn) + session.flush() + return txn + + +def mark_transaction( + session: Session, + txn: TopupTransaction, + status: TopupStatus, +) -> TopupTransaction: + txn.status = status + session.add(txn) + session.flush() + return txn + + +def get_transaction_by_txn_ref( + session: Session, txn_ref: str +) -> TopupTransaction | None: + return session.exec( + select(TopupTransaction).where(TopupTransaction.txn_ref == txn_ref) + ).first() + + +def get_user_transactions( + session: Session, + user_id: uuid.UUID, + skip: int = 0, + limit: int = 50, +) -> list[TopupTransaction]: + return list( + session.exec( + select(TopupTransaction) + .where(TopupTransaction.user_id == user_id) + .offset(skip) + .limit(limit) + ).all() + ) + + +def apply_balance_change( + session: Session, + balance: UserBalance, + amount: float, + type: TopupType, +) -> UserBalance: + """Add or subtract *amount* from the balance row.""" + if type == TopupType.CREDIT: + balance.balance += amount + else: + balance.balance = max(0.0, balance.balance - amount) + balance.updated_at = get_datetime_utc() + session.add(balance) + session.flush() + return balance diff --git a/backend/app/topup/models.py b/backend/app/topup/models.py new file mode 100644 index 0000000000..ecdde18ff8 --- /dev/null +++ b/backend/app/topup/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import DateTime +from sqlmodel import Field, SQLModel + +from app.utils import get_datetime_utc + + +class TopupType(str, Enum): + CREDIT = "credit" # balance added (successful payment) + DEBIT = "debit" # balance deducted (service charge, refund, etc.) + + +class TopupStatus(str, Enum): + PENDING = "pending" + SUCCESS = "success" + FAILED = "failed" + + +class UserBalance(SQLModel, table=True): + """Tracks the current balance for each user.""" + + __tablename__ = "user_balances" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="users.id", + nullable=False, + ondelete="CASCADE", + unique=True, + index=True, + ) + balance: float = Field(default=0.0, ge=0.0, description="Current balance in VND") + updated_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + + +class TopupTransaction(SQLModel, table=True): + """Records every balance change (credit or debit).""" + + __tablename__ = "topup_transactions" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="users.id", + nullable=False, + ondelete="CASCADE", + index=True, + ) + # Payment gateway transaction reference (e.g. VNPAY txn_ref) + txn_ref: str | None = Field(default=None, max_length=100, index=True) + amount: float = Field(description="Transaction amount in VND (always positive)") + type: TopupType = Field(description="credit = add balance, debit = deduct balance") + status: TopupStatus = Field(default=TopupStatus.PENDING) + note: str | None = Field(default=None, max_length=500) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) diff --git a/backend/app/topup/router.py b/backend/app/topup/router.py new file mode 100644 index 0000000000..1a54d935b5 --- /dev/null +++ b/backend/app/topup/router.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import time +from typing import Any + +from fastapi import APIRouter, HTTPException, Request + +from app.auth.dependencies import CurrentUser, SessionDep +from app.core.config import settings +from app.topup.constants import ALLOWED_AMOUNTS, TOPUP_PACKAGES +from app.topup.schemas import ( + CreatePaymentRequest, + CreatePaymentResponse, + PaymentReturnResponse, + TopupPackage, + TopupPackagesResponse, + TopupTransactionPublic, + UserBalancePublic, +) +from app.topup.service import ( + create_topup_payment_url, + get_balance, + get_transaction_history, + handle_ipn, + handle_payment_return, +) + +router = APIRouter(prefix="/topup", tags=["topup"]) + + +@router.get("/packages", response_model=TopupPackagesResponse) +def get_topup_packages(_current_user: CurrentUser) -> Any: + """Return the list of available top-up packages.""" + return TopupPackagesResponse( + packages=[TopupPackage(**p) for p in TOPUP_PACKAGES] + ) + + +@router.post("/create-payment", response_model=CreatePaymentResponse) +def create_payment( + body: CreatePaymentRequest, + request: Request, + current_user: CurrentUser, + session: SessionDep, +) -> Any: + """ + Generate a VNPAY payment URL for the selected top-up amount. + The client should redirect the user (or display a QR) using the returned URL. + """ + if body.amount not in ALLOWED_AMOUNTS: + raise HTTPException( + status_code=400, + detail=f"Invalid topup amount. Allowed: {sorted(ALLOWED_AMOUNTS)}", + ) + + txn_ref = str(int(time.time() * 1000)) # Unique txn_ref using current time in milliseconds + origin = ( + request.headers.get("Origin") + or request.headers.get("Referer", "").rstrip("/") + or settings.FRONTEND_HOST.rstrip("/") + ) + # VNPAY sandbox does not approve https://localhost — downgrade to http for local dev + if "localhost" in origin or "127.0.0.1" in origin: + origin = origin.replace("https://", "http://") + return_url = f"{origin}/payment/return" + client_ip = ( + request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + or (request.client.host if request.client else "127.0.0.1") + ) + + return create_topup_payment_url( + session=session, + user_id=current_user.id, + user_email=current_user.email, + amount=body.amount, + txn_ref=txn_ref, + client_ip=client_ip, + return_url=return_url, + ) + + +@router.get("/return", response_model=PaymentReturnResponse) +def topup_return(request: Request, session: SessionDep, current_user: CurrentUser) -> Any: + """ + VNPAY ReturnURL handler — VNPAY redirects the customer's browser here + after payment. Updates the user's balance accordingly. + """ + params = dict(request.query_params) + return handle_payment_return( + session, + user_id=current_user.id, + txn_ref=params.get("vnp_TxnRef", ""), + vnp_response_code=params.get("vnp_ResponseCode", ""), + amount_vnd=int(params.get("vnp_Amount", 0)) // 100, + order_info=params.get("vnp_OrderInfo", ""), + ) + + +@router.get("/balance", response_model=UserBalancePublic) +def get_my_balance(session: SessionDep, current_user: CurrentUser) -> Any: + """Return the current balance for the authenticated user.""" + balance = get_balance(session, user_id=current_user.id) + return UserBalancePublic( + user_id=balance.user_id, + balance=balance.balance, + updated_at=balance.updated_at, + ) + + +@router.get("/transactions", response_model=list[TopupTransactionPublic]) +def get_my_transactions( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 50, +) -> Any: + """Return paginated transaction history for the authenticated user.""" + return get_transaction_history(session, user_id=current_user.id, skip=skip, limit=limit) + +@router.get("/ipn") +def topup_ipn(request: Request, session: SessionDep) -> Any: + """ + VNPAY IPN URL handler — VNPAY calls this server-to-server after payment. + Must respond with JSON {"RspCode": "...", "Message": "..."} within 5 seconds. + """ + params = dict(request.query_params) + return handle_ipn(session, params) diff --git a/backend/app/topup/schemas.py b/backend/app/topup/schemas.py new file mode 100644 index 0000000000..06021c1996 --- /dev/null +++ b/backend/app/topup/schemas.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.topup.models import TopupStatus, TopupType + +# --------------------------------------------------------------------------- +# Internal / service schemas +# --------------------------------------------------------------------------- + + +class TopupCreate(BaseModel): + """Used internally to create a topup/debit transaction.""" + + user_id: uuid.UUID + amount: float = Field(gt=0, description="Amount in VND (positive)") + type: TopupType + txn_ref: str | None = None + note: str | None = None + + +class TopupTransactionPublic(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + txn_ref: str | None + amount: float + type: TopupType + status: TopupStatus + note: str | None + created_at: datetime + + +class UserBalancePublic(BaseModel): + user_id: uuid.UUID + balance: float + updated_at: datetime + + +# --------------------------------------------------------------------------- +# Router / API schemas +# --------------------------------------------------------------------------- + + +class TopupPackage(BaseModel): + id: str + amount: int + label: str + + +class TopupPackagesResponse(BaseModel): + packages: list[TopupPackage] + + +class CreatePaymentRequest(BaseModel): + amount: int = Field(gt=0, description="Top-up amount in VND") + + +class CreatePaymentResponse(BaseModel): + payment_url: str + txn_ref: str + amount: int + + +class PaymentReturnResponse(BaseModel): + status: str + txn_ref: str + message: str + code: str | None = None diff --git a/backend/app/topup/service.py b/backend/app/topup/service.py new file mode 100644 index 0000000000..864018ed1e --- /dev/null +++ b/backend/app/topup/service.py @@ -0,0 +1,309 @@ +""" +Topup service — business logic, no router. + +Call these functions from the router or payment callbacks. +""" +from __future__ import annotations + +import uuid + +from sqlmodel import Session + +from app.topup import crud +from app.topup.models import TopupStatus, TopupTransaction, TopupType, UserBalance +from app.topup.schemas import CreatePaymentResponse, PaymentReturnResponse +from app.vnpay import ( + IPNRequest, + IPNResponse, + PaymentRequest, + VNPayClient, + VNPayConfig, +) +from app.vnpay.constants import OrderType + +# --------------------------------------------------------------------------- +# VNPay helpers +# --------------------------------------------------------------------------- + + +def get_vnpay_client(return_url: str | None = None) -> VNPayClient: + """Build a VNPayClient from application settings.""" + from app.core.config import settings + + config = VNPayConfig( + tmn_code=settings.VNPAY_TMN_CODE or "36PBP850", + hash_secret=settings.VNPAY_HASH_SECRET or "Q6NRDOTHBWMJ5KWUMAZUNRT4MNYLHR2E", + return_url=return_url or settings.VNPAY_RETURN_URL or "https://localhost:5173/payment/return", + expire_minutes=15, + ) + return VNPayClient(config) + + +def create_topup_payment_url( + *, + session: Session, + user_id: uuid.UUID, + user_email: str, + amount: int, + txn_ref: str, + client_ip: str, + return_url: str, +) -> CreatePaymentResponse: + """ + Build a VNPAY payment URL for a top-up request and persist a PENDING + transaction so the IPN / return callback can look it up later. + Returns a ``CreatePaymentResponse`` with the URL, txn_ref and amount. + """ + client = get_vnpay_client(return_url=return_url) + payment_request = PaymentRequest( + txn_ref=txn_ref, + amount=amount, + order_info=f"Nap tien tai khoan {user_email}", + order_type=OrderType.TOPUP, + ip_addr=client_ip, + ) + response = client.create_payment_url(payment_request) + + # Persist a PENDING transaction so IPN / return can resolve the user later + crud.create_transaction( + session, + user_id=user_id, + amount=float(amount), + type=TopupType.CREDIT, + txn_ref=txn_ref, + note=f"Nap tien tai khoan {user_email}", + status=TopupStatus.PENDING, + ) + session.commit() + + return CreatePaymentResponse( + payment_url=response.payment_url, + txn_ref=response.txn_ref, + amount=response.amount, + ) + + +def handle_payment_return( + session: Session, + *, + user_id: uuid.UUID, + txn_ref: str, + vnp_response_code: str, + amount_vnd: int, + order_info: str, +) -> PaymentReturnResponse: + """ + Process the VNPAY ReturnURL / IPN callback: + - credits balance on success + - marks the transaction as failed on failure + Returns a ``PaymentReturnResponse``. + """ + if vnp_response_code == "00": + process_payment_success( + session, + user_id=user_id, + amount=float(amount_vnd), + txn_ref=txn_ref, + note=order_info, + ) + return PaymentReturnResponse( + status="success", + txn_ref=txn_ref, + message="Payment successful", + ) + + process_payment_failure(session, txn_ref=txn_ref) + return PaymentReturnResponse( + status="failed", + txn_ref=txn_ref, + message="Payment failed", + code=vnp_response_code, + ) + + +# --------------------------------------------------------------------------- +# Balance / transaction operations +# --------------------------------------------------------------------------- + + +def process_payment_success( + session: Session, + *, + user_id: uuid.UUID, + amount: float, + txn_ref: str | None = None, + note: str | None = None, +) -> tuple[TopupTransaction, UserBalance]: + """ + Credit *amount* VND to the user's balance after a successful payment. + + 1. Gets or creates the user balance row. + 2. Creates a CREDIT transaction (status=SUCCESS). + 3. Adds *amount* to the balance. + 4. Commits everything atomically. + + Returns ``(transaction, updated_balance)``. + """ + balance = crud.get_or_create_balance(session, user_id) + txn = crud.create_transaction( + session, + user_id=user_id, + amount=amount, + type=TopupType.CREDIT, + txn_ref=txn_ref, + note=note, + status=TopupStatus.SUCCESS, + ) + balance = crud.apply_balance_change(session, balance, amount, TopupType.CREDIT) + session.commit() + session.refresh(txn) + session.refresh(balance) + return txn, balance + + +def process_payment_failure( + session: Session, + *, + txn_ref: str, +) -> TopupTransaction | None: + """ + Mark a pending transaction as FAILED when the payment is declined. + + Returns the updated transaction, or ``None`` if no matching pending + transaction is found. + """ + txn = crud.get_transaction_by_txn_ref(session, txn_ref) + if txn is None or txn.status != TopupStatus.PENDING: + return txn + txn = crud.mark_transaction(session, txn, TopupStatus.FAILED) + session.commit() + session.refresh(txn) + return txn + + +def deduct_balance( + session: Session, + *, + user_id: uuid.UUID, + amount: float, + txn_ref: str | None = None, + note: str | None = None, +) -> tuple[TopupTransaction, UserBalance]: + """ + Deduct *amount* VND from the user's balance (service charge, etc.). + + Raises ``ValueError`` if the user does not have sufficient balance. + + Returns ``(transaction, updated_balance)``. + """ + balance = crud.get_or_create_balance(session, user_id) + if balance.balance < amount: + raise ValueError( + f"Insufficient balance: has {balance.balance}, needs {amount}" + ) + txn = crud.create_transaction( + session, + user_id=user_id, + amount=amount, + type=TopupType.DEBIT, + txn_ref=txn_ref, + note=note, + status=TopupStatus.SUCCESS, + ) + balance = crud.apply_balance_change(session, balance, amount, TopupType.DEBIT) + session.commit() + session.refresh(txn) + session.refresh(balance) + return txn, balance + + +def get_balance(session: Session, *, user_id: uuid.UUID) -> UserBalance: + """Return the current balance for *user_id* (creates row with 0 if absent).""" + balance = crud.get_or_create_balance(session, user_id) + session.commit() + session.refresh(balance) + return balance + + +def get_transaction_history( + session: Session, + *, + user_id: uuid.UUID, + skip: int = 0, + limit: int = 50, +) -> list[TopupTransaction]: + """Return paginated transaction history for *user_id*.""" + return crud.get_user_transactions(session, user_id, skip=skip, limit=limit) + + +def handle_ipn(session: Session, params: dict[str, str]) -> IPNResponse: + """ + Process a VNPAY IPN (Instant Payment Notification) server-to-server callback. + + Follows VNPAY spec: + - "00" = success / already confirmed + - "01" = order not found + - "04" = invalid amount + - "97" = invalid signature + - "99" = unknown error + + Returns an ``IPNResponse`` JSON that VNPAY expects within 5 seconds. + """ + from app.backend_pre_start import logger + + logger.info("Received VNPAY IPN: %s", params) + + try: + # Pydantic will coerce string values to the correct types (e.g. vnp_Amount → int) + ipn = IPNRequest.model_validate(params) + except Exception as exc: + logger.warning("IPN parse error: %s", exc) + return IPNResponse(RspCode="99", Message="Unknown error") + + # 1. Verify signature + client = get_vnpay_client() + ipn_response = client.verify_ipn(ipn) + if ipn_response.RspCode != "00": + return ipn_response + + txn_ref = ipn.vnp_TxnRef + amount_vnd = ipn.amount_vnd + + # 2. Look up the transaction + txn = crud.get_transaction_by_txn_ref(session, txn_ref) + if txn is None: + # Order not found — create a new SUCCESS transaction for the user + # We don't know the user_id from IPN alone, so just log and confirm + logger.warning("IPN: transaction not found for txn_ref=%s, amount=%s", txn_ref, amount_vnd) + return IPNResponse(RspCode="01", Message="Order not found") + + # 3. Check amount + if int(txn.amount) != amount_vnd: + logger.warning( + "IPN amount mismatch: expected %s, got %s for txn_ref=%s", + txn.amount, amount_vnd, txn_ref, + ) + return IPNResponse(RspCode="04", Message="Invalid amount") + + # 4. Check if already processed (idempotent) + if txn.status == TopupStatus.SUCCESS: + return IPNResponse(RspCode="00", Message="Confirm Success") + + # 5. Process payment result + if ipn.is_success: + try: + balance = crud.get_or_create_balance(session, txn.user_id) + crud.mark_transaction(session, txn, TopupStatus.SUCCESS) + crud.apply_balance_change(session, balance, txn.amount, TopupType.CREDIT) + session.commit() + logger.info("IPN: credited %s VND to user %s (txn_ref=%s)", txn.amount, txn.user_id, txn_ref) + except Exception as exc: + session.rollback() + logger.error("IPN: failed to process payment: %s", exc) + return IPNResponse(RspCode="99", Message="Unknown error") + else: + crud.mark_transaction(session, txn, TopupStatus.FAILED) + session.commit() + logger.info("IPN: marked txn_ref=%s as FAILED (code=%s)", txn_ref, ipn.vnp_ResponseCode) + + return IPNResponse(RspCode="00", Message="Confirm Success") diff --git a/backend/app/users/__init__.py b/backend/app/users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/users/constants.py b/backend/app/users/constants.py new file mode 100644 index 0000000000..af973d0e8c --- /dev/null +++ b/backend/app/users/constants.py @@ -0,0 +1,9 @@ +from enum import Enum + +MAX_USERS_PER_PAGE = 100 + + +class UserType(str, Enum): + NORMAL = "normal" # self-registered via the signup page + COMPANY = "company" # created by an admin for a company account + ADMIN = "admin" # platform administrator diff --git a/backend/app/users/dependencies.py b/backend/app/users/dependencies.py new file mode 100644 index 0000000000..0023a6cacf --- /dev/null +++ b/backend/app/users/dependencies.py @@ -0,0 +1,8 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + TokenDep, + get_current_active_superuser, + get_current_user, + get_db, +) diff --git a/backend/app/users/exceptions.py b/backend/app/users/exceptions.py new file mode 100644 index 0000000000..ac437a9e44 --- /dev/null +++ b/backend/app/users/exceptions.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, status + +UserNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", +) + +UserAlreadyExistsException = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this email already exists in the system.", +) + +InsufficientPrivilegesException = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", +) diff --git a/backend/app/users/models.py b/backend/app/users/models.py new file mode 100644 index 0000000000..8a83731b98 --- /dev/null +++ b/backend/app/users/models.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from app.utils import get_datetime_utc +import uuid +from datetime import datetime +from pydantic import EmailStr +from sqlalchemy import DateTime, String +from sqlmodel import Field, SQLModel + +from app.users.constants import UserType + +class UserBase(SQLModel): + email: EmailStr = Field(unique=True, index=True, max_length=255) + is_active: bool = True + is_superuser: bool = False + user_type: UserType = Field( + default=UserType.NORMAL, + sa_type=String(20), # type: ignore[call-arg] + index=True, + ) + full_name: str | None = Field(default=None, max_length=255) + + +class User(UserBase, table=True): + __tablename__ = "users" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + hashed_password: str + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) \ No newline at end of file diff --git a/backend/app/users/router.py b/backend/app/users/router.py new file mode 100644 index 0000000000..b07533b83e --- /dev/null +++ b/backend/app/users/router.py @@ -0,0 +1,225 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import col, delete, func, select + +from app.auth.dependencies 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.users.models import User +from app.users.schemas import ( + Message, + UpdatePassword, + UserCreate, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) +from app.users.service import create_user, get_user_by_email, update_user +from app.users.utils import generate_new_account_email, send_email + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=UsersPublic, +) +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() + + return UsersPublic(data=users, count=count) # ty:ignore[invalid-argument-type] + + +@router.post( + "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic +) +def create_user_endpoint(*, session: SessionDep, user_in: UserCreate) -> Any: + """ + Create new user. + """ + user = 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_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 # type: ignore[arg-type] + ) + send_email( + email_to=user_in.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return user + + +@router.patch("/me", response_model=UserPublic) +def update_user_me( + *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser +) -> Any: + """ + Update own 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 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 + + +@router.patch("/me/password", response_model=Message) +def update_password_me( + *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser +) -> Any: + """ + 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" + ) + hashed_password = get_password_hash(body.new_password) + current_user.hashed_password = hashed_password + session.add(current_user) + session.commit() + return Message(message="Password updated successfully") + + +@router.get("/me", response_model=UserPublic) +def read_user_me(current_user: CurrentUser) -> Any: + """ + Get current user. + """ + return current_user + + +@router.delete("/me", response_model=Message) +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() + return Message(message="User deleted successfully") + + +@router.post("/signup", response_model=UserPublic) +def register_user(session: SessionDep, user_in: UserRegister) -> Any: + """ + Create new user without the need to be logged in. + """ + user = 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 = create_user(session=session, user_create=user_create) + return user + + +@router.get("/{user_id}", response_model=UserPublic) +def read_user_by_id( + user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser +) -> Any: + """ + 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", + ) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch( + "/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def update_user_endpoint( + *, + session: SessionDep, + user_id: uuid.UUID, + user_in: UserUpdate, +) -> Any: + """ + 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", + ) + 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 HTTPException( + status_code=409, detail="User with this email already exists" + ) + + db_user = update_user(session=session, db_user=db_user, user_in=user_in) + return db_user + + +@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) +def delete_user( + session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID +) -> Message: + """ + Delete a user. + """ + from app.items.models import Item + + 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" + ) + statement = delete(Item).where(col(Item.user_id) == user_id) + session.exec(statement) + session.delete(user) + session.commit() + return Message(message="User deleted successfully") diff --git a/backend/app/users/schemas.py b/backend/app/users/schemas.py new file mode 100644 index 0000000000..99f6a46879 --- /dev/null +++ b/backend/app/users/schemas.py @@ -0,0 +1,64 @@ +import uuid +from datetime import datetime + +from pydantic import EmailStr +from sqlmodel import Field, SQLModel + +from app.users.constants import UserType + + +# Properties to receive via API on creation +class UserCreate(SQLModel): + email: EmailStr = Field(max_length=255) + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + is_active: bool = True + is_superuser: bool = False + user_type: UserType = UserType.NORMAL + + +class UserRegister(SQLModel): + email: EmailStr = Field(max_length=255) + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + + +# Properties to receive via API on update, all are optional +class UserUpdate(SQLModel): + email: EmailStr | None = Field(default=None, max_length=255) + password: str | None = Field(default=None, min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + is_active: bool | None = None + is_superuser: bool | None = None + user_type: UserType | None = None + + +class UserUpdateMe(SQLModel): + full_name: str | None = Field(default=None, max_length=255) + email: EmailStr | None = Field(default=None, max_length=255) + + +class UpdatePassword(SQLModel): + current_password: str = Field(min_length=8, max_length=128) + new_password: str = Field(min_length=8, max_length=128) + + +# Properties to return via API, id is always required +class UserPublic(SQLModel): + id: uuid.UUID + email: EmailStr + is_active: bool = True + is_superuser: bool = False + user_type: UserType = UserType.NORMAL + full_name: str | None = None + created_at: datetime | None = None + + +class UsersPublic(SQLModel): + data: list[UserPublic] + count: int + + +# Generic message +class Message(SQLModel): + message: str diff --git a/backend/app/users/service.py b/backend/app/users/service.py new file mode 100644 index 0000000000..1fe4c9ac39 --- /dev/null +++ b/backend/app/users/service.py @@ -0,0 +1,49 @@ +from typing import Any + +from sqlmodel import Session, select + +from app.core.security import get_password_hash +from app.users.constants import UserType +from app.users.models import User +from app.users.schemas import UserCreate, UserUpdate + + +def create_user(*, session: Session, user_create: UserCreate) -> User: + extra_data: dict[str, Any] = { + "hashed_password": get_password_hash(user_create.password) + } + # Keep the legacy is_superuser flag and user_type consistent + if user_create.user_type == UserType.ADMIN: + extra_data["is_superuser"] = True + elif user_create.is_superuser: + extra_data["user_type"] = UserType.ADMIN + db_obj = User.model_validate(user_create, update=extra_data) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +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 + # Keep the legacy is_superuser flag and user_type consistent + if "user_type" in user_data: + extra_data["is_superuser"] = user_data["user_type"] == UserType.ADMIN + elif user_data.get("is_superuser"): + extra_data["user_type"] = UserType.ADMIN + db_user.sqlmodel_update(user_data, update=extra_data) + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +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 diff --git a/backend/app/users/utils.py b/backend/app/users/utils.py new file mode 100644 index 0000000000..94d2ba3701 --- /dev/null +++ b/backend/app/users/utils.py @@ -0,0 +1,96 @@ +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import emails # type: ignore +from jinja2 import Template + +from app.core.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@dataclass +class EmailData: + html_content: str + subject: str + + +def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: + template_str = ( + Path(__file__).parent.parent / "email-templates" / "build" / template_name + ).read_text() + html_content = Template(template_str).render(context) + return html_content + + +def send_email( + *, + email_to: str, + subject: str = "", + html_content: str = "", +) -> None: + assert settings.emails_enabled, "no provided configuration for email variables" + message = emails.Message( + subject=subject, + html=html_content, + mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), + ) + smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} + if settings.SMTP_TLS: + smtp_options["tls"] = True + elif settings.SMTP_SSL: + smtp_options["ssl"] = True + if settings.SMTP_USER: + smtp_options["user"] = settings.SMTP_USER + if settings.SMTP_PASSWORD: + smtp_options["password"] = settings.SMTP_PASSWORD + response = message.send(to=email_to, smtp=smtp_options) + logger.info(f"send email result: {response}") + + +def generate_test_email(email_to: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Test email" + html_content = render_email_template( + template_name="test_email.html", + context={"project_name": settings.PROJECT_NAME, "email": email_to}, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Password recovery for user {email}" + link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" + html_content = render_email_template( + template_name="reset_password.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": email, + "email": email_to, + "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, + "link": link, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_new_account_email( + email_to: str, username: str, password: str +) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - New account for user {username}" + html_content = render_email_template( + template_name="new_account.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": username, + "password": password, + "email": email_to, + "link": settings.FRONTEND_HOST, + }, + ) + return EmailData(html_content=html_content, subject=subject) \ No newline at end of file diff --git a/backend/app/utils.py b/backend/app/utils.py index ac029f6342..408be7e713 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,123 +1,34 @@ -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any - -import emails # type: ignore -import jwt -from jinja2 import Template -from jwt.exceptions import InvalidTokenError - -from app.core import security -from app.core.config import settings - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -@dataclass -class EmailData: - html_content: str - subject: str - - -def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: - template_str = ( - Path(__file__).parent / "email-templates" / "build" / template_name - ).read_text() - html_content = Template(template_str).render(context) - return html_content - - -def send_email( - *, - email_to: str, - subject: str = "", - html_content: str = "", -) -> None: - assert settings.emails_enabled, "no provided configuration for email variables" - message = emails.Message( - subject=subject, - html=html_content, - mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), - ) - smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} - if settings.SMTP_TLS: - smtp_options["tls"] = True - elif settings.SMTP_SSL: - smtp_options["ssl"] = True - if settings.SMTP_USER: - smtp_options["user"] = settings.SMTP_USER - if settings.SMTP_PASSWORD: - smtp_options["password"] = settings.SMTP_PASSWORD - response = message.send(to=email_to, smtp=smtp_options) - logger.info(f"send email result: {response}") - - -def generate_test_email(email_to: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Test email" - html_content = render_email_template( - template_name="test_email.html", - context={"project_name": settings.PROJECT_NAME, "email": email_to}, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Password recovery for user {email}" - link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" - html_content = render_email_template( - template_name="reset_password.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": email, - "email": email_to, - "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, - "link": link, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_new_account_email( - email_to: str, username: str, password: str -) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - New account for user {username}" - html_content = render_email_template( - template_name="new_account.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": username, - "password": password, - "email": email_to, - "link": settings.FRONTEND_HOST, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_password_reset_token(email: str) -> str: - delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) - now = datetime.now(timezone.utc) - expires = now + delta - exp = expires.timestamp() - encoded_jwt = jwt.encode( - {"exp": exp, "nbf": now, "sub": email}, - settings.SECRET_KEY, - algorithm=security.ALGORITHM, - ) - return encoded_jwt - - -def verify_password_reset_token(token: str) -> str | None: - try: - decoded_token = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - return str(decoded_token["sub"]) - except InvalidTokenError: - return None +""" +Backwards-compatibility shim. +All utilities now live in the per-domain utils modules: + - app.users.utils (email helpers) + - app.auth.utils (password reset token helpers) +""" +from datetime import datetime, timezone + +from app.auth.utils import ( # noqa: F401 + generate_password_reset_token, + verify_password_reset_token, +) +from app.users.utils import ( # noqa: F401 + EmailData, + generate_new_account_email, + generate_reset_password_email, + generate_test_email, + send_email, +) + + +def get_datetime_utc() -> datetime: + return datetime.now(timezone.utc) + +def get_bytes_from_file_url(file_url: str) -> bytes: + """ + Utility function to fetch file bytes from a given URL. + This can be used for processing files stored in R2/S3 or other locations. + """ + import requests + + response = requests.get(file_url) + response.raise_for_status() + return response.content \ No newline at end of file diff --git a/backend/app/vnpay/__init__.py b/backend/app/vnpay/__init__.py new file mode 100644 index 0000000000..0bcaabacf9 --- /dev/null +++ b/backend/app/vnpay/__init__.py @@ -0,0 +1,27 @@ +from .client import VNPayClient +from .config import VNPayConfig +from .constants import BankCode, ResponseCode, TransactionStatus +from .exceptions import InvalidSignatureError, OrderNotFoundError, VNPayException +from .schemas import ( + IPNRequest, + IPNResponse, + PaymentRequest, + PaymentResponse, + ReturnURLRequest, +) + +__all__ = [ + "VNPayClient", + "VNPayConfig", + "PaymentRequest", + "PaymentResponse", + "IPNRequest", + "IPNResponse", + "ReturnURLRequest", + "ResponseCode", + "TransactionStatus", + "BankCode", + "VNPayException", + "InvalidSignatureError", + "OrderNotFoundError", +] diff --git a/backend/app/vnpay/client.py b/backend/app/vnpay/client.py new file mode 100644 index 0000000000..543c776d78 --- /dev/null +++ b/backend/app/vnpay/client.py @@ -0,0 +1,245 @@ +""" +VNPay PAY API client. + +Usage example:: + + from app.vnpay import VNPayClient, VNPayConfig, PaymentRequest + + config = VNPayConfig( + tmn_code="YOUR_TMN_CODE", + hash_secret="YOUR_HASH_SECRET", + return_url="https://yourdomain.vn/payment/return", + ) + client = VNPayClient(config) + + # 1. Create a payment URL and redirect the customer to it + response = client.create_payment_url( + PaymentRequest( + txn_ref="ORDER-001", + amount=150000, + order_info="Thanh toan don hang ORDER-001", + ip_addr="127.0.0.1", + ) + ) + print(response.payment_url) + + # 2. Handle the IPN callback (server-to-server) + ipn_data = IPNRequest(**request.query_params) + ipn_response = client.verify_ipn(ipn_data) + return ipn_response.model_dump() + + # 3. Handle the ReturnURL callback (browser redirect) + return_data = ReturnURLRequest(**request.query_params) + is_valid, parsed = client.verify_return_url(return_data) +""" +from app.backend_pre_start import logger + +import hashlib +import hmac +import urllib.parse +from datetime import datetime, timedelta, timezone + +from .config import VNPayConfig +from .constants import IPNRspCode +from .exceptions import InvalidSignatureError +from .schemas import ( + IPNRequest, + IPNResponse, + PaymentRequest, + PaymentResponse, + ReturnURLRequest, +) + +# UTC+7 timezone (Vietnam Standard Time) +_VST = timezone(timedelta(hours=7)) +_DATE_FMT = "%Y%m%d%H%M%S" + + +def _vst_now() -> datetime: + return datetime.now(_VST) + + +def _fmt_date(dt: datetime) -> str: + """Format a datetime to VNPAY's yyyyMMddHHmmss format in VST.""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_VST) + return dt.astimezone(_VST).strftime(_DATE_FMT) + + +def _build_query_string(params: dict[str, str]) -> tuple[str, str]: + """ + Sort params by key, then build both: + - ``hash_data``: the string to sign (urlencode each key=value pair) + - ``query_string``: full query string for the payment URL + """ + sorted_params = sorted(params.items()) + parts: list[str] = [ + f"{urllib.parse.quote_plus(k)}={urllib.parse.quote_plus(v)}" + for k, v in sorted_params + if v # skip blank values + ] + joined = "&".join(parts) + return joined, joined # both hash_data and query_string are the same format + + +def _hmac_sha512(secret: str, data: str) -> str: + return hmac.new( + secret.encode("utf-8"), + data.encode("utf-8"), + hashlib.sha512, + ).hexdigest() + + +def _verify_signature(params: dict[str, str], secure_hash: str, secret: str) -> bool: + """Verify HMAC-SHA512 signature received from VNPAY.""" + filtered = {k: v for k, v in params.items() if k != "vnp_SecureHash"} + data, _ = _build_query_string(filtered) + expected = _hmac_sha512(secret, data) + return hmac.compare_digest(expected, secure_hash) + + +class VNPayClient: + """ + High-level client for the VNPAY PAY API. + + All methods are synchronous and stateless; the client holds only + the ``VNPayConfig`` configuration object. + """ + + def __init__(self, config: VNPayConfig) -> None: + self.config = config + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def create_payment_url(self, request: PaymentRequest) -> PaymentResponse: + """ + Build and return a signed VNPAY payment URL. + + The customer should be redirected to ``PaymentResponse.payment_url``. + """ + now = _vst_now() + expire = ( + request.expire_date + if request.expire_date is not None + else now + timedelta(minutes=self.config.expire_minutes) + ) + + params: dict[str, str] = { + "vnp_Version": self.config.version, + "vnp_Command": "pay", + "vnp_TmnCode": self.config.tmn_code, + "vnp_Amount": str(request.amount * 100), + "vnp_CreateDate": _fmt_date(now), + "vnp_CurrCode": self.config.curr_code, + "vnp_IpAddr": request.ip_addr, + "vnp_Locale": ( + request.locale.value if request.locale else self.config.locale + ), + "vnp_OrderInfo": request.order_info, + "vnp_OrderType": request.order_type.value, + "vnp_ReturnUrl": self.config.return_url, + "vnp_TxnRef": str(request.txn_ref), + "vnp_ExpireDate": _fmt_date(expire), + } + + # Optional params + if request.bank_code: + params["vnp_BankCode"] = request.bank_code.value + if request.bill_mobile: + params["vnp_Bill_Mobile"] = request.bill_mobile + if request.bill_email: + params["vnp_Bill_Email"] = request.bill_email + if request.bill_first_name: + params["vnp_Bill_FirstName"] = request.bill_first_name + if request.bill_last_name: + params["vnp_Bill_LastName"] = request.bill_last_name + if request.bill_address: + params["vnp_Bill_Address"] = request.bill_address + if request.bill_city: + params["vnp_Bill_City"] = request.bill_city + if request.bill_country: + params["vnp_Bill_Country"] = request.bill_country + if request.bill_state: + params["vnp_Bill_State"] = request.bill_state + if request.inv_phone: + params["vnp_Inv_Phone"] = request.inv_phone + if request.inv_email: + params["vnp_Inv_Email"] = request.inv_email + if request.inv_customer: + params["vnp_Inv_Customer"] = request.inv_customer + if request.inv_address: + params["vnp_Inv_Address"] = request.inv_address + if request.inv_company: + params["vnp_Inv_Company"] = request.inv_company + if request.inv_taxcode: + params["vnp_Inv_Taxcode"] = request.inv_taxcode + if request.inv_type: + params["vnp_Inv_Type"] = request.inv_type + + hash_data, query_string = _build_query_string(params) + secure_hash = _hmac_sha512(self.config.hash_secret, hash_data) + + payment_url = ( + f"{self.config.payment_url}?{query_string}" + f"&vnp_SecureHash={secure_hash}" + ) + + return PaymentResponse( + payment_url=payment_url, + txn_ref=request.txn_ref, + amount=request.amount, + created_at=now, + ) + + def verify_ipn(self, ipn: IPNRequest) -> IPNResponse: + """ + Validate an IPN request sent by VNPAY to the merchant's IPN URL. + + Returns an ``IPNResponse`` that the merchant **must** send back as + a JSON response to VNPAY. + + Raises ``InvalidSignatureError`` only in unexpected situations; + invalid signatures are returned as ``RspCode="97"`` per VNPAY spec. + """ + raw = ipn.model_dump() + secure_hash = raw.pop("vnp_SecureHash", "") + str_params = {k: str(v) for k, v in raw.items() if v is not None} + + if not _verify_signature(str_params, secure_hash, self.config.hash_secret): + return IPNResponse(RspCode=IPNRspCode.INVALID_SIGNATURE, Message="Invalid signature") + + return IPNResponse(RspCode=IPNRspCode.CONFIRMED, Message="Confirm Success") + + def verify_return_url(self, data: ReturnURLRequest) -> tuple[bool, ReturnURLRequest]: + """ + Validate the ReturnURL callback that VNPAY sends back to the customer's + browser after payment. + + Returns ``(is_valid, data)``. When ``is_valid`` is ``False`` the + checksum did not match; **do not** trust the payment result. + + Raises ``InvalidSignatureError`` if you prefer exception-based flow — + pass ``raise_on_invalid=True``:: + + is_valid, parsed = client.verify_return_url(data) + """ + raw = data.model_dump() + secure_hash = raw.pop("vnp_SecureHash", "") + str_params = {k: str(v) for k, v in raw.items() if v is not None} + + is_valid = _verify_signature(str_params, secure_hash, self.config.hash_secret) + return is_valid, data + + def verify_return_url_strict(self, data: ReturnURLRequest) -> ReturnURLRequest: + """ + Same as ``verify_return_url`` but raises ``InvalidSignatureError`` + if the checksum does not match. + """ + is_valid, result = self.verify_return_url(data) + if not is_valid: + raise InvalidSignatureError( + f"VNPAY ReturnURL signature mismatch for txn_ref={data.vnp_TxnRef}" + ) + return result \ No newline at end of file diff --git a/backend/app/vnpay/config.py b/backend/app/vnpay/config.py new file mode 100644 index 0000000000..70d002ff40 --- /dev/null +++ b/backend/app/vnpay/config.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + + +@dataclass +class VNPayConfig: + """ + Holds all credentials and endpoint URLs needed to talk to VNPAY. + + Sandbox defaults are pre-filled so you can get started quickly. + Replace them with your production values before going live. + """ + + # Merchant credentials (provided by VNPAY after registration) + tmn_code: str + hash_secret: str + + # Merchant's return URLs + return_url: str + + # VNPAY endpoints + payment_url: str = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html" + api_url: str = "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction" + bank_list_url: str = "https://sandbox.vnpayment.vn/qrpayauth/api/merchant/get_bank_list" + + # API version + version: str = "2.1.0" + + # Default locale shown on VNPAY's payment page ("vn" or "en") + locale: str = "vn" + + # Default currency (only VND is supported at this time) + curr_code: str = "VND" + + # Payment expiry window in minutes (default: 15 minutes) + expire_minutes: int = 15 + + def __post_init__(self) -> None: + if not self.tmn_code: + raise ValueError("tmn_code must not be empty.") + if not self.hash_secret: + raise ValueError("hash_secret must not be empty.") + if not self.return_url: + raise ValueError("return_url must not be empty.") diff --git a/backend/app/vnpay/constants.py b/backend/app/vnpay/constants.py new file mode 100644 index 0000000000..ee1198c3d3 --- /dev/null +++ b/backend/app/vnpay/constants.py @@ -0,0 +1,106 @@ +from enum import StrEnum + + +class BankCode(StrEnum): + """Supported payment method / bank codes.""" + + VNPAYQR = "VNPAYQR" # QR code scan + VNBANK = "VNBANK" # Domestic ATM / internet banking + INTCARD = "INTCARD" # International card (Visa/Master/JCB) + + +class OrderType(StrEnum): + """Product / service category codes defined by VNPAY.""" + + FASHION = "fashion" + FOOD = "food" + OTHERS = "other" + TOPUP = "topup" + TRAVEL = "travel" + EDUCATION = "edu" + COSMETICS = "cos" + TECHNOLOGY = "tec" + + +class Locale(StrEnum): + VIETNAMESE = "vn" + ENGLISH = "en" + + +class ResponseCode(StrEnum): + """ + ``vnp_ResponseCode`` values returned by VNPAY through IPN / ReturnURL. + """ + + SUCCESS = "00" + SUSPICIOUS_TRANSACTION = "07" + NOT_REGISTERED_INTERNET_BANKING = "09" + WRONG_CARD_INFO_3_TIMES = "10" + PAYMENT_EXPIRED = "11" + CARD_LOCKED = "12" + WRONG_OTP = "13" + TRANSACTION_CANCELLED = "24" + INSUFFICIENT_BALANCE = "51" + DAILY_LIMIT_EXCEEDED = "65" + BANK_MAINTENANCE = "75" + WRONG_PAYMENT_PASSWORD = "79" + UNKNOWN_ERROR = "99" + + @property + def description(self) -> str: + _MAP: dict[str, str] = { + "00": "Giao dịch thành công", + "07": "Trừ tiền thành công. Giao dịch bị nghi ngờ (lừa đảo, giao dịch bất thường).", + "09": "Thẻ/Tài khoản chưa đăng ký dịch vụ InternetBanking.", + "10": "Xác thực thông tin thẻ/tài khoản không đúng quá 3 lần.", + "11": "Đã hết hạn chờ thanh toán.", + "12": "Thẻ/Tài khoản bị khóa.", + "13": "Nhập sai mật khẩu xác thực giao dịch (OTP).", + "24": "Khách hàng hủy giao dịch.", + "51": "Tài khoản không đủ số dư.", + "65": "Vượt quá hạn mức giao dịch trong ngày.", + "75": "Ngân hàng thanh toán đang bảo trì.", + "79": "Nhập sai mật khẩu thanh toán quá số lần quy định.", + "99": "Lỗi không xác định.", + } + return _MAP.get(self.value, "Lỗi không xác định.") + + +class TransactionStatus(StrEnum): + """ + ``vnp_TransactionStatus`` values describing VNPAY-side transaction state. + """ + + SUCCESS = "00" + PENDING = "01" + ERROR = "02" + REVERSED = "04" + REFUND_PROCESSING = "05" + REFUND_SENT_TO_BANK = "06" + SUSPECTED_FRAUD = "07" + REFUND_REJECTED = "09" + + @property + def description(self) -> str: + _MAP: dict[str, str] = { + "00": "Giao dịch thành công", + "01": "Giao dịch chưa hoàn tất", + "02": "Giao dịch bị lỗi", + "04": "Giao dịch đảo (đã trừ tiền nhưng chưa thành công ở VNPAY)", + "05": "VNPAY đang xử lý hoàn tiền", + "06": "VNPAY đã gửi yêu cầu hoàn tiền sang Ngân hàng", + "07": "Giao dịch bị nghi ngờ gian lận", + "09": "Giao dịch hoàn trả bị từ chối", + } + return _MAP.get(self.value, "Trạng thái không xác định.") + + +class IPNRspCode(StrEnum): + """Response codes that the merchant must send back to VNPAY on IPN.""" + + CONFIRMED = "00" # Successfully updated – VNPAY stops retrying + ORDER_NOT_FOUND = "01" # Order not found – VNPAY retries + ALREADY_CONFIRMED = "02" # Already confirmed – VNPAY stops retrying + INVALID_AMOUNT = "04" # Amount mismatch – VNPAY retries + INVALID_SIGNATURE = "97" # Checksum failed – VNPAY retries + UNKNOWN_ERROR = "99" # Unknown error – VNPAY retries diff --git a/backend/app/vnpay/exceptions.py b/backend/app/vnpay/exceptions.py new file mode 100644 index 0000000000..e2d7c16579 --- /dev/null +++ b/backend/app/vnpay/exceptions.py @@ -0,0 +1,18 @@ +class VNPayException(Exception): + """Base exception for VNPay library.""" + + +class InvalidSignatureError(VNPayException): + """Raised when the HMAC-SHA512 checksum does not match.""" + + +class OrderNotFoundError(VNPayException): + """Raised when the order referenced by vnp_TxnRef cannot be found.""" + + +class InvalidAmountError(VNPayException): + """Raised when the payment amount does not match the order amount.""" + + +class OrderAlreadyConfirmedError(VNPayException): + """Raised when the IPN for an already-confirmed order is received.""" diff --git a/backend/app/vnpay/schemas.py b/backend/app/vnpay/schemas.py new file mode 100644 index 0000000000..2d66207d2b --- /dev/null +++ b/backend/app/vnpay/schemas.py @@ -0,0 +1,154 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + +from .constants import BankCode, Locale, OrderType + + +# --------------------------------------------------------------------------- +# Payment request (merchant → VNPAY) +# --------------------------------------------------------------------------- + + +class PaymentRequest(BaseModel): + """ + Parameters needed to build a VNPAY payment URL. + + ``amount`` is in **VND** (integer). The library will multiply by 100 + before sending to VNPAY as required by the API spec. + """ + + txn_ref: str = Field( + ..., + description="Unique order / transaction reference on the merchant side.", + max_length=100, + ) + amount: int = Field( + ..., + description="Amount in VND (not multiplied by 100 yet).", + gt=0, + ) + order_info: str = Field( + ..., + description="Payment description (no special characters, no Vietnamese diacritics).", + max_length=255, + ) + order_type: OrderType = Field( + default=OrderType.OTHERS, + description="Product category code.", + ) + ip_addr: str = Field( + ..., + description="IP address of the customer making the payment.", + ) + bank_code: BankCode | None = Field( + default=None, + description="Pre-select a payment method. Leave None to let the customer choose.", + ) + locale: Locale | None = Field( + default=None, + description="Override the default locale (vn/en).", + ) + expire_date: datetime | None = Field( + default=None, + description="Override the default payment expiry time (UTC+7).", + ) + + # Optional billing info + bill_mobile: str | None = Field(default=None, max_length=20) + bill_email: str | None = Field(default=None, max_length=255) + bill_first_name: str | None = Field(default=None, max_length=255) + bill_last_name: str | None = Field(default=None, max_length=255) + bill_address: str | None = Field(default=None, max_length=255) + bill_city: str | None = Field(default=None, max_length=255) + bill_country: str | None = Field(default=None, max_length=2) + bill_state: str | None = Field(default=None, max_length=255) + + # Optional invoice info + inv_phone: str | None = Field(default=None, max_length=20) + inv_email: str | None = Field(default=None, max_length=255) + inv_customer: str | None = Field(default=None, max_length=255) + inv_address: str | None = Field(default=None, max_length=255) + inv_company: str | None = Field(default=None, max_length=255) + inv_taxcode: str | None = Field(default=None, max_length=20) + inv_type: str | None = Field(default=None, max_length=20) + + @field_validator("txn_ref") + @classmethod + def txn_ref_no_spaces(cls, v: str) -> str: + if " " in v: + raise ValueError("txn_ref must not contain spaces.") + return v + + +# --------------------------------------------------------------------------- +# Payment response (VNPAY → merchant, via ReturnURL or IPN) +# --------------------------------------------------------------------------- + + +class _VNPayCallbackBase(BaseModel): + """Fields shared by both IPN and ReturnURL callbacks.""" + + vnp_TmnCode: str + vnp_Amount: int + vnp_BankCode: str + vnp_BankTranNo: str | None = None + vnp_CardType: str | None = None + vnp_PayDate: str | None = None + vnp_OrderInfo: str + vnp_TransactionNo: str + vnp_ResponseCode: str + vnp_TransactionStatus: str + vnp_TxnRef: str + vnp_SecureHash: str + + @property + def amount_vnd(self) -> int: + """Returns the real VND amount (VNPAY sends amount × 100).""" + return self.vnp_Amount // 100 + + @property + def is_success(self) -> bool: + return ( + self.vnp_ResponseCode == "00" + and self.vnp_TransactionStatus == "00" + ) + + +class IPNRequest(_VNPayCallbackBase): + """ + Query parameters received on the merchant's IPN URL. + + Use ``VNPayClient.verify_ipn()`` to validate and parse these. + """ + + +class ReturnURLRequest(_VNPayCallbackBase): + """ + Query parameters received on the merchant's ReturnURL. + + Use ``VNPayClient.verify_return_url()`` to validate and parse these. + """ + + +# --------------------------------------------------------------------------- +# Structured response objects returned by the library +# --------------------------------------------------------------------------- + + +class PaymentResponse(BaseModel): + """Returned by ``VNPayClient.create_payment_url()``.""" + + payment_url: str + txn_ref: str + amount: int + created_at: datetime + + +class IPNResponse(BaseModel): + """ + The JSON body the merchant must return to VNPAY after processing an IPN. + """ + + RspCode: str + Message: str diff --git a/backend/certs/local.crt b/backend/certs/local.crt new file mode 100644 index 0000000000..26e0ce84a9 --- /dev/null +++ b/backend/certs/local.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICtjCCAZ4CCQDG2k3twe91MzANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBIq +LmRvY2tlci5sb2NhbGhvc3QwHhcNMjYwNDAxMTY0ODM4WhcNMjcwNDAxMTY0ODM4 +WjAdMRswGQYDVQQDDBIqLmRvY2tlci5sb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC6QdjHBlXtMNegDTOJGyhrrYoN23zaX4YiCWzz7PAm +E7QO3fag+0SjzAU7xTdVo9Q1Qc0ReTpBiYn1/4iR6M+372V3Wpm7yulHcFAPukdq +6venXOov3BDcaVoITlqj7Rjb9nFQVsaK5f3bnel9d5KD+h4+nBQHkpvc3HqHPSMG +8IOEshBVSk/hRLI+0FOt5mo3zJJzOxb43C8pBpWn01hZkAEQyoIvkiPXmOQrL7wy +yY1kJFPFfFQo7D+P+h8k7DnLHWCMmIxEvL+qXQdnfW73XpfIW8CRGyQ/igdeTpRs +Es7J1ioFDlpeJDCFJHhasCsohuOz5cx6C57zf2kOt6mlAgMBAAEwDQYJKoZIhvcN +AQELBQADggEBAKTNSnq72a9kGmSKaDJRmo41inkNOcGsim0t3FHqawjj36ivL9FS +D9b4Dsu+YiveNA88iTOspXl25MuC5xswP9rVxBsLGRCHsqOdveJEJoV47Vk0Un1J +FPS+yBGjmJM4LEp2uEfU0XEsuCd5zGr0X2BUPVkKTeDcR6+VkntJd9zpMNc7aIXv +j4prlVzYPw9hhr2QeTbc6/kvCSu2pNJeWL0ZjwYXS2xl73ysdrGstcFck4FJZnTt +pJKHWs1FbMySgpSFvGVNfLFulkSnaN3Vz23MTlfeTVtSBiL/mJtf0fx7Z2BKaxVa +Bv2sZiF5wRjr7p3XUZyKIkq2CA+5S8o/dZU= +-----END CERTIFICATE----- diff --git a/backend/certs/local.key b/backend/certs/local.key new file mode 100644 index 0000000000..90881c666c --- /dev/null +++ b/backend/certs/local.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6QdjHBlXtMNeg +DTOJGyhrrYoN23zaX4YiCWzz7PAmE7QO3fag+0SjzAU7xTdVo9Q1Qc0ReTpBiYn1 +/4iR6M+372V3Wpm7yulHcFAPukdq6venXOov3BDcaVoITlqj7Rjb9nFQVsaK5f3b +nel9d5KD+h4+nBQHkpvc3HqHPSMG8IOEshBVSk/hRLI+0FOt5mo3zJJzOxb43C8p +BpWn01hZkAEQyoIvkiPXmOQrL7wyyY1kJFPFfFQo7D+P+h8k7DnLHWCMmIxEvL+q +XQdnfW73XpfIW8CRGyQ/igdeTpRsEs7J1ioFDlpeJDCFJHhasCsohuOz5cx6C57z +f2kOt6mlAgMBAAECggEAZpmBFVlLGgZusO60tdDs+iu1QZ7nbs9x7uvsRY3+V6tA +43OnuNPQ4r2vIFap/ZXqfo/Jq9dwnMtr4MOrclyhl7va091nlAfZaw3WPGOrlZzr +YRkQs95wt0mdW7f1vBkOOZTOpKe4ZKj+puycww2L+wFbibemXOmIzCfzou2tjtMa +xhc0HpKZNHB6EeUUapHefvqEFEqMe72rFkqqEPeoQF914NFfocCQXdN7pLI47+OH +zYdqP3x2ReGSifV1PdG1CrpbgKioJswlholfD2hhv6DTriCllVdYeliwjatWHUL9 +IcgbbtBixnuGGSd78My6koxN0VPlKbpVvlJI46pIoQKBgQDuz3m8uCyyBFuY52p7 +hnzM74DlbzjqFXx57CsbP4LR+3oB/ZPczqwiemyYJkSfne5SkC/VVXjch9IeAFRq +XQfwNm5blg+zyNZySvtPfuKcrx1ojeRlU8q8anzCFxLMZSzYY6YpKTSphRbEJMeS +irDtfwhUlg6b54hVsKsWY+4d7QKBgQDHqfsJJoRghg1wF7tZGJUyT/8gMUuvOtJY +hTX1pCjG7P168xTU935EX4kVCiyH3Rr/Bv0updv07bzqAThhJSJvkovfqcG/NSmh +jC168m4Yk9CGLmM4KpujOS26UsWO4bYSneMngaiQ1suZOwt2JSTD03CWNdxOXX0A +AQ7jRYYDmQKBgQCjS1F13wYI7/vmMQ9Zydtaksazm/rx7aFBCWFsb2A3z1pdNBTA +Xr3LkaTh4QD5mBdXc2qR2LEdMu5VP2p5lIWSFtYdYB36lHE2k9kGQcAY3ZEhZizv +sH0nmzUVzos3IlOo33LGIHv3Ep8/ndqtdJKIw11h4X2503chCP3kAI7Y/QKBgE2S +dtvJQSkXK+Ve8wTcjiqr9d0WCeecnNiTeLFlBAq1TI4WHwPW3BHIZEPuXfqzJqfq +mTckbV6tdvYbX0Iu4UAj2YAePg4Bo5kGEy1vPuMBmsRnBVlvBGTX9DItsl+exdRZ +z0UsFMehDB0OWZefOrdyUI2rg1pW7BeyUYxvGHARAoGAGaMr+vwQ//yafgnPGWfw +wBFtl0cqy3yxgD0wtW5eAE2WIsZdeFg9a+2AFYmILL/S8WSxYJftBugw7P2R6Tvi +MVN9phSxuAMryH7sFsxRU+6HMJV/vXGKmfgAewK2k3mtRAMH46ZxQMpxtqeNXYGY +NxSNH1ORcH5aSSqPKIJmMX4= +-----END PRIVATE KEY----- diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 66b4d66683..06642e67b5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,11 @@ dependencies = [ "sentry-sdk[fastapi]>=2.0.0,<3.0.0", "pyjwt<3.0.0,>=2.8.0", "pwdlib[argon2,bcrypt]>=0.3.0", + "boto3>=1.26.0", + "python-multipart>=0.0.22", + "pandas>=2.0.0,<3.0.0", + "openpyxl>=3.1.0,<4.0.0", + "google-genai>=1.0.0", ] [dependency-groups] diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py index 3e82cd0134..91cac0e8bc 100644 --- a/backend/tests/api/routes/test_items.py +++ b/backend/tests/api/routes/test_items.py @@ -37,7 +37,7 @@ def test_read_item( assert content["title"] == item.title assert content["description"] == item.description assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) + assert content["owner_id"] == str(item.user_id) def test_read_item_not_found( @@ -94,7 +94,7 @@ def test_update_item( assert content["title"] == data["title"] assert content["description"] == data["description"] assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) + assert content["owner_id"] == str(item.user_id) def test_update_item_not_found( diff --git a/bun.lock b/bun.lock index 34d6b22a9b..d0bbcefc43 100644 --- a/bun.lock +++ b/bun.lock @@ -5,16 +5,47 @@ "": { "name": "fastapi-cloud", }, + "front-end": { + "name": "tabula-web", + "version": "0.1.0", + "dependencies": { + "axios": "1.13.5", + "clsx": "^2.1.1", + "framer-motion": "^11.3.8", + "lucide-react": "^0.408.0", + "next": "14.2.15", + "next-intl": "^3.21.1", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.12.7", + "tailwind-merge": "^2.4.0", + }, + "devDependencies": { + "@hey-api/openapi-ts": "0.73.0", + "@types/node": "^20.14.12", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "8.57.1", + "eslint-config-next": "14.2.15", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.4", + }, + }, "frontend": { "name": "frontend", "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -31,13 +62,17 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.20", "form-data": "4.0.5", + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.68.0", + "react-i18next": "^17.0.2", "react-icons": "^5.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -62,6 +97,8 @@ }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], @@ -94,6 +131,8 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -118,6 +157,12 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -170,6 +215,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -178,12 +231,30 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="], + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.0.6", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w=="], "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.73.0", "", { "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.6", "ansi-colors": "4.1.3", "c12": "2.0.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "open": "10.1.2" }, "peerDependencies": { "typescript": "^5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-sUscR3OIGW0k9U//28Cu6BTp3XaogWMDORj9H+5Du9E5AvTT7LZbCEDvkLhebFOPkp2cZAQfd66HiZsiwssBcQ=="], "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -196,23 +267,61 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + + "@next/env": ["@next/env@14.2.15", "", {}, "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@14.2.15", "", { "dependencies": { "glob": "10.3.10" } }, "sha512-pKU0iqKRBlFB/ocOI1Ip2CkKePZpYpnw5bEItEkuZ/Nr9FQP1+p7VDWr4VfOdff4i9bFmrOaeaU1bFEyAcxiMQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@14.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@14.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@14.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@14.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@14.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@14.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@14.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g=="], + + "@next/swc-win32-ia32-msvc": ["@next/swc-win32-ia32-msvc@14.2.15", "", { "os": "win32", "cpu": "ia32" }, "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@14.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], @@ -238,7 +347,9 @@ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], @@ -330,6 +441,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.2", "", { "os": "win32", "cpu": "x64" }, "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@swc/core": ["@swc/core@1.15.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="], @@ -356,6 +471,8 @@ "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + "@swc/helpers": ["@swc/helpers@0.5.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A=="], + "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -424,53 +541,201 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.12.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.12.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.12.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA=="], + + "@unrs/resolver-binding-linux-loong64-gnu": ["@unrs/resolver-binding-linux-loong64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q=="], + + "@unrs/resolver-binding-linux-loong64-musl": ["@unrs/resolver-binding-linux-loong64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.12.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.12.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A=="], + + "@unrs/resolver-binding-openharmony-arm64": ["@unrs/resolver-binding-openharmony-arm64@1.12.2", "", { "os": "none", "cpu": "arm64" }, "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.12.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.12.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.12.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.3", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2", "@swc/core": "^1.15.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA=="], "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.16", "", { "bin": "dist/cli.js" }, "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "c12": ["c12@2.0.1", "", { "dependencies": { "chokidar": "^4.0.1", "confbox": "^0.1.7", "defu": "^6.1.4", "dotenv": "^16.4.5", "giget": "^1.2.3", "jiti": "^2.3.0", "mlly": "^1.7.1", "ohash": "^1.1.4", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", "pkg-types": "^1.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -480,14 +745,22 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-support": ["color-support@1.1.3", "", { "bin": "bin.js" }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "commander": ["commander@13.0.0", "", {}, "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], @@ -496,16 +769,62 @@ "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -516,46 +835,138 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-iterator-helpers": ["es-iterator-helpers@1.3.3", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": "bin/esbuild" }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], + + "eslint-config-next": ["eslint-config-next@14.2.15", "", { "dependencies": { "@next/eslint-plugin-next": "14.2.15", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.28.1", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-mKg+NC/8a4JKLZRIOBplxXNdStgxy7lzWuedUaCc8tev+Al9mwDUTujQH6W6qXDH9kycWiVo28tADWGvpBsZcQ=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.13.0", "", { "dependencies": { "debug": "^3.2.7" }, "peerDependencies": { "eslint": "*" }, "optionalPeers": ["eslint"] }, "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@4.6.2", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ=="], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + "frontend": ["frontend@workspace:frontend"], "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -564,11 +975,19 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": "dist/cli.mjs" }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob": ["glob@10.3.10", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.5", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], @@ -576,30 +995,120 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": "bin/handlebars" }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + + "i18next": ["i18next@26.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": "cli.js" }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": "cli.js" }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbot": ["isbot@5.1.33", "", {}, "sha512-P4Hgb5NqswjkI0J1CM6XKXon/sxKY1SuowE7Qx2hrBhIwICFyXy54mfgB5eMHXsbe/eStzzpbIGNOvGmz+dlKg=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], + "jiti": ["jiti@2.6.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -608,8 +1117,24 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -634,8 +1159,18 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], @@ -644,10 +1179,16 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], @@ -658,26 +1199,84 @@ "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "next": ["next@14.2.15", "", { "dependencies": { "@next/env": "14.2.15", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", "postcss": "8.4.31", "styled-jsx": "5.1.1" }, "optionalDependencies": { "@next/swc-darwin-arm64": "14.2.15", "@next/swc-darwin-x64": "14.2.15", "@next/swc-linux-arm64-gnu": "14.2.15", "@next/swc-linux-arm64-musl": "14.2.15", "@next/swc-linux-x64-gnu": "14.2.15", "@next/swc-linux-x64-musl": "14.2.15", "@next/swc-win32-arm64-msvc": "14.2.15", "@next/swc-win32-ia32-msvc": "14.2.15", "@next/swc-win32-x64-msvc": "14.2.15" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw=="], + + "next-intl": ["next-intl@3.26.5", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^3.26.5" }, "peerDependencies": { "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": "dist/cli.mjs" }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], @@ -686,18 +1285,44 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.8.0", "", { "bin": "bin/prettier.cjs" }, "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -708,24 +1333,58 @@ "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], + "react-i18next": ["react-i18next@17.0.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rollup": ["rollup@4.55.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.2", "@rollup/rollup-android-arm64": "4.55.2", "@rollup/rollup-darwin-arm64": "4.55.2", "@rollup/rollup-darwin-x64": "4.55.2", "@rollup/rollup-freebsd-arm64": "4.55.2", "@rollup/rollup-freebsd-x64": "4.55.2", "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", "@rollup/rollup-linux-arm-musleabihf": "4.55.2", "@rollup/rollup-linux-arm64-gnu": "4.55.2", "@rollup/rollup-linux-arm64-musl": "4.55.2", "@rollup/rollup-linux-loong64-gnu": "4.55.2", "@rollup/rollup-linux-loong64-musl": "4.55.2", "@rollup/rollup-linux-ppc64-gnu": "4.55.2", "@rollup/rollup-linux-ppc64-musl": "4.55.2", "@rollup/rollup-linux-riscv64-gnu": "4.55.2", "@rollup/rollup-linux-riscv64-musl": "4.55.2", "@rollup/rollup-linux-s390x-gnu": "4.55.2", "@rollup/rollup-linux-x64-gnu": "4.55.2", "@rollup/rollup-linux-x64-musl": "4.55.2", "@rollup/rollup-openbsd-x64": "4.55.2", "@rollup/rollup-openharmony-arm64": "4.55.2", "@rollup/rollup-win32-arm64-msvc": "4.55.2", "@rollup/rollup-win32-ia32-msvc": "4.55.2", "@rollup/rollup-win32-x64-gnu": "4.55.2", "@rollup/rollup-win32-x64-msvc": "4.55.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -734,12 +1393,72 @@ "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.1", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tabula-web": ["tabula-web@workspace:front-end"], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], @@ -748,6 +1467,12 @@ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -758,108 +1483,122 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-intl": ["use-intl@3.26.5", "", { "dependencies": { "@formatjs/fast-memoize": "^2.2.0", "intl-messageformat": "^10.5.14" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], - "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], - "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], - "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@babel/helper-compilation-targets/browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], - "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], - "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], - "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -880,58 +1619,190 @@ "@tanstack/router-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "array-includes/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "array.prototype.findlast/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "array.prototype.findlastindex/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "es-abstract/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-abstract/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "es-shim-unscopables/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-import-resolver-node/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "eslint-plugin-import/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "eslint-plugin-jsx-a11y/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "eslint-plugin-react/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "eslint-plugin-react/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + + "espree/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "function.prototype.name/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "internal-slot/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "is-bun-module/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "is-core-module/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "is-regex/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "iterator.prototype/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "next/caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "next/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "next/react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "object.assign/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "object.entries/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "object.values/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "reflect.getprototypeof/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "set-proto/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string.prototype.matchall/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "string.prototype.trim/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "string.prototype.trimend/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "string.prototype.trimstart/es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "tabula-web/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "tabula-web/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + + "tabula-web/@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], - "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tabula-web/lucide-react": ["lucide-react@0.408.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8kETAAeWmOvtGIr7HPHm51DXoxlfkNncQ5FZWXR+abX8saQwMYXANWIkUstaYtcKSo/imOe/q+tVFA8ANzdSVA=="], - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tabula-web/next-themes": ["next-themes@0.3.0", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" } }, "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w=="], - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tabula-web/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tabula-web/react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], - "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tabula-web/tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], - "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tabula-web/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@babel/helper-compilation-targets/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.9.16", "", { "bin": "dist/cli.js" }, "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw=="], + + "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + + "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + + "@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@tanstack/react-router/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], "@tanstack/router-devtools-core/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "next/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "tabula-web/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "tabula-web/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "tabula-web/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/compose.direct.yml b/compose.direct.yml new file mode 100644 index 0000000000..cd5bd41eaf --- /dev/null +++ b/compose.direct.yml @@ -0,0 +1,125 @@ +services: + + db: + image: postgres:18 + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + volumes: + - app-db-data:/var/lib/postgresql/data/pgdata + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_DB=${POSTGRES_DB?Variable not set} + ports: + - "5432:5432" + + adminer: + image: adminer + restart: always + depends_on: + - db + environment: + - ADMINER_DESIGN=pepa-linha-dark + ports: + - "8080:8080" + + prestart: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + build: + context: . + dockerfile: backend/Dockerfile + depends_on: + db: + condition: service_healthy + restart: true + command: bash scripts/prestart.sh + env_file: + - .env + environment: + - DOMAIN=${DOMAIN} + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - SENTRY_DSN=${SENTRY_DSN} + + backend: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + restart: always + build: + context: . + dockerfile: backend/Dockerfile + depends_on: + db: + condition: service_healthy + restart: true + prestart: + condition: service_completed_successfully + env_file: + - .env + environment: + - DOMAIN=${DOMAIN} + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - SENTRY_DSN=${SENTRY_DSN} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "8000:8000" + + frontend: + image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' + restart: always + build: + context: . + dockerfile: frontend/Dockerfile + args: + - VITE_API_URL=http://${DOMAIN?Variable not set}:8000 + - NODE_ENV=production + depends_on: + backend: + condition: service_healthy + ports: + - "80:80" + +volumes: + app-db-data: + +networks: + traefik-public: + external: true \ No newline at end of file diff --git a/compose.traefik.yml b/compose.traefik.yml index bcd7d142ca..2547e6045b 100644 --- a/compose.traefik.yml +++ b/compose.traefik.yml @@ -6,6 +6,8 @@ services: - 80:80 # Listen on port 443, default for HTTPS - 443:443 + + - 8080:8080 # Port for the Traefik Dashboard (optional) restart: always labels: # Enable Traefik for this service, to make it available in the public network diff --git a/compose.yml b/compose.yml index 2488fc007b..0805b5573c 100644 --- a/compose.yml +++ b/compose.yml @@ -18,7 +18,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_DB=${POSTGRES_DB?Variable not set} - + # platform: linux/amd64 adminer: image: adminer restart: always @@ -41,6 +41,7 @@ services: - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le - traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080 + # platform: linux/amd64 prestart: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' @@ -75,7 +76,7 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} - + # platform: linux/amd64 backend: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' restart: always @@ -108,6 +109,7 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} + # platform: linux/amd64 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] @@ -165,6 +167,7 @@ services: # Enable redirection for HTTP and HTTPS - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect + # platform: linux/amd64 volumes: app-db-data: diff --git a/docs/upload-and-parse-flow.md b/docs/upload-and-parse-flow.md new file mode 100644 index 0000000000..f5105713c5 --- /dev/null +++ b/docs/upload-and-parse-flow.md @@ -0,0 +1,371 @@ +## Upload & Parse Flow + +Purpose +------- + +This document describes a production-ready end-to-end flow for "user uploads a document → server persists file to R2 → OCR parsing runs (parallel) → client polls for result." It includes the sequence, API contracts, data shapes, orchestration, error handling, security, and examples. + +High-level contract +------------------- +- Input: user-authenticated file upload (single file or multipart). Output: job id that can be polled for status and final parse result. +- Processing: file stored durably in R2, a Job + Document record created, parsing tasks submitted to workers/OCR provider in parallel, partial results persisted and aggregated. +- Success: final status = `completed` with parsed data and extracted metadata. Error modes: transient (retryable) vs permanent (manual intervention). +- Non-functional: idempotent uploads, rate-limited OCR calls, auditable operations, monitoring and alerts for failures. + +Edge cases to handle +-------------------- +- Very large files (large PDFs, many pages) → chunking or reject with guidance. +- Duplicate uploads (user retries) → idempotency via `client_upload_id` or checksum. +- OCR provider rate limits / outages → queue + backoff + DLQ. +- Partial parsing (some pages fail) → aggregated job-level status with per-page errors. +- PII / compliance: redact or restrict storage of sensitive fields when storing parsed text (policy). + +Sequence overview +----------------- + +1. Client POST `/upload` (or presigned PUT) with file + metadata. +2. Server stores file to R2 (or returns presigned URL to client for direct upload). +3. Server creates DB Job and Document metadata, enqueues parsing tasks (per-file or per-page). +4. Worker pool consumes tasks, calls external OCR API in parallel, stores ParseResult(s) to DB/R2. +5. Aggregation completes, Job status updated to `completed`/`failed`. Client polls `/jobs/{job_id}` or receives webhook. + +Design choices & rationale +------------------------- +- Store original file in R2 (cheap, durable); keep references in DB (filename, size, checksum, R2 key). +- Prefer server-generated presigned PUT URLs for large uploads to avoid server memory pressure. +- Use a queue (Redis + RQ/Celery, or SQS) + worker pool to decouple ingestion from slow OCR API calls. +- Split work into smaller units (pages or chunks) for parallelism and better error isolation. +- Provide both polling and webhook options for clients; polling is simpler and robust for many clients. + +API Contracts +------------- + +1) Upload endpoint (server-mediated or presigned) + +Option A — Server-handled upload (multipart) + +- POST `/upload` +- Auth: Bearer JWT +- Request: `multipart/form-data` { file: file, metadata?: JSON, client_upload_id?: string } +- Response `202 Accepted` + +``` +{ + "job_id": "uuid", + "document_id": "uuid", + "status": "pending", + "poll_url": "/jobs/{job_id}" +} +``` + +Option B — Presigned upload (recommended for large uploads) + +- POST `/uploads/presign` +- Request JSON: + +``` +{ "filename": "file.pdf", "content_type": "application/pdf", "client_upload_id?": "string", "metadata?": { ... } } +``` +- Response 200: + +``` +{ + "upload_url": "https://r2...signed-url", + "file_key": "r2-key", + "job_id": "uuid", + "document_id": "uuid", + "complete_upload_url": "/uploads/complete" +} +``` +- Client: PUT file to `upload_url`. Then POST `/uploads/complete` { file_key, job_id } to signal server to start parsing. + +2) Start/Complete upload (server) + +- POST `/uploads/complete` +- Body: `{ job_id: "uuid", file_key: "string", checksum?: "sha256", metadata?: {} }` +- Response 200 -> same job_id. + +3) Polling/Get job + +- GET `/jobs/{job_id}` +- Auth: Bearer JWT (owners or admins) +- Response: + +``` +{ + "job_id":"uuid", + "document_id":"uuid", + "status":"pending|processing|partial|completed|failed", + "created_at":"iso", + "updated_at":"iso", + "progress": { "total_tasks": 10, "completed_tasks": 7 }, + "results": [ { "page":1,"status":"completed","result_key":"r2-key-or-db-id" }, ... ], + "error": { "message": "...", "code": "OCR_PROVIDER_429" } +} +``` + +4) Get parse result + +- GET `/documents/{document_id}/result` or `/jobs/{job_id}/result` +- Support pagination for large parse outputs or per-page retrieval. + +5) Webhook (optional) + +- POST `/webhooks/parse-complete` +- Body: `{ job_id, document_id, status, summary: {...} }` +- Security: sign webhook (HMAC) or mutual TLS. + +Data Models (conceptual) +------------------------ +- Job + - id uuid PK + - user_id uuid FK + - status enum + - client_upload_id nullable string (idempotency) + - created_at, updated_at + - error JSON nullable +- Document + - id uuid + - job_id uuid + - r2_key string + - filename string + - size int + - checksum string + - pages int nullable +- Task (optional — per-page) + - id uuid + - document_id + - page_number int nullable + - status enum + - attempts int + - last_error JSON + - result_key string (link to parsed output in R2 or DB) +- ParseResult + - id uuid + - document_id + - task_id + - extracted_text (or link) + - structured_data JSON + - created_at + +Storage conventions (R2) +----------------------- +- Bucket layout: + - `originals/{year}/{month}/{job_id}/{document_id}/{filename}` + - `results/{year}/{month}/{job_id}/{document_id}/page-{n}.json` +- Filenames: `{uuid}_{sanitized_name}` for traceability. +- Store checksums (sha256) & content-type. +- Lifecycle: set retention policy if needed; consider lifecycle rules to move older originals to cold storage. +- Signed URLs: presigned PUT for upload and presigned GET for downloads; short TTL (e.g., 15m). + +OCR orchestration & parallel parsing +----------------------------------- +- Chunking: + - For PDFs: extract pages server-side (if needed) and submit one task per page. + - For images: per-file task. +- Worker pool: + - Use a queue (Redis, SQS) and workers (Celery/RQ or managed pool). + - Concurrency limits: set by OCR provider rate limits and CPU/memory costs. + - Task flow: worker fetches task -> download file/page from R2 -> call OCR -> store result -> ack. +- Parallelism & rate limiting: + - Workers implement a rate-limited client for OCR provider (token bucket). + - For bursty loads, use a local queue and throttle to avoid exceeding external quotas. +- Idempotency: + - Each task should have an idempotency key (job_id + document_id + page_number). If a ParseResult exists, skip reprocessing. +- Timeouts & retries: + - Set a sensible timeout for OCR calls (e.g., 60s). Retry transient errors with exponential backoff (max 3-5 attempts). Permanent errors mark task failed and optionally send to DLQ. +- Aggregation: + - A coordinator or the Job record tracks number of tasks vs completed tasks; when all tasks are completed/failed, compute job-level status. + +Polling strategy +---------------- +- Client receives `job_id` and `poll_url`. +- Poll frequency: start aggressive for a short time, then back off exponentially with cap. Example: 1s, 2s, 4s, 8s, 16s, 30s (cap). +- Include `ETag`/`Last-Modified` in responses to reduce payloads. Clients may use conditional requests. +- Provide webhook/SSE/websocket alternative for real-time needs. + +Backoff and retry policy for client polling +----------------------------------------- +- Use exponential backoff with jitter to avoid thundering herd. +- If job age > threshold (e.g., 10 minutes), switch to polling every 30–60s and surface a message to the client that the job is long-running. + +Error handling and retry policy (server) +--------------------------------------- +- Error types: + - Transient: OCR provider 429/5xx, network timeouts → retry with backoff. + - Permanent: invalid file, unsupported format → mark task failed immediately. +- Retries: + - For transient OCR errors: up to N attempts (3–5) with exponential backoff. + - After all attempts fail: write to DLQ and mark task failed; notify via alert. +- Partial success: + - If some pages succeed and others fail, return aggregated results and per-page error details. +- Audit: + - Store full error payloads for debugging in DB or logs (mask PII). +- Circuit breaker: + - If OCR provider returns many failures, temporarily stop making new calls and back off globally. + +Security & compliance +--------------------- +- Auth: Bearer JWT with scopes for upload and job read; verify user owns job/document. +- Signed URLs: presigned PUT/GET URLs with short expiry; restrict methods/headers. +- Input validation: validate content-types, enforce file size limits. +- Malware scanning: integrate virus scanning on upload (optional) before enqueueing. +- PII & encryption: encrypt at rest (R2 settings), TLS in transit; implement data retention and deletion policy. +- Audit logs: log who uploaded, processed, and accessed results. +- Webhook security: sign webhook payloads with HMAC secret. + +Monitoring, metrics & alerts +--------------------------- +- Metrics: + - `job_created`, `job_completed`, `job_failed`, `average_job_time`, `queue_depth`, `worker_count`, `ocr_api_errors`, `ocr_api_throttles`. +- Logs: + - Structured logs for each job/task including `job_id`. +- Alerts: + - Alert on high queue depth, elevated failure rates, OCR provider downtime, or sudden latency spikes. +- Healthcheck endpoints for API and workers. + +Data retention & deletion +------------------------ +- Soft-delete documents (mark as deleted) with a scheduled job to purge after retention period. +- Offer user-initiated deletion that marks job/document and schedules removal from R2 and DB. +- When removing data, also remove parse results and event logs (or archive them as needed for compliance). + +Data schemas / Example JSON +--------------------------- + +Upload response + +``` +{ + "job_id": "7b8e1a2e-....", + "document_id": "a1234bcd-....", + "status": "pending", + "poll_url": "/jobs/7b8e1a2e-...." +} +``` + +Job status response + +``` +{ + "job_id":"7b8e1a2e-....", + "status":"processing", + "progress":{"total_tasks":10,"completed_tasks":6}, + "results":[ + {"page":1,"status":"completed","result_key":"results/.../page-1.json"}, + {"page":2,"status":"failed","error":{"code":"OCR_500","message":"timeout"}} + ], + "error":null +} +``` + +Parse result (per page) + +``` +{ + "document_id":"a1234bcd-....", + "page":1, + "text":"Extracted OCR text...", + "entities":[ {"type":"date","value":"2026-01-23","span":[10,20]} ], + "confidence":0.98 +} +``` + +Example server-side pseudocode (Python / FastAPI + Celery style) +--------------------------------------------------------------- + +1) `POST /uploads/complete` handler +- validate `job_id` and `file_key` +- create `Document` row with `r2_key = file_key` +- compute checksum optionally +- determine splitting strategy (if pdf -> number of pages) +- create `Task` rows per page or per-file +- enqueue each task to queue: `queue.enqueue("process_task", task_id=task.id)` + +2) Worker `process_task(task_id)` +- load Task and Document +- idempotency: if `ParseResult` exists for `task_id` -> return +- download source (R2) to temp +- call OCR client (with timeout, retry wrapper) +- on success -> upload parsed JSON to R2 `results/{job_id}/...` and write `ParseResult` row, mark Task completed +- on failure -> increment attempts, if attempts < max => re-enqueue with backoff, else mark Task.failed and write last_error + +Client-side pseudocode (JS) +--------------------------- +- Upload (presigned): + 1) POST `/uploads/presign` -> get `upload_url` + `job_id`. + 2) PUT `upload_url` file (fetch, axios, etc.) + 3) POST `/uploads/complete` { job_id, file_key } + 4) Poll: GET `/jobs/{job_id}` until `status=completed|failed`. + +Examples (curl) +--------------- + +Presign: + +``` +curl -X POST "https://api.example.com/uploads/presign" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"filename":"doc.pdf","content_type":"application/pdf"}' +``` + +PUT to R2 (presigned URL) + +``` +curl -X PUT "https://r2-presigned-url" -H "Content-Type: application/pdf" --upload-file doc.pdf +``` + +Complete: + +``` +curl -X POST "https://api.example.com/uploads/complete" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"job_id":"...","file_key":"originals/.../doc.pdf"}' +``` + +Poll: + +``` +curl -X GET "https://api.example.com/jobs/{job_id}" -H "Authorization: Bearer $TOKEN" +``` + +Testing & validation +-------------------- +- Unit tests: + - validate upload request validation, DB creation. + - worker idempotency and retry logic (mock OCR client). +- Integration tests (fast): + - use in-memory queue or test Redis; send a small PDF, ensure tasks are created, workers process, final job completed. +- E2E test: + - run with a small OCR test provider or a mocked HTTP server to emulate OCR API responses including 429 and 5xx to validate backoff. +- CI guard: + - avoid calling real OCR provider in CI; mock external HTTP calls. + +Quality gates (quick triage) +--------------------------- +- Build: N/A (docs). Server code should run in local dev. +- Lint/Typecheck: ensure code follows project linters & types. +- Tests: add unit and integration tests for upload/worker flow. + +Operational notes & cost controls +------------------------------- +- Track per-job OCR API calls and cost; add throttling at queue or worker level. +- Provide rate-limiting by user to avoid abuse. +- For high-volume customers consider batching or a dedicated OCR plan with provider. + +Implementation checklist (practical) +---------------------------------- +- [ ] Implement `POST /uploads/presign` and presigned PUT flow. +- [ ] Implement `POST /uploads/complete` to create Document + Tasks. +- [ ] Implement queue + worker with rate-limited OCR client. +- [ ] Implement `GET /jobs/{job_id}`. +- [ ] Add idempotency via `client_upload_id` or checksum. +- [ ] Add monitoring metrics and DLQ. +- [ ] Add webhook option and signing. +- [ ] Add tests and docs (`docs/upload-and-parse-flow.md`). + +Next steps +---------- +- Implement the API endpoint stubs and a worker prototype in `backend/app/ocrs`. +- Add TypeScript/Python SDK snippets for upload + polling. +- Create tests that mock the OCR provider to validate retry/backoff and idempotency. + +--- + +Document created on: 2026-04-08 diff --git a/front-end/.env.local b/front-end/.env.local new file mode 100644 index 0000000000..c14b50c140 --- /dev/null +++ b/front-end/.env.local @@ -0,0 +1,2 @@ +# Backend API base URL (FastAPI) +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/front-end/.eslintrc.json b/front-end/.eslintrc.json new file mode 100644 index 0000000000..bffb357a71 --- /dev/null +++ b/front-end/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/front-end/.gitignore b/front-end/.gitignore new file mode 100644 index 0000000000..84f945b01d --- /dev/null +++ b/front-end/.gitignore @@ -0,0 +1,11 @@ +/node_modules +/.next +/out +/build +next-env.d.ts +*.tsbuildinfo +.DS_Store +.design-ref +bun.lockb +npm-debug.log* +openapi.json diff --git a/front-end/CLAUDE.md b/front-end/CLAUDE.md new file mode 100644 index 0000000000..018810b279 --- /dev/null +++ b/front-end/CLAUDE.md @@ -0,0 +1,459 @@ +# Claude Code — Next.js UI Clone Instructions + +> Attach this file to your Claude Code session. Then say: +> **"Follow CLAUDE.md and build the design at this URL: https://fcf9af8a-887b-48e9-8959-20fc729d1570.claudeusercontent.com/v1/design/projects/fcf9af8a-887b-48e9-8959-20fc729d1570/serve/Tabula-bundled.html?t=7a8e7a034f3adb118aebb7a2880618a5a478b71580190a030be6f207fb39cf82.16ca1d11-f9a7-478f-9b39-e5f9f99fd747.7cd9be66-50f7-4faf-874e-dc229a66e339.1781016670.fp&direct=1#how"** + +--- + +## 1. ANALYSIS PHASE + +Before writing any code: + +1. Fetch and screenshot the URL. Identify: + - All visible UI sections and their layout structure + - Color palette (extract exact hex values for both light AND dark variants if present) + - Typography: font families, sizes, weights, line-heights + - Spacing rhythm (padding, margin, gap patterns) + - Interactive elements (buttons, inputs, hover states, animations) + - Responsive breakpoints if detectable + - Any icons or illustration style +2. List every distinct component you see (e.g. Navbar, HeroSection, FeatureCard, Footer) +3. State your implementation plan before writing any code + +--- + +## 2. PROJECT STRUCTURE + +Scaffold this exact structure: + +``` +├── app/ +│ ├── layout.tsx # Root layout: fonts, metadata, theme + i18n providers +│ ├── globals.css # CSS reset + all design tokens (light + dark CSS vars) +│ │ +│ ├── [locale]/ # i18n dynamic segment (e.g. /en, /vi, /ja) +│ │ ├── layout.tsx # Locale layout: wraps with locale context +│ │ ├── page.tsx # Public marketing home page +│ │ │ +│ │ ├── (user)/ # Route group — registered users +│ │ │ ├── layout.tsx # User shell: sidebar + topbar for normal users +│ │ │ ├── dashboard/page.tsx +│ │ │ ├── profile/page.tsx +│ │ │ └── settings/page.tsx +│ │ │ +│ │ ├── (company)/ # Route group — company customers +│ │ │ ├── layout.tsx # Company shell: wider nav, team/org switcher +│ │ │ ├── dashboard/page.tsx +│ │ │ ├── members/page.tsx +│ │ │ ├── billing/page.tsx +│ │ │ └── settings/page.tsx +│ │ │ +│ │ └── (admin)/ # Route group — platform admins +│ │ ├── layout.tsx # Admin shell: full-width, dense data layout +│ │ ├── dashboard/page.tsx +│ │ ├── users/page.tsx +│ │ ├── companies/page.tsx +│ │ └── settings/page.tsx +│ +├── components/ +│ ├── ui/ # Generic reusable primitives +│ │ ├── Button.tsx +│ │ ├── Badge.tsx +│ │ ├── ThemeToggle.tsx # Dark/light mode switcher +│ │ ├── LocaleSwitcher.tsx # Language picker dropdown +│ │ └── ... # Only what the design uses +│ │ +│ ├── layouts/ # Layout shells per role +│ │ ├── UserShell.tsx # Sidebar + topbar for (user) group +│ │ ├── CompanyShell.tsx # Sidebar + org switcher for (company) group +│ │ └── AdminShell.tsx # Dense full-width shell for (admin) group +│ │ +│ └── sections/ # Public page sections (marketing site) +│ ├── Navbar.tsx +│ ├── HeroSection.tsx +│ └── ... # One file per visual section +│ +├── lib/ +│ ├── utils.ts # cn() helper (clsx + tailwind-merge) +│ ├── auth.ts # Role type definitions + mock auth helpers +│ └── i18n.ts # Locale config, supported locales list +│ +├── messages/ # i18n translation files +│ ├── en.json +│ ├── vi.json +│ └── [other locales as needed] +│ +├── middleware.ts # next-intl locale detection + redirect +├── public/ +│ └── fonts/ # Self-hosted fonts if needed +│ +├── tailwind.config.ts # Extended with design tokens, dark variant: 'class' +└── next.config.ts # Wrapped with next-intl plugin +``` + +--- + +## 3. DARK MODE + +**Implementation: `next-themes` with Tailwind class strategy** + +### Setup + +- Install `next-themes` +- In `tailwind.config.ts`: set `darkMode: 'class'` +- Wrap root layout with `` + +### CSS token strategy + +Define ALL colors as CSS custom properties in `globals.css` with both light and dark values: + +```css +:root { + --color-bg-primary: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-bg-surface: #f1f3f5; + --color-text-primary: #111827; + --color-text-secondary: #6b7280; + --color-text-muted: #9ca3af;s + --color-border: #e5e7eb; + --color-accent: #6366f1; /* replace with exact value from the URL */ + --color-accent-hover: #4f46e5; +} + +.dark { + --color-bg-primary: #0f1117; + --color-bg-secondary: #1a1d27; + --color-bg-surface: #22263a; + --color-text-primary: #f9fafb; + --color-text-secondary: #9ca3af; + --color-text-muted: #6b7280; + --color-border: #2d3147; + --color-accent: #818cf8; /* lighter variant for dark bg readability */ + --color-accent-hover: #6366f1; +} +``` + +Map every token into `tailwind.config.ts`: + +```ts +colors: { + bg: { + primary: 'var(--color-bg-primary)', + secondary: 'var(--color-bg-secondary)', + surface: 'var(--color-bg-surface)', + }, + text: { + primary: 'var(--color-text-primary)', + secondary: 'var(--color-text-secondary)', + muted: 'var(--color-text-muted)', + }, + border: 'var(--color-border)', + accent: { + DEFAULT: 'var(--color-accent)', + hover: 'var(--color-accent-hover)', + }, +} +``` + +### ThemeToggle component + +- Use `useTheme()` from `next-themes` +- Show sun icon in dark mode, moon icon in light mode +- Place in every layout shell topbar (UserShell, CompanyShell, AdminShell) and public Navbar +- Preference persists to localStorage automatically via `next-themes` + +### Rules + +- NEVER use hardcoded hex values in components — always use token classes (`bg-bg-primary`, `text-text-secondary`) +- Every color must have a dark-mode counterpart via the token system +- Images: add `dark:opacity-90` or `dark:brightness-90` if they look blown out in dark mode +- Mental test before finishing: "if this background were near-black, is every text element still readable?" + +--- + +## 4. MULTI-LANGUAGE (i18n) + +**Implementation: `next-intl` with App Router** + +### Setup + +```ts +// lib/i18n.ts +export const locales = ['en', 'vi'] as const // extend as needed +export type Locale = typeof locales[number] +export const defaultLocale: Locale = 'en' +``` + +```ts +// middleware.ts +import createMiddleware from 'next-intl/middleware' +import { locales, defaultLocale } from '@/lib/i18n' + +export default createMiddleware({ + locales, + defaultLocale, + localePrefix: 'always' // URLs: /en/dashboard, /vi/dashboard +}) + +export const config = { + matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'] +} +``` + +### Translation file structure + +```json +// messages/en.json +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "loading": "Loading...", + "error": "Something went wrong" + }, + "nav": { + "dashboard": "Dashboard", + "profile": "Profile", + "settings": "Settings", + "logout": "Log out" + }, + "user": { + "dashboard": { "title": "My Dashboard", "welcome": "Welcome back, {name}" } + }, + "company": { + "dashboard": { "title": "Company Dashboard", "members": "Team Members" } + }, + "admin": { + "dashboard": { "title": "Admin Panel", "users": "All Users", "companies": "All Companies" } + }, + "public": { + "hero": { "headline": "...", "subheading": "...", "cta": "Get Started" } + } +} +``` + +Mirror identical keys in `messages/vi.json` with Vietnamese translations. + +### Usage in components + +```tsx +import { useTranslations } from 'next-intl' + +export default function HeroSection() { + const t = useTranslations('public.hero') + return

{t('headline')}

+} +``` + +### LocaleSwitcher component + +- Dropdown listing all supported locales with their native names (English, Tiếng Việt, etc.) +- On select: use `next-intl`'s `useRouter` to call `router.replace(pathname, { locale: newLocale })` +- Place in every layout shell topbar and on the public Navbar, next to ThemeToggle + +### Rules + +- Zero hardcoded UI strings in JSX — every visible string goes through `t()` +- Data constants (nav items, feature lists) store translation keys, not raw strings +- Date/number formatting uses `next-intl`'s `useFormatter` for locale-aware output + +--- + +## 5. ROLE-BASED LAYOUTS + +Three distinct layout shells with separate visual hierarchy and navigation scope. + +### Role definitions + +```ts +// lib/auth.ts +export type UserRole = 'user' | 'company' | 'admin' + +export interface AuthUser { + id: string + name: string + email: string + role: UserRole + companyId?: string // present only for 'company' role +} +``` + +--- + +### Layout A — UserShell (registered users) + +**Character:** Personal, friendly, focused on individual tasks. Compact sidebar. + +``` +┌─────────────────────────────────────────────────────┐ +│ Topbar: Logo | Breadcrumb ThemeToggle Lang Avatar │ +├──────────┬──────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Page Content │ +│ (240px) │ │ +│ │ │ +│ Dashboard│ │ +│ Profile │ │ +│ Settings │ │ +│ │ │ +│ [Logout] │ │ +└──────────┴──────────────────────────────────────────┘ +``` + +- Sidebar collapses to icon-only on mobile via hamburger toggle +- Avatar displays user name + role badge labeled "User" +- No org/team switcher + +--- + +### Layout B — CompanyShell (company customers) + +**Character:** Professional, team-oriented. Wider sidebar with org switcher at top. + +``` +┌─────────────────────────────────────────────────────┐ +│ Topbar: [Org Switcher ▾] ThemeToggle Lang Avatar │ +├──────────┬──────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Page Content │ +│ (260px) │ │ +│ │ │ +│ Dashboard│ │ +│ Members │ │ +│ Billing │ │ +│ Settings │ │ +│ │ │ +│ [Logout] │ │ +└──────────┴──────────────────────────────────────────┘ +``` + +- Org Switcher dropdown shows company name + logo; supports multi-org switching +- Avatar displays user name + role badge labeled "Company" +- Sidebar accent color is visually distinct from UserShell (use a separate brand token) + +--- + +### Layout C — AdminShell (platform admins) + +**Character:** Dense, data-first, full-width. Top navigation bar instead of sidebar. + +``` +┌─────────────────────────────────────────────────────┐ +│ Logo | Dashboard Users Companies Settings | ThemeToggle Lang Avatar [ADMIN] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Page Content (full width) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +- Horizontal top nav (no sidebar) — maximises horizontal space for data tables +- Role badge "Admin" displayed in accent-red on the avatar chip +- Active nav item uses underline indicator, not background highlight +- Mobile: top nav collapses to hamburger → full-screen overlay menu + +--- + +### Route guard pattern + +Apply this to every role-group layout: + +```tsx +// app/[locale]/(user)/layout.tsx +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import UserShell from '@/components/layouts/UserShell' + +export default async function UserLayout({ + children, + params, +}: { + children: React.ReactNode + params: { locale: string } +}) { + const session = await getSession() + if (!session || session.role !== 'user') redirect(`/${params.locale}`) + return {children} +} +``` + +Repeat for `(company)` checking `role !== 'company'` and `(admin)` checking `role !== 'admin'`. + +--- + +## 6. TECHNICAL REQUIREMENTS + +**Stack:** +- Next.js 14+ with App Router +- TypeScript with `"strict": true` in `tsconfig.json` +- Tailwind CSS v3 — `darkMode: 'class'`, extended with all design tokens +- `next-themes` for dark mode persistence +- `next-intl` for i18n routing and translations +- No additional UI library unless the original design clearly uses one (shadcn/ui is acceptable if it fits) + +**Component rules:** +- Each component is a default export with a named TypeScript interface for its props +- All visible strings go through `useTranslations()` +- All colors go through CSS token classes — no hardcoded hex values in JSX +- Semantic HTML throughout: `
`, `