Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ashcroft08/provesa-web/llms.txt

Use this file to discover all available pages before exploring further.

The Postulaciones Service manages job applications (postulaciones) from potential employees, including CV file uploads to Cloudinary storage.

Overview

This service handles the complete lifecycle of job applications: creation with optional CV upload, retrieval, and deletion. CV files are automatically uploaded to Cloudinary in the provesa-web/postulaciones folder.

Dependencies

  • postulacionesRepository - Database operations for applications
  • uploadRepository - Cloudinary file upload operations

Methods

getAll()

Retrieves all job applications, ordered by most recent first.
import { postulacionesService } from '$lib/server/services/postulaciones.service.js';

const postulaciones = await postulacionesService.getAll();
postulaciones
array
Array of job application objects:
  • id: Application identifier (string)
  • nombre: Applicant name
  • telefono: Phone number
  • email: Email address
  • sucursal: Branch/location applied for
  • cvUrl: Cloudinary URL to uploaded CV (nullable)
  • mensaje: Additional message/comments
  • createdAt: Timestamp of application

Example Response

[
  {
    id: '1',
    nombre: 'Juan Pérez',
    telefono: '+595981234567',
    email: 'juan.perez@example.com',
    sucursal: 'Asunción Centro',
    cvUrl: 'https://res.cloudinary.com/provesa/raw/upload/v1234567890/provesa-web/postulaciones/cv_123.pdf',
    mensaje: 'Tengo 5 años de experiencia en ventas',
    createdAt: '2024-03-12T10:30:00Z'
  },
  {
    id: '2',
    nombre: 'María González',
    telefono: '+595987654321',
    email: 'maria.g@example.com',
    sucursal: 'Ciudad del Este',
    cvUrl: null,
    mensaje: null,
    createdAt: '2024-03-11T15:20:00Z'
  }
]

Implementation Details

Source: src/lib/server/services/postulaciones.service.js:6-8 Uses postulacionesRepository.getAll() which orders by createdAt DESC.

create()

Creates a new job application with optional CV file upload to Cloudinary.
import { postulacionesService } from '$lib/server/services/postulaciones.service.js';

// From SvelteKit form action
const formData = await request.formData();
const cvFile = formData.get('cv'); // File object

await postulacionesService.create(
  {
    nombre: 'Carlos Rodríguez',
    telefono: '+595981111222',
    email: 'carlos@example.com',
    sucursal: 'Encarnación',
    mensaje: 'Disponible para comenzar inmediatamente'
  },
  cvFile
);
data
object
required
Application data object
data.nombre
string
required
Applicant’s full name
data.telefono
string
required
Phone number for contact
data.email
string
required
Email address
data.sucursal
string
required
Branch or location applying for
data.mensaje
string
Additional message or comments (optional)
cvFile
File
CV file to upload (optional). Must be a File object with size > 0
CV files are uploaded to Cloudinary folder: provesa-web/postulaciones

CV Upload Process

The service checks if a valid CV file is provided:
if (cvFile && cvFile instanceof File && cvFile.size > 0) {
  const result = await uploadRepository.uploadFile(cvFile, {
    folder: 'provesa-web/postulaciones'
  });
  cvUrl = result.secure_url;
}
If no file is provided or the file is empty, cvUrl is set to null.

Implementation Details

Source: src/lib/server/services/postulaciones.service.js:15-28

delete()

Deletes a job application by ID.
import { postulacionesService } from '$lib/server/services/postulaciones.service.js';

await postulacionesService.delete('1');
id
string
required
Application ID to delete
This does NOT delete the CV file from Cloudinary. Consider implementing cleanup if needed.

Implementation Details

Source: src/lib/server/services/postulaciones.service.js:31-33

Usage Examples

Public Job Application Form

// src/routes/empleo/+page.server.js
import { postulacionesService } from '$lib/server/services/postulaciones.service.js';
import { empleoService } from '$lib/server/services/empleo.service.js';
import { fail } from '@sveltejs/kit';

