Apariencia
Perfil profesional
El perfil es la tarjeta de presentación del candidato dentro de HUMAE. Es lo que ven los recruiters en el directorio y lo que alimenta el CV en PDF.
Secciones del perfil
El perfil está compuesto por 9 sub-entidades, cada una editable por separado desde /me/profile:
| Sección | Endpoint base | Cantidad típica |
|---|---|---|
| Datos básicos (incluye categoría empleado/practicante) | GET/PATCH /me/profile | 1 (el propio perfil) |
| Áreas de interés laboral | PATCH /me/profile (campo functional_areas[]) | 1–10 |
| Foto de perfil | POST /me/profile/avatar | 1 |
| Experiencias laborales | /me/profile/experiences | 0–N |
| Educación | /me/profile/educations | 0–N |
| Cursos y certificaciones | /me/profile/courses, /me/profile/certifications | 0–N |
| Habilidades (skills) | /me/profile/skills | 3+ recomendado |
| Idiomas | /me/profile/languages | 1+ |
| Referencias | /me/profile/references | 0–N |
| Documentos (CV, cartas, etc.) | /me/profile/documents | 0–N |
1. Datos básicos (CandidateProfile)
Todos los campos son editables en /me/profile. Vistos en el directorio por recruiters.
Campos principales:
| Campo | Tipo | Obligatorio | Visible directorio |
|---|---|---|---|
first_name, last_name | string | ✅ | ✅ |
headline | string (200) | ⬜ (recomendado) | ✅ |
summary | text (5000) | ⬜ | ✅ (al abrir detalle) |
birth_date | date | ⬜ | Solo admin |
contact_phone, whatsapp | string | ⬜ | Sí (para recruiter) |
linkedin_url, portfolio_url, github_url | URL | ⬜ | ✅ |
country_id, state_id, city_id | FK catálogo | ⬜ | ✅ |
career_level_id | FK | ⬜ | ✅ (filtro) |
functional_area_id | FK | ⬜ | ✅ (filtro · derivado del área principal) |
position_id | FK | ⬜ | ✅ |
candidate_kind | enum employee | intern | ⬜ (recomendado) | ✅ (filtro · badge en card) |
other_area_text | string (200) | ⬜ | ✅ (al abrir detalle) |
years_of_experience | int (0–70) | ⬜ | ✅ (filtro) |
expected_salary_min, expected_salary_max | decimal | ⬜ | ✅ (filtro salary_max) |
availability | string | ⬜ | ✅ (filtro) |
open_to_relocation, open_to_remote | boolean | ⬜ | ✅ (filtros) |
Validación del formulario (src/schemas/profile.ts):
ts
updateProfileSchema = z.object({
first_name: z.string().min(1).max(120),
last_name: z.string().min(1).max(120),
headline: z.string().max(200).optional(),
// ...
linkedin_url: z.string().url().max(300).optional().or(z.literal("")),
});Estados del perfil (CandidateState):
sin_perfil— recién creadoen_registro— llenando datosactivo— listo para ser vistoen_proceso— ya hay asignaciones activaspresentado_empresa— está siendo considerado por una empresaentrevistado— en fase de entrevistascontratado— finalizó exitosamenterechazado,retirado,inactivo— terminales
El estado se mueve automáticamente por el sistema al avanzar el pipeline. El candidato no puede cambiarlo manualmente.
1.bis Categoría de postulación (empleado o practicante)
Desde el perfil el candidato debe indicar bajo qué categoría desea ser considerado. Es uno de los datos más importantes para el matching: define qué vacantes son relevantes para esta persona.
Campo: candidate_kind — enum employee (Empleado) o intern (Practicante).
Por qué importa:
- El reclutador filtra el directorio por categoría desde el sidebar (Todos / Empleado / Practicante).
- Cada vacante tiene un campo equivalente
target_candidate_kind(employee,intern,any). El matching estructurado compara ambos: si la vacante busca un practicante y el candidato se registró como empleado, el eje "Categoría" del score queda en cero. - En cards de directorio aparece un badge distintivo: ámbar para Practicante, color marca para Empleado.
Cómo se llena en la UI (/me/profile):
¿Cómo te quieres postular?
( • ) Empleado ( ) Practicante
Elige si buscas colocarte como empleado o como practicante.
Los reclutadores filtran candidatos por esta categoría.Validación: el backend acepta null (campo opcional al crear el perfil) pero idealmente el candidato lo selecciona antes de pagar la membresía. Valores fuera del enum ("banana") devuelven 422.
1.ter Áreas de interés laboral
El candidato puede indicar una o varias áreas en las que le gustaría trabajar (PDF cosasfaltanteshumae, punto 1). Esto reemplaza al viejo campo único functional_area_id, que ahora se mantiene en sincronía con la área principal elegida por el candidato.
Almacén: tabla pivote candidate_functional_areas con columnas:
functional_area_id— FK afunctional_areas.is_primary— true en exactamente una de las áreas (la "principal").sort_order— orden de captura.
Catálogo de áreas (las 15 del PDF + 6 auxiliares):
Producción · Calidad · Mantenimiento · Logística · Recursos Humanos · Administración · Seguridad Industrial · Almacén · Ventas · Ingeniería · Compras · Sistemas · Atención al cliente · Operación · Finanzas · Producto · Diseño · Datos / Analítica · Marketing · Legal / Compliance · Otra.
El admin puede agregar / desactivar áreas desde /admin/catalogos.
Endpoint:
http
PATCH /api/v1/me/profile
Content-Type: application/json
{
"functional_areas": [
{ "id": 4, "is_primary": true },
{ "id": 7, "is_primary": false },
{ "id": 12, "is_primary": false }
],
"other_area_text": "Bioingeniería"
}Reglas:
- Máximo 10 áreas por perfil.
- Solo una puede ser
is_primary = true. Si el cliente envía dos primarias, elProfileServicerespeta la primera y descarta el resto. - Al guardar, el campo legacy
candidate_profiles.functional_area_idqueda sincronizado con la primaria. other_area_text(máx. 200 caracteres) es para áreas que no estén en el catálogo. Es texto libre, no participa del matching estructurado.- Quitar todas las áreas deja
functional_area_id = null.
Cómo se llena en la UI:
El componente FunctionalAreasMultiSelect muestra:
- Chips superiores con las áreas seleccionadas (estrella ⭐ marca la principal).
- Buscador + lista filtrable del catálogo con check al elegir.
- Botón "Quitar" (✕) en cada chip; click en la estrella vacía promueve esa área a principal.
- Input de texto opcional "Otra (texto libre)" debajo de la lista.
Áreas en las que te gustaría trabajar
[⭐ Producción ✕] [Calidad ☆ ✕] [Mantenimiento ☆ ✕]
🔍 Buscar área…
┌──────────────────────────────────┐
│ ✓ Producción │
│ ✓ Calidad │
│ ✓ Mantenimiento │
│ Logística │
│ Recursos Humanos │
│ … │
└──────────────────────────────────┘
Otra (texto libre): [_______________]Caso de uso típico (registro de un practicante):
- Pablo se registra como candidato y entra a
/me/profile. - Marca el RadioGroup
Practicanteen la sección "¿Cómo te quieres postular?". - En "Áreas en las que te gustaría trabajar" elige Ingeniería (estrella → principal) + Producción (sin estrella).
- Guarda. El backend persiste
candidate_kind = 'intern', sincroniza el pivote con dos filas (una conis_primary = true) y dejafunctional_area_id = id_de_ingenieria. - Al día siguiente un reclutador filtra el directorio por
candidate_kind=intern+functional_area_ids[]=ingenieria,producciony Pablo aparece. Si entra a una vacante "Practicante de Ingeniería Industrial · Producción", Pablo aparece arriba en el panel de Sugerencias con score alto.
2. Foto de perfil
Endpoint: POST /me/profile/avatar (multipart con campo avatar).
- Validación: archivo imagen jpg/jpeg/png/webp, máximo 4 MB.
- Se guarda en el disco
publicdel propio servidor bajostorage/app/public/avatars/{user_id}/..., reencodeado a WebP 400×400. - La URL pública (
/storage/avatars/{user_id}/{hash}.webp) se guarda enUser.avatar_url; la ruta relativa queda enUser.avatar_pathpara permitir el borrado del archivo físico cuando se reemplaza. - La versión anterior se elimina automáticamente del disco al subir una nueva.
- Rate limit: 10 uploads por minuto.
Ver Storage local para detalles.
3. Experiencias laborales
Endpoint: POST /me/profile/experiences + PATCH + DELETE.
Campos (CandidateExperience):
| Campo | Validación |
|---|---|
company_name | Requerido, 1–200 |
position_title | Requerido, 1–200 |
location | 200 max |
start_date | Requerido (date) |
end_date | Opcional (date >= start_date) |
is_current | Boolean; si true, end_date se ignora |
description | 5000 max |
Orden: las experiencias se muestran en orden cronológico inverso (más recientes primero).
4. Educación
Endpoint: /me/profile/educations.
Campos similares a experiencias, más:
degree_level_id→ FK a catálogodegree_levels(Bachillerato, Licenciatura, Maestría, Doctorado, Certificación).institution_name,field_of_study,start_year,end_year,is_current.
5. Cursos y certificaciones
Dos entidades separadas (CandidateCourse, CandidateCertification) con endpoints propios:
- Cursos: nombre, institución, duración, fecha de término, URL de certificado.
- Certificaciones: nombre, entidad emisora, fecha de emisión, fecha de vencimiento, código de verificación, URL.
6. Habilidades (skills)
Endpoints:
POST /me/profile/skills→ agrega una skill con nivelDELETE /me/profile/skills/{id}
Estructura:
skill_id: FK al catálogoskills(≈ 150 skills seed: React, Python, SQL, Excel, ...).level:basico,intermedio,avanzado,experto.- Unique constraint:
(candidate_profile_id, skill_id)— no se duplican.
El directorio permite filtrado AND por múltiples skills: "candidatos con Python AND SQL".
Skills no listadas
Si el candidato no encuentra su skill, el admin puede agregarla al catálogo desde /admin/catalogos/skills.
7. Idiomas
Endpoints similares a skills.
language_id: FK alanguages(Español, Inglés, Francés, Alemán, Portugués, Italiano, Chino, Japonés, ...).level:A1–C2(escala MCER) onativo.
Filtro del directorio soporta AND: "español nativo AND inglés B2+".
8. Referencias
Endpoint: /me/profile/references.
name,relationship(jefe, colega, cliente),company,contact_email,contact_phone.- Máximo recomendado: 3 referencias.
- Solo visibles a recruiters (no aparecen en el CV público automáticamente a menos que el candidato lo permita).
9. Documentos adjuntos
Endpoint: POST /me/profile/documents (multipart con file + type).
- Tipos (
DocumentTypeenum):cv_personal,carta_recomendacion,constancia_estudios,certificado,identificacion,rfc,curp,otros. - Validación: PDF, JPG, PNG, WEBP, DOC, DOCX; máximo 10 MB por archivo.
- Se guardan en el disco
localprivado (storage/app/private/documents/{candidate_profile_id}/...) del propio servidor. - La descarga pasa por un endpoint autenticado:
GET /api/v1/me/profile/documents/{id}/download(rate limit 60/min) con verificación de ownership. - Rate limit de upload: 20 por minuto.
- Los documentos son solo visibles al candidato y a recruiters/admin. Las empresas NO los ven directamente.
Acceso a los datos
Quién ve qué
| Acceso | Candidato propio | Recruiter | Company user | Admin |
|---|---|---|---|---|
| Perfil básico | ✅ edit | ✅ read | ✅ read (solo si presentado) | ✅ edit |
| Foto de perfil | ✅ | ✅ | ✅ | ✅ |
| Teléfono/email | ✅ | ✅ | ⬜ (oculto) | ✅ |
| Salario esperado | ✅ | ✅ | ✅ | ✅ |
| Documentos | ✅ | ✅ | ⬜ | ✅ |
| Referencias | ✅ | ✅ | ⬜ | ✅ |
Implementado con CandidateProfilePolicy + Resources que filtran campos sensibles según $request->user()->role.
Soft delete
Cuando un candidato elimina su perfil, se hace soft delete (deleted_at): el registro queda en DB para auditoría pero deja de aparecer. Un admin puede restaurarlo desde el panel.
Completitud del perfil
El dashboard muestra un progreso porcentual calculado en frontend:
Foto 20% ← 1 pt si hay avatar
Resumen 10% ← 1 pt si headline + summary ≥ 100 chars
Ubicación 10% ← 1 pt si country+state+city
Experiencia 20% ← 2 pts si hay ≥ 1 experiencia
Educación 10% ← 1 pt si hay ≥ 1 educación
Skills 15% ← 1.5 pts si hay ≥ 3 skills
Idiomas 10% ← 1 pt si hay ≥ 1 idioma
Preferencias 5% ← 0.5 pts si rellenó salario/modalidad
────────────────────
Total 100%Validaciones globales
- Todos los endpoints de perfil están bajo
auth:sanctum+ middlewarerole:candidate. - Cada acción valida ownership: un candidato solo puede editar su propio
CandidateProfile. - Las FormRequests (
UpdateCandidateProfileRequest,CreateExperienceRequest, ...) contienen las reglas de Zod-equivalente.
Siguiente
Ya con el perfil completo, el candidato debe responder las pruebas psicométricas. Pruebas psicométricas →

