petly
Aplicación web para la gestión del cuidado de mascotas. Combina historial clínico con alertas, un foro, mapas de servicios y mascotas perdidas, y un marketplace comunitario.
Resumen
petly centraliza en una sola plataforma la información que los tutores de mascotas suelen tener dispersa: historial de vacunas y medicamentos, recordatorios de dosis, localización de servicios veterinarios, reportes de mascotas perdidas, marketplace de artículos, y una comunidad con foro gamificado. El proyecto pasó de la planificación a producción en tres meses (mayo–julio 2025).
El sistema soporta múltiples mascotas por usuario, alertas automáticas por correo electrónico, geolocalización con Mapbox GL, y un sistema de insignias con 14+ logros desbloqueables.
Módulos
| Módulo | ¿Qué hace? | ¿Cómo está hecho? |
|---|---|---|
| Salud | Registro de medicamentos, vacunas y alertas con recordatorios automáticos por correo | Cron job diario vía GitHub Actions + SSH + resend, transacciones Prisma |
| Foro | Comunidad con 3 categorías, 7 subforos, temas y publicaciones gamificadas | Roles USER/MODERATOR/ADMIN, cooldown anti-spam de 10s, suspensiones temporales |
| Find | Reporte de mascotas perdidas/encontradas con mapa interactivo y sistema de avistamientos | Mapbox GL full-screen (createPortal), geocodificación inversa, API con 9 endpoints |
| Marketplace | Compra-venta de artículos con categorías, favoritos y filtros por distancia | PostGIS ST_DWithin, borrado lógico con estados, geocodificación automática |
| Timeline | Línea de tiempo cronológica con hitos y múltiples fotos por entrada | IDs basados en CUID, relación M:N con Milestones |
| Servicios | Directorio geolocalizado de veterinarias y tiendas con reseñas de usuarios | Fórmula Haversine manual, restricción admin para CRUD, mapa full-screen |
| Mascotas | Hasta 10 mascotas por usuario con selección de mascota activa global | Zustand + localStorage, crop cuadrado vía Canvas API, badges por especie |
| Insignias | 14+ logros desbloqueables que los usuarios pueden exhibir en el foro | upsert idempotente, sistema desacoplado vía assignBadge(), consulta dual obtenidas/bloqueadas |
Stack Tecnológico
| Capa | Tecnología |
|---|---|
| Framework | Next.js 15 (App Router, React Server Components) |
| Lenguaje | TypeScript 5.7 |
| Estilos | Tailwind CSS 3.4 + shadcn/ui (CSS variables, tema neutral) |
| Animaciones | Framer Motion |
| Autenticación | Supabase Auth (SSR, sesiones basadas en cookies + Bearer token) |
| Base de datos | PostgreSQL + Prisma ORM + Prisma Accelerate (connection pooling) |
| Almacenamiento | Supabase Storage (imágenes de mascotas, perfil, timeline, marketplace, find) |
| Mapas | Mapbox GL JS + Mapbox Geocoding API v6 |
| Resend (alertas de salud, correos de autenticación) | |
| Estado cliente | Zustand 5 (4 stores con persistencia en localStorage) |
| Validación | Zod + React Hook Form + TanStack Form |
| Despliegue | PM2 sobre Ubuntu, CI/CD con GitHub Actions + OpenVPN |
| Procesamiento de imágenes | Sharp (redimensionado y compresión server-side) |
Mi Contribución
Dentro del equipo de 5 integrantes, fui responsable del desarrollo completo (backend + frontend) de dos módulos: Find y Marketplace.
Find: Mascotas Perdidas y Encontradas
Módulo para reportar mascotas perdidas y encontradas sobre un mapa interactivo con Mapbox GL. Los dueños pueden registrar la desaparición de su mascota geolocalizando el punto exacto en el mapa, y otros usuarios pueden reportar avistamientos colaborando con la búsqueda.
Reportes y geolocalización:
- Creación de reportes con coordenadas obtenidas del mapa, resueltas a dirección legible (calle, ciudad, región, país) mediante la API de Geocoding de Mapbox v6 en español
- Degradación elegante ante fallos de la API externa: si el geocoding no responde, los campos de dirección quedan vacíos pero el reporte se crea igual
- Prevención de duplicados: validación en controller que impide crear un reporte activo para una mascota que ya tiene uno sin resolver
- Marcado como encontrada con resolución automática de todos los reportes activos en una sola operación
Sistema de avistamientos (found reports):
- Modelo
FoundReportscon relación M:1 aMissingPets, registrando al usuario colaborador, fotos, descripción y ubicación propia del avistamiento - El dueño puede revisar, aceptar o eliminar avistamientos falsos desde su panel
- Fotos múltiples por reporte almacenadas como array de strings en PostgreSQL referenciando URLs de Supabase Storage
Mapa interactivo:
- Mapa full-screen renderizado con
createPortalaldocument.body, evitando conflictos de hidratación con layouts anidados de Next.js - Hook
useUserLocationque provee la posición inicial del usuario con fallback a Concepción, Chile - Marcadores con foto de la mascota, descripción y fecha del reporte
API:
- 9 endpoints en
/api/findque comparten una misma ruta base diferenciándose por query params (?mode=recent|all|pets|my|others|found) - Endpoints cubren consultas de reportes recientes, todos los activos, mascotas del usuario, reportes propios, reportes de otros usuarios y avistamientos recibidos
- Operaciones CRUD completas con validación Zod en
server/validations/find.validation.ts
Marketplace: Compra y Venta de Artículos
Plataforma de compra-venta de artículos para mascotas con búsqueda geoespacial. Los usuarios publican artículos con categorías, los compradores filtran por proximidad geográfica usando PostGIS, y el sistema registra cada transacción concretada.
Publicaciones:
- 7 categorías de artículo (comida, juguetes, accesorios de paseo, salud/higiene, viaje, cama/descanso, otros) y 16 especies de mascota como filtro destino
- Condición
NEW/USED, precio con decimales a 2 posiciones, y fotos múltiples por publicación - Borrado lógico con 3 estados (
ACTIVE,SOLD,REMOVED) que permite pausar y republicar artículos sin perder el historial - Geocodificación automática que resuelve coordenadas a ciudad, región y país al crear o actualizar una publicación
Filtros y búsqueda espacial:
- Filtrado por radio de distancia usando
ST_DWithinde PostGIS con SQL crudo ($queryRaw), consultando coordenadas en SRID 4326 - Filtros combinables por categoría, especie, rango de precio, ordenamiento y paginación
- Endpoint de ciudades y categorías de mascota en uso para poblar los filtros del frontend sin datos hardcodeados
Sistema de favoritos:
- Modelo
Favoritecon restricción@@unique([userId, itemId])a nivel de base de datos que previene duplicados incluso ante race conditions - Eliminación en cascada al remover un artículo, manteniendo integridad referencial
Flujo de venta:
- Al marcar como vendido se crea una entidad
Sale(1:1 conMarketplaceItem) registrando precio final, comprador, fecha y notas - La operación completa (cambio de estado + creación de Sale + asignación del badge
MARKETPLACE_SALE) se ejecuta dentro de una transacción Prisma ($transaction) - Las ventas quedan registradas para auditoría y estadísticas
Frontend:
- Organizado en 4 pestañas: explorar publicaciones con filtros, favoritos, formulario de publicación y gestión de mis artículos (editar, vender, republicar, eliminar)
- En móvil los filtros se muestran en un drawer lateral (
Sheet) para no saturar la pantalla - Badge
MARKETPLACE_PUBLISHotorgado automáticamente en la primera publicación del usuario
Arquitectura
Server-side (capas)
app/api/* → server/controllers/* → server/services/* → Prisma (lib/db.ts)
↑ ↑
server/middlewares/ server/validations/*
(auth, roles) (Zod schemas)
- Controllers: Manejan la petición HTTP, delegan en servicios y retornan
NextResponse. Cada dominio tiene su controller dedicado. - Services: Contienen la lógica de negocio pura, sin dependencias de HTTP. Reciben DTOs validados y operan sobre Prisma.
- Validations: Schemas Zod para cada operación. Validan body, query params y path params.
- Middleware de auth:
authenticateUser()enauth.middleware.tssoporta ambos modos de autenticación (cookie de Supabase SSR y Bearer token para API calls externas). Retorna el usuario con su rol de Prisma (USER|MODERATOR|ADMIN).
Client-side
Pages (Server Components) → Client Components → Hooks → Stores (Zustand) → API Routes
- Server Components para data fetching inicial (layout protegido obtiene el perfil del usuario y lo pasa como prop).
- Client Components para interactividad (mapas, formularios, drawers, tabs).
- Hooks encapsulan lógica reutilizable (uploads de imágenes con crop, formularios, geolocalización).
- Zustand con 4 stores.
activePetylocationpersisten en localStorage para sobrevivir refrescos de página;healthyuserProfileson en memoria.
Autenticación
Supabase SSR con middleware de Next.js que refresca sesiones automáticamente. El callback /auth/callback intercambia el código de verificación por una sesión y asigna el badge WELCOME. Las rutas protegidas redirigen a /sign-in si no hay sesión; los usuarios autenticados en / son redirigidos a /home.
Destacados Técnicos
Pipeline de Upload de Imágenes
El endpoint /api/upload procesa imágenes con sharp antes de subirlas a Supabase Storage. Soporta 6 tipos de upload con configuraciones independientes:
| Tipo | Carpeta | Ancho máx | Calidad JPEG | Peso máx |
|---|---|---|---|---|
pet | pets | 1024px | 80% | 5 MB |
profile | profile | 400px | 90% | 2 MB |
timeline_photo | timeline | 1920px | 85% | 5 MB |
find | find | 1024px | 80% | 5 MB |
marketplace | marketplace | 1024px | 80% | 5 MB |
user | users | 512px | 85% | 3 MB |
El procesamiento redimensiona sin agrandar (solo reduce si excede el máximo), convierte a JPEG con la calidad configurada, valida tipo MIME y tamaño, y retorna la URL pública de Supabase Storage. El endpoint de DELETE extrae el path relativo de la URL pública para eliminar el archivo del bucket.
Del lado del cliente, usePetImageUpload realiza un recorte cuadrado vía Canvas API antes de enviar la imagen, asegurando que las fotos de mascota siempre sean cuadradas sin depender del servidor.
Cron Job de Alertas de Salud
Las alertas de medicamentos y vacunas se despachan mediante un cron job que no depende de Vercel Cron ni de un scheduler en el servidor. En su lugar, GitHub Actions ejecuta un workflow a diario a las 9:00 UTC que:
- Instala OpenVPN y se conecta a la VPN de la UBB
- Se conecta por SSH al servidor de producción
- Ejecuta
curl -X POST http://localhost:3000/api/cron/health-alertscon elCRON_SECRETcomo Bearer token
Esta arquitectura evita exponer el endpoint de cron a internet y no requiere servicios externos de scheduling. El endpoint procesa todas las alertas pendientes del día, envía correos HTML personalizados con Resend, marca las alertas como enviadas, y retorna métricas de éxito/error.
Sistema de Insignias
14+ insignias que se desbloquean automáticamente como efecto secundario de acciones en distintos módulos. Cada servicio que otorga insignias llama a assignBadge(userId, badgeKey) que usa upsert para ser idempotente. El sistema está desacoplado: los servicios de dominio no conocen la lógica de badges, solo invocan una función utilitaria.
Las insignias se consultan en dos modalidades: obtenidas (join con UserBadge) y bloqueadas (badges que el usuario aún no tiene). El store userProfile.ts expone un selector useSelectedBadges() para obtener las insignias que el usuario eligió mostrar en el foro.
Middleware de Autenticación Dual
El middleware de Supabase (utils/supabase/middleware.ts) soporta dos modos de autenticación simultáneamente: cookies (para navegador) y Bearer token (para API calls desde el cron job o clientes externos). Detecta el modo según la presencia del header Authorization y construye el cliente de Supabase con la estrategia correspondiente.
Base de Datos
Motor: PostgreSQL con Prisma ORM y la extensión Accelerate para connection pooling y caché en edge.
Modelos principales
| Modelo | Propósito | Relaciones clave |
|---|---|---|
users | Perfil de usuario (RLS en Supabase) | 1:N con Pets, Posts, MarketplaceItems, MissingPets, FoundReports, Reviews |
Pets | Mascotas del usuario | 1:N con Medications, Vaccinations, TimelineEntries, MissingPets |
MissingPets | Reportes de mascota perdida | N:1 con Pets, N:1 con users (reporter), 1:N con FoundReports |
FoundReports | Avistamientos de mascotas perdidas | N:1 con MissingPets, N:1 con users (helper) |
MarketplaceItem | Publicaciones de artículos | N:1 con users (seller), 1:1 con Sale, 1:N con Favorite |
Sale | Registro de ventas concretadas | 1:1 con MarketplaceItem (@unique), N:1 con users |
Favorite | Favoritos de artículos | N:1 con users + MarketplaceItem, @@unique([userId, itemId]) |
Badge / UserBadge | Insignias y su asignación | @@unique([userId, badgeId]) |
TimelineEntries / TimelineEntryPhotos | Línea de tiempo con fotos | IDs CUID, 1:N con fotos, M:N con Milestones |
Índices relevantes
@@index([alert_date, sent])enHealthAlerts: optimiza la consulta del cron job@@index([status, category, created_at])enMarketplaceItem: optimiza los filtros del marketplace@@index([itemId])enFavorite: optimiza el conteo de favoritos por artículo@@unique([userId, itemId])enFavorite: previene duplicados a nivel de integridad
CI/CD y Despliegue
El pipeline de despliegue se ejecuta en GitHub Actions al hacer push a main:
- Build: Clona el repo, instala dependencias con
npm install, ejecutanpm run build(que incluyeprisma generate+next build). - Conexión VPN: Establece túnel OpenVPN hacia la red de la Universidad del Bío-Bío usando credenciales almacenadas en GitHub Secrets.
- SSH al servidor: Se conecta al servidor de producción, navega a
cicd/gps, actualiza el.env, hacegit fetch+git reset --hard origin/main, instala dependencias connpm ci, ejecutanpm run build, y reinicia el proceso conpm2 restart next-app. - PM2 gestiona el proceso de Next.js en producción con 1 instancia, auto-restart habilitado y límite de memoria de 1 GB.
Las migraciones de base de datos no se ejecutan en CI; se aplican manualmente con npm run deploy (prisma migrate deploy) o localmente durante el desarrollo.
Aprendizajes
Geolocalización y mapas: Integrar Mapbox GL en una app Next.js con Server Components presentó desafíos de hidratación. Lo resolví renderizando el mapa con createPortal fuera del árbol de layouts y compartiendo la posición del usuario mediante stores de Zustand, sin prop drilling.
Consultas espaciales con PostGIS: La búsqueda por radio de distancia requirió escribir SQL crudo dentro de Prisma ($queryRaw) para usar ST_DWithin, ya que Prisma no tiene soporte nativo para tipos geoespaciales. El equilibrio entre precisión (Haversine) y rendimiento (índices espaciales de PostGIS) fue deliberado.
Transacciones y badges: Las operaciones que crean un recurso y otorgan un badge debían ser atómicas para evitar inconsistencias. El $transaction de Prisma con callback-style garantiza que si la asignación del badge falla, el recurso tampoco se crea, y viceversa.
Geolocalización inversa como dependencia externa: La API de Mapbox Geocoding es un punto de fallo externo. Si la API falla, los campos de dirección quedan null y el resto del flujo continúa, así que una caída de Mapbox nunca bloquea la creación de reportes o publicaciones.
Trabajo en equipo con arquitectura por módulos: La separación en controllers, services y validations por dominio permitió que 5 desarrolladores trabajaran en paralelo sin conflictos de merge. Cada módulo es autocontenido: tiene sus tipos (types/), sus hooks (hooks/), sus componentes (components/<modulo>/) y sus endpoints (app/api/<modulo>/).
Referencias
- Next.js: Framework React con App Router, Server Components y SSR.
- Supabase: Autenticación SSR, base de datos PostgreSQL y almacenamiento de archivos.
- Prisma: ORM tipado con Accelerate para connection pooling.
- Tailwind CSS: Framework de utilidades CSS con variables y tema oscuro.
- shadcn/ui: Componentes React basados en Radix UI y Tailwind.
- Mapbox GL JS: Mapas interactivos y geocodificación inversa.
- Zustand: Estado global con persistencia en localStorage.
- Resend: Envío de correos transaccionales y alertas.
- PM2: Administrador de procesos para Node.js en producción.
- Sharp: Procesamiento y optimización de imágenes en el servidor.
- Zod: Validación de esquemas con inferencia de tipos TypeScript.
- Framer Motion: Animaciones declarativas para React.
- GitHub Actions: CI/CD con despliegue SSH y cron jobs.
Proyecto desarrollado en conjunto con Rocío Rivas, Álvaro Loyola, Anaís Saldías y Nicolás Ibieta.