export async function load() {
  const sucursales = await empleoService.getActive();
  return { sucursales };
}

export const actions = {
  apply: async ({ request }) => {
    const formData = await request.formData();
    
    const nombre = formData.get('nombre');
    const telefono = formData.get('telefono');
    const email = formData.get('email');
    const sucursal = formData.get('sucursal');
    const mensaje = formData.get('mensaje');
    const cvFile = formData.get('cv');
    
    // Validation
    if (!nombre || !telefono || !email || !sucursal) {
      return fail(400, { error: 'Todos los campos requeridos deben completarse' });
    }
    
    // Email validation
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      return fail(400, { error: 'Email inválido' });
    }
    
    // CV file validation (if provided)
    if (cvFile && cvFile.size > 0) {
      const maxSize = 5 * 1024 * 1024; // 5MB
      if (cvFile.size > maxSize) {
        return fail(400, { error: 'El CV no puede exceder 5MB' });
      }
      
      const allowedTypes = ['application/pdf', 'application/msword', 
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
      if (!allowedTypes.includes(cvFile.type)) {
        return fail(400, { error: 'Solo se permiten archivos PDF o Word' });
      }
    }
    
    try {
      await postulacionesService.create(
        { nombre, telefono, email, sucursal, mensaje },
        cvFile
      );
      return { success: true };
    } catch (error) {
      console.error('Error creating application:', error);
      return fail(500, { error: 'Error al enviar postulación' });
    }
  }
};
<!-- src/routes/empleo/+page.svelte -->
<script>
  import { enhance } from '$app/forms';
  export let data;
  export let form;
  
  let uploading = false;
</script>

<form 
  method="POST" 
  action="?/apply" 
  enctype="multipart/form-data"
  use:enhance={() => {
    uploading = true;
    return async ({ update }) => {
      await update();
      uploading = false;
    };
  }}
