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.
Overview
PROVESA Web implements a clean separation between business logic (Services) and data access (Repositories). This pattern provides:
- Testability - Business logic can be tested independently of database
- Maintainability - Changes to database queries don’t affect business logic
- Reusability - Services can orchestrate multiple repositories
- Type Safety - Drizzle ORM provides compile-time type checking
Architecture Pattern
┌─────────────────────────────────────────┐
│ Route Handler │
│ Handles HTTP, calls service methods │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Service Layer │
│ • Business logic │
│ • Validation │
│ • Data transformation │
│ • File uploads (Cloudinary) │
│ • Orchestrates repositories │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Repository Layer │
│ • Database queries (Drizzle) │
│ • CRUD operations │
│ • Data access abstraction │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
└─────────────────────────────────────────┘
Repository Layer
Purpose
Repositories provide a consistent API for database operations, abstracting away SQL and ORM implementation details.
Standard Repository Pattern
Every repository exports an object with standard CRUD methods:
export const {domain}Repository = {
async getAll() { /* ... */ },
async getById(id) { /* ... */ },
async create(data) { /* ... */ },
async update(id, data) { /* ... */ },
async remove(id) { /* ... */ }
};
Example: Products Repository
File: /src/lib/server/repositories/products.repository.js
import { db } from '$lib/server/db';
import { products } from '$lib/server/db/schema';
import { eq, asc } from 'drizzle-orm';
export const productsRepository = {
/** Obtiene todos los productos ordenados */
async getAll() {
return await db.select().from(products).orderBy(asc(products.sortOrder));
},
/** Crea un nuevo producto */
async create(data) {
await db.insert(products).values(data);
},
/** Actualiza un producto por ID */
async update(id, data) {
await db
.update(products)
.set(data)
.where(eq(products.id, id));
},
/** Elimina un producto por ID */
async remove(id) {
await db.delete(products).where(eq(products.id, id));
}
};
Key Repository Patterns
1. Always Return Data
Repositories should return query results, not void:
// ✅ Good
async create(data) {
const rows = await db.insert(products).values(data).returning();
return rows[0];
}
// ❌ Avoid
async create(data) {
await db.insert(products).values(data);
// No return value
}
2. Use Type Inference
Drizzle provides type inference for insert/update:
// TypeScript will infer the correct type
async create(data) {
// data is typed as: typeof products.$inferInsert
return db.insert(products).values(data).returning();
}
3. Sort by Default
Always include default sorting for consistency:
async getAll() {
return await db
.select()
.from(products)
.orderBy(asc(products.sortOrder)); // Consistent ordering
}
4. Use Proper Operators
Drizzle provides type-safe operators:
import { eq, asc, desc, like, gte, lte } from 'drizzle-orm';
// Equality
where(eq(products.id, id))
// Sorting
orderBy(asc(products.sortOrder))
orderBy(desc(products.createdAt))
// LIKE queries
where(like(products.name, '%search%'))
// Comparisons
where(gte(products.price, 100))
Advanced Repository Example: Concursos
File: /src/lib/server/repositories/concursos.repository.js
import { db } from '$lib/server/db/index.js';
import { concursos, concursosGanadores } from '$lib/server/db/schema.js';
import { eq, asc } from 'drizzle-orm';
export const concursosRepository = {
// ─── Concursos ───
async getAll() {
return db.select().from(concursos).orderBy(asc(concursos.sortOrder));
},
/** Get the currently active contest */
async getActive() {
const rows = await db
.select()
.from(concursos)
.where(eq(concursos.isActive, true))
.limit(1);
return rows[0] || null;
},
async getById(id) {
const rows = await db.select().from(concursos).where(eq(concursos.id, id)).limit(1);
return rows[0] || null;
},
async create(data) {
const rows = await db.insert(concursos).values(data).returning();
return rows[0];
},
async update(id, data) {
return db.update(concursos).set(data).where(eq(concursos.id, id));
},
async remove(id) {
return db.delete(concursos).where(eq(concursos.id, id));
},
// ─── Ganadores ───
async getAllGanadores() {
return db.select().from(concursosGanadores).orderBy(asc(concursosGanadores.sortOrder));
},
/** Get winners for a specific contest */
async getGanadoresByConcurso(concursoId) {
return db.select().from(concursosGanadores)
.where(eq(concursosGanadores.concursoId, concursoId))
.orderBy(asc(concursosGanadores.sortOrder));
},
async createGanador(data) {
const rows = await db.insert(concursosGanadores).values(data).returning();
return rows[0];
},
async updateGanador(id, data) {
return db.update(concursosGanadores).set(data).where(eq(concursosGanadores.id, id));
},
async removeGanador(id) {
return db.delete(concursosGanadores).where(eq(concursosGanadores.id, id));
}
};
Key Points:
- Handles two related tables (
concursos and concursosGanadores)
- Provides specialized queries (
getActive, getGanadoresByConcurso)
- Returns
null for not found instead of throwing
Service Layer
Purpose
Services contain business logic, validation, and orchestration. They:
- Call one or more repositories
- Transform and validate data
- Handle file uploads
- Calculate derived values
- Enforce business rules
Standard Service Pattern
import { {domain}Repository } from '../repositories/{domain}.repository.js';
export const {domain}Service = {
async getAll() { /* ... */ },
async getById(id) { /* ... */ },
async create(data) { /* ... */ },
async update(id, data) { /* ... */ },
async delete(id) { /* ... */ }
};
Example: Products Service
File: /src/lib/server/services/products.service.js
import { productsRepository } from '../repositories/products.repository.js';
export const productsService = {
/** Obtiene todos los productos */
async getAllProducts() {
return await productsRepository.getAll();
},
/** Agrega un nuevo producto con sortOrder automático y JSON parsing */
async addProduct({ name, description, align, accentColor, displayType, images, features, categories }) {
// Calculate next sortOrder
const existing = await productsRepository.getAll();
const nextOrder = existing.length > 0
? existing[existing.length - 1].sortOrder + 1
: 0;
// Parse JSON fields if needed
await productsRepository.create({
name,
description,
align,
accentColor,
displayType,
images: typeof images === 'string' ? JSON.parse(images) : images,
features: typeof features === 'string' ? JSON.parse(features) : features,
categories: typeof categories === 'string' ? JSON.parse(categories) : categories,
sortOrder: nextOrder
});
},
/** Actualiza un producto existente */
async updateProduct(id, { name, description, align, accentColor, displayType, images, features, categories }) {
await productsRepository.update(id, {
name,
description,
align,
accentColor,
displayType,
images: typeof images === 'string' ? JSON.parse(images) : images,
features: typeof features === 'string' ? JSON.parse(features) : features,
categories: typeof categories === 'string' ? JSON.parse(categories) : categories
});
},
/** Elimina un producto */
async deleteProduct(id) {
await productsRepository.remove(id);
}
};
Key Points:
- Automatic
sortOrder calculation
- JSON parsing for JSONB fields
- Delegates database operations to repository
Advanced Service Example: Concursos
File: /src/lib/server/services/concursos.service.js
import { concursosRepository } from '$lib/server/repositories/concursos.repository.js';
import { uploadRepository } from '$lib/server/repositories/upload.repository.js';
export const concursosService = {
// ─── Public Data ───
/** Retorna el concurso activo + sus ganadores para la página pública */
async getPublicData() {
const concurso = await concursosRepository.getActive();
let ganadores = [];
if (concurso) {
ganadores = await concursosRepository.getGanadoresByConcurso(concurso.id);
}
return { concurso, ganadores };
},
// ─── Admin: Concursos ───
async getAllConcursos() {
return concursosRepository.getAll();
},
async getAllGanadores() {
return concursosRepository.getAllGanadores();
},
async addConcurso(formData) {
// Handle image upload
const imageFile = formData.get('image');
let imageUrl = '';
if (imageFile && imageFile.size > 0) {
const result = await uploadRepository.uploadImage(imageFile, {
folder: 'provesa/concursos'
});
imageUrl = result.secure_url;
}
// Calculate sortOrder
const existing = await concursosRepository.getAll();
const sortOrder = existing.length > 0
? Math.max(...existing.map(c => c.sortOrder)) + 1
: 0;
return concursosRepository.create({
title: formData.get('title')?.toString() || '',
titleHighlight: formData.get('titleHighlight')?.toString() || '',
description: formData.get('description')?.toString() || '',
imageUrl,
badgeText: formData.get('badgeText')?.toString() || 'Sorteo Activo',
closeDate: formData.get('closeDate')?.toString() || '',
prizeName: formData.get('prizeName')?.toString() || '',
ctaText: formData.get('ctaText')?.toString() || 'Ver Marcas Auspiciantes',
disclaimer: formData.get('disclaimer')?.toString() || '',
isActive: formData.get('isActive') === 'true',
sortOrder
});
},
async updateConcurso(id, formData) {
const data = {
title: formData.get('title')?.toString() || '',
titleHighlight: formData.get('titleHighlight')?.toString() || '',
description: formData.get('description')?.toString() || '',
badgeText: formData.get('badgeText')?.toString() || '',
closeDate: formData.get('closeDate')?.toString() || '',
prizeName: formData.get('prizeName')?.toString() || '',
ctaText: formData.get('ctaText')?.toString() || '',
disclaimer: formData.get('disclaimer')?.toString() || '',
isActive: formData.get('isActive') === 'true'
};
// Handle optional image update
const imageFile = formData.get('image');
if (imageFile && imageFile.size > 0) {
const result = await uploadRepository.uploadImage(imageFile, {
folder: 'provesa/concursos'
});
data.imageUrl = result.secure_url;
}
return concursosRepository.update(id, data);
},
async deleteConcurso(id) {
return concursosRepository.remove(id);
},
// ─── Admin: Ganadores ───
async addGanador(formData) {
const imageFile = formData.get('image');
let imageUrl = '';
if (imageFile && imageFile.size > 0) {
const result = await uploadRepository.uploadImage(imageFile, {
folder: 'provesa/concursos/ganadores'
});
imageUrl = result.secure_url;
}
const existing = await concursosRepository.getAllGanadores();
const sortOrder = existing.length > 0
? Math.max(...existing.map(g => g.sortOrder)) + 1
: 0;
return concursosRepository.createGanador({
concursoId: parseInt(formData.get('concursoId')?.toString() || '0') || null,
winnerName: formData.get('winnerName')?.toString() || '',
prize: formData.get('prize')?.toString() || '',
testimonial: formData.get('testimonial')?.toString() || '',
imageUrl,
dateLabel: formData.get('dateLabel')?.toString() || '',
sortOrder
});
},
async updateGanador(id, formData) {
const data = {
concursoId: parseInt(formData.get('concursoId')?.toString() || '0') || null,
winnerName: formData.get('winnerName')?.toString() || '',
prize: formData.get('prize')?.toString() || '',
testimonial: formData.get('testimonial')?.toString() || '',
dateLabel: formData.get('dateLabel')?.toString() || ''
};
const imageFile = formData.get('image');
if (imageFile && imageFile.size > 0) {
const result = await uploadRepository.uploadImage(imageFile, {
folder: 'provesa/concursos/ganadores'
});
data.imageUrl = result.secure_url;
}
return concursosRepository.updateGanador(id, data);
},
async deleteGanador(id) {
return concursosRepository.removeGanador(id);
}
};
Key Features:
- Orchestrates multiple repositories (
concursosRepository, uploadRepository)
- Handles file uploads to Cloudinary
- Extracts and validates FormData
- Provides public API (
getPublicData) and admin APIs
- Manages related entities (contests and winners)
Common Patterns
Pattern 1: Auto-Incrementing Sort Order
Many tables use a sortOrder field for manual ordering:
// In service
const existing = await repository.getAll();
const nextOrder = existing.length > 0
? Math.max(...existing.map(item => item.sortOrder)) + 1
: 0;
await repository.create({
...data,
sortOrder: nextOrder
});
Pattern 2: File Upload Integration
Services handle file uploads, not repositories:
// In service
const imageFile = formData.get('image');
let imageUrl = '';
if (imageFile && imageFile.size > 0) {
const result = await uploadRepository.uploadImage(imageFile, {
folder: 'provesa/domain-name'
});
imageUrl = result.secure_url;
}
await repository.create({
...data,
imageUrl
});
Pattern 3: JSON Field Parsing
JSONB fields often come as strings from FormData:
images: typeof images === 'string' ? JSON.parse(images) : images,
features: typeof features === 'string' ? JSON.parse(features) : features,
Pattern 4: Singleton Config Tables
Some tables should only have one row:
// In repository
async get() {
const rows = await db.select().from(footerInfo).limit(1);
return rows[0] || null;
},
async upsert(data) {
const existing = await this.get();
if (existing) {
return db.update(footerInfo).set(data).where(eq(footerInfo.id, existing.id));
} else {
return db.insert(footerInfo).values(data);
}
}
Pattern 5: Parallel Data Loading
Services can orchestrate multiple repositories in parallel:
// In route +page.server.js
const [slides, products, footer] = await Promise.all([
slidesService.getAllSlides(),
productsService.getAllProducts(),
footerService.getFooterData()
]);
Route Integration
Loading Data
Routes call services in the load function:
// src/routes/+page.server.js
import { slidesService } from '$lib/server/services/slides.service.js';
import { productsService } from '$lib/server/services/products.service.js';
export const load = async () => {
const [slides, productsList] = await Promise.all([
slidesService.getAllSlides(),
productsService.getAllProducts()
]);
return {
slides,
products: productsList
};
};
Routes call services in form actions:
export const actions = {
addProduct: async ({ request, locals }) => {
if (!locals.session) return fail(401);
const formData = await request.formData();
const name = formData.get('name')?.toString() || '';
if (!name) {
return fail(400, { error: 'El nombre del producto es requerido.' });
}
try {
await productsService.addProduct({
name,
description: formData.get('description')?.toString() || '',
// ... other fields
});
return { productAdded: true };
} catch (e) {
console.error('Error agregando producto:', e);
return fail(500, { error: 'No se pudo agregar el producto.' });
}
}
};
Testing Considerations
Repository Testing
Repositories can be tested against a real database:
import { describe, it, expect } from 'vitest';
import { productsRepository } from './products.repository';
describe('productsRepository', () => {
it('should fetch all products', async () => {
const products = await productsRepository.getAll();
expect(Array.isArray(products)).toBe(true);
});
});
Service Testing
Services can be tested with mocked repositories:
import { describe, it, expect, vi } from 'vitest';
import { productsService } from './products.service';
vi.mock('../repositories/products.repository', () => ({
productsRepository: {
getAll: vi.fn(() => Promise.resolve([])),
create: vi.fn()
}
}));
describe('productsService', () => {
it('should calculate sortOrder correctly', async () => {
await productsService.addProduct({ /* ... */ });
// Assertions
});
});
Best Practices
DO:
✅ Keep repositories simple - only database operations
✅ Put business logic in services
✅ Use consistent naming ({domain}.service.js, {domain}.repository.js)
✅ Return typed results from repositories
✅ Handle errors in services, propagate to routes
✅ Use Promise.all() for independent operations
✅ Document complex business logic with comments
DON’T:
❌ Put business logic in repositories
❌ Call repositories directly from routes
❌ Mix database queries with file uploads in repositories
❌ Ignore type safety from Drizzle
❌ Swallow errors silently
❌ Use raw SQL unless absolutely necessary
Next Steps