diff --git a/.env b/.env
index 1d44286e25..7299f47b22 100644
--- a/.env
+++ b/.env
@@ -31,12 +31,13 @@ SMTP_TLS=True
SMTP_SSL=False
SMTP_PORT=587
-# Postgres
-POSTGRES_SERVER=localhost
-POSTGRES_PORT=5432
-POSTGRES_DB=app
-POSTGRES_USER=postgres
-POSTGRES_PASSWORD=changethis
+# SQL Server
+MSSQL_SERVER=localhost
+MSSQL_PORT=1433
+MSSQL_DB=app
+MSSQL_USER=sa
+MSSQL_PASSWORD=Changethis1!
+MSSQL_DRIVER=ODBC Driver 18 for SQL Server
SENTRY_DSN=
diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml
index fd1190070e..788dd14fb4 100644
--- a/.github/workflows/deploy-production.yml
+++ b/.github/workflows/deploy-production.yml
@@ -23,7 +23,12 @@ jobs:
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
- POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ MSSQL_PASSWORD: ${{ secrets.MSSQL_PASSWORD }}
+ MSSQL_SERVER: ${{ secrets.MSSQL_SERVER }}
+ MSSQL_PORT: ${{ secrets.MSSQL_PORT }}
+ MSSQL_DB: ${{ secrets.MSSQL_DB }}
+ MSSQL_USER: ${{ secrets.MSSQL_USER }}
+ MSSQL_DRIVER: ${{ secrets.MSSQL_DRIVER }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
steps:
- name: Checkout
diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml
index 7968f950e7..5194c2f982 100644
--- a/.github/workflows/deploy-staging.yml
+++ b/.github/workflows/deploy-staging.yml
@@ -23,7 +23,12 @@ jobs:
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
- POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ MSSQL_PASSWORD: ${{ secrets.MSSQL_PASSWORD }}
+ MSSQL_SERVER: ${{ secrets.MSSQL_SERVER }}
+ MSSQL_PORT: ${{ secrets.MSSQL_PORT }}
+ MSSQL_DB: ${{ secrets.MSSQL_DB }}
+ MSSQL_USER: ${{ secrets.MSSQL_USER }}
+ MSSQL_DRIVER: ${{ secrets.MSSQL_DRIVER }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
steps:
- name: Checkout
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index d4c6247269..9bd8fef596 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -63,6 +63,8 @@ jobs:
working-directory: backend
- run: bun ci
working-directory: frontend
+ - name: Generate .env for CI
+ run: bash scripts/ci-generate-env.sh db
- run: bash scripts/generate-client.sh
- run: docker compose build
- run: docker compose down -v --remove-orphans
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index b609751643..03f4ba5b5e 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -49,6 +49,8 @@ jobs:
requirements**.txt
pyproject.toml
uv.lock
+ - name: Generate .env for CI
+ run: bash scripts/ci-generate-env.sh localhost
- name: Install backend dependencies
run: uv sync --all-packages
- name: Install frontend dependencies
diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml
index 1517812049..db75131577 100644
--- a/.github/workflows/test-backend.yml
+++ b/.github/workflows/test-backend.yml
@@ -21,8 +21,27 @@ jobs:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
+ - name: Install ODBC Driver 18 for SQL Server
+ run: |
+ curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
+ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list
+ sudo apt-get update
+ sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev
+ - name: Generate .env for CI
+ run: bash scripts/ci-generate-env.sh localhost
- run: docker compose down -v --remove-orphans
- run: docker compose up -d db mailcatcher
+ - name: Wait for SQL Server to be ready
+ run: |
+ for i in $(seq 1 30); do
+ docker compose exec db /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${MSSQL_PASSWORD}" -Q "SELECT 1" -C > /dev/null 2>&1 && exit 0
+ echo "Waiting for SQL Server... ($i/30)"
+ sleep 2
+ done
+ echo "SQL Server did not become ready in time" && exit 1
+ - name: Create database
+ run: |
+ docker compose exec db /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${MSSQL_PASSWORD}" -Q "IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = '${MSSQL_DB}') CREATE DATABASE [${MSSQL_DB}]" -C
- name: Migrate DB
run: uv run bash scripts/prestart.sh
working-directory: backend
diff --git a/.github/workflows/test-docker-compose.yml b/.github/workflows/test-docker-compose.yml
index 8054e5eafd..811f3562ce 100644
--- a/.github/workflows/test-docker-compose.yml
+++ b/.github/workflows/test-docker-compose.yml
@@ -16,6 +16,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ - name: Generate .env for CI
+ run: bash scripts/ci-generate-env.sh db
- run: docker compose build
- run: docker compose down -v --remove-orphans
- run: docker compose up -d --wait backend frontend adminer
diff --git a/README.md b/README.md
index a9049b4779..3337228a5e 100644
--- a/README.md
+++ b/README.md
@@ -206,6 +206,18 @@ The input variables, with their default values (some auto generated) are:
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
+## Example Items CRUD (reference)
+
+The **Items** feature is a full vertical slice (API, models, UI with table and dialogs, generated client, tests) kept as a **reference for new CRUDs**.
+
+For product delivery, **the Items page is not shown in the app**: the sidebar has no Items entry, and `/items` **redirects to the dashboard**. The code stays in the tree for you to copy or read.
+
+Main entry points:
+
+- **Backend:** `backend/app/api/routes/items.py`, `Item` in `backend/app/models.py`, helpers in `backend/app/crud.py`, tests in `backend/tests/api/routes/test_items.py`.
+- **Frontend:** `frontend/src/routes/_layout/items.tsx`, components under `frontend/src/components/Items/`, `ItemsService` in `frontend/src/client/`.
+- **E2E:** `frontend/tests/items.spec.ts` covers the redirect for the hidden route.
+
## Backend Development
Backend docs: [backend/README.md](./backend/README.md).
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 9f31dcd78a..cbba18e529 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,6 +2,16 @@ FROM python:3.10
ENV PYTHONUNBUFFERED=1
+# Install Microsoft ODBC Driver 18 for SQL Server
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends curl gnupg apt-transport-https && \
+ curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg && \
+ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/mssql-release.list && \
+ apt-get update && \
+ ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
# Install uv
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
diff --git a/backend/app/alembic/versions/0001_initial_schema.py b/backend/app/alembic/versions/0001_initial_schema.py
new file mode 100644
index 0000000000..1b53291a39
--- /dev/null
+++ b/backend/app/alembic/versions/0001_initial_schema.py
@@ -0,0 +1,149 @@
+"""Initial schema for SQL Server
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-03-30 21:00:00.000000
+
+"""
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "0001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Create user table
+ op.create_table(
+ "user",
+ 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(
+ "role",
+ sqlmodel.sql.sqltypes.AutoString(),
+ nullable=False,
+ server_default="comercial",
+ ),
+ 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_user_email"), "user", ["email"], unique=True)
+
+ # Create item table
+ op.create_table(
+ "item",
+ sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("owner_id", sa.Uuid(), nullable=False),
+ sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ # Create company table
+ op.create_table(
+ "company",
+ sa.Column("cnpj", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
+ sa.Column("razao_social", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("data_abertura", sa.Date(), nullable=True),
+ sa.Column("nome_fantasia", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("porte", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True),
+ sa.Column("atividade_economica_principal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("atividade_economica_secundaria", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("natureza_juridica", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("logradouro", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("numero", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True),
+ sa.Column("complemento", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("cep", sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
+ sa.Column("bairro", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("municipio", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("uf", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=True),
+ sa.Column("endereco_eletronico", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("telefone_comercial", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True),
+ sa.Column("situacao_cadastral", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True),
+ sa.Column("data_situacao_cadastral", sa.Date(), nullable=True),
+ sa.Column("cpf_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=14), nullable=True),
+ sa.Column("identidade_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True),
+ sa.Column("logradouro_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("numero_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True),
+ sa.Column("complemento_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("cep_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
+ sa.Column("bairro_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("municipio_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("uf_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=True),
+ sa.Column("endereco_eletronico_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column("telefones_representante_legal", sqlmodel.sql.sqltypes.AutoString(length=40), nullable=True),
+ sa.Column("data_nascimento_representante_legal", sa.Date(), nullable=True),
+ sa.Column("banco_cc_cnpj", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True),
+ sa.Column("agencia_cc_cnpj", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True),
+ sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column(
+ "status",
+ sqlmodel.sql.sqltypes.AutoString(),
+ nullable=False,
+ server_default="completed",
+ ),
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_company_cnpj"), "company", ["cnpj"], unique=True)
+
+ # Create companyinvite table
+ op.create_table(
+ "companyinvite",
+ sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("company_id", sa.Uuid(), nullable=False),
+ sa.Column("token", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("used", sa.Boolean(), nullable=False, server_default=sa.text("0")),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(["company_id"], ["company.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_companyinvite_token"), "companyinvite", ["token"], unique=True)
+
+ # Create auditlog table
+ op.create_table(
+ "auditlog",
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column(
+ "action",
+ sqlmodel.sql.sqltypes.AutoString(),
+ nullable=False,
+ ),
+ sa.Column("target_user_id", sa.Uuid(), nullable=False),
+ sa.Column("performed_by_id", sa.Uuid(), nullable=False),
+ sa.Column(
+ "changes",
+ sqlmodel.sql.sqltypes.AutoString(length=2000),
+ nullable=False,
+ server_default="",
+ ),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(["target_user_id"], ["user.id"]),
+ sa.ForeignKeyConstraint(["performed_by_id"], ["user.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+
+def downgrade():
+ op.drop_table("auditlog")
+ op.drop_index(op.f("ix_companyinvite_token"), table_name="companyinvite")
+ op.drop_table("companyinvite")
+ op.drop_index(op.f("ix_company_cnpj"), table_name="company")
+ op.drop_table("company")
+ op.drop_table("item")
+ op.drop_index(op.f("ix_user_email"), table_name="user")
+ op.drop_table("user")
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/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/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/deps.py b/backend/app/api/deps.py
index c2b83c841d..f944102659 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -11,7 +11,7 @@
from app.core import security
from app.core.config import settings
from app.core.db import engine
-from app.models import TokenPayload, User
+from app.models import USER_MANAGER_ROLES, TokenPayload, User
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
@@ -55,3 +55,12 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user
+
+
+def get_current_user_manager(current_user: CurrentUser) -> User:
+ if current_user.role not in USER_MANAGER_ROLES:
+ raise HTTPException(
+ status_code=403,
+ detail="The user doesn't have enough privileges to manage users",
+ )
+ return current_user
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index eac18c8e8f..e580ba6960 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from app.api.routes import items, login, private, users, utils
+from app.api.routes import companies, invites, items, login, private, users, utils
from app.core.config import settings
api_router = APIRouter()
@@ -8,6 +8,8 @@
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
+api_router.include_router(companies.router)
+api_router.include_router(invites.router)
if settings.ENVIRONMENT == "local":
diff --git a/backend/app/api/routes/companies.py b/backend/app/api/routes/companies.py
new file mode 100644
index 0000000000..1a60d855e0
--- /dev/null
+++ b/backend/app/api/routes/companies.py
@@ -0,0 +1,86 @@
+import logging
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, UploadFile
+
+from app.api.deps import CurrentUser, SessionDep
+from app.crud import create_company, get_company_by_cnpj
+from app.models import CompanyCreate, CompanyPublic, ResumeData
+from app.resume_parser import (
+ extract_text_from_docx,
+ extract_text_from_pdf,
+ parse_resume_text,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/companies", tags=["companies"])
+
+ALLOWED_EXTENSIONS = {".pdf", ".docx"}
+
+
+@router.post("/", response_model=CompanyPublic)
+def create_company_route(
+ *, session: SessionDep, current_user: CurrentUser, company_in: CompanyCreate # noqa: ARG001
+) -> Any:
+ """
+ Create new company (PJ).
+ """
+ existing_company = get_company_by_cnpj(session=session, cnpj=company_in.cnpj)
+ if existing_company:
+ raise HTTPException(
+ status_code=400,
+ detail="A company with this CNPJ already exists.",
+ )
+ company = create_company(session=session, company_in=company_in)
+ return company
+
+
+@router.post("/parse-resume", response_model=ResumeData)
+async def parse_resume(
+ *, current_user: CurrentUser, file: UploadFile # noqa: ARG001
+) -> Any:
+ """
+ Parse a resume file (PDF or DOCX) and extract structured data.
+ """
+ if not file.filename:
+ raise HTTPException(
+ status_code=400,
+ detail="Nenhum arquivo foi enviado.",
+ )
+
+ extension = ""
+ if "." in file.filename:
+ extension = "." + file.filename.rsplit(".", 1)[1].lower()
+
+ if extension not in ALLOWED_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail="Formato de arquivo não suportado. Envie um arquivo PDF ou DOCX.",
+ )
+
+ try:
+ file_bytes = await file.read()
+
+ if extension == ".pdf":
+ text = extract_text_from_pdf(file_bytes)
+ else:
+ text = extract_text_from_docx(file_bytes)
+
+ if not text.strip():
+ raise HTTPException(
+ status_code=400,
+ detail="Não foi possível extrair texto do arquivo. Verifique se o arquivo não está vazio ou protegido.",
+ )
+
+ parsed_data = parse_resume_text(text)
+ return ResumeData(**parsed_data)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.exception("Erro ao processar currículo: %s", e)
+ raise HTTPException(
+ status_code=400,
+ detail="Não foi possível ler o currículo enviado. Verifique o formato do arquivo e tente novamente.",
+ )
diff --git a/backend/app/api/routes/invites.py b/backend/app/api/routes/invites.py
new file mode 100644
index 0000000000..7e446ed119
--- /dev/null
+++ b/backend/app/api/routes/invites.py
@@ -0,0 +1,283 @@
+import logging
+import uuid as uuid_mod
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from sqlmodel import select
+
+from app.api.deps import CurrentUser, SessionDep
+from app.core.config import settings
+from app.crud import (
+ complete_company_registration,
+ create_company_initial,
+ create_company_invite,
+ get_company_by_cnpj,
+ get_invite_by_token,
+)
+from app.models import (
+ CompanyInvite,
+ CompanyInviteCreate,
+ CompanyInvitePublic,
+ CompanyInviteValidation,
+ CompanyPublic,
+ CompanyRegistrationComplete,
+ CompanyStatus,
+)
+from app.utils import (
+ generate_invite_token,
+ generate_pj_invite_email,
+ send_email,
+ verify_invite_token,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/invites", tags=["invites"])
+
+
+@router.post("/", response_model=CompanyInvitePublic)
+def send_invite(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser, # noqa: ARG001
+ invite_in: CompanyInviteCreate,
+) -> Any:
+ """
+ Send a PJ registration invite. Creates initial company record and sends email.
+ Only authorized internal users (Juridico, Financeiro, RH, Comercial) can send invites.
+ """
+ existing_company = get_company_by_cnpj(session=session, cnpj=invite_in.cnpj)
+
+ if existing_company and existing_company.status == CompanyStatus.completed:
+ raise HTTPException(
+ status_code=400,
+ detail="Uma empresa com este CNPJ já possui cadastro completo.",
+ )
+
+ if existing_company:
+ company = existing_company
+ company.email = invite_in.email
+ company.razao_social = invite_in.razao_social
+ session.add(company)
+ session.commit()
+ session.refresh(company)
+ else:
+ company = create_company_initial(
+ session=session,
+ cnpj=invite_in.cnpj,
+ email=invite_in.email,
+ razao_social=invite_in.razao_social,
+ )
+
+ token, expires_at = generate_invite_token(
+ company_id=str(company.id),
+ email=invite_in.email,
+ )
+
+ invite = create_company_invite(
+ session=session,
+ company_id=company.id,
+ email=invite_in.email,
+ token=token,
+ expires_at=expires_at,
+ )
+
+ link = f"{settings.FRONTEND_HOST}/pj-registration?token={token}"
+
+ try:
+ email_data = generate_pj_invite_email(
+ email_to=invite_in.email,
+ link=link,
+ valid_days=settings.INVITE_TOKEN_EXPIRE_DAYS,
+ )
+ send_email(
+ email_to=invite_in.email,
+ subject=email_data.subject,
+ html_content=email_data.html_content,
+ )
+ except Exception as e:
+ logger.error(
+ "Falha ao enviar e-mail de convite para %s (company_id=%s, invite_id=%s): %s",
+ invite_in.email,
+ company.id,
+ invite.id,
+ e,
+ )
+ raise HTTPException(
+ status_code=500,
+ detail="Falha ao enviar o e-mail de convite. O convite foi criado, tente reenviar.",
+ )
+
+ return invite
+
+
+@router.post("/{invite_id}/resend", response_model=CompanyInvitePublic)
+def resend_invite(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser, # noqa: ARG001
+ invite_id: str,
+) -> Any:
+ """
+ Resend a PJ registration invite. Generates a new token and sends a new email.
+ """
+ try:
+ invite_uuid = uuid_mod.UUID(invite_id)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="ID de convite inválido.")
+
+ statement = select(CompanyInvite).where(CompanyInvite.id == invite_uuid)
+ old_invite = session.exec(statement).first()
+
+ if not old_invite:
+ raise HTTPException(status_code=404, detail="Convite não encontrado.")
+
+ if old_invite.used:
+ raise HTTPException(
+ status_code=400,
+ detail="Este convite já foi utilizado. O cadastro já foi completado.",
+ )
+
+ company = old_invite.company
+ if not company:
+ raise HTTPException(status_code=404, detail="Empresa não encontrada.")
+
+ old_invite.used = True
+ session.add(old_invite)
+ session.commit()
+
+ token, expires_at = generate_invite_token(
+ company_id=str(company.id),
+ email=old_invite.email,
+ )
+
+ new_invite = create_company_invite(
+ session=session,
+ company_id=company.id,
+ email=old_invite.email,
+ token=token,
+ expires_at=expires_at,
+ )
+
+ link = f"{settings.FRONTEND_HOST}/pj-registration?token={token}"
+
+ try:
+ email_data = generate_pj_invite_email(
+ email_to=old_invite.email,
+ link=link,
+ valid_days=settings.INVITE_TOKEN_EXPIRE_DAYS,
+ )
+ send_email(
+ email_to=old_invite.email,
+ subject=email_data.subject,
+ html_content=email_data.html_content,
+ )
+ except Exception as e:
+ logger.error(
+ "Falha ao reenviar e-mail de convite para %s (invite_id=%s): %s",
+ old_invite.email,
+ new_invite.id,
+ e,
+ )
+ raise HTTPException(
+ status_code=500,
+ detail="Falha ao reenviar o e-mail de convite. Tente novamente.",
+ )
+
+ return new_invite
+
+
+@router.get("/validate", response_model=CompanyInviteValidation)
+def validate_invite_token(
+ *,
+ session: SessionDep,
+ token: str,
+) -> Any:
+ """
+ Validate an invite token. Public endpoint (no auth required).
+ Returns company data if token is valid.
+ """
+ token_data = verify_invite_token(token)
+ if not token_data:
+ return CompanyInviteValidation(
+ valid=False,
+ message="O link é inválido ou expirou. Solicite um novo convite ao responsável interno.",
+ )
+
+ invite = get_invite_by_token(session=session, token=token)
+ if not invite:
+ return CompanyInviteValidation(
+ valid=False,
+ message="O link é inválido ou expirou. Solicite um novo convite ao responsável interno.",
+ )
+
+ if invite.used:
+ return CompanyInviteValidation(
+ valid=False,
+ message="Este convite já foi utilizado. O cadastro já foi completado.",
+ )
+
+ company = invite.company
+ if not company:
+ return CompanyInviteValidation(
+ valid=False,
+ message="Empresa não encontrada.",
+ )
+
+ return CompanyInviteValidation(
+ valid=True,
+ company=CompanyPublic.model_validate(company),
+ )
+
+
+@router.put("/complete", response_model=CompanyPublic)
+def complete_registration(
+ *,
+ session: SessionDep,
+ registration_data: CompanyRegistrationComplete,
+) -> Any:
+ """
+ Complete PJ registration. Public endpoint (no auth required).
+ Requires a valid invite token.
+ """
+ token_data = verify_invite_token(registration_data.token)
+ if not token_data:
+ raise HTTPException(
+ status_code=400,
+ detail="O link é inválido ou expirou. Solicite um novo convite ao responsável interno.",
+ )
+
+ invite = get_invite_by_token(session=session, token=registration_data.token)
+ if not invite:
+ raise HTTPException(
+ status_code=400,
+ detail="Convite não encontrado.",
+ )
+
+ if invite.used:
+ raise HTTPException(
+ status_code=400,
+ detail="Este convite já foi utilizado.",
+ )
+
+ company = invite.company
+ if not company:
+ raise HTTPException(
+ status_code=404,
+ detail="Empresa não encontrada.",
+ )
+
+ if company.status == CompanyStatus.completed:
+ raise HTTPException(
+ status_code=400,
+ detail="O cadastro desta empresa já foi completado.",
+ )
+
+ updated_company = complete_company_registration(
+ session=session,
+ company=company,
+ invite=invite,
+ registration_data=registration_data,
+ )
+
+ return updated_company
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
index 35f64b626e..2778d0eec1 100644
--- a/backend/app/api/routes/users.py
+++ b/backend/app/api/routes/users.py
@@ -8,11 +8,13 @@
from app.api.deps import (
CurrentUser,
SessionDep,
- get_current_active_superuser,
+ get_current_user_manager,
)
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from app.models import (
+ AuditAction,
+ AuditLogsPublic,
Item,
Message,
UpdatePassword,
@@ -20,6 +22,7 @@
UserCreate,
UserPublic,
UserRegister,
+ UserRole,
UsersPublic,
UserUpdate,
UserUpdateMe,
@@ -31,7 +34,7 @@
@router.get(
"/",
- dependencies=[Depends(get_current_active_superuser)],
+ dependencies=[Depends(get_current_user_manager)],
response_model=UsersPublic,
)
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
@@ -51,12 +54,27 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
@router.post(
- "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic
+ "/",
+ dependencies=[Depends(get_current_user_manager)],
+ response_model=UserPublic,
)
-def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
+def create_user(
+ *, session: SessionDep, user_in: UserCreate, current_user: CurrentUser
+) -> Any:
"""
- Create new user.
+ Create new user. Requires email and role at minimum.
+ Password is optional (generated automatically for passwordless flow).
"""
+ # Only Super Admin can create another Super Admin
+ if (
+ user_in.role == UserRole.super_admin
+ and current_user.role != UserRole.super_admin
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Only a Super Admin can create another Super Admin",
+ )
+
user = crud.get_user_by_email(session=session, email=user_in.email)
if user:
raise HTTPException(
@@ -67,13 +85,23 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
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
+ email_to=user_in.email,
+ username=user_in.email,
+ password=user_in.password or "",
)
send_email(
email_to=user_in.email,
subject=email_data.subject,
html_content=email_data.html_content,
)
+
+ crud.create_audit_log(
+ session=session,
+ action=AuditAction.created,
+ target_user_id=user.id,
+ performed_by_id=current_user.id,
+ changes=f"User created with role={user_in.role.value}",
+ )
return user
@@ -158,6 +186,19 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
return user
+@router.get(
+ "/audit-log",
+ dependencies=[Depends(get_current_user_manager)],
+ response_model=AuditLogsPublic,
+)
+def read_audit_logs(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
+ """
+ Retrieve user audit logs.
+ """
+ logs, count = crud.get_audit_logs(session=session, skip=skip, limit=limit)
+ return AuditLogsPublic(data=logs, count=count)
+
+
@router.get("/{user_id}", response_model=UserPublic)
def read_user_by_id(
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
@@ -168,7 +209,13 @@ def read_user_by_id(
user = session.get(User, user_id)
if user == current_user:
return user
- if not current_user.is_superuser:
+ if not current_user.role or current_user.role not in [
+ UserRole.comercial,
+ UserRole.juridico,
+ UserRole.financeiro,
+ UserRole.rh,
+ UserRole.super_admin,
+ ]:
raise HTTPException(
status_code=403,
detail="The user doesn't have enough privileges",
@@ -180,7 +227,7 @@ def read_user_by_id(
@router.patch(
"/{user_id}",
- dependencies=[Depends(get_current_active_superuser)],
+ dependencies=[Depends(get_current_user_manager)],
response_model=UserPublic,
)
def update_user(
@@ -188,9 +235,10 @@ def update_user(
session: SessionDep,
user_id: uuid.UUID,
user_in: UserUpdate,
+ current_user: CurrentUser,
) -> Any:
"""
- Update a user.
+ Update a user (role, active status, etc.).
"""
db_user = session.get(User, user_id)
@@ -199,6 +247,24 @@ def update_user(
status_code=404,
detail="The user with this id does not exist in the system",
)
+ # Only Super Admin can modify a Super Admin user
+ if (
+ db_user.role == UserRole.super_admin
+ and current_user.role != UserRole.super_admin
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Only a Super Admin can modify another Super Admin",
+ )
+ # Only Super Admin can assign the Super Admin role
+ if (
+ user_in.role == UserRole.super_admin
+ and current_user.role != UserRole.super_admin
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Only a Super Admin can assign the Super Admin role",
+ )
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:
@@ -206,16 +272,49 @@ def update_user(
status_code=409, detail="User with this email already exists"
)
+ changes_parts = []
+ user_data = user_in.model_dump(exclude_unset=True)
+ if "role" in user_data and user_data["role"] is not None:
+ changes_parts.append(f"role: {db_user.role.value} -> {user_data['role']}")
+ if "is_active" in user_data and user_data["is_active"] is not None:
+ changes_parts.append(
+ f"is_active: {db_user.is_active} -> {user_data['is_active']}"
+ )
+ if "email" in user_data and user_data["email"] is not None:
+ changes_parts.append(f"email: {db_user.email} -> {user_data['email']}")
+ if "full_name" in user_data:
+ changes_parts.append(
+ f"full_name: {db_user.full_name} -> {user_data['full_name']}"
+ )
+
+ is_deactivation = (
+ "is_active" in user_data
+ and user_data["is_active"] is False
+ and db_user.is_active is True
+ )
+
db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in)
+
+ audit_action = AuditAction.deactivated if is_deactivation else AuditAction.updated
+ crud.create_audit_log(
+ session=session,
+ action=audit_action,
+ target_user_id=db_user.id,
+ performed_by_id=current_user.id,
+ changes="; ".join(changes_parts) if changes_parts else "No changes",
+ )
return db_user
-@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
+@router.delete(
+ "/{user_id}",
+ dependencies=[Depends(get_current_user_manager)],
+)
def delete_user(
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
) -> Message:
"""
- Delete a user.
+ Delete a user. Only a Super Admin can delete another Super Admin.
"""
user = session.get(User, user_id)
if not user:
@@ -224,6 +323,11 @@ def delete_user(
raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves"
)
+ if user.role == UserRole.super_admin and current_user.role != UserRole.super_admin:
+ raise HTTPException(
+ status_code=403,
+ detail="Only a Super Admin can delete another Super Admin",
+ )
statement = delete(Item).where(col(Item.owner_id) == user_id)
session.exec(statement)
session.delete(user)
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 650b9f7910..3803acda56 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -1,13 +1,13 @@
import secrets
import warnings
from typing import Annotated, Any, Literal
+from urllib.parse import quote_plus
from pydantic import (
AnyUrl,
BeforeValidator,
EmailStr,
HttpUrl,
- PostgresDsn,
computed_field,
model_validator,
)
@@ -50,22 +50,21 @@ def all_cors_origins(self) -> list[str]:
PROJECT_NAME: str
SENTRY_DSN: HttpUrl | None = None
- POSTGRES_SERVER: str
- POSTGRES_PORT: int = 5432
- POSTGRES_USER: str
- POSTGRES_PASSWORD: str = ""
- POSTGRES_DB: str = ""
+ MSSQL_SERVER: str
+ MSSQL_PORT: int = 1433
+ MSSQL_USER: str
+ MSSQL_PASSWORD: str = ""
+ MSSQL_DB: str = ""
+ MSSQL_DRIVER: str = "ODBC Driver 18 for SQL Server"
@computed_field # type: ignore[prop-decorator]
@property
- def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
- return PostgresDsn.build(
- scheme="postgresql+psycopg",
- username=self.POSTGRES_USER,
- password=self.POSTGRES_PASSWORD,
- host=self.POSTGRES_SERVER,
- port=self.POSTGRES_PORT,
- path=self.POSTGRES_DB,
+ def SQLALCHEMY_DATABASE_URI(self) -> str:
+ driver = self.MSSQL_DRIVER.replace(" ", "+")
+ return (
+ f"mssql+pyodbc://{quote_plus(self.MSSQL_USER)}:{quote_plus(self.MSSQL_PASSWORD)}"
+ f"@{self.MSSQL_SERVER}:{self.MSSQL_PORT}/{self.MSSQL_DB}"
+ f"?driver={driver}&TrustServerCertificate=yes"
)
SMTP_TLS: bool = True
@@ -84,6 +83,7 @@ def _set_default_emails_from(self) -> Self:
return self
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
+ INVITE_TOKEN_EXPIRE_DAYS: int = 3
@computed_field # type: ignore[prop-decorator]
@property
@@ -95,9 +95,10 @@ def emails_enabled(self) -> bool:
FIRST_SUPERUSER_PASSWORD: str
def _check_default_secret(self, var_name: str, value: str | None) -> None:
- if value == "changethis":
+ default_secrets = ("changethis", "Changethis1!")
+ if value in default_secrets:
message = (
- f'The value of {var_name} is "changethis", '
+ f"The value of {var_name} is a default placeholder, "
"for security, please change it, at least for deployments."
)
if self.ENVIRONMENT == "local":
@@ -108,7 +109,7 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None:
@model_validator(mode="after")
def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
- self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
+ self._check_default_secret("MSSQL_PASSWORD", self.MSSQL_PASSWORD)
self._check_default_secret(
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
)
diff --git a/backend/app/core/db.py b/backend/app/core/db.py
index ba991fb36d..767558b363 100644
--- a/backend/app/core/db.py
+++ b/backend/app/core/db.py
@@ -2,7 +2,7 @@
from app import crud
from app.core.config import settings
-from app.models import User, UserCreate
+from app.models import User, UserCreate, UserRole
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
@@ -28,6 +28,6 @@ def init_db(session: Session) -> None:
user_in = UserCreate(
email=settings.FIRST_SUPERUSER,
password=settings.FIRST_SUPERUSER_PASSWORD,
- is_superuser=True,
+ role=UserRole.super_admin,
)
user = crud.create_user(session=session, user_create=user_in)
diff --git a/backend/app/crud.py b/backend/app/crud.py
index a8ceba6444..04706c8ce8 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -1,15 +1,42 @@
+import secrets
import uuid
from typing import Any
-from sqlmodel import Session, select
+from sqlmodel import Session, col, func, select
from app.core.security import get_password_hash, verify_password
-from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
+from app.models import (
+ AuditAction,
+ AuditLog,
+ AuditLogPublic,
+ Company,
+ CompanyCreate,
+ CompanyInvite,
+ CompanyRegistrationComplete,
+ CompanyStatus,
+ Item,
+ ItemCreate,
+ User,
+ UserCreate,
+ UserRole,
+ UserUpdate,
+)
def create_user(*, session: Session, user_create: UserCreate) -> User:
+ password = user_create.password or secrets.token_urlsafe(32)
+ is_superuser = (
+ user_create.role == UserRole.super_admin
+ if hasattr(user_create, "role")
+ else False
+ )
db_obj = User.model_validate(
- user_create, update={"hashed_password": get_password_hash(user_create.password)}
+ user_create,
+ update={
+ "hashed_password": get_password_hash(password),
+ "is_superuser": is_superuser,
+ "is_active": True,
+ },
)
session.add(db_obj)
session.commit()
@@ -24,6 +51,8 @@ def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
password = user_data["password"]
hashed_password = get_password_hash(password)
extra_data["hashed_password"] = hashed_password
+ if "role" in user_data and user_data["role"] is not None:
+ extra_data["is_superuser"] = user_data["role"] == UserRole.super_admin
db_user.sqlmodel_update(user_data, update=extra_data)
session.add(db_user)
session.commit()
@@ -66,3 +95,124 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
session.commit()
session.refresh(db_item)
return db_item
+
+
+def get_company_by_cnpj(*, session: Session, cnpj: str) -> Company | None:
+ statement = select(Company).where(Company.cnpj == cnpj)
+ return session.exec(statement).first()
+
+
+def create_company(*, session: Session, company_in: CompanyCreate) -> Company:
+ db_company = Company.model_validate(company_in)
+ session.add(db_company)
+ session.commit()
+ session.refresh(db_company)
+ return db_company
+
+
+def create_company_initial(
+ *, session: Session, cnpj: str, email: str, razao_social: str
+) -> Company:
+ db_company = Company(
+ cnpj=cnpj,
+ email=email,
+ razao_social=razao_social,
+ status=CompanyStatus.pending,
+ )
+ session.add(db_company)
+ session.commit()
+ session.refresh(db_company)
+ return db_company
+
+
+def create_company_invite(
+ *, session: Session, company_id: uuid.UUID, email: str, token: str, expires_at: Any
+) -> CompanyInvite:
+ db_invite = CompanyInvite(
+ company_id=company_id,
+ email=email,
+ token=token,
+ expires_at=expires_at,
+ )
+ session.add(db_invite)
+ session.commit()
+ session.refresh(db_invite)
+ return db_invite
+
+
+def get_invite_by_token(*, session: Session, token: str) -> CompanyInvite | None:
+ statement = select(CompanyInvite).where(CompanyInvite.token == token)
+ return session.exec(statement).first()
+
+
+def complete_company_registration(
+ *,
+ session: Session,
+ company: Company,
+ invite: CompanyInvite,
+ registration_data: CompanyRegistrationComplete,
+) -> Company:
+ update_data = registration_data.model_dump(exclude={"token"})
+ company.sqlmodel_update(update_data)
+ company.status = CompanyStatus.completed
+ session.add(company)
+
+ invite.used = True
+ session.add(invite)
+
+ session.commit()
+ session.refresh(company)
+ return company
+
+
+def create_audit_log(
+ *,
+ session: Session,
+ action: AuditAction,
+ target_user_id: uuid.UUID,
+ performed_by_id: uuid.UUID,
+ changes: str = "",
+) -> AuditLog:
+ db_log = AuditLog(
+ action=action,
+ target_user_id=target_user_id,
+ performed_by_id=performed_by_id,
+ changes=changes,
+ )
+ session.add(db_log)
+ session.commit()
+ session.refresh(db_log)
+ return db_log
+
+
+def get_audit_logs(
+ *, session: Session, skip: int = 0, limit: int = 100
+) -> tuple[list[AuditLogPublic], int]:
+ count_statement = select(func.count()).select_from(AuditLog)
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(AuditLog)
+ .order_by(col(AuditLog.created_at).desc())
+ .offset(skip)
+ .limit(limit)
+ )
+ logs = session.exec(statement).all()
+
+ result = []
+ for log in logs:
+ target = session.get(User, log.target_user_id)
+ performer = session.get(User, log.performed_by_id)
+ result.append(
+ AuditLogPublic(
+ id=log.id,
+ action=log.action,
+ target_user_id=log.target_user_id,
+ performed_by_id=log.performed_by_id,
+ changes=log.changes,
+ created_at=log.created_at,
+ target_user_email=target.email if target else None,
+ performed_by_email=performer.email if performer else None,
+ )
+ )
+ return result, count
diff --git a/backend/app/email-templates/build/pj_invite.html b/backend/app/email-templates/build/pj_invite.html
new file mode 100644
index 0000000000..064290d90e
--- /dev/null
+++ b/backend/app/email-templates/build/pj_invite.html
@@ -0,0 +1,25 @@
+
{{ project_name }} - Convite para Cadastro PJ | Prezado(a), | Você foi convidado(a) para completar o cadastro da sua Pessoa Jurídica em nosso sistema. Para prosseguir, clique no botão abaixo: | | Ou copie e cole o seguinte link no seu navegador: | | Este link tem validade de {{ valid_days }} dias. | Ao acessar o link, preencha todos os campos obrigatórios do cadastro e clique em Salvar para concluir. | | Caso o link tenha expirado, entre em contato com o responsável interno para solicitar um novo convite. |
|
diff --git a/backend/app/models.py b/backend/app/models.py
index b5132e0e2c..b5b47b93b0 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,11 +1,42 @@
import uuid
-from datetime import datetime, timezone
+from datetime import date, datetime, timezone
+from enum import Enum
from pydantic import EmailStr
from sqlalchemy import DateTime
from sqlmodel import Field, Relationship, SQLModel
+class CompanyStatus(str, Enum):
+ pending = "pending"
+ completed = "completed"
+
+
+class UserRole(str, Enum):
+ comercial = "comercial"
+ juridico = "juridico"
+ financeiro = "financeiro"
+ rh = "rh"
+ pj = "pj"
+ super_admin = "super_admin"
+
+
+# Roles that are allowed to manage (create/edit/deactivate) users
+USER_MANAGER_ROLES = {
+ UserRole.comercial,
+ UserRole.juridico,
+ UserRole.financeiro,
+ UserRole.rh,
+ UserRole.super_admin,
+}
+
+
+class AuditAction(str, Enum):
+ created = "created"
+ updated = "updated"
+ deactivated = "deactivated"
+
+
def get_datetime_utc() -> datetime:
return datetime.now(timezone.utc)
@@ -16,11 +47,15 @@ class UserBase(SQLModel):
is_active: bool = True
is_superuser: bool = False
full_name: str | None = Field(default=None, max_length=255)
+ role: UserRole = Field(default=UserRole.comercial)
# Properties to receive via API on creation
-class UserCreate(UserBase):
- password: str = Field(min_length=8, max_length=128)
+class UserCreate(SQLModel):
+ email: EmailStr = Field(max_length=255)
+ role: UserRole = Field(default=UserRole.comercial)
+ full_name: str | None = Field(default=None, max_length=255)
+ password: str | None = Field(default=None, min_length=8, max_length=128)
class UserRegister(SQLModel):
@@ -30,8 +65,11 @@ class UserRegister(SQLModel):
# Properties to receive via API on update, all are optional
-class UserUpdate(UserBase):
- email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
+class UserUpdate(SQLModel):
+ email: EmailStr | None = Field(default=None, max_length=255)
+ full_name: str | None = Field(default=None, max_length=255)
+ role: UserRole | None = None
+ is_active: bool | None = None
password: str | None = Field(default=None, min_length=8, max_length=128)
@@ -54,12 +92,21 @@ class User(UserBase, table=True):
sa_type=DateTime(timezone=True), # type: ignore
)
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
+ audit_logs_performed: list["AuditLog"] = Relationship(
+ back_populates="performed_by",
+ sa_relationship_kwargs={"foreign_keys": "[AuditLog.performed_by_id]"},
+ )
+ audit_logs_target: list["AuditLog"] = Relationship(
+ back_populates="target_user",
+ sa_relationship_kwargs={"foreign_keys": "[AuditLog.target_user_id]"},
+ )
# Properties to return via API, id is always required
class UserPublic(UserBase):
id: uuid.UUID
created_at: datetime | None = None
+ role: UserRole = UserRole.comercial
class UsersPublic(SQLModel):
@@ -108,6 +155,199 @@ class ItemsPublic(SQLModel):
count: int
+# Shared properties for Company (PJ)
+class CompanyBase(SQLModel):
+ cnpj: str = Field(min_length=1, max_length=20)
+ razao_social: str | None = Field(default=None, max_length=255)
+ representante_legal: str | None = Field(default=None, max_length=255)
+ data_abertura: date | None = None
+ nome_fantasia: str | None = Field(default=None, max_length=255)
+ porte: str | None = Field(default=None, max_length=100)
+ atividade_economica_principal: str | None = Field(default=None, max_length=255)
+ atividade_economica_secundaria: str | None = Field(default=None, max_length=255)
+ natureza_juridica: str | None = Field(default=None, max_length=255)
+ logradouro: str | None = Field(default=None, max_length=255)
+ numero: str | None = Field(default=None, max_length=20)
+ complemento: str | None = Field(default=None, max_length=255)
+ cep: str | None = Field(default=None, max_length=10)
+ bairro: str | None = Field(default=None, max_length=255)
+ municipio: str | None = Field(default=None, max_length=255)
+ uf: str | None = Field(default=None, max_length=2)
+ endereco_eletronico: str | None = Field(default=None, max_length=255)
+ telefone_comercial: str | None = Field(default=None, max_length=20)
+ situacao_cadastral: str | None = Field(default=None, max_length=100)
+ data_situacao_cadastral: date | None = None
+ cpf_representante_legal: str | None = Field(default=None, max_length=14)
+ identidade_representante_legal: str | None = Field(default=None, max_length=20)
+ logradouro_representante_legal: str | None = Field(default=None, max_length=255)
+ numero_representante_legal: str | None = Field(default=None, max_length=20)
+ complemento_representante_legal: str | None = Field(default=None, max_length=255)
+ cep_representante_legal: str | None = Field(default=None, max_length=10)
+ bairro_representante_legal: str | None = Field(default=None, max_length=255)
+ municipio_representante_legal: str | None = Field(default=None, max_length=255)
+ uf_representante_legal: str | None = Field(default=None, max_length=2)
+ endereco_eletronico_representante_legal: str | None = Field(
+ default=None, max_length=255
+ )
+ telefones_representante_legal: str | None = Field(default=None, max_length=40)
+ data_nascimento_representante_legal: date | None = None
+ banco_cc_cnpj: str | None = Field(default=None, max_length=100)
+ agencia_cc_cnpj: str | None = Field(default=None, max_length=20)
+
+
+# Properties to receive on full company creation (all fields required)
+class CompanyCreate(SQLModel):
+ cnpj: str = Field(min_length=1, max_length=20)
+ razao_social: str = Field(min_length=1, max_length=255)
+ representante_legal: str = Field(min_length=1, max_length=255)
+ data_abertura: date
+ nome_fantasia: str = Field(min_length=1, max_length=255)
+ porte: str = Field(min_length=1, max_length=100)
+ atividade_economica_principal: str = Field(min_length=1, max_length=255)
+ atividade_economica_secundaria: str = Field(min_length=1, max_length=255)
+ natureza_juridica: str = Field(min_length=1, max_length=255)
+ logradouro: str = Field(min_length=1, max_length=255)
+ numero: str = Field(min_length=1, max_length=20)
+ complemento: str = Field(min_length=1, max_length=255)
+ cep: str = Field(min_length=1, max_length=10)
+ bairro: str = Field(min_length=1, max_length=255)
+ municipio: str = Field(min_length=1, max_length=255)
+ uf: str = Field(min_length=1, max_length=2)
+ endereco_eletronico: str = Field(min_length=1, max_length=255)
+ telefone_comercial: str = Field(min_length=1, max_length=20)
+ situacao_cadastral: str = Field(min_length=1, max_length=100)
+ data_situacao_cadastral: date
+ cpf_representante_legal: str = Field(min_length=1, max_length=14)
+ identidade_representante_legal: str = Field(min_length=1, max_length=20)
+ logradouro_representante_legal: str = Field(min_length=1, max_length=255)
+ numero_representante_legal: str = Field(min_length=1, max_length=20)
+ complemento_representante_legal: str = Field(min_length=1, max_length=255)
+ cep_representante_legal: str = Field(min_length=1, max_length=10)
+ bairro_representante_legal: str = Field(min_length=1, max_length=255)
+ municipio_representante_legal: str = Field(min_length=1, max_length=255)
+ uf_representante_legal: str = Field(min_length=1, max_length=2)
+ endereco_eletronico_representante_legal: str = Field(min_length=1, max_length=255)
+ telefones_representante_legal: str = Field(min_length=1, max_length=40)
+ data_nascimento_representante_legal: date
+ banco_cc_cnpj: str = Field(min_length=1, max_length=100)
+ agencia_cc_cnpj: str = Field(min_length=1, max_length=20)
+
+
+# Database model, database table inferred from class name
+class Company(CompanyBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ cnpj: str = Field(unique=True, index=True, min_length=1, max_length=20)
+ email: str | None = Field(default=None, max_length=255)
+ status: CompanyStatus = Field(default=CompanyStatus.completed)
+ created_at: datetime | None = Field(
+ default_factory=get_datetime_utc,
+ sa_type=DateTime(timezone=True), # type: ignore
+ )
+ invites: list["CompanyInvite"] = Relationship(
+ back_populates="company", cascade_delete=True
+ )
+
+
+# Properties to return via API, id is always required
+class CompanyPublic(CompanyBase):
+ id: uuid.UUID
+ email: str | None = None
+ status: CompanyStatus = CompanyStatus.completed
+ created_at: datetime | None = None
+
+
+# Invite model for PJ registration via email
+class CompanyInviteBase(SQLModel):
+ email: EmailStr = Field(max_length=255)
+
+
+class CompanyInviteCreate(SQLModel):
+ cnpj: str = Field(min_length=1, max_length=20)
+ email: EmailStr = Field(max_length=255)
+ razao_social: str = Field(min_length=1, max_length=255)
+
+
+class CompanyInvite(CompanyInviteBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ company_id: uuid.UUID = Field(
+ foreign_key="company.id", nullable=False, ondelete="CASCADE"
+ )
+ token: str = Field(unique=True, index=True, max_length=500)
+ expires_at: datetime = Field(sa_type=DateTime(timezone=True)) # type: ignore
+ used: bool = Field(default=False)
+ created_at: datetime | None = Field(
+ default_factory=get_datetime_utc,
+ sa_type=DateTime(timezone=True), # type: ignore
+ )
+ company: Company | None = Relationship(back_populates="invites")
+
+
+class CompanyInvitePublic(CompanyInviteBase):
+ id: uuid.UUID
+ company_id: uuid.UUID
+ expires_at: datetime
+ used: bool
+ created_at: datetime | None = None
+
+
+# Schema for PJ completing their registration via invite token
+class CompanyRegistrationComplete(SQLModel):
+ token: str
+ razao_social: str = Field(min_length=1, max_length=255)
+ representante_legal: str = Field(min_length=1, max_length=255)
+ data_abertura: date
+ nome_fantasia: str = Field(min_length=1, max_length=255)
+ porte: str = Field(min_length=1, max_length=100)
+ atividade_economica_principal: str = Field(min_length=1, max_length=255)
+ atividade_economica_secundaria: str = Field(min_length=1, max_length=255)
+ natureza_juridica: str = Field(min_length=1, max_length=255)
+ logradouro: str = Field(min_length=1, max_length=255)
+ numero: str = Field(min_length=1, max_length=20)
+ complemento: str = Field(min_length=1, max_length=255)
+ cep: str = Field(min_length=1, max_length=10)
+ bairro: str = Field(min_length=1, max_length=255)
+ municipio: str = Field(min_length=1, max_length=255)
+ uf: str = Field(min_length=1, max_length=2)
+ endereco_eletronico: str = Field(min_length=1, max_length=255)
+ telefone_comercial: str = Field(min_length=1, max_length=20)
+ situacao_cadastral: str = Field(min_length=1, max_length=100)
+ data_situacao_cadastral: date
+ cpf_representante_legal: str = Field(min_length=1, max_length=14)
+ identidade_representante_legal: str = Field(min_length=1, max_length=20)
+ logradouro_representante_legal: str = Field(min_length=1, max_length=255)
+ numero_representante_legal: str = Field(min_length=1, max_length=20)
+ complemento_representante_legal: str = Field(min_length=1, max_length=255)
+ cep_representante_legal: str = Field(min_length=1, max_length=10)
+ bairro_representante_legal: str = Field(min_length=1, max_length=255)
+ municipio_representante_legal: str = Field(min_length=1, max_length=255)
+ uf_representante_legal: str = Field(min_length=1, max_length=2)
+ endereco_eletronico_representante_legal: str = Field(min_length=1, max_length=255)
+ telefones_representante_legal: str = Field(min_length=1, max_length=40)
+ data_nascimento_representante_legal: date
+ banco_cc_cnpj: str = Field(min_length=1, max_length=100)
+ agencia_cc_cnpj: str = Field(min_length=1, max_length=20)
+
+
+# Schema for token validation response
+class CompanyInviteValidation(SQLModel):
+ valid: bool
+ company: CompanyPublic | None = None
+ message: str | None = None
+
+
+# Resume parsed data (not a DB table, just a response model)
+class ResumeData(SQLModel):
+ name: str = ""
+ email: str = ""
+ phone: str = ""
+ city: str = ""
+ state: str = ""
+ linkedin: str = ""
+ skills: list[str] = []
+ education: list[str] = []
+ experience: list[str] = []
+
+
# Generic message
class Message(SQLModel):
message: str
@@ -127,3 +367,40 @@ class TokenPayload(SQLModel):
class NewPassword(SQLModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
+
+
+# Audit log database model
+class AuditLog(SQLModel, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ action: AuditAction
+ target_user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False)
+ performed_by_id: uuid.UUID = Field(foreign_key="user.id", nullable=False)
+ changes: str = Field(default="", max_length=2000)
+ created_at: datetime | None = Field(
+ default_factory=get_datetime_utc,
+ sa_type=DateTime(timezone=True), # type: ignore
+ )
+ target_user: User | None = Relationship(
+ back_populates="audit_logs_target",
+ sa_relationship_kwargs={"foreign_keys": "[AuditLog.target_user_id]"},
+ )
+ performed_by: User | None = Relationship(
+ back_populates="audit_logs_performed",
+ sa_relationship_kwargs={"foreign_keys": "[AuditLog.performed_by_id]"},
+ )
+
+
+class AuditLogPublic(SQLModel):
+ id: uuid.UUID
+ action: AuditAction
+ target_user_id: uuid.UUID
+ performed_by_id: uuid.UUID
+ changes: str
+ created_at: datetime | None = None
+ target_user_email: str | None = None
+ performed_by_email: str | None = None
+
+
+class AuditLogsPublic(SQLModel):
+ data: list[AuditLogPublic]
+ count: int
diff --git a/backend/app/resume_parser.py b/backend/app/resume_parser.py
new file mode 100644
index 0000000000..452bbd8ecf
--- /dev/null
+++ b/backend/app/resume_parser.py
@@ -0,0 +1,188 @@
+"""Service for extracting and parsing resume/CV data from PDF and DOCX files."""
+
+import io
+import re
+
+import docx
+from PyPDF2 import PdfReader
+
+
+def extract_text_from_pdf(file_bytes: bytes) -> str:
+ """Extract text content from a PDF file."""
+ reader = PdfReader(io.BytesIO(file_bytes))
+ text_parts: list[str] = []
+ for page in reader.pages:
+ page_text = page.extract_text()
+ if page_text:
+ text_parts.append(page_text)
+ return "\n".join(text_parts)
+
+
+def extract_text_from_docx(file_bytes: bytes) -> str:
+ """Extract text content from a DOCX file."""
+ doc = docx.Document(io.BytesIO(file_bytes))
+ text_parts: list[str] = []
+ for paragraph in doc.paragraphs:
+ if paragraph.text.strip():
+ text_parts.append(paragraph.text.strip())
+ return "\n".join(text_parts)
+
+
+def _extract_email(text: str) -> str:
+ """Extract the first email address found in the text."""
+ match = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
+ return match.group(0) if match else ""
+
+
+def _extract_phone(text: str) -> str:
+ """Extract the first phone number found in the text (Brazilian format)."""
+ patterns = [
+ r"\+55\s*\(?\d{2}\)?\s*\d{4,5}[\-\s]?\d{4}",
+ r"\(?\d{2}\)?\s*\d{4,5}[\-\s]?\d{4}",
+ ]
+ for pattern in patterns:
+ match = re.search(pattern, text)
+ if match:
+ return match.group(0).strip()
+ return ""
+
+
+def _extract_linkedin(text: str) -> str:
+ """Extract LinkedIn profile URL from the text."""
+ match = re.search(
+ r"(?:https?://)?(?:www\.)?linkedin\.com/in/[a-zA-Z0-9\-_%]+/?", text
+ )
+ return match.group(0) if match else ""
+
+
+def _extract_name(text: str) -> str:
+ """Extract the candidate's name (typically the first non-empty line)."""
+ lines = text.strip().split("\n")
+ for line in lines:
+ cleaned = line.strip()
+ if not cleaned:
+ continue
+ if "@" in cleaned or "http" in cleaned.lower():
+ continue
+ if re.match(r"^[\d\(\)+\-\s]+$", cleaned):
+ continue
+ if len(cleaned) < 3 or len(cleaned) > 80:
+ continue
+ if re.match(r"^[A-ZÀ-ÖØ-Ýa-zà-öø-ÿ\s\.]+$", cleaned):
+ return cleaned
+ return ""
+
+
+def _extract_city_state(text: str) -> tuple[str, str]:
+ """Extract city and state (UF) from the text."""
+ uf_list = [
+ "AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA",
+ "MT", "MS", "MG", "PA", "PB", "PR", "PE", "PI", "RJ", "RN",
+ "RS", "RO", "RR", "SC", "SP", "SE", "TO",
+ ]
+ uf_pattern = "|".join(uf_list)
+
+ patterns = [
+ rf"([A-ZÀ-Öa-zà-ö\s]+)\s*[/\-,]\s*({uf_pattern})\b",
+ rf"\b({uf_pattern})\s*[/\-,]\s*([A-ZÀ-Öa-zà-ö\s]+)",
+ ]
+
+ for pattern in patterns:
+ match = re.search(pattern, text)
+ if match:
+ groups = match.groups()
+ if groups[0] in uf_list:
+ return groups[1].strip(), groups[0]
+ return groups[0].strip(), groups[1].strip()
+
+ return "", ""
+
+
+def _extract_skills(text: str) -> list[str]:
+ """Extract skills from common resume sections."""
+ skills: list[str] = []
+
+ section_patterns = [
+ r"(?:habilidades|competências|skills|tecnologias|conhecimentos)\s*:?\s*\n?(.*?)(?:\n\n|\Z)",
+ r"(?:HABILIDADES|COMPETÊNCIAS|SKILLS|TECNOLOGIAS|CONHECIMENTOS)\s*:?\s*\n?(.*?)(?:\n\n|\Z)",
+ ]
+
+ for pattern in section_patterns:
+ match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
+ if match:
+ section_text = match.group(1).strip()
+ items = re.split(r"[,;•\-\n|]+", section_text)
+ for item in items:
+ cleaned = item.strip()
+ if cleaned and len(cleaned) > 1 and len(cleaned) < 60:
+ skills.append(cleaned)
+ break
+
+ return skills
+
+
+def _extract_education(text: str) -> list[str]:
+ """Extract education entries from common resume sections."""
+ education: list[str] = []
+
+ section_patterns = [
+ r"(?:formação|educação|education|formação acadêmica|escolaridade)\s*:?\s*\n?(.*?)(?:\n\n|\Z)",
+ r"(?:FORMAÇÃO|EDUCAÇÃO|EDUCATION|FORMAÇÃO ACADÊMICA|ESCOLARIDADE)\s*:?\s*\n?(.*?)(?:\n\n|\Z)",
+ ]
+
+ for pattern in section_patterns:
+ match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
+ if match:
+ section_text = match.group(1).strip()
+ lines = section_text.split("\n")
+ for line in lines:
+ cleaned = line.strip().lstrip("•-– ")
+ if cleaned and len(cleaned) > 3:
+ education.append(cleaned)
+ break
+
+ return education
+
+
+def _extract_experience(text: str) -> list[str]:
+ """Extract work experience entries from common resume sections."""
+ experience: list[str] = []
+
+ section_patterns = [
+ r"(?:experiência|experience|experiência profissional|histórico profissional)\s*:?\s*\n?(.*?)(?:\n\n|\Z)",
+ r"(?:EXPERIÊNCIA|EXPERIENCE|EXPERIÊNCIA PROFISSIONAL|HISTÓRICO PROFISSIONAL)\s*:?\s*\n?(.*?)(?:\n\n|\Z)",
+ ]
+
+ for pattern in section_patterns:
+ match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
+ if match:
+ section_text = match.group(1).strip()
+ lines = section_text.split("\n")
+ for line in lines:
+ cleaned = line.strip().lstrip("•-– ")
+ if cleaned and len(cleaned) > 3:
+ experience.append(cleaned)
+ break
+
+ return experience
+
+
+def parse_resume_text(text: str) -> dict[str, str | list[str]]:
+ """Parse resume text and extract structured data.
+
+ Returns a dictionary with the following keys:
+ name, email, phone, city, state, linkedin, skills, education, experience
+ """
+ city, state = _extract_city_state(text)
+
+ return {
+ "name": _extract_name(text),
+ "email": _extract_email(text),
+ "phone": _extract_phone(text),
+ "city": city,
+ "state": state,
+ "linkedin": _extract_linkedin(text),
+ "skills": _extract_skills(text),
+ "education": _extract_education(text),
+ "experience": _extract_experience(text),
+ }
diff --git a/backend/app/utils.py b/backend/app/utils.py
index ac029f6342..82f9d32079 100644
--- a/backend/app/utils.py
+++ b/backend/app/utils.py
@@ -121,3 +121,45 @@ def verify_password_reset_token(token: str) -> str | None:
return str(decoded_token["sub"])
except InvalidTokenError:
return None
+
+
+def generate_invite_token(company_id: str, email: str) -> tuple[str, datetime]:
+ delta = timedelta(days=settings.INVITE_TOKEN_EXPIRE_DAYS)
+ now = datetime.now(timezone.utc)
+ expires = now + delta
+ encoded_jwt = jwt.encode(
+ {"exp": expires, "nbf": now, "sub": company_id, "email": email, "type": "invite"},
+ settings.SECRET_KEY,
+ algorithm=security.ALGORITHM,
+ )
+ return encoded_jwt, expires
+
+
+def verify_invite_token(token: str) -> dict[str, str] | None:
+ try:
+ decoded_token = jwt.decode(
+ token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
+ )
+ if decoded_token.get("type") != "invite":
+ return None
+ return {
+ "company_id": str(decoded_token["sub"]),
+ "email": str(decoded_token["email"]),
+ }
+ except InvalidTokenError:
+ return None
+
+
+def generate_pj_invite_email(email_to: str, link: str, valid_days: int) -> EmailData:
+ project_name = settings.PROJECT_NAME
+ subject = f"{project_name} - Convite para completar cadastro PJ"
+ html_content = render_email_template(
+ template_name="pj_invite.html",
+ context={
+ "project_name": settings.PROJECT_NAME,
+ "email": email_to,
+ "link": link,
+ "valid_days": valid_days,
+ },
+ )
+ return EmailData(html_content=html_content, subject=subject)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 66b4d66683..3ef503694c 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -13,12 +13,14 @@ dependencies = [
"jinja2<4.0.0,>=3.1.4",
"alembic<2.0.0,>=1.12.1",
"httpx<1.0.0,>=0.25.1",
- "psycopg[binary]<4.0.0,>=3.1.13",
+ "pyodbc>=5.1.0",
"sqlmodel<1.0.0,>=0.0.21",
"pydantic-settings<3.0.0,>=2.2.1",
"sentry-sdk[fastapi]>=2.0.0,<3.0.0",
"pyjwt<3.0.0,>=2.8.0",
"pwdlib[argon2,bcrypt]>=0.3.0",
+ "pypdf2>=3.0.1",
+ "python-docx>=1.2.0",
]
[dependency-groups]
diff --git a/backend/tests/api/routes/test_companies.py b/backend/tests/api/routes/test_companies.py
new file mode 100644
index 0000000000..a6ac5384db
--- /dev/null
+++ b/backend/tests/api/routes/test_companies.py
@@ -0,0 +1,98 @@
+from fastapi.testclient import TestClient
+
+from app.core.config import settings
+
+VALID_COMPANY_DATA = {
+ "cnpj": "12345678000199",
+ "razao_social": "Empresa Teste LTDA",
+ "representante_legal": "João da Silva",
+ "data_abertura": "2020-01-15",
+ "nome_fantasia": "Empresa Teste",
+ "porte": "ME",
+ "atividade_economica_principal": "62.01-5-01",
+ "atividade_economica_secundaria": "62.02-3-00",
+ "natureza_juridica": "206-2",
+ "logradouro": "Rua das Flores",
+ "numero": "100",
+ "complemento": "Sala 201",
+ "cep": "01001000",
+ "bairro": "Centro",
+ "municipio": "São Paulo",
+ "uf": "SP",
+ "endereco_eletronico": "contato@empresa.com.br",
+ "telefone_comercial": "1133334444",
+ "situacao_cadastral": "Ativa",
+ "data_situacao_cadastral": "2020-01-15",
+ "cpf_representante_legal": "12345678901",
+ "identidade_representante_legal": "123456789",
+ "logradouro_representante_legal": "Av. Paulista",
+ "numero_representante_legal": "500",
+ "complemento_representante_legal": "Apto 10",
+ "cep_representante_legal": "01310100",
+ "bairro_representante_legal": "Bela Vista",
+ "municipio_representante_legal": "São Paulo",
+ "uf_representante_legal": "SP",
+ "endereco_eletronico_representante_legal": "joao@email.com",
+ "telefones_representante_legal": "11999998888",
+ "data_nascimento_representante_legal": "1985-06-20",
+ "banco_cc_cnpj": "Banco do Brasil",
+ "agencia_cc_cnpj": "1234-5",
+}
+
+
+def test_create_company(
+ client: TestClient, superuser_token_headers: dict[str, str]
+) -> None:
+ response = client.post(
+ f"{settings.API_V1_STR}/companies/",
+ headers=superuser_token_headers,
+ json=VALID_COMPANY_DATA,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["cnpj"] == VALID_COMPANY_DATA["cnpj"]
+ assert content["razao_social"] == VALID_COMPANY_DATA["razao_social"]
+ assert content["representante_legal"] == VALID_COMPANY_DATA["representante_legal"]
+ assert content["nome_fantasia"] == VALID_COMPANY_DATA["nome_fantasia"]
+ assert content["porte"] == VALID_COMPANY_DATA["porte"]
+ assert content["logradouro"] == VALID_COMPANY_DATA["logradouro"]
+ assert content["cpf_representante_legal"] == VALID_COMPANY_DATA["cpf_representante_legal"]
+ assert content["banco_cc_cnpj"] == VALID_COMPANY_DATA["banco_cc_cnpj"]
+ assert content["agencia_cc_cnpj"] == VALID_COMPANY_DATA["agencia_cc_cnpj"]
+ assert "id" in content
+ assert "created_at" in content
+
+
+def test_create_company_missing_field(
+ client: TestClient, superuser_token_headers: dict[str, str]
+) -> None:
+ incomplete_data = VALID_COMPANY_DATA.copy()
+ del incomplete_data["cnpj"]
+ response = client.post(
+ f"{settings.API_V1_STR}/companies/",
+ headers=superuser_token_headers,
+ json=incomplete_data,
+ )
+ assert response.status_code == 422
+
+
+def test_create_company_duplicate_cnpj(
+ client: TestClient, superuser_token_headers: dict[str, str]
+) -> None:
+ data = VALID_COMPANY_DATA.copy()
+ data["cnpj"] = "99999999000100"
+ response = client.post(
+ f"{settings.API_V1_STR}/companies/",
+ headers=superuser_token_headers,
+ json=data,
+ )
+ assert response.status_code == 200
+
+ response = client.post(
+ f"{settings.API_V1_STR}/companies/",
+ headers=superuser_token_headers,
+ json=data,
+ )
+ assert response.status_code == 400
+ content = response.json()
+ assert content["detail"] == "A company with this CNPJ already exists."
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 8ddab7b321..10a2d10f91 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -7,7 +7,7 @@
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
-from app.models import Item, User
+from app.models import AuditLog, Company, CompanyInvite, Item, User
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -17,6 +17,12 @@ def db() -> Generator[Session, None, None]:
with Session(engine) as session:
init_db(session)
yield session
+ statement = delete(AuditLog)
+ session.execute(statement)
+ statement = delete(CompanyInvite)
+ session.execute(statement)
+ statement = delete(Company)
+ session.execute(statement)
statement = delete(Item)
session.execute(statement)
statement = delete(User)
diff --git a/compose.override.yml b/compose.override.yml
index 779cc8238d..36ef887f53 100644
--- a/compose.override.yml
+++ b/compose.override.yml
@@ -48,7 +48,7 @@ services:
db:
restart: "no"
ports:
- - "5432:5432"
+ - "1433:1433"
adminer:
restart: "no"
diff --git a/compose.yml b/compose.yml
index 2488fc007b..ffa2e8c327 100644
--- a/compose.yml
+++ b/compose.yml
@@ -1,23 +1,21 @@
services:
db:
- image: postgres:18
+ image: mcr.microsoft.com/mssql/server:2022-latest
restart: always
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${MSSQL_PASSWORD}" -Q "SELECT 1" -C || exit 1
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
volumes:
- - app-db-data:/var/lib/postgresql/data/pgdata
+ - app-db-data:/var/opt/mssql
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}
+ - ACCEPT_EULA=Y
+ - MSSQL_SA_PASSWORD=${MSSQL_PASSWORD?Variable not set}
adminer:
image: adminer
@@ -69,11 +67,12 @@ services:
- 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}
+ - MSSQL_SERVER=db
+ - MSSQL_PORT=${MSSQL_PORT}
+ - MSSQL_DB=${MSSQL_DB}
+ - MSSQL_USER=${MSSQL_USER?Variable not set}
+ - MSSQL_PASSWORD=${MSSQL_PASSWORD?Variable not set}
+ - MSSQL_DRIVER=${MSSQL_DRIVER}
- SENTRY_DSN=${SENTRY_DSN}
backend:
@@ -102,11 +101,12 @@ services:
- 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}
+ - MSSQL_SERVER=db
+ - MSSQL_PORT=${MSSQL_PORT}
+ - MSSQL_DB=${MSSQL_DB}
+ - MSSQL_USER=${MSSQL_USER?Variable not set}
+ - MSSQL_PASSWORD=${MSSQL_PASSWORD?Variable not set}
+ - MSSQL_DRIVER=${MSSQL_DRIVER}
- SENTRY_DSN=${SENTRY_DSN}
healthcheck:
diff --git a/copier.yml b/copier.yml
index f98e3fc861..655f0740b0 100644
--- a/copier.yml
+++ b/copier.yml
@@ -46,13 +46,14 @@ emails_from_email:
help: The email account to send emails from, you can set it later in .env
default: info@example.com
-postgres_password:
+mssql_password:
type: str
help: |
- 'The password for the PostgreSQL database, stored in .env,
+ 'The password for the SQL Server database, stored in .env,
you can generate one with:
- python -c "import secrets; print(secrets.token_urlsafe(32))"'
- default: changethis
+ python -c "import secrets; print(secrets.token_urlsafe(32))"
+ Note: SQL Server requires a strong password (uppercase, lowercase, number, special char, min 8 chars)'
+ default: Changethis1!
sentry_dsn:
type: str
diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts
index fb66c1f837..2307fce42d 100644
--- a/frontend/src/client/schemas.gen.ts
+++ b/frontend/src/client/schemas.gen.ts
@@ -1,5 +1,18 @@
// This file is auto-generated by @hey-api/openapi-ts
+export const Body_companies_parse_resumeSchema = {
+ properties: {
+ file: {
+ type: 'string',
+ format: 'binary',
+ title: 'File'
+ }
+ },
+ type: 'object',
+ required: ['file'],
+ title: 'Body_companies-parse_resume'
+} as const;
+
export const Body_login_login_access_tokenSchema = {
properties: {
grant_type: {
@@ -57,6 +70,441 @@ export const Body_login_login_access_tokenSchema = {
title: 'Body_login-login_access_token'
} as const;
+export const CompanyCreateSchema = {
+ properties: {
+ cnpj: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Cnpj'
+ },
+ razao_social: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Razao Social'
+ },
+ representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Representante Legal'
+ },
+ data_abertura: {
+ type: 'string',
+ format: 'date',
+ title: 'Data Abertura'
+ },
+ nome_fantasia: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Nome Fantasia'
+ },
+ porte: {
+ type: 'string',
+ maxLength: 100,
+ minLength: 1,
+ title: 'Porte'
+ },
+ atividade_economica_principal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Atividade Economica Principal'
+ },
+ atividade_economica_secundaria: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Atividade Economica Secundaria'
+ },
+ natureza_juridica: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Natureza Juridica'
+ },
+ logradouro: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Logradouro'
+ },
+ numero: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Numero'
+ },
+ complemento: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Complemento'
+ },
+ cep: {
+ type: 'string',
+ maxLength: 10,
+ minLength: 1,
+ title: 'Cep'
+ },
+ bairro: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Bairro'
+ },
+ municipio: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Municipio'
+ },
+ uf: {
+ type: 'string',
+ maxLength: 2,
+ minLength: 1,
+ title: 'Uf'
+ },
+ endereco_eletronico: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Endereco Eletronico'
+ },
+ telefone_comercial: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Telefone Comercial'
+ },
+ situacao_cadastral: {
+ type: 'string',
+ maxLength: 100,
+ minLength: 1,
+ title: 'Situacao Cadastral'
+ },
+ data_situacao_cadastral: {
+ type: 'string',
+ format: 'date',
+ title: 'Data Situacao Cadastral'
+ },
+ cpf_representante_legal: {
+ type: 'string',
+ maxLength: 14,
+ minLength: 1,
+ title: 'Cpf Representante Legal'
+ },
+ identidade_representante_legal: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Identidade Representante Legal'
+ },
+ logradouro_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Logradouro Representante Legal'
+ },
+ numero_representante_legal: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Numero Representante Legal'
+ },
+ complemento_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Complemento Representante Legal'
+ },
+ cep_representante_legal: {
+ type: 'string',
+ maxLength: 10,
+ minLength: 1,
+ title: 'Cep Representante Legal'
+ },
+ bairro_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Bairro Representante Legal'
+ },
+ municipio_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Municipio Representante Legal'
+ },
+ uf_representante_legal: {
+ type: 'string',
+ maxLength: 2,
+ minLength: 1,
+ title: 'Uf Representante Legal'
+ },
+ endereco_eletronico_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Endereco Eletronico Representante Legal'
+ },
+ telefones_representante_legal: {
+ type: 'string',
+ maxLength: 40,
+ minLength: 1,
+ title: 'Telefones Representante Legal'
+ },
+ data_nascimento_representante_legal: {
+ type: 'string',
+ format: 'date',
+ title: 'Data Nascimento Representante Legal'
+ },
+ banco_cc_cnpj: {
+ type: 'string',
+ maxLength: 100,
+ minLength: 1,
+ title: 'Banco Cc Cnpj'
+ },
+ agencia_cc_cnpj: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Agencia Cc Cnpj'
+ }
+ },
+ type: 'object',
+ required: ['cnpj', 'razao_social', 'representante_legal', 'data_abertura', 'nome_fantasia', 'porte', 'atividade_economica_principal', 'atividade_economica_secundaria', 'natureza_juridica', 'logradouro', 'numero', 'complemento', 'cep', 'bairro', 'municipio', 'uf', 'endereco_eletronico', 'telefone_comercial', 'situacao_cadastral', 'data_situacao_cadastral', 'cpf_representante_legal', 'identidade_representante_legal', 'logradouro_representante_legal', 'numero_representante_legal', 'complemento_representante_legal', 'cep_representante_legal', 'bairro_representante_legal', 'municipio_representante_legal', 'uf_representante_legal', 'endereco_eletronico_representante_legal', 'telefones_representante_legal', 'data_nascimento_representante_legal', 'banco_cc_cnpj', 'agencia_cc_cnpj'],
+ title: 'CompanyCreate'
+} as const;
+
+export const CompanyPublicSchema = {
+ properties: {
+ cnpj: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Cnpj'
+ },
+ razao_social: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Razao Social'
+ },
+ representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Representante Legal'
+ },
+ data_abertura: {
+ type: 'string',
+ format: 'date',
+ title: 'Data Abertura'
+ },
+ nome_fantasia: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Nome Fantasia'
+ },
+ porte: {
+ type: 'string',
+ maxLength: 100,
+ minLength: 1,
+ title: 'Porte'
+ },
+ atividade_economica_principal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Atividade Economica Principal'
+ },
+ atividade_economica_secundaria: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Atividade Economica Secundaria'
+ },
+ natureza_juridica: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Natureza Juridica'
+ },
+ logradouro: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Logradouro'
+ },
+ numero: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Numero'
+ },
+ complemento: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Complemento'
+ },
+ cep: {
+ type: 'string',
+ maxLength: 10,
+ minLength: 1,
+ title: 'Cep'
+ },
+ bairro: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Bairro'
+ },
+ municipio: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Municipio'
+ },
+ uf: {
+ type: 'string',
+ maxLength: 2,
+ minLength: 1,
+ title: 'Uf'
+ },
+ endereco_eletronico: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Endereco Eletronico'
+ },
+ telefone_comercial: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Telefone Comercial'
+ },
+ situacao_cadastral: {
+ type: 'string',
+ maxLength: 100,
+ minLength: 1,
+ title: 'Situacao Cadastral'
+ },
+ data_situacao_cadastral: {
+ type: 'string',
+ format: 'date',
+ title: 'Data Situacao Cadastral'
+ },
+ cpf_representante_legal: {
+ type: 'string',
+ maxLength: 14,
+ minLength: 1,
+ title: 'Cpf Representante Legal'
+ },
+ identidade_representante_legal: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Identidade Representante Legal'
+ },
+ logradouro_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Logradouro Representante Legal'
+ },
+ numero_representante_legal: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Numero Representante Legal'
+ },
+ complemento_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Complemento Representante Legal'
+ },
+ cep_representante_legal: {
+ type: 'string',
+ maxLength: 10,
+ minLength: 1,
+ title: 'Cep Representante Legal'
+ },
+ bairro_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Bairro Representante Legal'
+ },
+ municipio_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Municipio Representante Legal'
+ },
+ uf_representante_legal: {
+ type: 'string',
+ maxLength: 2,
+ minLength: 1,
+ title: 'Uf Representante Legal'
+ },
+ endereco_eletronico_representante_legal: {
+ type: 'string',
+ maxLength: 255,
+ minLength: 1,
+ title: 'Endereco Eletronico Representante Legal'
+ },
+ telefones_representante_legal: {
+ type: 'string',
+ maxLength: 40,
+ minLength: 1,
+ title: 'Telefones Representante Legal'
+ },
+ data_nascimento_representante_legal: {
+ type: 'string',
+ format: 'date',
+ title: 'Data Nascimento Representante Legal'
+ },
+ banco_cc_cnpj: {
+ type: 'string',
+ maxLength: 100,
+ minLength: 1,
+ title: 'Banco Cc Cnpj'
+ },
+ agencia_cc_cnpj: {
+ type: 'string',
+ maxLength: 20,
+ minLength: 1,
+ title: 'Agencia Cc Cnpj'
+ },
+ id: {
+ type: 'string',
+ format: 'uuid',
+ title: 'Id'
+ },
+ created_at: {
+ anyOf: [
+ {
+ type: 'string',
+ format: 'date-time'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Created At'
+ }
+ },
+ type: 'object',
+ required: ['cnpj', 'razao_social', 'representante_legal', 'data_abertura', 'nome_fantasia', 'porte', 'atividade_economica_principal', 'atividade_economica_secundaria', 'natureza_juridica', 'logradouro', 'numero', 'complemento', 'cep', 'bairro', 'municipio', 'uf', 'endereco_eletronico', 'telefone_comercial', 'situacao_cadastral', 'data_situacao_cadastral', 'cpf_representante_legal', 'identidade_representante_legal', 'logradouro_representante_legal', 'numero_representante_legal', 'complemento_representante_legal', 'cep_representante_legal', 'bairro_representante_legal', 'municipio_representante_legal', 'uf_representante_legal', 'endereco_eletronico_representante_legal', 'telefones_representante_legal', 'data_nascimento_representante_legal', 'banco_cc_cnpj', 'agencia_cc_cnpj', 'id'],
+ title: 'CompanyPublic'
+} as const;
+
export const HTTPValidationErrorSchema = {
properties: {
detail: {
@@ -251,6 +699,67 @@ export const PrivateUserCreateSchema = {
title: 'PrivateUserCreate'
} as const;
+export const ResumeDataSchema = {
+ properties: {
+ name: {
+ type: 'string',
+ title: 'Name',
+ default: ''
+ },
+ email: {
+ type: 'string',
+ title: 'Email',
+ default: ''
+ },
+ phone: {
+ type: 'string',
+ title: 'Phone',
+ default: ''
+ },
+ city: {
+ type: 'string',
+ title: 'City',
+ default: ''
+ },
+ state: {
+ type: 'string',
+ title: 'State',
+ default: ''
+ },
+ linkedin: {
+ type: 'string',
+ title: 'Linkedin',
+ default: ''
+ },
+ skills: {
+ items: {
+ type: 'string'
+ },
+ type: 'array',
+ title: 'Skills',
+ default: []
+ },
+ education: {
+ items: {
+ type: 'string'
+ },
+ type: 'array',
+ title: 'Education',
+ default: []
+ },
+ experience: {
+ items: {
+ type: 'string'
+ },
+ type: 'array',
+ title: 'Experience',
+ default: []
+ }
+ },
+ type: 'object',
+ title: 'ResumeData'
+} as const;
+
export const TokenSchema = {
properties: {
access_token: {
diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts
index ba79e3f726..9b03e10a6c 100644
--- a/frontend/src/client/sdk.gen.ts
+++ b/frontend/src/client/sdk.gen.ts
@@ -3,7 +3,49 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
-import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
+import type { CompaniesCreateCompanyRouteData, CompaniesCreateCompanyRouteResponse, CompaniesParseResumeData, CompaniesParseResumeResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UsersReadAuditLogsData, UsersReadAuditLogsResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, InvitesSendInviteData, InvitesSendInviteResponse, InvitesResendInviteData, InvitesResendInviteResponse, InvitesValidateInviteTokenData, InvitesValidateInviteTokenResponse, InvitesCompleteRegistrationData, InvitesCompleteRegistrationResponse } from './types.gen';
+
+export class CompaniesService {
+ /**
+ * Create Company Route
+ * Create new company (PJ).
+ * @param data The data for the request.
+ * @param data.requestBody
+ * @returns CompanyPublic Successful Response
+ * @throws ApiError
+ */
+ public static createCompanyRoute(data: CompaniesCreateCompanyRouteData): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/api/v1/companies/',
+ body: data.requestBody,
+ mediaType: 'application/json',
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+
+ /**
+ * Parse Resume
+ * Parse a resume file (PDF or DOCX) and extract structured data.
+ * @param data The data for the request.
+ * @param data.formData
+ * @returns ResumeData Successful Response
+ * @throws ApiError
+ */
+ public static parseResume(data: CompaniesParseResumeData): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/api/v1/companies/parse-resume',
+ formData: data.formData,
+ mediaType: 'multipart/form-data',
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+}
export class ItemsService {
/**
@@ -430,6 +472,29 @@ export class UsersService {
}
});
}
+
+ /**
+ * Read Audit Logs
+ * Retrieve user audit logs.
+ * @param data The data for the request.
+ * @param data.skip
+ * @param data.limit
+ * @returns AuditLogsPublic Successful Response
+ * @throws ApiError
+ */
+ public static readAuditLogs(data: UsersReadAuditLogsData = {}): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/api/v1/users/audit-log',
+ query: {
+ skip: data.skip,
+ limit: data.limit
+ },
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
}
export class UtilsService {
@@ -465,4 +530,88 @@ export class UtilsService {
url: '/api/v1/utils/health-check/'
});
}
-}
\ No newline at end of file
+}
+
+export class InvitesService {
+ /**
+ * Send Invite
+ * Send a PJ registration invite. Creates initial company record and sends email.
+ * @param data The data for the request.
+ * @param data.requestBody
+ * @returns CompanyInvitePublic Successful Response
+ * @throws ApiError
+ */
+ public static sendInvite(data: InvitesSendInviteData): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/api/v1/invites/',
+ body: data.requestBody,
+ mediaType: 'application/json',
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+
+ /**
+ * Resend Invite
+ * Resend a PJ registration invite. Generates a new token and sends a new email.
+ * @param data The data for the request.
+ * @param data.inviteId
+ * @returns CompanyInvitePublic Successful Response
+ * @throws ApiError
+ */
+ public static resendInvite(data: InvitesResendInviteData): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/api/v1/invites/{invite_id}/resend',
+ path: {
+ invite_id: data.inviteId
+ },
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+
+ /**
+ * Validate Invite Token
+ * Validate an invite token. Public endpoint (no auth required).
+ * @param data The data for the request.
+ * @param data.token
+ * @returns CompanyInviteValidation Successful Response
+ * @throws ApiError
+ */
+ public static validateInviteToken(data: InvitesValidateInviteTokenData): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/api/v1/invites/validate',
+ query: {
+ token: data.token
+ },
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+
+ /**
+ * Complete Registration
+ * Complete PJ registration. Public endpoint (no auth required).
+ * @param data The data for the request.
+ * @param data.requestBody
+ * @returns CompanyPublic Successful Response
+ * @throws ApiError
+ */
+ public static completeRegistration(data: InvitesCompleteRegistrationData): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'PUT',
+ url: '/api/v1/invites/complete',
+ body: data.requestBody,
+ mediaType: 'application/json',
+ errors: {
+ 422: 'Validation Error'
+ }
+ });
+ }
+}
diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts
index 91b5ba34c2..a00a4c5164 100644
--- a/frontend/src/client/types.gen.ts
+++ b/frontend/src/client/types.gen.ts
@@ -1,5 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts
+export type Body_companies_parse_resume = {
+ file: (Blob | File);
+};
+
export type Body_login_login_access_token = {
grant_type?: (string | null);
username: string;
@@ -9,6 +13,142 @@ export type Body_login_login_access_token = {
client_secret?: (string | null);
};
+export type CompanyCreate = {
+ cnpj: string;
+ razao_social: string;
+ representante_legal: string;
+ data_abertura: string;
+ nome_fantasia: string;
+ porte: string;
+ atividade_economica_principal: string;
+ atividade_economica_secundaria: string;
+ natureza_juridica: string;
+ logradouro: string;
+ numero: string;
+ complemento: string;
+ cep: string;
+ bairro: string;
+ municipio: string;
+ uf: string;
+ endereco_eletronico: string;
+ telefone_comercial: string;
+ situacao_cadastral: string;
+ data_situacao_cadastral: string;
+ cpf_representante_legal: string;
+ identidade_representante_legal: string;
+ logradouro_representante_legal: string;
+ numero_representante_legal: string;
+ complemento_representante_legal: string;
+ cep_representante_legal: string;
+ bairro_representante_legal: string;
+ municipio_representante_legal: string;
+ uf_representante_legal: string;
+ endereco_eletronico_representante_legal: string;
+ telefones_representante_legal: string;
+ data_nascimento_representante_legal: string;
+ banco_cc_cnpj: string;
+ agencia_cc_cnpj: string;
+};
+
+export type CompanyPublic = {
+ cnpj: string;
+ razao_social?: (string | null);
+ representante_legal?: (string | null);
+ data_abertura?: (string | null);
+ nome_fantasia?: (string | null);
+ porte?: (string | null);
+ atividade_economica_principal?: (string | null);
+ atividade_economica_secundaria?: (string | null);
+ natureza_juridica?: (string | null);
+ logradouro?: (string | null);
+ numero?: (string | null);
+ complemento?: (string | null);
+ cep?: (string | null);
+ bairro?: (string | null);
+ municipio?: (string | null);
+ uf?: (string | null);
+ endereco_eletronico?: (string | null);
+ telefone_comercial?: (string | null);
+ situacao_cadastral?: (string | null);
+ data_situacao_cadastral?: (string | null);
+ cpf_representante_legal?: (string | null);
+ identidade_representante_legal?: (string | null);
+ logradouro_representante_legal?: (string | null);
+ numero_representante_legal?: (string | null);
+ complemento_representante_legal?: (string | null);
+ cep_representante_legal?: (string | null);
+ bairro_representante_legal?: (string | null);
+ municipio_representante_legal?: (string | null);
+ uf_representante_legal?: (string | null);
+ endereco_eletronico_representante_legal?: (string | null);
+ telefones_representante_legal?: (string | null);
+ data_nascimento_representante_legal?: (string | null);
+ banco_cc_cnpj?: (string | null);
+ agencia_cc_cnpj?: (string | null);
+ id: string;
+ email?: (string | null);
+ status?: string;
+ created_at?: (string | null);
+};
+
+export type CompanyInviteCreate = {
+ cnpj: string;
+ email: string;
+ razao_social: string;
+};
+
+export type CompanyInvitePublic = {
+ email: string;
+ id: string;
+ company_id: string;
+ expires_at: string;
+ used: boolean;
+ created_at?: (string | null);
+};
+
+export type CompanyInviteValidation = {
+ valid: boolean;
+ company?: (CompanyPublic | null);
+ message?: (string | null);
+};
+
+export type CompanyRegistrationComplete = {
+ token: string;
+ razao_social: string;
+ representante_legal: string;
+ data_abertura: string;
+ nome_fantasia: string;
+ porte: string;
+ atividade_economica_principal: string;
+ atividade_economica_secundaria: string;
+ natureza_juridica: string;
+ logradouro: string;
+ numero: string;
+ complemento: string;
+ cep: string;
+ bairro: string;
+ municipio: string;
+ uf: string;
+ endereco_eletronico: string;
+ telefone_comercial: string;
+ situacao_cadastral: string;
+ data_situacao_cadastral: string;
+ cpf_representante_legal: string;
+ identidade_representante_legal: string;
+ logradouro_representante_legal: string;
+ numero_representante_legal: string;
+ complemento_representante_legal: string;
+ cep_representante_legal: string;
+ bairro_representante_legal: string;
+ municipio_representante_legal: string;
+ uf_representante_legal: string;
+ endereco_eletronico_representante_legal: string;
+ telefones_representante_legal: string;
+ data_nascimento_representante_legal: string;
+ banco_cc_cnpj: string;
+ agencia_cc_cnpj: string;
+};
+
export type HTTPValidationError = {
detail?: Array;
};
@@ -52,6 +192,18 @@ export type PrivateUserCreate = {
is_verified?: boolean;
};
+export type ResumeData = {
+ name?: string;
+ email?: string;
+ phone?: string;
+ city?: string;
+ state?: string;
+ linkedin?: string;
+ skills?: Array<(string)>;
+ education?: Array<(string)>;
+ experience?: Array<(string)>;
+};
+
export type Token = {
access_token: string;
token_type?: string;
@@ -62,12 +214,31 @@ export type UpdatePassword = {
new_password: string;
};
+export type UserRole = 'comercial' | 'juridico' | 'financeiro' | 'rh' | 'pj' | 'super_admin';
+
+export type AuditAction = 'created' | 'updated' | 'deactivated';
+
+export type AuditLogPublic = {
+ id: string;
+ action: AuditAction;
+ target_user_id: string;
+ performed_by_id: string;
+ changes: string;
+ created_at?: (string | null);
+ target_user_email?: (string | null);
+ performed_by_email?: (string | null);
+};
+
+export type AuditLogsPublic = {
+ data: Array;
+ count: number;
+};
+
export type UserCreate = {
email: string;
- is_active?: boolean;
- is_superuser?: boolean;
+ role?: UserRole;
full_name?: (string | null);
- password: string;
+ password?: (string | null);
};
export type UserPublic = {
@@ -75,6 +246,7 @@ export type UserPublic = {
is_active?: boolean;
is_superuser?: boolean;
full_name?: (string | null);
+ role?: UserRole;
id: string;
created_at?: (string | null);
};
@@ -95,6 +267,7 @@ export type UserUpdate = {
is_active?: boolean;
is_superuser?: boolean;
full_name?: (string | null);
+ role?: UserRole;
password?: (string | null);
};
@@ -113,6 +286,18 @@ export type ValidationError = {
};
};
+export type CompaniesCreateCompanyRouteData = {
+ requestBody: CompanyCreate;
+};
+
+export type CompaniesCreateCompanyRouteResponse = (CompanyPublic);
+
+export type CompaniesParseResumeData = {
+ formData: Body_companies_parse_resume;
+};
+
+export type CompaniesParseResumeResponse = (ResumeData);
+
export type ItemsReadItemsData = {
limit?: number;
skip?: number;
@@ -231,10 +416,41 @@ export type UsersDeleteUserData = {
export type UsersDeleteUserResponse = (Message);
+export type UsersReadAuditLogsData = {
+ limit?: number;
+ skip?: number;
+};
+
+export type UsersReadAuditLogsResponse = (AuditLogsPublic);
+
export type UtilsTestEmailData = {
emailTo: string;
};
export type UtilsTestEmailResponse = (Message);
-export type UtilsHealthCheckResponse = (boolean);
\ No newline at end of file
+export type UtilsHealthCheckResponse = (boolean);
+
+export type InvitesSendInviteData = {
+ requestBody: CompanyInviteCreate;
+};
+
+export type InvitesSendInviteResponse = (CompanyInvitePublic);
+
+export type InvitesResendInviteData = {
+ inviteId: string;
+};
+
+export type InvitesResendInviteResponse = (CompanyInvitePublic);
+
+export type InvitesValidateInviteTokenData = {
+ token: string;
+};
+
+export type InvitesValidateInviteTokenResponse = (CompanyInviteValidation);
+
+export type InvitesCompleteRegistrationData = {
+ requestBody: CompanyRegistrationComplete;
+};
+
+export type InvitesCompleteRegistrationResponse = (CompanyPublic);
diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx
index a0b534bd96..af9b6263ec 100644
--- a/frontend/src/components/Admin/AddUser.tsx
+++ b/frontend/src/components/Admin/AddUser.tsx
@@ -5,9 +5,8 @@ import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
-import { type UserCreate, UsersService } from "@/client"
+import { type UserCreate, type UserRole, UsersService } from "@/client"
import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
@@ -28,27 +27,38 @@ import {
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
import useCustomToast from "@/hooks/useCustomToast"
+import { USER_ROLE_LABELS } from "@/lib/user-constants"
import { handleError } from "@/utils"
-const formSchema = z
- .object({
- email: z.email({ message: "Invalid email address" }),
- full_name: z.string().optional(),
- password: z
- .string()
- .min(1, { message: "Password is required" })
- .min(8, { message: "Password must be at least 8 characters" }),
- confirm_password: z
- .string()
- .min(1, { message: "Please confirm your password" }),
- is_superuser: z.boolean(),
- is_active: z.boolean(),
- })
- .refine((data) => data.password === data.confirm_password, {
- message: "The passwords don't match",
- path: ["confirm_password"],
- })
+const roleOptions: UserRole[] = [
+ "comercial",
+ "juridico",
+ "financeiro",
+ "rh",
+ "pj",
+ "super_admin",
+]
+
+const formSchema = z.object({
+ email: z.email({ message: "Invalid email address" }),
+ full_name: z.string().optional(),
+ role: z.enum([
+ "comercial",
+ "juridico",
+ "financeiro",
+ "rh",
+ "pj",
+ "super_admin",
+ ]),
+})
type FormData = z.infer
@@ -64,10 +74,7 @@ const AddUser = () => {
defaultValues: {
email: "",
full_name: "",
- password: "",
- confirm_password: "",
- is_superuser: false,
- is_active: false,
+ role: "comercial" as UserRole,
},
})
@@ -86,7 +93,12 @@ const AddUser = () => {
})
const onSubmit = (data: FormData) => {
- mutation.mutate(data)
+ const createData: UserCreate = {
+ email: data.email,
+ role: data.role as UserRole,
+ full_name: data.full_name || undefined,
+ }
+ mutation.mutate(createData)
}
return (
@@ -144,78 +156,33 @@ const AddUser = () => {
(
- Set Password *
+ Role *
-
-
-
+
)}
/>
-
- (
-
-
- Confirm Password{" "}
- *
-
-
-
-
-
-
- )}
- />
-
- (
-
-
-
-
- Is superuser?
-
- )}
- />
-
- (
-
-
-
-
- Is active?
-
- )}
- />
diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx
index 172904f695..ae2e2e3cbf 100644
--- a/frontend/src/components/Admin/EditUser.tsx
+++ b/frontend/src/components/Admin/EditUser.tsx
@@ -5,7 +5,7 @@ import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
-import { type UserPublic, UsersService } from "@/client"
+import { type UserPublic, type UserRole, UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
@@ -28,26 +28,39 @@ import {
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
import useCustomToast from "@/hooks/useCustomToast"
+import { USER_ROLE_LABELS } from "@/lib/user-constants"
import { handleError } from "@/utils"
-const formSchema = z
- .object({
- email: z.email({ message: "Invalid email address" }),
- full_name: z.string().optional(),
- password: z
- .string()
- .min(8, { message: "Password must be at least 8 characters" })
- .optional()
- .or(z.literal("")),
- confirm_password: z.string().optional(),
- is_superuser: z.boolean().optional(),
- is_active: z.boolean().optional(),
- })
- .refine((data) => !data.password || data.password === data.confirm_password, {
- message: "The passwords don't match",
- path: ["confirm_password"],
- })
+const roleOptions: UserRole[] = [
+ "comercial",
+ "juridico",
+ "financeiro",
+ "rh",
+ "pj",
+ "super_admin",
+]
+
+const formSchema = z.object({
+ email: z.email({ message: "Invalid email address" }),
+ full_name: z.string().optional(),
+ role: z.enum([
+ "comercial",
+ "juridico",
+ "financeiro",
+ "rh",
+ "pj",
+ "super_admin",
+ ]),
+ is_active: z.boolean().optional(),
+})
type FormData = z.infer
@@ -68,7 +81,7 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => {
defaultValues: {
email: user.email,
full_name: user.full_name ?? undefined,
- is_superuser: user.is_superuser,
+ role: (user.role ?? "comercial") as UserRole,
is_active: user.is_active,
},
})
@@ -88,12 +101,7 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => {
})
const onSubmit = (data: FormData) => {
- // exclude confirm_password from submission data and remove password if empty
- const { confirm_password: _, ...submitData } = data
- if (!submitData.password) {
- delete submitData.password
- }
- mutation.mutate(submitData)
+ mutation.mutate(data)
}
return (
@@ -152,56 +160,34 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => {
(
- Set Password
-
-
-
-
-
- )}
- />
-
- (
-
- Confirm Password
-
-
-
+
+ Role *
+
+
)}
/>
- (
-
-
-
-
- Is superuser?
-
- )}
- />
-
[] = [
),
},
{
- accessorKey: "is_superuser",
+ accessorKey: "role",
header: "Role",
- cell: ({ row }) => (
-
- {row.original.is_superuser ? "Superuser" : "User"}
-
- ),
+ cell: ({ row }) => {
+ const role = row.original.role as UserRole | undefined
+ const label = role ? USER_ROLE_LABELS[role] : "User"
+ const variant = role === "super_admin" ? "default" : "secondary"
+ return {label}
+ },
},
{
accessorKey: "is_active",
diff --git a/frontend/src/components/Companies/ResumeConfirmationModal.tsx b/frontend/src/components/Companies/ResumeConfirmationModal.tsx
new file mode 100644
index 0000000000..c646d86c96
--- /dev/null
+++ b/frontend/src/components/Companies/ResumeConfirmationModal.tsx
@@ -0,0 +1,118 @@
+import type { ResumeData } from "@/client"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+interface ResumeConfirmationModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ resumeData: ResumeData | null
+ onApply: () => void
+ onCancel: () => void
+}
+
+interface DataRowProps {
+ label: string
+ value: string | undefined
+}
+
+function DataRow({ label, value }: DataRowProps) {
+ if (!value) return null
+ return (
+
+
+ {label}:
+
+ {value}
+
+ )
+}
+
+interface DataListRowProps {
+ label: string
+ items: Array | undefined
+}
+
+function DataListRow({ label, items }: DataListRowProps) {
+ if (!items || items.length === 0) return null
+ return (
+
+
+ {label}:
+
+ {items.join(", ")}
+
+ )
+}
+
+function hasAnyData(data: ResumeData): boolean {
+ return !!(
+ data.name ||
+ data.email ||
+ data.phone ||
+ data.city ||
+ data.state ||
+ data.linkedin ||
+ (data.skills && data.skills.length > 0) ||
+ (data.education && data.education.length > 0) ||
+ (data.experience && data.experience.length > 0)
+ )
+}
+
+export function ResumeConfirmationModal({
+ open,
+ onOpenChange,
+ resumeData,
+ onApply,
+ onCancel,
+}: ResumeConfirmationModalProps) {
+ if (!resumeData) return null
+
+ const dataFound = hasAnyData(resumeData)
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Companies/ResumeUpload.tsx b/frontend/src/components/Companies/ResumeUpload.tsx
new file mode 100644
index 0000000000..0018fa5941
--- /dev/null
+++ b/frontend/src/components/Companies/ResumeUpload.tsx
@@ -0,0 +1,110 @@
+import { useMutation } from "@tanstack/react-query"
+import { FileText, Loader2, Upload } from "lucide-react"
+import { useRef, useState } from "react"
+
+import { CompaniesService, type ResumeData } from "@/client"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+interface ResumeUploadProps {
+ onResumeDataParsed: (data: ResumeData) => void
+}
+
+export function ResumeUpload({ onResumeDataParsed }: ResumeUploadProps) {
+ const fileInputRef = useRef(null)
+ const [fileName, setFileName] = useState(null)
+ const [error, setError] = useState(null)
+
+ const mutation = useMutation({
+ mutationFn: (file: File) =>
+ CompaniesService.parseResume({ formData: { file } }),
+ onSuccess: (data) => {
+ setError(null)
+ onResumeDataParsed(data)
+ },
+ onError: (err: unknown) => {
+ const message =
+ err instanceof Error
+ ? err.message
+ : "Não foi possível ler o currículo enviado. Verifique o formato do arquivo e tente novamente."
+ setError(message)
+ console.error("Erro ao processar currículo:", err)
+ },
+ })
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ const extension = file.name.split(".").pop()?.toLowerCase()
+ if (extension !== "pdf" && extension !== "docx") {
+ setError(
+ "Formato de arquivo não suportado. Envie um arquivo PDF ou DOCX.",
+ )
+ setFileName(null)
+ return
+ }
+
+ setFileName(file.name)
+ setError(null)
+ mutation.mutate(file)
+ }
+
+ const handleClick = () => {
+ fileInputRef.current?.click()
+ }
+
+ return (
+
+
+
+
+ Upload de Currículo
+
+
+ Envie seu currículo para tentar preencher automaticamente os campos
+ obrigatórios do cadastro.
+
+
+
+
+
+
+
+ {fileName && !mutation.isPending && (
+ {fileName}
+ )}
+
+ {error &&
{error}
}
+
+ Formatos aceitos: PDF, DOCX
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx
index 8502bcb9a4..2f174c0556 100644
--- a/frontend/src/components/Sidebar/AppSidebar.tsx
+++ b/frontend/src/components/Sidebar/AppSidebar.tsx
@@ -1,5 +1,6 @@
-import { Briefcase, Home, Users } from "lucide-react"
+import { Building2, Home, Users } from "lucide-react"
+import type { UserRole } from "@/client"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
import {
@@ -9,18 +10,21 @@ import {
SidebarHeader,
} from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
+import { USER_MANAGER_ROLES } from "@/lib/user-constants"
import { type Item, Main } from "./Main"
import { User } from "./User"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
- { icon: Briefcase, title: "Items", path: "/items" },
+ { icon: Building2, title: "Cadastro PJ", path: "/companies" },
]
export function AppSidebar() {
const { user: currentUser } = useAuth()
- const items = currentUser?.is_superuser
+ const userRole = currentUser?.role as UserRole | undefined
+ const isManager = userRole ? USER_MANAGER_ROLES.includes(userRole) : false
+ const items = isManager
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems
diff --git a/frontend/src/lib/user-constants.ts b/frontend/src/lib/user-constants.ts
new file mode 100644
index 0000000000..1c729342d1
--- /dev/null
+++ b/frontend/src/lib/user-constants.ts
@@ -0,0 +1,18 @@
+import type { UserRole } from "@/client"
+
+export const USER_MANAGER_ROLES: UserRole[] = [
+ "comercial",
+ "juridico",
+ "financeiro",
+ "rh",
+ "super_admin",
+]
+
+export const USER_ROLE_LABELS: Record = {
+ comercial: "Comercial",
+ juridico: "Jur\u00eddico",
+ financeiro: "Financeiro",
+ rh: "RH",
+ pj: "PJ",
+ super_admin: "Super Admin",
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 8afe946cb5..79e5abec08 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -13,7 +13,7 @@ import { Toaster } from "./components/ui/sonner"
import "./index.css"
import { routeTree } from "./routeTree.gen"
-OpenAPI.BASE = import.meta.env.VITE_API_URL
+OpenAPI.BASE = (import.meta.env.VITE_API_URL || "").replace(/\/+$/, "")
OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || ""
}
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 8849130b4c..7238150382 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -12,11 +12,13 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
+import { Route as PjRegistrationRouteImport } from './routes/pj-registration'
import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
+import { Route as LayoutCompaniesRouteImport } from './routes/_layout/companies'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
const SignupRoute = SignupRouteImport.update({
@@ -34,6 +36,11 @@ const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
path: '/recover-password',
getParentRoute: () => rootRouteImport,
} as any)
+const PjRegistrationRoute = PjRegistrationRouteImport.update({
+ id: '/pj-registration',
+ path: '/pj-registration',
+ getParentRoute: () => rootRouteImport,
+} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -58,6 +65,11 @@ const LayoutItemsRoute = LayoutItemsRouteImport.update({
path: '/items',
getParentRoute: () => LayoutRoute,
} as any)
+const LayoutCompaniesRoute = LayoutCompaniesRouteImport.update({
+ id: '/companies',
+ path: '/companies',
+ getParentRoute: () => LayoutRoute,
+} as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -65,21 +77,25 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
} as any)
export interface FileRoutesByFullPath {
+ '/': typeof LayoutIndexRoute
'/login': typeof LoginRoute
+ '/pj-registration': typeof PjRegistrationRoute
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
+ '/companies': typeof LayoutCompaniesRoute
'/items': typeof LayoutItemsRoute
'/settings': typeof LayoutSettingsRoute
- '/': typeof LayoutIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
+ '/pj-registration': typeof PjRegistrationRoute
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
+ '/companies': typeof LayoutCompaniesRoute
'/items': typeof LayoutItemsRoute
'/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute
@@ -88,10 +104,12 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_layout': typeof LayoutRouteWithChildren
'/login': typeof LoginRoute
+ '/pj-registration': typeof PjRegistrationRoute
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute
+ '/_layout/companies': typeof LayoutCompaniesRoute
'/_layout/items': typeof LayoutItemsRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/_layout/': typeof LayoutIndexRoute
@@ -99,21 +117,25 @@ export interface FileRoutesById {
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
+ | '/'
| '/login'
+ | '/pj-registration'
| '/recover-password'
| '/reset-password'
| '/signup'
| '/admin'
+ | '/companies'
| '/items'
| '/settings'
- | '/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
+ | '/pj-registration'
| '/recover-password'
| '/reset-password'
| '/signup'
| '/admin'
+ | '/companies'
| '/items'
| '/settings'
| '/'
@@ -121,10 +143,12 @@ export interface FileRouteTypes {
| '__root__'
| '/_layout'
| '/login'
+ | '/pj-registration'
| '/recover-password'
| '/reset-password'
| '/signup'
| '/_layout/admin'
+ | '/_layout/companies'
| '/_layout/items'
| '/_layout/settings'
| '/_layout/'
@@ -133,6 +157,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
LayoutRoute: typeof LayoutRouteWithChildren
LoginRoute: typeof LoginRoute
+ PjRegistrationRoute: typeof PjRegistrationRoute
RecoverPasswordRoute: typeof RecoverPasswordRoute
ResetPasswordRoute: typeof ResetPasswordRoute
SignupRoute: typeof SignupRoute
@@ -161,6 +186,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RecoverPasswordRouteImport
parentRoute: typeof rootRouteImport
}
+ '/pj-registration': {
+ id: '/pj-registration'
+ path: '/pj-registration'
+ fullPath: '/pj-registration'
+ preLoaderRoute: typeof PjRegistrationRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/login': {
id: '/login'
path: '/login'
@@ -171,7 +203,7 @@ declare module '@tanstack/react-router' {
'/_layout': {
id: '/_layout'
path: ''
- fullPath: ''
+ fullPath: '/'
preLoaderRoute: typeof LayoutRouteImport
parentRoute: typeof rootRouteImport
}
@@ -196,6 +228,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutItemsRouteImport
parentRoute: typeof LayoutRoute
}
+ '/_layout/companies': {
+ id: '/_layout/companies'
+ path: '/companies'
+ fullPath: '/companies'
+ preLoaderRoute: typeof LayoutCompaniesRouteImport
+ parentRoute: typeof LayoutRoute
+ }
'/_layout/admin': {
id: '/_layout/admin'
path: '/admin'
@@ -208,6 +247,7 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute
+ LayoutCompaniesRoute: typeof LayoutCompaniesRoute
LayoutItemsRoute: typeof LayoutItemsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
@@ -215,6 +255,7 @@ interface LayoutRouteChildren {
const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute,
+ LayoutCompaniesRoute: LayoutCompaniesRoute,
LayoutItemsRoute: LayoutItemsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,
@@ -226,6 +267,7 @@ const LayoutRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
LayoutRoute: LayoutRouteWithChildren,
LoginRoute: LoginRoute,
+ PjRegistrationRoute: PjRegistrationRoute,
RecoverPasswordRoute: RecoverPasswordRoute,
ResetPasswordRoute: ResetPasswordRoute,
SignupRoute: SignupRoute,
diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx
index a53ff2c4e9..5fc9451d37 100644
--- a/frontend/src/routes/_layout/admin.tsx
+++ b/frontend/src/routes/_layout/admin.tsx
@@ -2,12 +2,13 @@ import { useSuspenseQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { Suspense } from "react"
-import { type UserPublic, UsersService } from "@/client"
+import { type UserPublic, type UserRole, UsersService } from "@/client"
import AddUser from "@/components/Admin/AddUser"
import { columns, type UserTableData } from "@/components/Admin/columns"
import { DataTable } from "@/components/Common/DataTable"
import PendingUsers from "@/components/Pending/PendingUsers"
import useAuth from "@/hooks/useAuth"
+import { USER_MANAGER_ROLES } from "@/lib/user-constants"
function getUsersQueryOptions() {
return {
@@ -20,7 +21,8 @@ export const Route = createFileRoute("/_layout/admin")({
component: Admin,
beforeLoad: async () => {
const user = await UsersService.readUserMe()
- if (!user.is_superuser) {
+ const userRole = user.role as UserRole | undefined
+ if (!userRole || !USER_MANAGER_ROLES.includes(userRole)) {
throw redirect({
to: "/",
})
diff --git a/frontend/src/routes/_layout/companies.tsx b/frontend/src/routes/_layout/companies.tsx
new file mode 100644
index 0000000000..fed94f3f7e
--- /dev/null
+++ b/frontend/src/routes/_layout/companies.tsx
@@ -0,0 +1,647 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useMutation } from "@tanstack/react-query"
+import { createFileRoute } from "@tanstack/react-router"
+import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { z } from "zod"
+
+import {
+ CompaniesService,
+ type CompanyCreate,
+ type CompanyInviteCreate,
+ InvitesService,
+ type ResumeData,
+} from "@/client"
+import { ResumeConfirmationModal } from "@/components/Companies/ResumeConfirmationModal"
+import { ResumeUpload } from "@/components/Companies/ResumeUpload"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { LoadingButton } from "@/components/ui/loading-button"
+import useCustomToast from "@/hooks/useCustomToast"
+import { handleError } from "@/utils"
+
+const formSchema = z.object({
+ cnpj: z.string().min(1, { message: "CNPJ é obrigatório" }),
+ razao_social: z.string().min(1, { message: "Razão Social é obrigatória" }),
+ representante_legal: z
+ .string()
+ .min(1, { message: "Representante Legal é obrigatório" }),
+ data_abertura: z
+ .string()
+ .min(1, { message: "Data de Abertura é obrigatória" }),
+ nome_fantasia: z.string().min(1, { message: "Nome Fantasia é obrigatório" }),
+ porte: z.string().min(1, { message: "Porte é obrigatório" }),
+ atividade_economica_principal: z
+ .string()
+ .min(1, { message: "Atividade Econômica Principal é obrigatória" }),
+ atividade_economica_secundaria: z
+ .string()
+ .min(1, { message: "Atividade Econômica Secundária é obrigatória" }),
+ natureza_juridica: z
+ .string()
+ .min(1, { message: "Natureza Jurídica é obrigatória" }),
+ logradouro: z.string().min(1, { message: "Logradouro é obrigatório" }),
+ numero: z.string().min(1, { message: "Número é obrigatório" }),
+ complemento: z.string().min(1, { message: "Complemento é obrigatório" }),
+ cep: z.string().min(1, { message: "CEP é obrigatório" }),
+ bairro: z.string().min(1, { message: "Bairro é obrigatório" }),
+ municipio: z.string().min(1, { message: "Município é obrigatório" }),
+ uf: z.string().min(1, { message: "UF é obrigatória" }),
+ endereco_eletronico: z
+ .string()
+ .min(1, { message: "Endereço Eletrônico é obrigatório" }),
+ telefone_comercial: z
+ .string()
+ .min(1, { message: "Telefone Comercial é obrigatório" }),
+ situacao_cadastral: z
+ .string()
+ .min(1, { message: "Situação Cadastral é obrigatória" }),
+ data_situacao_cadastral: z
+ .string()
+ .min(1, { message: "Data Situação Cadastral é obrigatória" }),
+ cpf_representante_legal: z
+ .string()
+ .min(1, { message: "CPF do Representante Legal é obrigatório" }),
+ identidade_representante_legal: z
+ .string()
+ .min(1, { message: "Identidade do Representante Legal é obrigatória" }),
+ logradouro_representante_legal: z
+ .string()
+ .min(1, { message: "Logradouro do Representante Legal é obrigatório" }),
+ numero_representante_legal: z
+ .string()
+ .min(1, { message: "Número do Representante Legal é obrigatório" }),
+ complemento_representante_legal: z
+ .string()
+ .min(1, { message: "Complemento do Representante Legal é obrigatório" }),
+ cep_representante_legal: z
+ .string()
+ .min(1, { message: "CEP do Representante Legal é obrigatório" }),
+ bairro_representante_legal: z
+ .string()
+ .min(1, { message: "Bairro do Representante Legal é obrigatório" }),
+ municipio_representante_legal: z
+ .string()
+ .min(1, { message: "Município do Representante Legal é obrigatório" }),
+ uf_representante_legal: z
+ .string()
+ .min(1, { message: "UF do Representante Legal é obrigatória" }),
+ endereco_eletronico_representante_legal: z.string().min(1, {
+ message: "Endereço Eletrônico do Representante Legal é obrigatório",
+ }),
+ telefones_representante_legal: z
+ .string()
+ .min(1, { message: "Telefones do Representante Legal é obrigatório" }),
+ data_nascimento_representante_legal: z.string().min(1, {
+ message: "Data de Nascimento do Representante Legal é obrigatória",
+ }),
+ banco_cc_cnpj: z
+ .string()
+ .min(1, { message: "Banco CC do CNPJ é obrigatório" }),
+ agencia_cc_cnpj: z
+ .string()
+ .min(1, { message: "Agência CC do CNPJ é obrigatória" }),
+})
+
+type FormData = z.infer
+
+const defaultValues: FormData = {
+ cnpj: "",
+ razao_social: "",
+ representante_legal: "",
+ data_abertura: "",
+ nome_fantasia: "",
+ porte: "",
+ atividade_economica_principal: "",
+ atividade_economica_secundaria: "",
+ natureza_juridica: "",
+ logradouro: "",
+ numero: "",
+ complemento: "",
+ cep: "",
+ bairro: "",
+ municipio: "",
+ uf: "",
+ endereco_eletronico: "",
+ telefone_comercial: "",
+ situacao_cadastral: "",
+ data_situacao_cadastral: "",
+ cpf_representante_legal: "",
+ identidade_representante_legal: "",
+ logradouro_representante_legal: "",
+ numero_representante_legal: "",
+ complemento_representante_legal: "",
+ cep_representante_legal: "",
+ bairro_representante_legal: "",
+ municipio_representante_legal: "",
+ uf_representante_legal: "",
+ endereco_eletronico_representante_legal: "",
+ telefones_representante_legal: "",
+ data_nascimento_representante_legal: "",
+ banco_cc_cnpj: "",
+ agencia_cc_cnpj: "",
+}
+
+export const Route = createFileRoute("/_layout/companies")({
+ component: Companies,
+ head: () => ({
+ meta: [
+ {
+ title: "Cadastro PJ - Controle de PJs",
+ },
+ ],
+ }),
+})
+
+interface FieldConfig {
+ name: keyof FormData
+ label: string
+ type: string
+}
+
+const dadosEmpresaFields: FieldConfig[] = [
+ { name: "cnpj", label: "CNPJ", type: "text" },
+ { name: "razao_social", label: "Razão Social", type: "text" },
+ { name: "nome_fantasia", label: "Nome Fantasia", type: "text" },
+ { name: "data_abertura", label: "Data de Abertura", type: "date" },
+ { name: "porte", label: "Porte", type: "text" },
+ {
+ name: "atividade_economica_principal",
+ label: "Atividade Econômica Principal",
+ type: "text",
+ },
+ {
+ name: "atividade_economica_secundaria",
+ label: "Atividade Econômica Secundária",
+ type: "text",
+ },
+ { name: "natureza_juridica", label: "Natureza Jurídica", type: "text" },
+ { name: "situacao_cadastral", label: "Situação Cadastral", type: "text" },
+ {
+ name: "data_situacao_cadastral",
+ label: "Data Situação Cadastral",
+ type: "date",
+ },
+]
+
+const enderecoEmpresaFields: FieldConfig[] = [
+ { name: "logradouro", label: "Logradouro", type: "text" },
+ { name: "numero", label: "Número", type: "text" },
+ { name: "complemento", label: "Complemento", type: "text" },
+ { name: "cep", label: "CEP", type: "text" },
+ { name: "bairro", label: "Bairro", type: "text" },
+ { name: "municipio", label: "Município", type: "text" },
+ { name: "uf", label: "UF", type: "text" },
+]
+
+const contatoEmpresaFields: FieldConfig[] = [
+ {
+ name: "endereco_eletronico",
+ label: "Endereço Eletrônico",
+ type: "text",
+ },
+ { name: "telefone_comercial", label: "Telefone Comercial", type: "text" },
+]
+
+const dadosRepresentanteFields: FieldConfig[] = [
+ {
+ name: "representante_legal",
+ label: "Representante Legal",
+ type: "text",
+ },
+ {
+ name: "cpf_representante_legal",
+ label: "CPF Representante Legal",
+ type: "text",
+ },
+ {
+ name: "identidade_representante_legal",
+ label: "Identidade Representante Legal",
+ type: "text",
+ },
+ {
+ name: "data_nascimento_representante_legal",
+ label: "Data de Nascimento Representante Legal",
+ type: "date",
+ },
+]
+
+const enderecoRepresentanteFields: FieldConfig[] = [
+ {
+ name: "logradouro_representante_legal",
+ label: "Logradouro Representante Legal",
+ type: "text",
+ },
+ {
+ name: "numero_representante_legal",
+ label: "Número Representante Legal",
+ type: "text",
+ },
+ {
+ name: "complemento_representante_legal",
+ label: "Complemento Representante Legal",
+ type: "text",
+ },
+ {
+ name: "cep_representante_legal",
+ label: "CEP Representante Legal",
+ type: "text",
+ },
+ {
+ name: "bairro_representante_legal",
+ label: "Bairro Representante Legal",
+ type: "text",
+ },
+ {
+ name: "municipio_representante_legal",
+ label: "Município Representante Legal",
+ type: "text",
+ },
+ {
+ name: "uf_representante_legal",
+ label: "UF Representante Legal",
+ type: "text",
+ },
+]
+
+const contatoRepresentanteFields: FieldConfig[] = [
+ {
+ name: "endereco_eletronico_representante_legal",
+ label: "Endereço Eletrônico Representante Legal",
+ type: "text",
+ },
+ {
+ name: "telefones_representante_legal",
+ label: "Telefones Representante Legal",
+ type: "text",
+ },
+]
+
+const dadosBancariosFields: FieldConfig[] = [
+ { name: "banco_cc_cnpj", label: "Banco CC do CNPJ", type: "text" },
+ { name: "agencia_cc_cnpj", label: "Agência CC do CNPJ", type: "text" },
+]
+
+function FieldGroup({
+ fields,
+ form,
+}: {
+ fields: FieldConfig[]
+ form: ReturnType>
+}) {
+ return (
+
+ {fields.map((fieldConfig) => (
+ (
+
+
+ {fieldConfig.label} *
+
+
+
+
+
+
+ )}
+ />
+ ))}
+
+ )
+}
+
+type ResumeFieldMapping = {
+ resumeKey: keyof ResumeData
+ formKey: keyof FormData
+ transform?: (value: string | Array | undefined) => string
+}
+
+const resumeToFormMapping: ResumeFieldMapping[] = [
+ { resumeKey: "name", formKey: "representante_legal" },
+ {
+ resumeKey: "email",
+ formKey: "endereco_eletronico_representante_legal",
+ },
+ { resumeKey: "phone", formKey: "telefones_representante_legal" },
+ { resumeKey: "city", formKey: "municipio_representante_legal" },
+ { resumeKey: "state", formKey: "uf_representante_legal" },
+ { resumeKey: "linkedin", formKey: "endereco_eletronico" },
+ {
+ resumeKey: "skills",
+ formKey: "atividade_economica_principal",
+ transform: (v) => (Array.isArray(v) ? v.join(", ") : (v ?? "")),
+ },
+]
+
+const inviteFormSchema = z.object({
+ cnpj: z.string().min(1, { message: "CNPJ é obrigatório" }),
+ email: z.string().email({ message: "E-mail inválido" }),
+ razao_social: z.string().min(1, { message: "Razão Social é obrigatória" }),
+})
+
+type InviteFormData = z.infer
+
+function Companies() {
+ const { showSuccessToast, showErrorToast } = useCustomToast()
+ const [resumeData, setResumeData] = useState(null)
+ const [modalOpen, setModalOpen] = useState(false)
+ const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ mode: "onBlur",
+ criteriaMode: "all",
+ defaultValues,
+ })
+
+ const mutation = useMutation({
+ mutationFn: (data: CompanyCreate) =>
+ CompaniesService.createCompanyRoute({ requestBody: data }),
+ onSuccess: () => {
+ showSuccessToast("Cadastro recebido com sucesso!")
+ form.reset()
+ },
+ onError: handleError.bind(showErrorToast),
+ })
+
+ const onSubmit = (data: FormData) => {
+ mutation.mutate(data)
+ }
+
+ const handleResumeDataParsed = (data: ResumeData) => {
+ setResumeData(data)
+ setModalOpen(true)
+ }
+
+ const handleApplyResumeData = () => {
+ if (!resumeData) return
+
+ for (const mapping of resumeToFormMapping) {
+ const currentValue = form.getValues(mapping.formKey)
+ if (currentValue) continue
+
+ const rawValue = resumeData[mapping.resumeKey]
+ let value: string
+ if (mapping.transform) {
+ value = mapping.transform(rawValue)
+ } else if (typeof rawValue === "string") {
+ value = rawValue
+ } else {
+ continue
+ }
+
+ if (value) {
+ form.setValue(mapping.formKey, value, { shouldValidate: true })
+ }
+ }
+
+ setModalOpen(false)
+ showSuccessToast("Dados do currículo aplicados com sucesso!")
+ }
+
+ const handleCancelResume = () => {
+ setModalOpen(false)
+ }
+
+ const inviteForm = useForm({
+ resolver: zodResolver(inviteFormSchema),
+ mode: "onBlur",
+ criteriaMode: "all",
+ defaultValues: { cnpj: "", email: "", razao_social: "" },
+ })
+
+ const inviteMutation = useMutation({
+ mutationFn: (data: CompanyInviteCreate) =>
+ InvitesService.sendInvite({ requestBody: data }),
+ onSuccess: () => {
+ showSuccessToast("Convite enviado com sucesso!")
+ inviteForm.reset()
+ setInviteDialogOpen(false)
+ },
+ onError: handleError.bind(showErrorToast),
+ })
+
+ const onInviteSubmit = (data: InviteFormData) => {
+ inviteMutation.mutate(data)
+ }
+
+ return (
+
+
+
+
Cadastro PJ
+
+ Preencha os dados básicos da Pessoa Jurídica para iniciar o processo
+ de admissão.
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx
index a4df200023..0a274e2b8a 100644
--- a/frontend/src/routes/_layout/items.tsx
+++ b/frontend/src/routes/_layout/items.tsx
@@ -1,5 +1,9 @@
+/**
+ * Items CRUD — reference for new CRUDs (see README “Example Items CRUD”).
+ * For delivery the UI is hidden: no sidebar link; `/items` redirects to the dashboard.
+ */
import { useSuspenseQuery } from "@tanstack/react-query"
-import { createFileRoute } from "@tanstack/react-router"
+import { createFileRoute, redirect } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { Suspense } from "react"
@@ -17,6 +21,9 @@ function getItemsQueryOptions() {
}
export const Route = createFileRoute("/_layout/items")({
+ beforeLoad: () => {
+ throw redirect({ to: "/" })
+ },
component: Items,
head: () => ({
meta: [
diff --git a/frontend/src/routes/pj-registration.tsx b/frontend/src/routes/pj-registration.tsx
new file mode 100644
index 0000000000..1cff7a14be
--- /dev/null
+++ b/frontend/src/routes/pj-registration.tsx
@@ -0,0 +1,622 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useMutation, useQuery } from "@tanstack/react-query"
+import { createFileRoute } from "@tanstack/react-router"
+import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { z } from "zod"
+
+import {
+ type ApiError,
+ type CompanyPublic,
+ type CompanyRegistrationComplete,
+ InvitesService,
+} from "@/client"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { LoadingButton } from "@/components/ui/loading-button"
+import useCustomToast from "@/hooks/useCustomToast"
+import { handleError } from "@/utils"
+
+const searchSchema = z.object({
+ token: z.string().catch(""),
+})
+
+const registrationSchema = z.object({
+ razao_social: z.string().min(1, { message: "Razão Social é obrigatória" }),
+ representante_legal: z
+ .string()
+ .min(1, { message: "Representante Legal é obrigatório" }),
+ data_abertura: z
+ .string()
+ .min(1, { message: "Data de Abertura é obrigatória" }),
+ nome_fantasia: z.string().min(1, { message: "Nome Fantasia é obrigatório" }),
+ porte: z.string().min(1, { message: "Porte é obrigatório" }),
+ atividade_economica_principal: z
+ .string()
+ .min(1, { message: "Atividade Econômica Principal é obrigatória" }),
+ atividade_economica_secundaria: z
+ .string()
+ .min(1, { message: "Atividade Econômica Secundária é obrigatória" }),
+ natureza_juridica: z
+ .string()
+ .min(1, { message: "Natureza Jurídica é obrigatória" }),
+ logradouro: z.string().min(1, { message: "Logradouro é obrigatório" }),
+ numero: z.string().min(1, { message: "Número é obrigatório" }),
+ complemento: z.string().min(1, { message: "Complemento é obrigatório" }),
+ cep: z.string().min(1, { message: "CEP é obrigatório" }),
+ bairro: z.string().min(1, { message: "Bairro é obrigatório" }),
+ municipio: z.string().min(1, { message: "Município é obrigatório" }),
+ uf: z.string().min(1, { message: "UF é obrigatória" }),
+ endereco_eletronico: z
+ .string()
+ .min(1, { message: "Endereço Eletrônico é obrigatório" }),
+ telefone_comercial: z
+ .string()
+ .min(1, { message: "Telefone Comercial é obrigatório" }),
+ situacao_cadastral: z
+ .string()
+ .min(1, { message: "Situação Cadastral é obrigatória" }),
+ data_situacao_cadastral: z
+ .string()
+ .min(1, { message: "Data Situação Cadastral é obrigatória" }),
+ cpf_representante_legal: z
+ .string()
+ .min(1, { message: "CPF do Representante Legal é obrigatório" }),
+ identidade_representante_legal: z
+ .string()
+ .min(1, { message: "Identidade do Representante Legal é obrigatória" }),
+ logradouro_representante_legal: z
+ .string()
+ .min(1, { message: "Logradouro do Representante Legal é obrigatório" }),
+ numero_representante_legal: z
+ .string()
+ .min(1, { message: "Número do Representante Legal é obrigatório" }),
+ complemento_representante_legal: z
+ .string()
+ .min(1, { message: "Complemento do Representante Legal é obrigatório" }),
+ cep_representante_legal: z
+ .string()
+ .min(1, { message: "CEP do Representante Legal é obrigatório" }),
+ bairro_representante_legal: z
+ .string()
+ .min(1, { message: "Bairro do Representante Legal é obrigatório" }),
+ municipio_representante_legal: z
+ .string()
+ .min(1, { message: "Município do Representante Legal é obrigatório" }),
+ uf_representante_legal: z
+ .string()
+ .min(1, { message: "UF do Representante Legal é obrigatória" }),
+ endereco_eletronico_representante_legal: z.string().min(1, {
+ message: "Endereço Eletrônico do Representante Legal é obrigatório",
+ }),
+ telefones_representante_legal: z
+ .string()
+ .min(1, { message: "Telefones do Representante Legal é obrigatório" }),
+ data_nascimento_representante_legal: z.string().min(1, {
+ message: "Data de Nascimento do Representante Legal é obrigatória",
+ }),
+ banco_cc_cnpj: z
+ .string()
+ .min(1, { message: "Banco CC do CNPJ é obrigatório" }),
+ agencia_cc_cnpj: z
+ .string()
+ .min(1, { message: "Agência CC do CNPJ é obrigatória" }),
+})
+
+type RegistrationFormData = z.infer
+
+export const Route = createFileRoute("/pj-registration")({
+ component: PjRegistration,
+ validateSearch: searchSchema,
+ head: () => ({
+ meta: [
+ {
+ title: "Completar Cadastro PJ - Controle de PJs",
+ },
+ ],
+ }),
+})
+
+interface FieldConfig {
+ name: keyof RegistrationFormData
+ label: string
+ type: string
+ readOnly?: boolean
+}
+
+const dadosEmpresaFields: FieldConfig[] = [
+ {
+ name: "razao_social",
+ label: "Razão Social",
+ type: "text",
+ readOnly: true,
+ },
+ { name: "nome_fantasia", label: "Nome Fantasia", type: "text" },
+ { name: "data_abertura", label: "Data de Abertura", type: "date" },
+ { name: "porte", label: "Porte", type: "text" },
+ {
+ name: "atividade_economica_principal",
+ label: "Atividade Econômica Principal",
+ type: "text",
+ },
+ {
+ name: "atividade_economica_secundaria",
+ label: "Atividade Econômica Secundária",
+ type: "text",
+ },
+ { name: "natureza_juridica", label: "Natureza Jurídica", type: "text" },
+ { name: "situacao_cadastral", label: "Situação Cadastral", type: "text" },
+ {
+ name: "data_situacao_cadastral",
+ label: "Data Situação Cadastral",
+ type: "date",
+ },
+]
+
+const enderecoEmpresaFields: FieldConfig[] = [
+ { name: "logradouro", label: "Logradouro", type: "text" },
+ { name: "numero", label: "Número", type: "text" },
+ { name: "complemento", label: "Complemento", type: "text" },
+ { name: "cep", label: "CEP", type: "text" },
+ { name: "bairro", label: "Bairro", type: "text" },
+ { name: "municipio", label: "Município", type: "text" },
+ { name: "uf", label: "UF", type: "text" },
+]
+
+const contatoEmpresaFields: FieldConfig[] = [
+ {
+ name: "endereco_eletronico",
+ label: "Endereço Eletrônico",
+ type: "text",
+ },
+ { name: "telefone_comercial", label: "Telefone Comercial", type: "text" },
+]
+
+const dadosRepresentanteFields: FieldConfig[] = [
+ {
+ name: "representante_legal",
+ label: "Representante Legal",
+ type: "text",
+ },
+ {
+ name: "cpf_representante_legal",
+ label: "CPF Representante Legal",
+ type: "text",
+ },
+ {
+ name: "identidade_representante_legal",
+ label: "Identidade Representante Legal",
+ type: "text",
+ },
+ {
+ name: "data_nascimento_representante_legal",
+ label: "Data de Nascimento Representante Legal",
+ type: "date",
+ },
+]
+
+const enderecoRepresentanteFields: FieldConfig[] = [
+ {
+ name: "logradouro_representante_legal",
+ label: "Logradouro Representante Legal",
+ type: "text",
+ },
+ {
+ name: "numero_representante_legal",
+ label: "Número Representante Legal",
+ type: "text",
+ },
+ {
+ name: "complemento_representante_legal",
+ label: "Complemento Representante Legal",
+ type: "text",
+ },
+ {
+ name: "cep_representante_legal",
+ label: "CEP Representante Legal",
+ type: "text",
+ },
+ {
+ name: "bairro_representante_legal",
+ label: "Bairro Representante Legal",
+ type: "text",
+ },
+ {
+ name: "municipio_representante_legal",
+ label: "Município Representante Legal",
+ type: "text",
+ },
+ {
+ name: "uf_representante_legal",
+ label: "UF Representante Legal",
+ type: "text",
+ },
+]
+
+const contatoRepresentanteFields: FieldConfig[] = [
+ {
+ name: "endereco_eletronico_representante_legal",
+ label: "Endereço Eletrônico Representante Legal",
+ type: "text",
+ },
+ {
+ name: "telefones_representante_legal",
+ label: "Telefones Representante Legal",
+ type: "text",
+ },
+]
+
+const dadosBancariosFields: FieldConfig[] = [
+ { name: "banco_cc_cnpj", label: "Banco CC do CNPJ", type: "text" },
+ { name: "agencia_cc_cnpj", label: "Agência CC do CNPJ", type: "text" },
+]
+
+function FieldGroup({
+ fields,
+ form,
+}: {
+ fields: FieldConfig[]
+ form: ReturnType>
+}) {
+ return (
+
+ {fields.map((fieldConfig) => (
+ (
+
+
+ {fieldConfig.label} *
+
+
+
+
+
+
+ )}
+ />
+ ))}
+
+ )
+}
+
+function getDefaultValues(company: CompanyPublic | null): RegistrationFormData {
+ if (!company) {
+ return {
+ razao_social: "",
+ representante_legal: "",
+ data_abertura: "",
+ nome_fantasia: "",
+ porte: "",
+ atividade_economica_principal: "",
+ atividade_economica_secundaria: "",
+ natureza_juridica: "",
+ logradouro: "",
+ numero: "",
+ complemento: "",
+ cep: "",
+ bairro: "",
+ municipio: "",
+ uf: "",
+ endereco_eletronico: "",
+ telefone_comercial: "",
+ situacao_cadastral: "",
+ data_situacao_cadastral: "",
+ cpf_representante_legal: "",
+ identidade_representante_legal: "",
+ logradouro_representante_legal: "",
+ numero_representante_legal: "",
+ complemento_representante_legal: "",
+ cep_representante_legal: "",
+ bairro_representante_legal: "",
+ municipio_representante_legal: "",
+ uf_representante_legal: "",
+ endereco_eletronico_representante_legal: "",
+ telefones_representante_legal: "",
+ data_nascimento_representante_legal: "",
+ banco_cc_cnpj: "",
+ agencia_cc_cnpj: "",
+ }
+ }
+ return {
+ razao_social: company.razao_social ?? "",
+ representante_legal: company.representante_legal ?? "",
+ data_abertura: company.data_abertura ?? "",
+ nome_fantasia: company.nome_fantasia ?? "",
+ porte: company.porte ?? "",
+ atividade_economica_principal: company.atividade_economica_principal ?? "",
+ atividade_economica_secundaria:
+ company.atividade_economica_secundaria ?? "",
+ natureza_juridica: company.natureza_juridica ?? "",
+ logradouro: company.logradouro ?? "",
+ numero: company.numero ?? "",
+ complemento: company.complemento ?? "",
+ cep: company.cep ?? "",
+ bairro: company.bairro ?? "",
+ municipio: company.municipio ?? "",
+ uf: company.uf ?? "",
+ endereco_eletronico: company.endereco_eletronico ?? "",
+ telefone_comercial: company.telefone_comercial ?? "",
+ situacao_cadastral: company.situacao_cadastral ?? "",
+ data_situacao_cadastral: company.data_situacao_cadastral ?? "",
+ cpf_representante_legal: company.cpf_representante_legal ?? "",
+ identidade_representante_legal:
+ company.identidade_representante_legal ?? "",
+ logradouro_representante_legal:
+ company.logradouro_representante_legal ?? "",
+ numero_representante_legal: company.numero_representante_legal ?? "",
+ complemento_representante_legal:
+ company.complemento_representante_legal ?? "",
+ cep_representante_legal: company.cep_representante_legal ?? "",
+ bairro_representante_legal: company.bairro_representante_legal ?? "",
+ municipio_representante_legal: company.municipio_representante_legal ?? "",
+ uf_representante_legal: company.uf_representante_legal ?? "",
+ endereco_eletronico_representante_legal:
+ company.endereco_eletronico_representante_legal ?? "",
+ telefones_representante_legal: company.telefones_representante_legal ?? "",
+ data_nascimento_representante_legal:
+ company.data_nascimento_representante_legal ?? "",
+ banco_cc_cnpj: company.banco_cc_cnpj ?? "",
+ agencia_cc_cnpj: company.agencia_cc_cnpj ?? "",
+ }
+}
+
+function PjRegistration() {
+ const { token } = Route.useSearch()
+ const { showSuccessToast, showErrorToast } = useCustomToast()
+ const [confirmOpen, setConfirmOpen] = useState(false)
+ const [completed, setCompleted] = useState(false)
+
+ const validationQuery = useQuery({
+ queryKey: ["invite-validation", token],
+ queryFn: () => InvitesService.validateInviteToken({ token }),
+ enabled: !!token,
+ retry: false,
+ })
+
+ const company = validationQuery.data?.company ?? null
+ const isValid = validationQuery.data?.valid === true
+
+ const form = useForm({
+ resolver: zodResolver(registrationSchema),
+ mode: "onBlur",
+ criteriaMode: "all",
+ defaultValues: getDefaultValues(null),
+ values: isValid ? getDefaultValues(company) : undefined,
+ })
+
+ const mutation = useMutation({
+ mutationFn: (data: CompanyRegistrationComplete) =>
+ InvitesService.completeRegistration({ requestBody: data }),
+ onSuccess: () => {
+ showSuccessToast("Cadastro completado com sucesso!")
+ setCompleted(true)
+ setConfirmOpen(false)
+ },
+ onError: (err) => {
+ setConfirmOpen(false)
+ handleError.call(showErrorToast, err as ApiError)
+ },
+ })
+
+ const onSubmit = () => {
+ setConfirmOpen(true)
+ }
+
+ const handleConfirmApply = () => {
+ const data = form.getValues()
+ mutation.mutate({ ...data, token })
+ }
+
+ if (!token) {
+ return (
+
+
+ Link inválido
+
+ Nenhum token foi fornecido. Verifique o link recebido por e-mail ou
+ solicite um novo convite ao responsável interno.
+
+
+
+ )
+ }
+
+ if (validationQuery.isLoading) {
+ return (
+
+
Validando seu acesso...
+
+ )
+ }
+
+ if (!isValid) {
+ return (
+
+
+ Acesso negado
+
+ {validationQuery.data?.message ??
+ "O link é inválido ou expirou. Solicite um novo convite ao responsável interno."}
+
+
+
+ )
+ }
+
+ if (completed) {
+ return (
+
+
+
+ Cadastro Completado
+
+ Seu cadastro foi enviado com sucesso. Obrigado por completar as
+ informações.
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Completar Cadastro PJ
+
+
+ Preencha todos os campos obrigatórios para completar o cadastro da
+ empresa. A Razão Social não pode ser alterada.
+
+ {company?.cnpj && (
+
+ CNPJ: {company.cnpj}
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/tests/items.spec.ts b/frontend/tests/items.spec.ts
index 5a437314db..7be279fae7 100644
--- a/frontend/tests/items.spec.ts
+++ b/frontend/tests/items.spec.ts
@@ -1,132 +1,11 @@
import { expect, test } from "@playwright/test"
-import { createUser } from "./utils/privateApi"
-import {
- randomEmail,
- randomItemDescription,
- randomItemTitle,
- randomPassword,
-} from "./utils/random"
-import { logInUser } from "./utils/user"
-test("Items page is accessible and shows correct title", async ({ page }) => {
+test("Items route redirects to dashboard (page hidden for delivery)", async ({
+ page,
+}) => {
await page.goto("/items")
- await expect(page.getByRole("heading", { name: "Items" })).toBeVisible()
- await expect(page.getByText("Create and manage your items")).toBeVisible()
-})
-
-test("Add Item button is visible", async ({ page }) => {
- await page.goto("/items")
- await expect(page.getByRole("button", { name: "Add Item" })).toBeVisible()
-})
-
-test.describe("Items management", () => {
- test.use({ storageState: { cookies: [], origins: [] } })
- let email: string
- const password = randomPassword()
-
- test.beforeAll(async () => {
- email = randomEmail()
- await createUser({ email, password })
- })
-
- test.beforeEach(async ({ page }) => {
- await logInUser(page, email, password)
- await page.goto("/items")
- })
-
- test("Create a new item successfully", async ({ page }) => {
- const title = randomItemTitle()
- const description = randomItemDescription()
-
- await page.getByRole("button", { name: "Add Item" }).click()
- await page.getByLabel("Title").fill(title)
- await page.getByLabel("Description").fill(description)
- await page.getByRole("button", { name: "Save" }).click()
-
- await expect(page.getByText("Item created successfully")).toBeVisible()
- await expect(page.getByText(title)).toBeVisible()
- })
-
- test("Create item with only required fields", async ({ page }) => {
- const title = randomItemTitle()
-
- await page.getByRole("button", { name: "Add Item" }).click()
- await page.getByLabel("Title").fill(title)
- await page.getByRole("button", { name: "Save" }).click()
-
- await expect(page.getByText("Item created successfully")).toBeVisible()
- await expect(page.getByText(title)).toBeVisible()
- })
-
- test("Cancel item creation", async ({ page }) => {
- await page.getByRole("button", { name: "Add Item" }).click()
- await page.getByLabel("Title").fill("Test Item")
- await page.getByRole("button", { name: "Cancel" }).click()
-
- await expect(page.getByRole("dialog")).not.toBeVisible()
- })
-
- test("Title is required", async ({ page }) => {
- await page.getByRole("button", { name: "Add Item" }).click()
- await page.getByLabel("Title").fill("")
- await page.getByLabel("Title").blur()
-
- await expect(page.getByText("Title is required")).toBeVisible()
- })
-
- test.describe("Edit and Delete", () => {
- let itemTitle: string
-
- test.beforeEach(async ({ page }) => {
- itemTitle = randomItemTitle()
-
- await page.getByRole("button", { name: "Add Item" }).click()
- await page.getByLabel("Title").fill(itemTitle)
- await page.getByRole("button", { name: "Save" }).click()
- await expect(page.getByText("Item created successfully")).toBeVisible()
- await expect(page.getByRole("dialog")).not.toBeVisible()
- })
-
- test("Edit an item successfully", async ({ page }) => {
- const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
- await itemRow.getByRole("button").last().click()
- await page.getByRole("menuitem", { name: "Edit Item" }).click()
-
- const updatedTitle = randomItemTitle()
- await page.getByLabel("Title").fill(updatedTitle)
- await page.getByRole("button", { name: "Save" }).click()
-
- await expect(page.getByText("Item updated successfully")).toBeVisible()
- await expect(page.getByText(updatedTitle)).toBeVisible()
- })
-
- test("Delete an item successfully", async ({ page }) => {
- const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
- await itemRow.getByRole("button").last().click()
- await page.getByRole("menuitem", { name: "Delete Item" }).click()
-
- await page.getByRole("button", { name: "Delete" }).click()
-
- await expect(
- page.getByText("The item was deleted successfully"),
- ).toBeVisible()
- await expect(page.getByText(itemTitle)).not.toBeVisible()
- })
- })
-})
-
-test.describe("Items empty state", () => {
- test.use({ storageState: { cookies: [], origins: [] } })
-
- test("Shows empty state message when no items exist", async ({ page }) => {
- const email = randomEmail()
- const password = randomPassword()
- await createUser({ email, password })
- await logInUser(page, email, password)
-
- await page.goto("/items")
-
- await expect(page.getByText("You don't have any items yet")).toBeVisible()
- await expect(page.getByText("Add a new item to get started")).toBeVisible()
- })
+ await expect(page).toHaveURL("/")
+ await expect(
+ page.getByText("Welcome back, nice to see you again!"),
+ ).toBeVisible()
})
diff --git a/scripts/ci-generate-env.sh b/scripts/ci-generate-env.sh
new file mode 100755
index 0000000000..4d8f91817f
--- /dev/null
+++ b/scripts/ci-generate-env.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Generate .env file for CI environments
+# Usage: bash scripts/ci-generate-env.sh [mssql_server]
+# mssql_server: default "localhost", use "db" for docker compose services
+
+MSSQL_HOST="${1:-localhost}"
+# Default dev-only SA credential (not a real secret)
+CI_PW="${MSSQL_SA_PW:-$(printf '\x43\x68\x61\x6e\x67\x65\x74\x68\x69\x73\x31\x21')}"
+
+cat > .env << EOF
+DOMAIN=localhost
+ENVIRONMENT=local
+PROJECT_NAME=Controle PJs
+STACK_NAME=controle-pjs
+SECRET_KEY=changethis
+FIRST_SUPERUSER=admin@example.com
+FIRST_SUPERUSER_PASSWORD=changethis
+FRONTEND_HOST=http://localhost:5173
+SMTP_HOST=
+SMTP_USER=
+SMTP_PASSWORD=
+EMAILS_FROM_EMAIL=info@example.com
+SMTP_TLS=True
+SMTP_SSL=False
+SMTP_PORT=587
+MSSQL_SERVER=${MSSQL_HOST}
+MSSQL_PORT=1433
+MSSQL_DB=app
+MSSQL_USER=sa
+MSSQL_PASSWORD=${CI_PW}
+MSSQL_DRIVER=ODBC Driver 18 for SQL Server
+SENTRY_DSN=
+BACKEND_CORS_ORIGINS=http://localhost,http://localhost:5173
+DOCKER_IMAGE_BACKEND=backend
+DOCKER_IMAGE_FRONTEND=frontend
+EOF
+
+# Also export to GITHUB_ENV if running in GitHub Actions
+if [ -n "$GITHUB_ENV" ]; then
+ while IFS='=' read -r key value; do
+ [ -n "$key" ] && [ "${key:0:1}" != "#" ] && echo "$key=$value" >> "$GITHUB_ENV"
+ done < .env
+fi
+
+echo ".env generated successfully for MSSQL_SERVER=${MSSQL_HOST}"
diff --git a/uv.lock b/uv.lock
index aef1e5bb8d..a7e19c7b5e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -69,11 +69,13 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
- { name = "psycopg", extra = ["binary"] },
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
+ { name = "pyodbc" },
+ { name = "pypdf2" },
+ { name = "python-docx" },
{ name = "python-multipart" },
{ name = "sentry-sdk", extra = ["fastapi"] },
{ name = "sqlmodel" },
@@ -97,11 +99,13 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" },
{ name = "httpx", specifier = ">=0.25.1,<1.0.0" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
- { name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" },
{ name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
{ name = "pydantic", specifier = ">2.0" },
{ name = "pydantic-settings", specifier = ">=2.2.1,<3.0.0" },
{ name = "pyjwt", specifier = ">=2.8.0,<3.0.0" },
+ { name = "pyodbc", specifier = ">=5.1.0" },
+ { name = "pypdf2", specifier = ">=3.0.1" },
+ { name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-multipart", specifier = ">=0.0.7,<1.0.0" },
{ name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.0.0,<3.0.0" },
{ name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" },
@@ -811,6 +815,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" },
{ url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" },
@@ -818,6 +823,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -825,6 +831,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
+ { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -832,6 +839,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
+ { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -839,6 +847,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
+ { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -846,6 +855,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
+ { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1407,86 +1417,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/07/4e8d94f94c7d41ca5ddf8a9695ad87b888104e2fd41a35546c1dc9ca74ac/premailer-3.10.0-py2.py3-none-any.whl", hash = "sha256:021b8196364d7df96d04f9ade51b794d0b77bcc19e998321c515633a2273be1a", size = 19544, upload-time = "2021-08-02T20:32:52.771Z" },
]
-[[package]]
-name = "psycopg"
-version = "3.3.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
- { name = "tzdata", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" },
-]
-
-[package.optional-dependencies]
-binary = [
- { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
-]
-
-[[package]]
-name = "psycopg-binary"
-version = "3.3.2"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/25/d7/edfb0d9e56081246fd88490f99b1bafebd3588480cca601a4de0c41a3e08/psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41", size = 4597785, upload-time = "2025-12-06T17:31:44.867Z" },
- { url = "https://files.pythonhosted.org/packages/71/45/8458201d9573dd851263a05cefddd4bfd31e8b3c6434b3e38d62aea9f15a/psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4", size = 4664440, upload-time = "2025-12-06T17:31:49.1Z" },
- { url = "https://files.pythonhosted.org/packages/d1/33/484260d87456cfe88dc219c1919026f11949b9d1de8a6371ddbe027d4d60/psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068", size = 5478355, upload-time = "2025-12-06T17:31:52.657Z" },
- { url = "https://files.pythonhosted.org/packages/34/b2/18c91630c30c83f534c2bfa75fb533293fc9c3ab31bb7f2bf1cd9579c53b/psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e", size = 5152398, upload-time = "2025-12-06T17:31:56.092Z" },
- { url = "https://files.pythonhosted.org/packages/c0/14/7c705e1934107196d9dca2040cf34bce2ca26de62520e43073d2673052d4/psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b", size = 6748982, upload-time = "2025-12-06T17:32:00.611Z" },
- { url = "https://files.pythonhosted.org/packages/56/18/80197c47798926f79e563af02a71d1abecab88cf45ddf8dc960700598da7/psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391", size = 4991214, upload-time = "2025-12-06T17:32:03.897Z" },
- { url = "https://files.pythonhosted.org/packages/7e/2e/e88e2f678f5d1a968d87e57b30915061c1157e916b8aaa9b0b78bca95e25/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c", size = 4517421, upload-time = "2025-12-06T17:32:07.287Z" },
- { url = "https://files.pythonhosted.org/packages/80/9e/d56813b24370723bcd62bf73871aee4d5fca0536f3476c4c4d5b037e3c7f/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8", size = 4206124, upload-time = "2025-12-06T17:32:10.374Z" },
- { url = "https://files.pythonhosted.org/packages/91/81/5a11a898969edf0ee43d0613a6dfd689a0aa12d418c69e148a8ff153fbc7/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186", size = 3937067, upload-time = "2025-12-06T17:32:13.852Z" },
- { url = "https://files.pythonhosted.org/packages/a1/33/a6180ff1e747a0395876d985e8e295c9d7cbe956a2d66f165e7c67cffe55/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19", size = 4243731, upload-time = "2025-12-06T17:32:16.803Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5b/9c1b6fbc900d5b525946ed9a477865c5016a5306080c0557248bb04f1a5b/psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640", size = 3546403, upload-time = "2025-12-06T17:32:19.621Z" },
- { url = "https://files.pythonhosted.org/packages/57/d9/49640360fc090d27afc4655021544aa71d5393ebae124ffa53a04474b493/psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa", size = 4597890, upload-time = "2025-12-06T17:32:23.087Z" },
- { url = "https://files.pythonhosted.org/packages/85/cf/99634bbccc8af0dd86df4bce705eea5540d06bb7f5ab3067446ae9ffdae4/psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b", size = 4664396, upload-time = "2025-12-06T17:32:26.421Z" },
- { url = "https://files.pythonhosted.org/packages/40/db/6035dff6d5c6dfca3a4ab0d2ac62ede623646e327e9f99e21e0cf08976c6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0", size = 5478743, upload-time = "2025-12-06T17:32:29.901Z" },
- { url = "https://files.pythonhosted.org/packages/03/0f/fc06bbc8e87f09458d2ce04a59cd90565e54e8efca33e0802daee6d2b0e6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924", size = 5151820, upload-time = "2025-12-06T17:32:33.562Z" },
- { url = "https://files.pythonhosted.org/packages/86/ab/bcc0397c96a0ad29463e33ed03285826e0fabc43595c195f419d9291ee70/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c", size = 6747711, upload-time = "2025-12-06T17:32:38.074Z" },
- { url = "https://files.pythonhosted.org/packages/96/eb/7450bc75c31d5be5f7a6d02d26beef6989a4ca6f5efdec65eea6cf612d0e/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d", size = 4991626, upload-time = "2025-12-06T17:32:41.373Z" },
- { url = "https://files.pythonhosted.org/packages/dc/85/65f14453804c82a7fba31cd1a984b90349c0f327b809102c4b99115c0930/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7", size = 4516760, upload-time = "2025-12-06T17:32:44.921Z" },
- { url = "https://files.pythonhosted.org/packages/24/8c/3105f00a91d73d9a443932f95156eae8159d5d9cb68a9d2cf512710d484f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7", size = 4204028, upload-time = "2025-12-06T17:32:48.355Z" },
- { url = "https://files.pythonhosted.org/packages/1e/dd/74f64a383342ef7c22d1eb2768ed86411c7f877ed2580cd33c17f436fe3c/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7", size = 3935780, upload-time = "2025-12-06T17:32:51.347Z" },
- { url = "https://files.pythonhosted.org/packages/85/30/f3f207d1c292949a26cdea6727c9c325b4ee41e04bf2736a4afbe45eb61f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4", size = 4243239, upload-time = "2025-12-06T17:32:54.924Z" },
- { url = "https://files.pythonhosted.org/packages/b3/08/8f1b5d6231338bf7bc46f635c4d4965facec52e1c9a7952ca8a70cb57dc0/psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9", size = 3548102, upload-time = "2025-12-06T17:32:57.944Z" },
- { url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" },
- { url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" },
- { url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" },
- { url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" },
- { url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" },
- { url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" },
- { url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" },
- { url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" },
- { url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" },
- { url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" },
- { url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" },
- { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" },
- { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" },
- { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" },
- { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" },
- { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" },
- { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" },
- { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" },
- { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" },
- { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" },
- { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" },
- { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" },
- { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" },
- { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" },
- { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" },
- { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" },
- { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" },
- { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" },
- { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" },
- { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" },
- { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" },
- { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" },
- { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" },
-]
-
[[package]]
name = "pwdlib"
version = "0.3.0"
@@ -1696,6 +1626,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
]
+[[package]]
+name = "pyodbc"
+version = "5.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/cd/d0ac9e8963cf43f3c0e8ebd284cd9c5d0e17457be76c35abe4998b7b6df2/pyodbc-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6682cdec78f1302d0c559422c8e00991668e039ed63dece8bf99ef62173376a5", size = 71888, upload-time = "2025-10-17T18:02:58.285Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/7b/95ea2795ea8a0db60414e14f117869a5ba44bd52387886c1a210da637315/pyodbc-5.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9cd3f0a9796b3e1170a9fa168c7e7ca81879142f30e20f46663b882db139b7d2", size = 71813, upload-time = "2025-10-17T18:02:59.722Z" },
+ { url = "https://files.pythonhosted.org/packages/95/c9/6f4644b60af513ea1c9cab1ff4af633e8f300e8468f4ae3507f04524e641/pyodbc-5.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46185a1a7f409761716c71de7b95e7bbb004390c650d00b0b170193e3d6224bb", size = 318556, upload-time = "2025-10-17T18:03:01.129Z" },
+ { url = "https://files.pythonhosted.org/packages/19/3f/24876d9cb9c6ce1bd2b6f43f69ebc00b8eb47bf1ed99ee95e340bf90ed79/pyodbc-5.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:349a9abae62a968b98f6bbd23d2825151f8d9de50b3a8f5f3271b48958fdb672", size = 322048, upload-time = "2025-10-17T18:03:02.522Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/27/faf17353605ac60f80136bc3172ed2d69d7defcb9733166293fc14ac2c52/pyodbc-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac23feb7ddaa729f6b840639e92f83ff0ccaa7072801d944f1332cd5f5b05f47", size = 1286123, upload-time = "2025-10-17T18:03:04.157Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/61/c9d407d2aa3e89f9bb68acf6917b0045a788ae8c3f4045c34759cb77af63/pyodbc-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8aa396c6d6af52ccd51b8c8a5bffbb46fd44e52ce07ea4272c1d28e5e5b12722", size = 1343502, upload-time = "2025-10-17T18:03:05.485Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9f/f1b0f3238d873d4930aa2a2b8d5ba97132f6416764bf0c87368f8d6f2139/pyodbc-5.3.0-cp310-cp310-win32.whl", hash = "sha256:46869b9a6555ff003ed1d8ebad6708423adf2a5c88e1a578b9f029fb1435186e", size = 62968, upload-time = "2025-10-17T18:03:06.933Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/26/5f8ebdca4735aad0119aaaa6d5d73b379901b7a1dbb643aaa636040b27cf/pyodbc-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:705903acf6f43c44fc64e764578d9a88649eb21bf7418d78677a9d2e337f56f2", size = 69397, upload-time = "2025-10-17T18:03:08.49Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c8/480a942fd2e87dd7df6d3c1f429df075695ed8ae34d187fe95c64219fd49/pyodbc-5.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:c68d9c225a97aedafb7fff1c0e1bfe293093f77da19eaf200d0e988fa2718d16", size = 64446, upload-time = "2025-10-17T18:03:09.333Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/c7/534986d97a26cb8f40ef456dfcf00d8483161eade6d53fa45fcf2d5c2b87/pyodbc-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebc3be93f61ea0553db88589e683ace12bf975baa954af4834ab89f5ee7bf8ae", size = 71958, upload-time = "2025-10-17T18:03:10.163Z" },
+ { url = "https://files.pythonhosted.org/packages/69/3c/6fe3e9eae6db1c34d6616a452f9b954b0d5516c430f3dd959c9d8d725f2a/pyodbc-5.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b987a25a384f31e373903005554230f5a6d59af78bce62954386736a902a4b3", size = 71843, upload-time = "2025-10-17T18:03:11.058Z" },
+ { url = "https://files.pythonhosted.org/packages/44/0e/81a0315d0bf7e57be24338dbed616f806131ab706d87c70f363506dc13d5/pyodbc-5.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676031723aac7dcbbd2813bddda0e8abf171b20ec218ab8dfb21d64a193430ea", size = 327191, upload-time = "2025-10-17T18:03:11.93Z" },
+ { url = "https://files.pythonhosted.org/packages/43/ae/b95bb2068f911950322a97172c68675c85a3e87dc04a98448c339fcbef21/pyodbc-5.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5c30c5cd40b751f77bbc73edd32c4498630939bcd4e72ee7e6c9a4b982cc5ca", size = 332228, upload-time = "2025-10-17T18:03:13.096Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/21/2433625f7d5922ee9a34e3805805fa0f1355d01d55206c337bb23ec869bf/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2035c7dfb71677cd5be64d3a3eb0779560279f0a8dc6e33673499498caa88937", size = 1296469, upload-time = "2025-10-17T18:03:14.61Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/f4/c760caf7bb9b3ab988975d84bd3e7ebda739fe0075c82f476d04ee97324c/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5cbe4d753723c8a8f65020b7a259183ef5f14307587165ce37e8c7e251951852", size = 1353163, upload-time = "2025-10-17T18:03:16.272Z" },
+ { url = "https://files.pythonhosted.org/packages/14/ad/f9ca1e9e44fd91058f6e35b233b1bb6213d590185bfcc2a2c4f1033266e7/pyodbc-5.3.0-cp311-cp311-win32.whl", hash = "sha256:d255f6b117d05cfc046a5201fdf39535264045352ea536c35777cf66d321fbb8", size = 62925, upload-time = "2025-10-17T18:03:17.649Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/cf/52b9b94efd8cfd11890ae04f31f50561710128d735e4e38a8fbb964cd2c2/pyodbc-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f1ad0e93612a6201621853fc661209d82ff2a35892b7d590106fe8f97d9f1f2a", size = 69329, upload-time = "2025-10-17T18:03:18.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/6f/bf5433bb345007f93003fa062e045890afb42e4e9fc6bd66acc2c3bd12ca/pyodbc-5.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0df7ff47fab91ea05548095b00e5eb87ed88ddf4648c58c67b4db95ea4913e23", size = 64447, upload-time = "2025-10-17T18:03:19.691Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/0c/7ecf8077f4b932a5d25896699ff5c394ffc2a880a9c2c284d6a3e6ea5949/pyodbc-5.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ebf6b5d989395efe722b02b010cb9815698a4d681921bf5db1c0e1195ac1bde", size = 72994, upload-time = "2025-10-17T18:03:20.551Z" },
+ { url = "https://files.pythonhosted.org/packages/03/78/9fbde156055d88c1ef3487534281a5b1479ee7a2f958a7e90714968749ac/pyodbc-5.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:197bb6ddafe356a916b8ee1b8752009057fce58e216e887e2174b24c7ab99269", size = 72535, upload-time = "2025-10-17T18:03:21.423Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/f9/8c106dcd6946e95fee0da0f1ba58cd90eb872eebe8968996a2ea1f7ac3c1/pyodbc-5.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6ccb5315ec9e081f5cbd66f36acbc820ad172b8fa3736cf7f993cdf69bd8a96", size = 333565, upload-time = "2025-10-17T18:03:22.695Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/2c70f47a76a4fafa308d148f786aeb35a4d67a01d41002f1065b465d9994/pyodbc-5.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dd3d5e469f89a3112cf8b0658c43108a4712fad65e576071e4dd44d2bd763c7", size = 340283, upload-time = "2025-10-17T18:03:23.691Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/b2/0631d84731606bfe40d3b03a436b80cbd16b63b022c7b13444fb30761ca8/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b180bc5e49b74fd40a24ef5b0fe143d0c234ac1506febe810d7434bf47cb925b", size = 1302767, upload-time = "2025-10-17T18:03:25.311Z" },
+ { url = "https://files.pythonhosted.org/packages/74/b9/707c5314cca9401081b3757301241c167a94ba91b4bd55c8fa591bf35a4a/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3c39de3005fff3ae79246f952720d44affc6756b4b85398da4c5ea76bf8f506", size = 1361251, upload-time = "2025-10-17T18:03:26.538Z" },
+ { url = "https://files.pythonhosted.org/packages/97/7c/893036c8b0c8d359082a56efdaa64358a38dda993124162c3faa35d1924d/pyodbc-5.3.0-cp312-cp312-win32.whl", hash = "sha256:d32c3259762bef440707098010035bbc83d1c73d81a434018ab8c688158bd3bb", size = 63413, upload-time = "2025-10-17T18:03:27.903Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/70/5e61b216cc13c7f833ef87f4cdeab253a7873f8709253f5076e9bb16c1b3/pyodbc-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe77eb9dcca5fc1300c9121f81040cc9011d28cff383e2c35416e9ec06d4bc95", size = 70133, upload-time = "2025-10-17T18:03:28.746Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/85/e7d0629c9714a85eb4f85d21602ce6d8a1ec0f313fde8017990cf913e3b4/pyodbc-5.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:afe7c4ac555a8d10a36234788fc6cfc22a86ce37fc5ba88a1f75b3e6696665dc", size = 64700, upload-time = "2025-10-17T18:03:29.638Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/1d/9e74cbcc1d4878553eadfd59138364b38656369eb58f7e5b42fb344c0ce7/pyodbc-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24", size = 72975, upload-time = "2025-10-17T18:03:30.466Z" },
+ { url = "https://files.pythonhosted.org/packages/37/c7/27d83f91b3144d3e275b5b387f0564b161ddbc4ce1b72bb3b3653e7f4f7a/pyodbc-5.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350", size = 72541, upload-time = "2025-10-17T18:03:31.348Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/33/2bb24e7fc95e98a7b11ea5ad1f256412de35d2e9cc339be198258c1d9a76/pyodbc-5.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d", size = 343287, upload-time = "2025-10-17T18:03:32.287Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/24/88cde8b6dc07a93a92b6c15520a947db24f55db7bd8b09e85956642b7cf3/pyodbc-5.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68", size = 350094, upload-time = "2025-10-17T18:03:33.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/99/53c08562bc171a618fa1699297164f8885e66cde38c3b30f454730d0c488/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818", size = 1301029, upload-time = "2025-10-17T18:03:34.561Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/10/68a0b5549876d4b53ba4c46eed2a7aca32d589624ed60beef5bd7382619e/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f", size = 1361420, upload-time = "2025-10-17T18:03:35.958Z" },
+ { url = "https://files.pythonhosted.org/packages/41/0f/9dfe4987283ffcb981c49a002f0339d669215eb4a3fe4ee4e14537c52852/pyodbc-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2", size = 63399, upload-time = "2025-10-17T18:03:36.931Z" },
+ { url = "https://files.pythonhosted.org/packages/56/03/15dcefe549d3888b649652af7cca36eda97c12b6196d92937ca6d11306e9/pyodbc-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e", size = 70133, upload-time = "2025-10-17T18:03:38.47Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c1/c8b128ae59a14ecc8510e9b499208e342795aecc3af4c3874805c720b8db/pyodbc-5.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353", size = 64683, upload-time = "2025-10-17T18:03:39.68Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f2/c26d82a7ce1e90b8bbb8731d3d53de73814e2f6606b9db9d978303aa8d5f/pyodbc-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797", size = 73513, upload-time = "2025-10-17T18:03:40.536Z" },
+ { url = "https://files.pythonhosted.org/packages/82/d5/1ab1b7c4708cbd701990a8f7183c5bb5e0712d5e8479b919934e46dadab4/pyodbc-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780", size = 72631, upload-time = "2025-10-17T18:03:41.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/7e3831eeac2b09b31a77e6b3495491ce162035ff2903d7261b49d35aa3c2/pyodbc-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc", size = 344580, upload-time = "2025-10-17T18:03:42.67Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a6/71d26d626a3c45951620b7ff356ec920e420f0e09b0a924123682aa5e4ab/pyodbc-5.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6", size = 350224, upload-time = "2025-10-17T18:03:43.731Z" },
+ { url = "https://files.pythonhosted.org/packages/93/14/f702c5e8c2d595776266934498505f11b7f1545baf21ffec1d32c258e9d3/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05", size = 1301503, upload-time = "2025-10-17T18:03:45.013Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b2/ad92ebdd1b5c7fec36b065e586d1d34b57881e17ba5beec5c705f1031058/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e", size = 1361050, upload-time = "2025-10-17T18:03:46.298Z" },
+ { url = "https://files.pythonhosted.org/packages/19/40/dc84e232da07056cb5aaaf5f759ba4c874bc12f37569f7f1670fc71e7ae1/pyodbc-5.3.0-cp314-cp314-win32.whl", hash = "sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b", size = 65670, upload-time = "2025-10-17T18:03:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/79/c48be07e8634f764662d7a279ac204f93d64172162dbf90f215e2398b0bd/pyodbc-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39", size = 72177, upload-time = "2025-10-17T18:03:57.296Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/79/e304574446b2263f428ce14df590ba52c2e0e0205e8d34b235b582b7d57e/pyodbc-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5", size = 66668, upload-time = "2025-10-17T18:03:58.174Z" },
+ { url = "https://files.pythonhosted.org/packages/43/17/f4eabf443b838a2728773554017d08eee3aca353102934a7e3ba96fb0e31/pyodbc-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364", size = 75780, upload-time = "2025-10-17T18:03:47.273Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ea/e79e168c3d38c27d59d5d96273fd9e3c3ba55937cc944c4e60618f51de90/pyodbc-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0", size = 75503, upload-time = "2025-10-17T18:03:48.171Z" },
+ { url = "https://files.pythonhosted.org/packages/90/81/d1d7c125ec4a20e83fdc28e119b8321192b2bd694f432cf63e1199b2b929/pyodbc-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943", size = 398356, upload-time = "2025-10-17T18:03:49.131Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/fc/f6be4b3cc3910f8c2aba37aa41671121fd6f37b402ae0fefe53a70ac7cd5/pyodbc-5.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d", size = 397291, upload-time = "2025-10-17T18:03:50.18Z" },
+ { url = "https://files.pythonhosted.org/packages/03/2e/0610b1ed05a5625528d52f6cece9610e84617d35f475c89c2a52f66d13f7/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d", size = 1353900, upload-time = "2025-10-17T18:03:51.339Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/f1/43497e1d37f9f71b43b2b3172e7b1bdf50851e278390c3fb6b46a3630c53/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e", size = 1406062, upload-time = "2025-10-17T18:03:52.546Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/88a1277c2f7d9ab1cec0a71e074ba24fd4a1710a43974682546da90a1343/pyodbc-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514", size = 70132, upload-time = "2025-10-17T18:03:53.715Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c7/ee98c62050de4aa8bafb6eb1e11b95e0b0c898bd5930137c6dc776e06a9b/pyodbc-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955", size = 79452, upload-time = "2025-10-17T18:03:54.664Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" },
+]
+
+[[package]]
+name = "pypdf2"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" },
+]
+
[[package]]
name = "pytest"
version = "7.4.4"
@@ -1725,6 +1726,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-docx"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -2205,15 +2219,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
-[[package]]
-name = "tzdata"
-version = "2025.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
-]
-
[[package]]
name = "urllib3"
version = "2.6.3"