sass-imobiliaria/specs/028-trabalhe-conosco/data-model.md
MatheusAlves96 cf5603243c
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s
feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
  preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
  keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint

migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
            i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
2026-04-22 22:35:17 -03:00

7.5 KiB

Data Model: Trabalhe Conosco

Feature: 028-trabalhe-conosco
Phase: 1 — Design & Contracts
Source: spec.md


Entidade: JobApplication (Candidatura)

Tabela: job_applications

Coluna Tipo SQL Nullable Default Restrições
id SERIAL (INTEGER PK) NOT NULL auto-increment PRIMARY KEY
name VARCHAR(150) NOT NULL campo obrigatório
email VARCHAR(254) NOT NULL formato e-mail válido (validado no backend)
phone VARCHAR(30) NULL opcional conforme spec
role_interest VARCHAR(100) NOT NULL enum: Corretor(a), Assistente Administrativo, Estagiário(a), Outro
message TEXT NOT NULL apresentação/mensagem do candidato
file_name VARCHAR(255) NULL nome do arquivo de currículo (sem upload)
status VARCHAR(50) NOT NULL 'pending' estado da candidatura (pending / reviewed)
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL now() (server) imutável após criação

Índices

Índice Colunas Motivo
ix_job_applications_created_at created_at ordenação DESC na listagem admin
ix_job_applications_status status filtragem futura por estado

Invariantes

  1. name, email, role_interest e message nunca são deixados em branco (validação Pydantic).
  2. email deve ser validado com pydantic.EmailStr — formato RFC-5321.
  3. role_interest deve ser um dos valores permitidos: "Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro".
  4. message não pode ultrapassar 5000 caracteres (validação frontend + Pydantic max_length).
  5. phone é opcional — sem validação de formato nesta versão.
  6. file_name armazena apenas o nome do arquivo informado, sem conteúdo binário.
  7. Múltiplas candidaturas do mesmo email são permitidas (sem deduplicação nesta versão).
  8. Nenhum DELETE físico é exposto; o campo status permite rastreabilidade futura.

Diagrama ER

job_applications
├── id             PK SERIAL
├── name           VARCHAR(150)  NOT NULL
├── email          VARCHAR(254)  NOT NULL
├── phone          VARCHAR(30)   NULL
├── role_interest  VARCHAR(100)  NOT NULL
├── message        TEXT          NOT NULL
├── file_name      VARCHAR(255)  NULL
├── status         VARCHAR(50)   NOT NULL DEFAULT 'pending'
└── created_at     TIMESTAMP     NOT NULL DEFAULT now()

Sem relacionamentos com outras tabelas nesta versão. Entidade standalone.


Modelo SQLAlchemy: backend/app/models/job_application.py

from app.extensions import db


ROLE_INTEREST_OPTIONS = [
    "Corretor(a)",
    "Assistente Administrativo",
    "Estagiário(a)",
    "Outro",
]


class JobApplication(db.Model):
    __tablename__ = "job_applications"

    id            = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name          = db.Column(db.String(150), nullable=False)
    email         = db.Column(db.String(254), nullable=False)
    phone         = db.Column(db.String(30), nullable=True)
    role_interest = db.Column(db.String(100), nullable=False)
    message       = db.Column(db.Text, nullable=False)
    file_name     = db.Column(db.String(255), nullable=True)
    status        = db.Column(db.String(50), nullable=False, default="pending")
    created_at    = db.Column(
        db.DateTime, nullable=False, server_default=db.func.now()
    )

    def __repr__(self) -> str:
        return f"<JobApplication id={self.id} email={self.email!r}>"

Migration Alembic: backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py

"""add job_applications table

Revision ID: i1j2k3l4m5n6
Revises: h1i2j3k4l5m6
Create Date: 2026-04-21 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

revision = "i1j2k3l4m5n6"
down_revision = "h1i2j3k4l5m6"
branch_labels = None
depends_on = None


def upgrade():
    op.create_table(
        "job_applications",
        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
        sa.Column("name", sa.String(length=150), nullable=False),
        sa.Column("email", sa.String(length=254), nullable=False),
        sa.Column("phone", sa.String(length=30), nullable=True),
        sa.Column("role_interest", sa.String(length=100), nullable=False),
        sa.Column("message", sa.Text(), nullable=False),
        sa.Column("file_name", sa.String(length=255), nullable=True),
        sa.Column(
            "status",
            sa.String(length=50),
            nullable=False,
            server_default=sa.text("'pending'"),
        ),
        sa.Column(
            "created_at",
            sa.DateTime(),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.PrimaryKeyConstraint("id"),
    )
    op.create_index(
        "ix_job_applications_created_at", "job_applications", ["created_at"]
    )
    op.create_index(
        "ix_job_applications_status", "job_applications", ["status"]
    )


def downgrade():
    op.drop_index("ix_job_applications_status", table_name="job_applications")
    op.drop_index("ix_job_applications_created_at", table_name="job_applications")
    op.drop_table("job_applications")

Schemas Pydantic: backend/app/schemas/job_application.py

from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, ConfigDict, EmailStr, field_validator

VALID_ROLES = {"Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"}


class JobApplicationIn(BaseModel):
    name: str
    email: EmailStr
    phone: str | None = None
    role_interest: str
    message: str
    file_name: str | None = None

    @field_validator("name")
    @classmethod
    def name_not_empty(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("name não pode ser vazio")
        return v

    @field_validator("role_interest")
    @classmethod
    def role_must_be_valid(cls, v: str) -> str:
        if v not in VALID_ROLES:
            raise ValueError(f"role_interest deve ser um de: {', '.join(sorted(VALID_ROLES))}")
        return v

    @field_validator("message")
    @classmethod
    def message_not_empty(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("message não pode ser vazia")
        if len(v) > 5000:
            raise ValueError("message não pode ultrapassar 5000 caracteres")
        return v


class JobApplicationOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
    email: str
    phone: str | None
    role_interest: str
    message: str
    file_name: str | None
    status: str
    created_at: datetime