feat: Implementa validação de e-mail único e melhorias na aprovação de usuários

Backend:
- Adiciona constraint UNIQUE para 'email' na tabela cadastro_profissionais.
- Atualiza schema.sql para converter e-mails vazios para NULL automaticamente.
- Modifica query CreateProfissional para usar ON CONFLICT (email) DO UPDATE (Upsert).
- Ajusta helper toPgText para tratar string vazia como NULL, permitindo múltiplos profissionais sem e-mail.

Frontend:
- Adiciona Modal de Detalhes do Usuário na página de Aprovação.
- Oculta seletor de função para usuários do tipo 'Cliente'.
This commit is contained in:
NANDO9322 2026-02-03 17:46:52 -03:00
parent ec2d96333f
commit 02309f74c0
5 changed files with 223 additions and 33 deletions

View file

@ -46,7 +46,31 @@ INSERT INTO cadastro_profissionais (
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26 $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
) RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, email, avatar_url, criado_em, atualizado_em )
ON CONFLICT (email) DO UPDATE SET
nome = EXCLUDED.nome,
funcao_profissional_id = EXCLUDED.funcao_profissional_id,
whatsapp = EXCLUDED.whatsapp,
cpf_cnpj_titular = EXCLUDED.cpf_cnpj_titular,
banco = EXCLUDED.banco,
agencia = EXCLUDED.agencia,
conta_pix = EXCLUDED.conta_pix,
carro_disponivel = EXCLUDED.carro_disponivel,
tem_estudio = EXCLUDED.tem_estudio,
qtd_estudio = EXCLUDED.qtd_estudio,
tipo_cartao = EXCLUDED.tipo_cartao,
observacao = EXCLUDED.observacao,
qual_tec = EXCLUDED.qual_tec,
educacao_simpatia = EXCLUDED.educacao_simpatia,
desempenho_evento = EXCLUDED.desempenho_evento,
disp_horario = EXCLUDED.disp_horario,
media = EXCLUDED.media,
tabela_free = EXCLUDED.tabela_free,
extra_por_equipamento = EXCLUDED.extra_por_equipamento,
equipamentos = EXCLUDED.equipamentos,
avatar_url = EXCLUDED.avatar_url,
atualizado_em = NOW()
RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, email, avatar_url, criado_em, atualizado_em
` `
type CreateProfissionalParams struct { type CreateProfissionalParams struct {

View file

@ -8,7 +8,31 @@ INSERT INTO cadastro_profissionais (
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26 $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
) RETURNING *; )
ON CONFLICT (email) DO UPDATE SET
nome = EXCLUDED.nome,
funcao_profissional_id = EXCLUDED.funcao_profissional_id,
whatsapp = EXCLUDED.whatsapp,
cpf_cnpj_titular = EXCLUDED.cpf_cnpj_titular,
banco = EXCLUDED.banco,
agencia = EXCLUDED.agencia,
conta_pix = EXCLUDED.conta_pix,
carro_disponivel = EXCLUDED.carro_disponivel,
tem_estudio = EXCLUDED.tem_estudio,
qtd_estudio = EXCLUDED.qtd_estudio,
tipo_cartao = EXCLUDED.tipo_cartao,
observacao = EXCLUDED.observacao,
qual_tec = EXCLUDED.qual_tec,
educacao_simpatia = EXCLUDED.educacao_simpatia,
desempenho_evento = EXCLUDED.desempenho_evento,
disp_horario = EXCLUDED.disp_horario,
media = EXCLUDED.media,
tabela_free = EXCLUDED.tabela_free,
extra_por_equipamento = EXCLUDED.extra_por_equipamento,
equipamentos = EXCLUDED.equipamentos,
avatar_url = EXCLUDED.avatar_url,
atualizado_em = NOW()
RETURNING *;
-- name: GetProfissionalByUsuarioID :one -- name: GetProfissionalByUsuarioID :one
SELECT p.*, SELECT p.*,

View file

@ -501,3 +501,14 @@ BEGIN
END IF; END IF;
END $$; END $$;
DO $$
BEGIN
-- 1. Converter emails vazios para NULL para evitar erro de duplicidade em strings vazias
UPDATE cadastro_profissionais SET email = NULL WHERE email = '';
-- 2. Aplicar Constraint Única se não existir
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name='unique_email_profissional' AND table_name='cadastro_profissionais') THEN
ALTER TABLE cadastro_profissionais ADD CONSTRAINT unique_email_profissional UNIQUE (email);
END IF;
END $$;

View file

