feat(team): fluxo completo de cadastro de equipe com senha, correções de vínculo e avatar

- Frontend: Adiciona campos de senha e visibilidade na tela de Equipe.
- Frontend: Implementa criação de usuário prévia ao cadastro do profissional.
- Backend (Auth): Remove criação duplicada de perfil e ativa usuários automaticamente.
- Backend (Auth): Inclui dados do profissional (avatar) na resposta do endpoint /me.
- Backend (Profissionais): Corrige chave de contexto ('role') para permitir vínculo correto de usuário.
- Backend (Profissionais): Sincroniza exclusão para remover conta de usuário ao deletar profissional.
- Docs: Atualização dos arquivos Swagger.
This commit is contained in:
NANDO9322 2025-12-25 16:25:07 -03:00
parent 636ad73993
commit 958918cb8a
9 changed files with 259 additions and 28 deletions

View file

@ -3030,6 +3030,10 @@ const docTemplate = `{
"tabela_free": {
"type": "string"
},
"target_user_id": {
"description": "Optional: For admin creation",
"type": "string"
},
"tem_estudio": {
"type": "boolean"
},

View file

@ -3024,6 +3024,10 @@
"tabela_free": {
"type": "string"
},
"target_user_id": {
"description": "Optional: For admin creation",
"type": "string"
},
"tem_estudio": {
"type": "boolean"
},

View file

@ -296,6 +296,9 @@ definitions:
type: integer
tabela_free:
type: string
target_user_id:
description: 'Optional: For admin creation'
type: string
tem_estudio:
type: boolean
tipo_cartao:

View file

@ -361,9 +361,20 @@ func (h *Handler) Me(c *gin.Context) {
CompanyID: empresaID,
},
}
// Note: We are not returning AccessToken/ExpiresAt here as they are already set/active.
// But to match loginResponse structure we can leave them empty or fill appropriately if we were refreshing.
// For session restore, we mainly need the User object.
if user.Role == "PHOTOGRAPHER" || user.Role == "BUSINESS_OWNER" {
profData, err := h.service.GetProfessionalByUserID(c.Request.Context(), uuid.UUID(user.ID.Bytes).String())
if err == nil && profData != nil {
resp.Profissional = map[string]interface{}{
"id": uuid.UUID(profData.ID.Bytes).String(),
"nome": profData.Nome,
"funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(),
"funcao_profissional": profData.FuncaoNome.String,
"equipamentos": profData.Equipamentos.String,
"avatar_url": profData.AvatarUrl.String,
}
}
}
c.JSON(http.StatusOK, resp)
}
@ -468,7 +479,7 @@ func (h *Handler) AdminCreateUser(c *gin.Context) {
}
// Just reuse the request struct but call AdminCreateUser service
user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome)
user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, true)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})

View file

@ -194,7 +194,7 @@ func (s *Service) ApproveUser(ctx context.Context, id string) error {
return err
}
func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome string) (*generated.Usuario, error) {
func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome string, ativo bool) (*generated.Usuario, error) {
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost)
if err != nil {
@ -211,18 +211,22 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome
return nil, err
}
// If needed, create partial professional profile or just ignore for admin creation
// For simplicity, if name is provided and it's a professional role, we can stub it.
// But let's stick to basic user creation first as per plan.
// If the Admin creates a user, they might need to go to another endpoint to set profile.
// Or we can optionally create if name is present.
if (role == RolePhotographer || role == RoleBusinessOwner) && nome != "" {
userID := uuid.UUID(user.ID.Bytes).String()
_, _ = s.profissionaisService.Create(ctx, userID, profissionais.CreateProfissionalInput{
Nome: nome,
})
if ativo {
// Approve user immediately
err = s.ApproveUser(ctx, uuid.UUID(user.ID.Bytes).String())
if err != nil {
// Log error but don't fail user creation? Or fail?
// Better to return error
return nil, err
}
// Refresh user object to reflect changes if needed, but ID and Email are same.
user.Ativo = true
}
// Stub creation removed to prevent duplicate profiles.
// The frontend is responsible for creating the full professional profile
// immediately after user creation via the professionals service.
return &user, nil
}
@ -270,7 +274,7 @@ func (s *Service) EnsureDemoUsers(ctx context.Context) error {
existingUser, err := s.queries.GetUsuarioByEmail(ctx, u.Email)
if err != nil {
// User not found (or error), try to create
user, err := s.AdminCreateUser(ctx, u.Email, "123456", u.Role, u.Name)
user, err := s.AdminCreateUser(ctx, u.Email, "123456", u.Role, u.Name, true)
if err != nil {
return err
}
@ -313,6 +317,22 @@ func (s *Service) GetUser(ctx context.Context, id string) (*generated.GetUsuario
return &user, nil
}
func (s *Service) GetProfessionalByUserID(ctx context.Context, userID string) (*generated.GetProfissionalByUsuarioIDRow, error) {
parsedUUID, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
var pgID pgtype.UUID
pgID.Bytes = parsedUUID
pgID.Valid = true
p, err := s.queries.GetProfissionalByUsuarioID(ctx, pgID)
if err != nil {
return nil, err
}
return &p, nil
}
func toPgText(s *string) pgtype.Text {
if s == nil {
return pgtype.Text{Valid: false}

View file

@ -217,6 +217,21 @@ func (h *Handler) Create(c *gin.Context) {
return
}
// Security: Only allow TargetUserID if user is ADMIN or OWNER
if input.TargetUserID != nil && *input.TargetUserID != "" {
userRole, exists := c.Get("role")
if !exists {
// Should validation fail? Or just ignore target?
// Safer to ignore target user ID if role not found
input.TargetUserID = nil
} else {
roleStr, ok := userRole.(string)
if !ok || (roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER") {
input.TargetUserID = nil
}
}
}
prof, err := h.service.Create(c.Request.Context(), userIDStr, input)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View file

@ -45,12 +45,18 @@ type CreateProfissionalInput struct {
Equipamentos *string `json:"equipamentos"`
Email *string `json:"email"`
AvatarURL *string `json:"avatar_url"`
TargetUserID *string `json:"target_user_id"` // Optional: For admin creation
}
func (s *Service) Create(ctx context.Context, userID string, input CreateProfissionalInput) (*generated.CadastroProfissionai, error) {
usuarioUUID, err := uuid.Parse(userID)
finalUserID := userID
if input.TargetUserID != nil && *input.TargetUserID != "" {
finalUserID = *input.TargetUserID
}
usuarioUUID, err := uuid.Parse(finalUserID)
if err != nil {
return nil, errors.New("invalid usuario_id from context")
return nil, errors.New("invalid usuario_id")
}
var funcaoUUID uuid.UUID
@ -198,7 +204,35 @@ func (s *Service) Delete(ctx context.Context, id string) error {
if err != nil {
return errors.New("invalid id")
}
return s.queries.DeleteProfissional(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true})
// Get professional to find associated user
prof, err := s.queries.GetProfissionalByID(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true})
if err != nil {
return err
}
// Delete professional profile (should be done first or after?)
// If foreign key is SET NULL, it doesn't strictly matter for FK constraint,
// but logically deleting the profile first is cleaner if we want to ensure profile is gone.
// Actually, if we delete User first and it SETS NULL, we still have to delete Profile.
// So let's delete Profile first.
err = s.queries.DeleteProfissional(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true})
if err != nil {
return err
}
// Delete associated user if exists
if prof.UsuarioID.Valid {
err = s.queries.DeleteUsuario(ctx, prof.UsuarioID)
if err != nil {
// Create warning log? For now just return error or ignore?
// If user deletion fails, it's orphan but harmless-ish (except login).
// Better to return error.
return err
}
}
return nil
}
// Helpers

View file

@ -22,6 +22,8 @@ import {
AlertTriangle,
Check,
DollarSign,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "../components/Button";
import {
@ -64,10 +66,12 @@ export const TeamPage: React.FC = () => {
const [viewProfessional, setViewProfessional] = useState<Professional | null>(null);
// Form State
const initialFormState: CreateProfessionalDTO = {
const initialFormState: CreateProfessionalDTO & { senha?: string; confirmarSenha?: string } = {
nome: "",
funcao_profissional_id: "",
email: "",
senha: "",
confirmarSenha: "",
whatsapp: "",
cpf_cnpj_titular: "",
endereco: "",
@ -92,9 +96,12 @@ export const TeamPage: React.FC = () => {
avatar_url: "",
};
const [formData, setFormData] = useState<CreateProfessionalDTO>(initialFormState);
const [formData, setFormData] = useState<CreateProfessionalDTO & { senha?: string; confirmarSenha?: string }>(initialFormState);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>("");
// Password Visibility
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Fetch Data
useEffect(() => {
@ -228,6 +235,8 @@ export const TeamPage: React.FC = () => {
nome: professional.nome,
funcao_profissional_id: professional.funcao_profissional_id,
email: professional.email || "",
senha: "", // Não editamos senha aqui
confirmarSenha: "",
whatsapp: professional.whatsapp || "",
cpf_cnpj_titular: professional.cpf_cnpj_titular || "",
endereco: professional.endereco || "",
@ -287,6 +296,20 @@ export const TeamPage: React.FC = () => {
setIsSubmitting(true);
try {
// Validation for password on creation
if (!isEdit && (formData.senha || formData.confirmarSenha)) {
if (formData.senha !== formData.confirmarSenha) {
alert("As senhas não coincidem!");
setIsSubmitting(false);
return;
}
if (formData.senha && formData.senha.length < 6) {
alert("A senha deve ter pelo menos 6 caracteres.");
setIsSubmitting(false);
return;
}
}
let finalAvatarUrl = formData.avatar_url;
// Handle Avatar Upload if new file selected
@ -298,13 +321,52 @@ export const TeamPage: React.FC = () => {
}
}
const payload = { ...formData, avatar_url: finalAvatarUrl };
const payload: any = { ...formData, avatar_url: finalAvatarUrl };
// Remove password fields from professional payload
delete payload.senha;
delete payload.confirmarSenha;
if (isEdit && selectedProfessional) {
await updateProfessional(selectedProfessional.id, payload, token);
alert("Profissional atualizado com sucesso!");
} else {
await createProfessional(payload, token);
// Create User First (if password provided or mandatory logic?)
// If password is provided, we must create a user account.
// User requested: "ao cadastrar um novo profissional falta cadastrar a senha ... pra que esse profissional acesse a interface"
// So we should try to create user first.
let targetUserId = "";
if (formData.email && formData.senha) {
const { adminCreateUser } = await import("../services/apiService");
const createRes = await adminCreateUser({
email: formData.email,
senha: formData.senha,
nome: formData.nome,
role: "PHOTOGRAPHER", // Default role for professionals created here? Or map from selected role?
// Mapear função? Usually PHOTOGRAPHER or generic. Let's assume PHOTOGRAPHER for now as they are "Equipe".
tipo_profissional: roles.find(r => r.id === formData.funcao_profissional_id)?.nome || "",
ativo: true, // Auto-active as per request
}, token);
if (createRes.error) {
// If user API fails (e.g. email exists), we stop? Or let create professional proceed unlinked?
// User requirement implies linked account.
// If email exists, maybe we can't create user, but we can check if we should link to existing?
// For simplicity, error out.
throw new Error("Erro ao criar usuário de login: " + createRes.error);
}
if (createRes.data && createRes.data.id) {
targetUserId = createRes.data.id;
}
}
if (targetUserId) {
payload.target_user_id = targetUserId;
}
const res = await createProfessional(payload, token);
if (res.error) throw new Error(res.error);
alert("Profissional criado com sucesso!");
}
@ -312,11 +374,10 @@ export const TeamPage: React.FC = () => {
setShowEditModal(false);
fetchData();
// Reset form
// Reset form
resetForm();
} catch (error) {
} catch (error: any) {
console.error("Error submitting form:", error);
alert("Erro ao salvar profissional. Verifique o console.");
alert(error.message || "Erro ao salvar profissional. Verifique o console.");
} finally {
setIsSubmitting(false);
}
@ -553,9 +614,53 @@ export const TeamPage: React.FC = () => {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input type="email" value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
<label className="block text-sm font-medium text-gray-700">Email *</label>
<input required type="email" value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
</div>
{!showEditModal && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">Senha *</label>
<div className="relative mt-1">
<input
required
type={showPassword ? "text" : "password"}
value={formData.senha}
onChange={e => setFormData({ ...formData, senha: e.target.value })}
minLength={6}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
/>
<button
type="button"
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Confirmar Senha *</label>
<div className="relative mt-1">
<input
required
type={showConfirmPassword ? "text" : "password"}
value={formData.confirmarSenha}
onChange={e => setFormData({ ...formData, confirmarSenha: e.target.value })}
minLength={6}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
/>
<button
type="button"
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700">WhatsApp</label>
<input type="text" value={formData.whatsapp} onChange={e => setFormData({ ...formData, whatsapp: maskPhone(e.target.value) })} maxLength={15} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />

View file

@ -753,6 +753,41 @@ export async function updateEventStatus(token: string, eventId: string, status:
}
}
/**
* Cria um usuário pela interface administrativa
*/
export async function adminCreateUser(data: any, token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/admin/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const responseData = await response.json();
return {
data: responseData,
error: null,
isBackendDown: false,
};
} catch (error) {
console.error("Error creating admin user:", error);
return {
data: null,
error: error instanceof Error ? error.message : "Erro desconhecido",
isBackendDown: true,
};
}
}
/**
* Obtém URL pré-assinada para upload de arquivo
*/