Skip to content

Commit a07dfb6

Browse files
feat: implement PJ invite flow with email token-based registration
- Add CompanyInvite model and CompanyStatus enum - Make Company fields nullable to support partial initial creation - Add invite CRUD functions (create_company_initial, create_company_invite, etc.) - Add invite token generation/verification utils - Create PJ invite email template in Portuguese - Add invite API routes: send, resend, validate, complete registration - Add Alembic migration for companyinvite table and company changes - Add InvitesService to frontend client SDK - Create public /pj-registration route with token validation - Add invite dialog to companies page for sending invites - Confirmation modal before saving registration data - RAZÃO SOCIAL read-only during PJ registration Co-Authored-By: daniel.resgate <daniel.rider69@gmail.com>
1 parent 84610a4 commit a07dfb6

File tree

13 files changed

+1575
-19
lines changed

13 files changed

+1575
-19
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Add company invite table and update company fields
2+
3+
Revision ID: b2c3d4e5f6g7
4+
Revises: a1b2c3d4e5f6
5+
Create Date: 2026-03-26 16:00:00.000000
6+
7+
"""
8+
import sqlalchemy as sa
9+
import sqlmodel.sql.sqltypes
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "b2c3d4e5f6g7"
14+
down_revision = "a1b2c3d4e5f6"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# Add new columns to company table
21+
op.add_column(
22+
"company",
23+
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
24+
)
25+
op.add_column(
26+
"company",
27+
sa.Column(
28+
"status",
29+
sqlmodel.sql.sqltypes.AutoString(),
30+
nullable=False,
31+
server_default="completed",
32+
),
33+
)
34+
35+
# Make company fields nullable to support partial initial creation
36+
columns_to_make_nullable = [
37+
"razao_social", "representante_legal", "nome_fantasia", "porte",
38+
"atividade_economica_principal", "atividade_economica_secundaria",
39+
"natureza_juridica", "logradouro", "numero", "complemento", "cep",
40+
"bairro", "municipio", "uf", "endereco_eletronico", "telefone_comercial",
41+
"situacao_cadastral", "cpf_representante_legal",
42+
"identidade_representante_legal", "logradouro_representante_legal",
43+
"numero_representante_legal", "complemento_representante_legal",
44+
"cep_representante_legal", "bairro_representante_legal",
45+
"municipio_representante_legal", "uf_representante_legal",
46+
"endereco_eletronico_representante_legal", "telefones_representante_legal",
47+
"banco_cc_cnpj", "agencia_cc_cnpj",
48+
]
49+
date_columns_to_make_nullable = [
50+
"data_abertura", "data_situacao_cadastral", "data_nascimento_representante_legal",
51+
]
52+
53+
for col_name in columns_to_make_nullable:
54+
op.alter_column("company", col_name, existing_type=sa.String(), nullable=True)
55+
56+
for col_name in date_columns_to_make_nullable:
57+
op.alter_column("company", col_name, existing_type=sa.Date(), nullable=True)
58+
59+
# Create companyinvite table
60+
op.create_table(
61+
"companyinvite",
62+
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
63+
sa.Column("id", sa.Uuid(), nullable=False),
64+
sa.Column("company_id", sa.Uuid(), nullable=False),
65+
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
66+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
67+
sa.Column("used", sa.Boolean(), nullable=False, server_default=sa.text("false")),
68+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
69+
sa.ForeignKeyConstraint(["company_id"], ["company.id"], ondelete="CASCADE"),
70+
sa.PrimaryKeyConstraint("id"),
71+
)
72+
op.create_index(op.f("ix_companyinvite_token"), "companyinvite", ["token"], unique=True)
73+
74+
75+
def downgrade():
76+
op.drop_index(op.f("ix_companyinvite_token"), table_name="companyinvite")
77+
op.drop_table("companyinvite")
78+
79+
# Revert company columns to non-nullable
80+
columns_to_make_non_nullable = [
81+
"razao_social", "representante_legal", "nome_fantasia", "porte",
82+
"atividade_economica_principal", "atividade_economica_secundaria",
83+
"natureza_juridica", "logradouro", "numero", "complemento", "cep",
84+
"bairro", "municipio", "uf", "endereco_eletronico", "telefone_comercial",
85+
"situacao_cadastral", "cpf_representante_legal",
86+
"identidade_representante_legal", "logradouro_representante_legal",
87+
"numero_representante_legal", "complemento_representante_legal",
88+
"cep_representante_legal", "bairro_representante_legal",
89+
"municipio_representante_legal", "uf_representante_legal",
90+
"endereco_eletronico_representante_legal", "telefones_representante_legal",
91+
"banco_cc_cnpj", "agencia_cc_cnpj",
92+
]
93+
date_columns_to_make_non_nullable = [
94+
"data_abertura", "data_situacao_cadastral", "data_nascimento_representante_legal",
95+
]
96+
97+
for col_name in columns_to_make_non_nullable:
98+
op.alter_column("company", col_name, existing_type=sa.String(), nullable=False)
99+
100+
for col_name in date_columns_to_make_non_nullable:
101+
op.alter_column("company", col_name, existing_type=sa.Date(), nullable=False)
102+
103+
op.drop_column("company", "status")
104+
op.drop_column("company", "email")

backend/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import companies, items, login, private, users, utils
3+
from app.api.routes import companies, invites, items, login, private, users, utils
44
from app.core.config import settings
55

66
api_router = APIRouter()
@@ -9,6 +9,7 @@
99
api_router.include_router(utils.router)
1010
api_router.include_router(items.router)
1111
api_router.include_router(companies.router)
12+
api_router.include_router(invites.router)
1213

1314

1415
if settings.ENVIRONMENT == "local":

backend/app/api/routes/invites.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import logging
2+
import uuid as uuid_mod
3+
from typing import Any
4+
5+
from fastapi import APIRouter, HTTPException
6+
from sqlmodel import select
7+
8+
from app.api.deps import CurrentUser, SessionDep
9+
from app.core.config import settings
10+
from app.crud import (
11+
complete_company_registration,
12+
create_company_initial,
13+
create_company_invite,
14+
get_company_by_cnpj,
15+
get_invite_by_token,
16+
)
17+
from app.models import (
18+
CompanyInvite,
19+
CompanyInviteCreate,
20+
CompanyInvitePublic,
21+
CompanyInviteValidation,
22+
CompanyPublic,
23+
CompanyRegistrationComplete,
24+
CompanyStatus,
25+
)
26+
from app.utils import (
27+
generate_invite_token,
28+
generate_pj_invite_email,
29+
send_email,
30+
verify_invite_token,
31+
)
32+
33+
logger = logging.getLogger(__name__)
34+
35+
router = APIRouter(prefix="/invites", tags=["invites"])
36+
37+
38+
@router.post("/", response_model=CompanyInvitePublic)
39+
def send_invite(
40+
*,
41+
session: SessionDep,
42+
current_user: CurrentUser, # noqa: ARG001
43+
invite_in: CompanyInviteCreate,
44+
) -> Any:
45+
"""
46+
Send a PJ registration invite. Creates initial company record and sends email.
47+
Only authorized internal users (Juridico, Financeiro, RH, Comercial) can send invites.
48+
"""
49+
existing_company = get_company_by_cnpj(session=session, cnpj=invite_in.cnpj)
50+
51+
if existing_company and existing_company.status == CompanyStatus.completed:
52+
raise HTTPException(
53+
status_code=400,
54+
detail="Uma empresa com este CNPJ já possui cadastro completo.",
55+
)
56+
57+
if existing_company:
58+
company = existing_company
59+
company.email = invite_in.email
60+
session.add(company)
61+
session.commit()
62+
session.refresh(company)
63+
else:
64+
company = create_company_initial(
65+
session=session,
66+
cnpj=invite_in.cnpj,
67+
email=invite_in.email,
68+
)
69+
70+
token, expires_at = generate_invite_token(
71+
company_id=str(company.id),
72+
email=invite_in.email,
73+
)
74+
75+
invite = create_company_invite(
76+
session=session,
77+
company_id=company.id,
78+
email=invite_in.email,
79+
token=token,
80+
expires_at=expires_at,
81+
)
82+
83+
link = f"{settings.FRONTEND_HOST}/pj-registration?token={token}"
84+
85+
try:
86+
email_data = generate_pj_invite_email(
87+
email_to=invite_in.email,
88+
link=link,
89+
valid_days=settings.INVITE_TOKEN_EXPIRE_DAYS,
90+
)
91+
send_email(
92+
email_to=invite_in.email,
93+
subject=email_data.subject,
94+
html_content=email_data.html_content,
95+
)
96+
except Exception as e:
97+
logger.error(
98+
"Falha ao enviar e-mail de convite para %s (company_id=%s, invite_id=%s): %s",
99+
invite_in.email,
100+
company.id,
101+
invite.id,
102+
e,
103+
)
104+
raise HTTPException(
105+
status_code=500,
106+
detail="Falha ao enviar o e-mail de convite. O convite foi criado, tente reenviar.",
107+
)
108+
109+
return invite
110+
111+
112+
@router.post("/{invite_id}/resend", response_model=CompanyInvitePublic)
113+
def resend_invite(
114+
*,
115+
session: SessionDep,
116+
current_user: CurrentUser, # noqa: ARG001
117+
invite_id: str,
118+
) -> Any:
119+
"""
120+
Resend a PJ registration invite. Generates a new token and sends a new email.
121+
"""
122+
try:
123+
invite_uuid = uuid_mod.UUID(invite_id)
124+
except ValueError:
125+
raise HTTPException(status_code=400, detail="ID de convite inválido.")
126+
127+
statement = select(CompanyInvite).where(CompanyInvite.id == invite_uuid)
128+
old_invite = session.exec(statement).first()
129+
130+
if not old_invite:
131+
raise HTTPException(status_code=404, detail="Convite não encontrado.")
132+
133+
if old_invite.used:
134+
raise HTTPException(
135+
status_code=400,
136+
detail="Este convite já foi utilizado. O cadastro já foi completado.",
137+
)
138+
139+
company = old_invite.company
140+
if not company:
141+
raise HTTPException(status_code=404, detail="Empresa não encontrada.")
142+
143+
old_invite.used = True
144+
session.add(old_invite)
145+
session.commit()
146+
147+
token, expires_at = generate_invite_token(
148+
company_id=str(company.id),
149+
email=old_invite.email,
150+
)
151+
152+
new_invite = create_company_invite(
153+
session=session,
154+
company_id=company.id,
155+
email=old_invite.email,
156+
token=token,
157+
expires_at=expires_at,
158+
)
159+
160+
link = f"{settings.FRONTEND_HOST}/pj-registration?token={token}"
161+
162+
try:
163+
email_data = generate_pj_invite_email(
164+
email_to=old_invite.email,
165+
link=link,
166+
valid_days=settings.INVITE_TOKEN_EXPIRE_DAYS,
167+
)
168+
send_email(
169+
email_to=old_invite.email,
170+
subject=email_data.subject,
171+
html_content=email_data.html_content,
172+
)
173+
except Exception as e:
174+
logger.error(
175+
"Falha ao reenviar e-mail de convite para %s (invite_id=%s): %s",
176+
old_invite.email,
177+
new_invite.id,
178+
e,
179+
)
180+
raise HTTPException(
181+
status_code=500,
182+
detail="Falha ao reenviar o e-mail de convite. Tente novamente.",
183+
)
184+
185+
return new_invite
186+
187+
188+
@router.get("/validate", response_model=CompanyInviteValidation)
189+
def validate_invite_token(
190+
*,
191+
session: SessionDep,
192+
token: str,
193+
) -> Any:
194+
"""
195+
Validate an invite token. Public endpoint (no auth required).
196+
Returns company data if token is valid.
197+
"""
198+
token_data = verify_invite_token(token)
199+
if not token_data:
200+
return CompanyInviteValidation(
201+
valid=False,
202+
message="O link é inválido ou expirou. Solicite um novo convite ao responsável interno.",
203+
)
204+
205+
invite = get_invite_by_token(session=session, token=token)
206+
if not invite:
207+
return CompanyInviteValidation(
208+
valid=False,
209+
message="O link é inválido ou expirou. Solicite um novo convite ao responsável interno.",
210+
)
211+
212+
if invite.used:
213+
return CompanyInviteValidation(
214+
valid=False,
215+
message="Este convite já foi utilizado. O cadastro já foi completado.",
216+
)
217+
218+
company = invite.company
219+
if not company:
220+
return CompanyInviteValidation(
221+
valid=False,
222+
message="Empresa não encontrada.",
223+
)
224+
225+
return CompanyInviteValidation(
226+
valid=True,
227+
company=CompanyPublic.model_validate(company),
228+
)
229+
230+
231+
@router.put("/complete", response_model=CompanyPublic)
232+
def complete_registration(
233+
*,
234+
session: SessionDep,
235+
registration_data: CompanyRegistrationComplete,
236+
) -> Any:
237+
"""
238+
Complete PJ registration. Public endpoint (no auth required).
239+
Requires a valid invite token.
240+
"""
241+
token_data = verify_invite_token(registration_data.token)
242+
if not token_data:
243+
raise HTTPException(
244+
status_code=400,
245+
detail="O link é inválido ou expirou. Solicite um novo convite ao responsável interno.",
246+
)
247+
248+
invite = get_invite_by_token(session=session, token=registration_data.token)
249+
if not invite:
250+
raise HTTPException(
251+
status_code=400,
252+
detail="Convite não encontrado.",
253+
)
254+
255+
if invite.used:
256+
raise HTTPException(
257+
status_code=400,
258+
detail="Este convite já foi utilizado.",
259+
)
260+
261+
company = invite.company
262+
if not company:
263+
raise HTTPException(
264+
status_code=404,
265+
detail="Empresa não encontrada.",
266+
)
267+
268+
updated_company = complete_company_registration(
269+
session=session,
270+
company=company,
271+
invite=invite,
272+
registration_data=registration_data,
273+
)
274+
275+
return updated_company

0 commit comments

Comments
 (0)