@ -414,7 +414,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {
// Helpers // Helpers
func toPgText(s *string) pgtype.Text { func toPgText(s *string) pgtype.Text {
if s == nil { if s == nil || *s == "" {
return pgtype.Text{Valid: false} return pgtype.Text{Valid: false}
} }
return pgtype.Text{String: *s, Valid: true} return pgtype.Text{String: *s, Valid: true}

View file

@ -35,6 +35,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
"cliente" "cliente"
); );
const [isProcessing, setIsProcessing] = useState<string | null>(null); const [isProcessing, setIsProcessing] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<any | null>(null);
const fetchUsers = async () => { const fetchUsers = async () => {
if (!token) { if (!token) {
@ -144,6 +145,134 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
} }
}; };
// Modal Component
const UserDetailsModal = () => {
if (!selectedUser) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4 fade-in">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg overflow-hidden animate-slide-up">
<div className="flex justify-between items-center p-6 border-b border-gray-100">
<h3 className="text-xl font-bold text-gray-900 font-serif">
Detalhes do Cadastro
</h3>
<button
onClick={() => setSelectedUser(null)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
Nome
</label>
<p className="mt-1 text-base text-gray-900 font-medium">
{selectedUser.name || "-"}
</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
Email
</label>
<p className="mt-1 text-base text-gray-900">
{selectedUser.email || "-"}
</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
Telefone
</label>
<p className="mt-1 text-base text-gray-900">
{selectedUser.phone || "-"}
</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
Data Cadastro
</label>
<p className="mt-1 text-base text-gray-900">
{selectedUser.created_at ? new Date(selectedUser.created_at).toLocaleDateString("pt-BR") : "-"}
</p>
</div>
{selectedUser.role === "EVENT_OWNER" && (
<div className="col-span-2">
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
Empresa Vinculada
</label>
<p className="mt-1 text-base text-gray-900 font-medium">
{selectedUser.company_name || "-"}
</p>
</div>
)}
</div>
<div className="pt-4 border-t border-gray-100">
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Função / Cargo
</label>
{selectedUser.role === "EVENT_OWNER" ? (
<span className="inline-block px-3 py-1 bg-brand-gold/10 text-brand-gold rounded-full text-sm font-medium">
Cliente (Empresa)
</span>
) : (
<select
value={
selectedUser.role === "PHOTOGRAPHER" && selectedUser.professional_type === "Cinegrafista" ? "Cinegrafista" :
selectedUser.role === "PHOTOGRAPHER" && selectedUser.professional_type === "Recepcionista" ? "Recepcionista" :
selectedUser.role === "PHOTOGRAPHER" && selectedUser.professional_type === "Fotógrafo" ? "Fotógrafo" :
selectedUser.role === "PHOTOGRAPHER" ? "Fotógrafo" :
selectedUser.role
}
onChange={(e) => {
let newRole = e.target.value;
if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) {
newRole = "PHOTOGRAPHER";
}
// Update local selected user state optimistic
setSelectedUser({...selectedUser, role: newRole});
handleRoleChange(selectedUser.id, newRole);
}}
className="w-full text-sm border-gray-300 rounded-md shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2"
>
<option value="Fotógrafo">Fotógrafo</option>
<option value="Cinegrafista">Cinegrafista</option>
<option value="Recepcionista">Recepcionista</option>
<option value="RESEARCHER">Pesquisador</option>
<option value="BUSINESS_OWNER">Empresa</option>
</select>
)}
</div>
</div>
<div className="p-6 bg-gray-50 flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setSelectedUser(null)}
>
Cancelar
</Button>
<Button
onClick={() => {
handleApprove(selectedUser.id);
setSelectedUser(null);
}}
isLoading={isProcessing === selectedUser.id}
>
<CheckCircle className="w-4 h-4 mr-2" />
Aprovar Cadastro
</Button>
</div>
</div>
</div>
);
};
return ( return (
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12"> <div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@ -281,7 +410,8 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
filteredUsers.map((user, index) => ( filteredUsers.map((user, index) => (
<tr <tr
key={`${user.id}-${index}`} key={`${user.id}-${index}`}
className="hover:bg-gray-50 transition-colors" className="hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => setSelectedUser(user)}
> >
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
@ -306,34 +436,34 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
</div> </div>
</td> </td>
{/* Role Editor */} {/* Role Editor */}
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<select {activeTab === "cliente" ? (
value={ <span className="text-sm text-gray-900">Cliente</span>
user.role === "PHOTOGRAPHER" && user.professional_type === "Cinegrafista" ? "Cinegrafista" : ) : (
user.role === "PHOTOGRAPHER" && user.professional_type === "Recepcionista" ? "Recepcionista" : <select
user.role === "PHOTOGRAPHER" && user.professional_type === "Fotógrafo" ? "Fotógrafo" : value={
user.role === "PHOTOGRAPHER" ? "Fotógrafo" : // Default to Fotógrafo if generic Photographer role user.role === "PHOTOGRAPHER" && user.professional_type === "Cinegrafista" ? "Cinegrafista" :
user.role user.role === "PHOTOGRAPHER" && user.professional_type === "Recepcionista" ? "Recepcionista" :
} user.role === "PHOTOGRAPHER" && user.professional_type === "Fotógrafo" ? "Fotógrafo" :
onChange={(e) => { user.role === "PHOTOGRAPHER" ? "Fotógrafo" :
let newRole = e.target.value; user.role
if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) { }
newRole = "PHOTOGRAPHER"; onChange={(e) => {
// Note: We are currently only updating the System Role. let newRole = e.target.value;
// The 'professional_type' field is not updated by updateUserRole endpoint. if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) {
// This UI allows the user to confirm the System Role is correct for these types. newRole = "PHOTOGRAPHER";
} }
handleRoleChange(user.id, newRole); handleRoleChange(user.id, newRole);
}} }}
className="text-sm border-gray-300 rounded-md shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50" className="text-sm border-gray-300 rounded-md shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50"
> >
<option value="Fotógrafo">Fotógrafo</option> <option value="Fotógrafo">Fotógrafo</option>
<option value="Cinegrafista">Cinegrafista</option> <option value="Cinegrafista">Cinegrafista</option>
<option value="Recepcionista">Recepcionista</option> <option value="Recepcionista">Recepcionista</option>
<option value="RESEARCHER">Pesquisador</option> <option value="RESEARCHER">Pesquisador</option>
<option value="EVENT_OWNER">Cliente</option> <option value="BUSINESS_OWNER">Empresa</option>
<option value="BUSINESS_OWNER">Empresa</option> </select>
</select> )}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
@ -349,7 +479,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
user.approvalStatus || UserApprovalStatus.PENDING user.approvalStatus || UserApprovalStatus.PENDING
)} )}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
@ -373,6 +503,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
} }
</div > </div >
</div > </div >
<UserDetailsModal />
</div > </div >
</div > </div >
); );