>
  <h1>Postúlate a PROVESA</h1>
  
  <div>
    <label for="nombre">Nombre Completo *</label>
    <input
      type="text"
      id="nombre"
      name="nombre"
      required
    />
  </div>
  
  <div>
    <label for="telefono">Teléfono *</label>
    <input
      type="tel"
      id="telefono"
      name="telefono"
      placeholder="+595981234567"
      required
    />
  </div>
  
  <div>
    <label for="email">Email *</label>
    <input
      type="email"
      id="email"
      name="email"
      required
    />
  </div>
  
  <div>
    <label for="sucursal">Sucursal de Interés *</label>
    <select id="sucursal" name="sucursal" required>
      <option value="">Selecciona una sucursal</option>
      {#each data.sucursales as sucursal}
        <option value="{sucursal.nombre}">{sucursal.nombre}</option>
      {/each}
    </select>
  </div>
  
  <div>
    <label for="cv">CV (PDF o Word, máx 5MB)</label>
    <input
      type="file"
      id="cv"
      name="cv"
      accept=".pdf,.doc,.docx"
    />
  </div>
  
  <div>
    <label for="mensaje">Mensaje Adicional</label>
    <textarea
      id="mensaje"
      name="mensaje"
      rows="4"
      placeholder="Cuéntanos sobre tu experiencia..."
    ></textarea>
  </div>
  
  <button type="submit" disabled={uploading}>
    {uploading ? 'Enviando...' : 'Enviar Postulación'}
  </button>
  
  {#if form?.success}
    <p class="success">¡Gracias! Tu postulación ha sido enviada.</p>
  {/if}
  
  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}
</form>

Admin Dashboard

// src/routes/admin/postulaciones/+page.server.js
import { postulacionesService } from '$lib/server/services/postulaciones.service.js';
import { fail } from '@sveltejs/kit';

export async function load() {
  const postulaciones = await postulacionesService.getAll();
  return { postulaciones };
}

export const actions = {
  delete: async ({ request }) => {
    const formData = await request.formData();
    const id = formData.get('id');
    
    try {
      await postulacionesService.delete(id);
      return { success: true };
    } catch (error) {
      console.error('Error deleting application:', error);
      return fail(500, { error: 'Error al eliminar postulación' });
    }
  }
};
<!-- src/routes/admin/postulaciones/+page.svelte -->
<script>
  export let data;
  
  function formatDate(dateString) {
    return new Date(dateString).toLocaleDateString('es-PY', {
      year: 'numeric',
      month: 'short',
      day: 'numeric',
      hour: '2-digit',
      minute: '2-digit'
    });
  }
</script>

<div class="admin-postulaciones">
  <header>
    <h1>Postulaciones</h1>
    <span class="badge">{data.postulaciones.length} total</span>
  </header>
  
  <div class="postulaciones-list">
    {#each data.postulaciones as postulacion (postulacion.id)}
      <div class="postulacion-card">
        <div class="header">
          <h3>{postulacion.nombre}</h3>
          <span class="date">{formatDate(postulacion.createdAt)}</span>
        </div>
        
        <div class="info">
          <p><strong>Email:</strong> {postulacion.email}</p>
          <p><strong>Teléfono:</strong> {postulacion.telefono}</p>
          <p><strong>Sucursal:</strong> {postulacion.sucursal}</p>
          
          {#if postulacion.mensaje}
            <p><strong>Mensaje:</strong> {postulacion.mensaje}</p>
          {/if}
          
          {#if postulacion.cvUrl}
            <p>
              <strong>CV:</strong>
              <a href="{postulacion.cvUrl}" target="_blank" rel="noopener">
                Ver Documento
              </a>
            </p>
          {:else}
            <p class="no-cv">Sin CV adjunto</p>
          {/if}
        </div>
        
        <div class="actions">
          <form method="POST" action="?/delete">
            <input type="hidden" name="id" value="{postulacion.id}" />
            <button type="submit" class="delete">Eliminar</button>
          </form>
        </div>
      </div>
    {/each}
    
    {#if data.postulaciones.length === 0}
      <p class="empty">No hay postulaciones aún</p>
    {/if}
  </div>
</div>

Data Structure

Postulacion Object

interface Postulacion {
  id: string;
  nombre: string;
  telefono: string;
  email: string;
  sucursal: string;
  cvUrl: string | null;
  mensaje: string | null;
  createdAt: string; // ISO timestamp
}

Validation Helpers

function validatePostulacion(data, cvFile) {
  const errors = [];
  
  // Required fields
  if (!data.nombre || data.nombre.trim().length < 2) {
    errors.push('Nombre debe tener al menos 2 caracteres');
  }
  
  if (!data.telefono || data.telefono.trim().length < 8) {
    errors.push('Teléfono inválido');
  }
  
  // Email validation
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!data.email || !emailRegex.test(data.email)) {
    errors.push('Email inválido');
  }
  
  if (!data.sucursal || data.sucursal.trim().length === 0) {
    errors.push('Sucursal es requerida');
  }
  
  // CV file validation
  if (cvFile && cvFile.size > 0) {
    const maxSize = 5 * 1024 * 1024; // 5MB
    if (cvFile.size > maxSize) {
      errors.push('El CV no puede exceder 5MB');
    }
    
    const allowedTypes = [
      'application/pdf',
      'application/msword',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    ];
    
    if (!allowedTypes.includes(cvFile.type)) {
      errors.push('Solo se permiten archivos PDF o Word');
    }
  }
  
  return errors;
}

// Usage
const errors = validatePostulacion(data, cvFile);
if (errors.length > 0) {
  return fail(400, { errors });
}

Filtering and Statistics

Filter by Branch

import { postulacionesService } from '$lib/server/services/postulaciones.service.js';

const allPostulaciones = await postulacionesService.getAll();
const asuncionApps = allPostulaciones.filter(p => 
  p.sucursal === 'Asunción Centro'
);

Applications with CV

const withCV = allPostulaciones.filter(p => p.cvUrl !== null);
const withoutCV = allPostulaciones.filter(p => p.cvUrl === null);

console.log(`${withCV.length} con CV, ${withoutCV.length} sin CV`);

Statistics by Branch

async function getPostulacionesStats() {
  const postulaciones = await postulacionesService.getAll();
  
  const stats = {
    total: postulaciones.length,
    withCV: postulaciones.filter(p => p.cvUrl).length,
    bySucursal: {}
  };
  
  postulaciones.forEach(p => {
    stats.bySucursal[p.sucursal] = (stats.bySucursal[p.sucursal] || 0) + 1;
  });
  
  return stats;
}

Best Practices

Always validate file types and sizes before upload to prevent abuse.
Implement rate limiting on the application form to prevent spam submissions.
Send email notifications to HR when new applications are received.
Consider implementing CV file cleanup for deleted applications using Cloudinary’s destroy API.
Store Cloudinary public_id with the application to enable easier file management.

Email Notification Example

import { postulacionesService } from '$lib/server/services/postulaciones.service.js';
import { sendEmail } from '$lib/server/email';

async function createPostulacionWithNotification(data, cvFile) {
  // Create application
  await postulacionesService.create(data, cvFile);
  
  // Send notification to HR
  await sendEmail({
    to: 'rrhh@provesa.com',
    subject: `Nueva Postulación: ${data.nombre} - ${data.sucursal}`,
    html: `
      <h2>Nueva Postulación Recibida</h2>
      <p><strong>Nombre:</strong> ${data.nombre}</p>
      <p><strong>Email:</strong> ${data.email}</p>
      <p><strong>Teléfono:</strong> ${data.telefono}</p>
      <p><strong>Sucursal:</strong> ${data.sucursal}</p>
      ${data.mensaje ? `<p><strong>Mensaje:</strong> ${data.mensaje}</p>` : ''}
      ${cvFile && cvFile.size > 0 ? '<p>✓ Incluye CV adjunto</p>' : '<p>✗ Sin CV adjunto</p>'}
      <p><a href="https://admin.provesa.com/postulaciones">Ver en Admin</a></p>
    `
  });
}

CV File Cleanup

import { postulacionesService } from '$lib/server/services/postulaciones.service.js';
import { uploadRepository } from '$lib/server/repositories/upload.repository.js';

async function deletePostulacionWithCleanup(id) {
  // Get the postulacion to extract CV URL
  const postulaciones = await postulacionesService.getAll();
  const postulacion = postulaciones.find(p => p.id === id);
  
  if (!postulacion) {
    throw new Error('Postulación no encontrada');
  }
  
  // Delete from database
  await postulacionesService.delete(id);
  
  // Delete CV from Cloudinary if it exists
  if (postulacion.cvUrl) {
    // Extract public_id from URL
    const publicId = extractPublicIdFromUrl(postulacion.cvUrl);
    await uploadRepository.deleteFile(publicId);
  }
}

function extractPublicIdFromUrl(url) {
  // Example: https://res.cloudinary.com/provesa/raw/upload/v123/provesa-web/postulaciones/cv_abc.pdf
  // Extract: provesa-web/postulaciones/cv_abc
  const match = url.match(/upload\/v\d+\/(.+)\.[^.]+$/);
  return match ? match[1] : null;
}

Database Schema

The postulaciones table structure:
CREATE TABLE postulaciones (
  id TEXT PRIMARY KEY,
  nombre TEXT NOT NULL,
  telefono TEXT NOT NULL,
  email TEXT NOT NULL,
  sucursal TEXT NOT NULL,
  cv_url TEXT,
  mensaje TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_postulaciones_created_at ON postulaciones(created_at DESC);
CREATE INDEX idx_postulaciones_sucursal ON postulaciones(sucursal);

Build docs developers (and LLMs) love