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();
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
);
Branch or location applying for
Additional message or comments (optional)
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');
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
// 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);