The Concursos Service manages promotional contests and sweepstakes, including contest details, winner records, and image uploads to Cloudinary.
Overview
This service provides comprehensive contest management with two main entities: Concursos (contests) and Ganadores (winners). It handles public-facing data, admin operations, and automatic image uploads to Cloudinary.
Dependencies
concursosRepository - Database operations for contests and winners
uploadRepository - Cloudinary image upload operations
Public Methods
getPublicData()
Retrieves the active contest with its winners for public display.
import { concursosService } from '$lib/server/services/concursos.service.js';
const { concurso, ganadores } = await concursosService.getPublicData();
Active contest object or null if none active:
id: Contest identifier
title: Main title text
titleHighlight: Highlighted portion of title
description: Contest description
imageUrl: Cloudinary URL to contest image
badgeText: Badge label (e.g., “Sorteo Activo”)
closeDate: Contest close date string
prizeName: Prize description
ctaText: Call-to-action text
disclaimer: Legal disclaimer text
isActive: Boolean indicating active status
sortOrder: Display order number
Array of winner objects for the active contest:
id: Winner identifier
concursoId: Associated contest ID
winnerName: Winner’s name
prize: Prize won
testimonial: Winner testimonial
imageUrl: Cloudinary URL to winner photo
dateLabel: Date label (e.g., “Marzo 2024”)
sortOrder: Display order
Example Response
{
concurso: {
id: 1,
title: 'Gran Sorteo',
titleHighlight: 'PROVESA 2024',
description: 'Participa y gana increíbles premios',
imageUrl: 'https://res.cloudinary.com/provesa/image/upload/v123/provesa/concursos/sorteo.jpg',
badgeText: 'Sorteo Activo',
closeDate: '31 de Diciembre 2024',
prizeName: 'Camioneta 0KM',
ctaText: 'Ver Marcas Auspiciantes',
disclaimer: 'Válido hasta agotar stock. Aplican términos y condiciones.',
isActive: true,
sortOrder: 0
},
ganadores: [
{
id: 1,
concursoId: 1,
winnerName: 'Juan Pérez',
prize: 'Camioneta 0KM',
testimonial: '¡No puedo creer que gané! Gracias PROVESA',
imageUrl: 'https://res.cloudinary.com/provesa/image/upload/v123/provesa/concursos/ganadores/juan.jpg',
dateLabel: 'Diciembre 2023',
sortOrder: 0
}
]
}
Implementation Details
Source: src/lib/server/services/concursos.service.js:8-16
Returns empty array for ganadores if no active contest exists.
Contest Management (Admin)
getAllConcursos()
Retrieves all contests ordered by sortOrder.
import { concursosService } from '$lib/server/services/concursos.service.js';
const concursos = await concursosService.getAllConcursos();
Array of all contest objects, ordered by sortOrder ascending
Implementation Details
Source: src/lib/server/services/concursos.service.js:20-22
addConcurso()
Creates a new contest with optional image upload.
import { concursosService } from '$lib/server/services/concursos.service.js';
// From SvelteKit form action
const formData = await request.formData();
const concurso = await concursosService.addConcurso(formData);
FormData object containing contest fields
Highlighted portion of title (optional)
Contest image file (uploaded to Cloudinary folder: provesa/concursos)
Badge text (default: “Sorteo Activo”)
Call-to-action text (default: “Ver Marcas Auspiciantes”)
“true” or “false” - active status
Newly created contest object with auto-generated sortOrder
Sort Order Calculation
The service automatically calculates sortOrder as the maximum existing sortOrder + 1:
const existing = await concursosRepository.getAll();
const sortOrder = existing.length > 0
? Math.max(...existing.map(c => c.sortOrder)) + 1
: 0;
Implementation Details
Source: src/lib/server/services/concursos.service.js:29-59
updateConcurso()
Updates an existing contest with optional new image.
import { concursosService } from '$lib/server/services/concursos.service.js';
const formData = await request.formData();
await concursosService.updateConcurso(1, formData);
FormData with fields to update (same as addConcurso)
If a new image file is provided, it uploads to Cloudinary and updates imageUrl. Otherwise, existing image URL is preserved.
Implementation Details
Source: src/lib/server/services/concursos.service.js:65-88
deleteConcurso()
Deletes a contest by ID.
import { concursosService } from '$lib/server/services/concursos.service.js';
await concursosService.deleteConcurso(1);
This does NOT delete associated winners or images from Cloudinary.
Implementation Details
Source: src/lib/server/services/concursos.service.js:91-93
Winner Management (Admin)
getAllGanadores()
Retrieves all winners across all contests.
import { concursosService } from '$lib/server/services/concursos.service.js';
const ganadores = await concursosService.getAllGanadores();
Array of all winner objects, ordered by sortOrder ascending
Implementation Details
Source: src/lib/server/services/concursos.service.js:24-26
addGanador()
Creates a new winner record with optional photo upload.
import { concursosService } from '$lib/server/services/concursos.service.js';
const formData = await request.formData();
const ganador = await concursosService.addGanador(formData);
FormData object containing winner fields
Contest ID (parsed to integer, nullable)
Winner testimonial or quote
Winner photo (uploaded to Cloudinary folder: provesa/concursos/ganadores)
Date label (e.g., “Marzo 2024”)
Newly created winner object with auto-generated sortOrder
Implementation Details
Source: src/lib/server/services/concursos.service.js:98-124
updateGanador()
Updates an existing winner record.
import { concursosService } from '$lib/server/services/concursos.service.js';
const formData = await request.formData();
await concursosService.updateGanador(1, formData);
FormData with fields to update (same as addGanador)
Implementation Details
Source: src/lib/server/services/concursos.service.js:130-149
deleteGanador()
Deletes a winner record by ID.
import { concursosService } from '$lib/server/services/concursos.service.js';
await concursosService.deleteGanador(1);
Implementation Details
Source: src/lib/server/services/concursos.service.js:152-154
Usage Examples
Public Contest Page
// src/routes/concursos/+page.server.js
import { concursosService } from '$lib/server/services/concursos.service.js';
export async function load() {
const { concurso, ganadores } = await concursosService.getPublicData();
return { concurso, ganadores };
}
<!-- src/routes/concursos/+page.svelte -->
<script>
export let data;
</script>
{#if data.concurso}
<section class="concurso-hero">
<div class="badge">{data.concurso.badgeText}</div>
<h1>
{data.concurso.title}
<span class="highlight">{data.concurso.titleHighlight}</span>
</h1>
<p>{data.concurso.description}</p>
{#if data.concurso.imageUrl}
<img src="{data.concurso.imageUrl}" alt="{data.concurso.title}" />
{/if}
<div class="prize">
<h2>Premio: {data.concurso.prizeName}</h2>
<p>Cierre: {data.concurso.closeDate}</p>
</div>
<button>{data.concurso.ctaText}</button>
<p class="disclaimer">{data.concurso.disclaimer}</p>
</section>
{#if data.ganadores.length > 0}
<section class="ganadores">
<h2>Ganadores Anteriores</h2>
<div class="ganadores-grid">
{#each data.ganadores as ganador}
<div class="ganador-card">
{#if ganador.imageUrl}
<img src="{ganador.imageUrl}" alt="{ganador.winnerName}" />
{/if}
<h3>{ganador.winnerName}</h3>
<p class="prize">{ganador.prize}</p>
<p class="date">{ganador.dateLabel}</p>
{#if ganador.testimonial}
<blockquote>{ganador.testimonial}</blockquote>
{/if}
</div>
{/each}
</div>
</section>
{/if}
{:else}
<p>No hay concursos activos en este momento.</p>
{/if}
Admin Contest Management
// src/routes/admin/concursos/+page.server.js
import { concursosService } from '$lib/server/services/concursos.service.js';
import { fail } from '@sveltejs/kit';
export async function load() {
const [concursos, ganadores] = await Promise.all([
concursosService.getAllConcursos(),
concursosService.getAllGanadores()
]);
return { concursos, ganadores };
}
export const actions = {
addConcurso: async ({ request }) => {
const formData = await request.formData();
try {
await concursosService.addConcurso(formData);
return { success: true };
} catch (error) {
console.error('Error creating contest:', error);
return fail(500, { error: 'Error al crear concurso' });
}
},
updateConcurso: async ({ request }) => {
const formData = await request.formData();
const id = parseInt(formData.get('id'));
try {
await concursosService.updateConcurso(id, formData);
return { success: true };
} catch (error) {
console.error('Error updating contest:', error);
return fail(500, { error: 'Error al actualizar concurso' });
}
},
deleteConcurso: async ({ request }) => {
const formData = await request.formData();
const id = parseInt(formData.get('id'));
try {
await concursosService.deleteConcurso(id);
return { success: true };
} catch (error) {
console.error('Error deleting contest:', error);
return fail(500, { error: 'Error al eliminar concurso' });
}
},
addGanador: async ({ request }) => {
const formData = await request.formData();
try {
await concursosService.addGanador(formData);
return { success: true };
} catch (error) {
console.error('Error creating winner:', error);
return fail(500, { error: 'Error al crear ganador' });
}
},
deleteGanador: async ({ request }) => {
const formData = await request.formData();
const id = parseInt(formData.get('id'));
try {
await concursosService.deleteGanador(id);
return { success: true };
} catch (error) {
console.error('Error deleting winner:', error);
return fail(500, { error: 'Error al eliminar ganador' });
}
}
};
<!-- AdminConcursoForm.svelte -->
<script>
import { enhance } from '$app/forms';
export let concurso = null; // null for new, object for edit
let uploading = false;
</script>
<form
method="POST"
action="?/{concurso ? 'updateConcurso' : 'addConcurso'}"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return async ({ update }) => {
await update();
uploading = false;
};
}}
>
{#if concurso}
<input type="hidden" name="id" value="{concurso.id}" />
{/if}
<div>
<label for="title">Título Principal *</label>
<input
type="text"
id="title"
name="title"
value="{concurso?.title || ''}"
required
/>
</div>
<div>
<label for="titleHighlight">Título Destacado</label>
<input
type="text"
id="titleHighlight"
name="titleHighlight"
value="{concurso?.titleHighlight || ''}"
/>
</div>
<div>
<label for="description">Descripción *</label>
<textarea
id="description"
name="description"
rows="4"
required
>{concurso?.description || ''}</textarea>
</div>
<div>
<label for="image">Imagen del Concurso</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
/>
{#if concurso?.imageUrl}
<img src="{concurso.imageUrl}" alt="Preview" class="preview" />
{/if}
</div>
<div>
<label for="badgeText">Texto del Badge</label>
<input
type="text"
id="badgeText"
name="badgeText"
value="{concurso?.badgeText || 'Sorteo Activo'}"
/>
</div>
<div>
<label for="prizeName">Premio *</label>
<input
type="text"
id="prizeName"
name="prizeName"
value="{concurso?.prizeName || ''}"
required
/>
</div>
<div>
<label for="closeDate">Fecha de Cierre</label>
<input
type="text"
id="closeDate"
name="closeDate"
value="{concurso?.closeDate || ''}"
placeholder="31 de Diciembre 2024"
/>
</div>
<div>
<label for="ctaText">Texto del Botón</label>
<input
type="text"
id="ctaText"
name="ctaText"
value="{concurso?.ctaText || 'Ver Marcas Auspiciantes'}"
/>
</div>
<div>
<label for="disclaimer">Disclaimer Legal</label>
<textarea
id="disclaimer"
name="disclaimer"
rows="2"
>{concurso?.disclaimer || ''}</textarea>
</div>
<div class="checkbox">
<input
type="checkbox"
id="isActive"
name="isActive"
value="true"
checked="{concurso?.isActive || false}"
/>
<label for="isActive">Concurso Activo</label>
</div>
<button type="submit" disabled={uploading}>
{uploading ? 'Guardando...' : concurso ? 'Actualizar' : 'Crear'}
</button>
</form>
Data Structures
Concurso Object
interface Concurso {
id: number;
title: string;
titleHighlight: string;
description: string;
imageUrl: string;
badgeText: string;
closeDate: string;
prizeName: string;
ctaText: string;
disclaimer: string;
isActive: boolean;
sortOrder: number;
}
Ganador Object
interface Ganador {
id: number;
concursoId: number | null;
winnerName: string;
prize: string;
testimonial: string;
imageUrl: string;
dateLabel: string;
sortOrder: number;
}
Best Practices
Only one contest should be active (isActive: true) at a time for clear user experience.
Validate image files before upload. Recommended max size: 2MB, formats: JPG, PNG, WebP.
Use descriptive sortOrder values to control display order in admin interfaces.
The getPublicData() method returns the first active contest. Ensure only one contest is marked active.
Store Cloudinary public_id with records to enable easier image deletion and management.
Contest Activation Helper
import { concursosService } from '$lib/server/services/concursos.service.js';
/**
* Activates a contest and deactivates all others
*/
async function activateConcurso(id) {
const concursos = await concursosService.getAllConcursos();
// Deactivate all
for (const concurso of concursos) {
if (concurso.isActive) {
const formData = new FormData();
formData.set('isActive', 'false');
// Copy other fields
Object.keys(concurso).forEach(key => {
if (key !== 'isActive' && key !== 'id') {
formData.set(key, concurso[key]);
}
});
await concursosService.updateConcurso(concurso.id, formData);
}
}
// Activate target
const target = concursos.find(c => c.id === id);
if (target) {
const formData = new FormData();
formData.set('isActive', 'true');
Object.keys(target).forEach(key => {
if (key !== 'isActive' && key !== 'id') {
formData.set(key, target[key]);
}
});
await concursosService.updateConcurso(id, formData);
}
}
Database Schema
Concursos Table
CREATE TABLE concursos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
title_highlight TEXT,
description TEXT NOT NULL,
image_url TEXT,
badge_text TEXT DEFAULT 'Sorteo Activo',
close_date TEXT,
prize_name TEXT,
cta_text TEXT DEFAULT 'Ver Marcas Auspiciantes',
disclaimer TEXT,
is_active BOOLEAN DEFAULT FALSE,
sort_order INTEGER DEFAULT 0
);
CREATE INDEX idx_concursos_active ON concursos(is_active);
CREATE INDEX idx_concursos_sort ON concursos(sort_order);
Concursos Ganadores Table
CREATE TABLE concursos_ganadores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
concurso_id INTEGER,
winner_name TEXT NOT NULL,
prize TEXT NOT NULL,
testimonial TEXT,
image_url TEXT,
date_label TEXT,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (concurso_id) REFERENCES concursos(id) ON DELETE SET NULL
);
CREATE INDEX idx_ganadores_concurso ON concursos_ganadores(concurso_id);
CREATE INDEX idx_ganadores_sort ON concursos_ganadores(sort_order);