EN
petly — banner
← Volver a proyectos

petly

· 11 min de lectura

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.

Next.js TypeScript TailwindCSS Supabase Prisma ORM Mapbox

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?
SaludRegistro de medicamentos, vacunas y alertas con recordatorios automáticos por correoCron job diario vía GitHub Actions + SSH + resend, transacciones Prisma
ForoComunidad con 3 categorías, 7 subforos, temas y publicaciones gamificadasRoles USER/MODERATOR/ADMIN, cooldown anti-spam de 10s, suspensiones temporales
FindReporte de mascotas perdidas/encontradas con mapa interactivo y sistema de avistamientosMapbox GL full-screen (createPortal), geocodificación inversa, API con 9 endpoints
MarketplaceCompra-venta de artículos con categorías, favoritos y filtros por distanciaPostGIS ST_DWithin, borrado lógico con estados, geocodificación automática
TimelineLínea de tiempo cronológica con hitos y múltiples fotos por entradaIDs basados en CUID, relación M:N con Milestones
ServiciosDirectorio geolocalizado de veterinarias y tiendas con reseñas de usuariosFórmula Haversine manual, restricción admin para CRUD, mapa full-screen
MascotasHasta 10 mascotas por usuario con selección de mascota activa globalZustand + localStorage, crop cuadrado vía Canvas API, badges por especie
Insignias14+ logros desbloqueables que los usuarios pueden exhibir en el foroupsert idempotente, sistema desacoplado vía assignBadge(), consulta dual obtenidas/bloqueadas

Stack Tecnológico

CapaTecnología
FrameworkNext.js 15 (App Router, React Server Components)
LenguajeTypeScript 5.7
EstilosTailwind CSS 3.4 + shadcn/ui (CSS variables, tema neutral)
AnimacionesFramer Motion
AutenticaciónSupabase Auth (SSR, sesiones basadas en cookies + Bearer token)
Base de datosPostgreSQL + Prisma ORM + Prisma Accelerate (connection pooling)
AlmacenamientoSupabase Storage (imágenes de mascotas, perfil, timeline, marketplace, find)
MapasMapbox GL JS + Mapbox Geocoding API v6
EmailResend (alertas de salud, correos de autenticación)
Estado clienteZustand 5 (4 stores con persistencia en localStorage)
ValidaciónZod + React Hook Form + TanStack Form
DesplieguePM2 sobre Ubuntu, CI/CD con GitHub Actions + OpenVPN
Procesamiento de imágenesSharp (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 FoundReports con relación M:1 a MissingPets, 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 createPortal al document.body, evitando conflictos de hidratación con layouts anidados de Next.js
  • Hook useUserLocation que 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/find que 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_DWithin de 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 Favorite con 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 con MarketplaceItem) 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_PUBLISH otorgado 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() en auth.middleware.ts soporta 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. activePet y location persisten en localStorage para sobrevivir refrescos de página; health y userProfile son 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:

TipoCarpetaAncho máxCalidad JPEGPeso máx
petpets1024px80%5 MB
profileprofile400px90%2 MB
timeline_phototimeline1920px85%5 MB
findfind1024px80%5 MB
marketplacemarketplace1024px80%5 MB
userusers512px85%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:

  1. Instala OpenVPN y se conecta a la VPN de la UBB
  2. Se conecta por SSH al servidor de producción
  3. Ejecuta curl -X POST http://localhost:3000/api/cron/health-alerts con el CRON_SECRET como 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

ModeloPropósitoRelaciones clave
usersPerfil de usuario (RLS en Supabase)1:N con Pets, Posts, MarketplaceItems, MissingPets, FoundReports, Reviews
PetsMascotas del usuario1:N con Medications, Vaccinations, TimelineEntries, MissingPets
MissingPetsReportes de mascota perdidaN:1 con Pets, N:1 con users (reporter), 1:N con FoundReports
FoundReportsAvistamientos de mascotas perdidasN:1 con MissingPets, N:1 con users (helper)
MarketplaceItemPublicaciones de artículosN:1 con users (seller), 1:1 con Sale, 1:N con Favorite
SaleRegistro de ventas concretadas1:1 con MarketplaceItem (@unique), N:1 con users
FavoriteFavoritos de artículosN:1 con users + MarketplaceItem, @@unique([userId, itemId])
Badge / UserBadgeInsignias y su asignación@@unique([userId, badgeId])
TimelineEntries / TimelineEntryPhotosLínea de tiempo con fotosIDs CUID, 1:N con fotos, M:N con Milestones

Índices relevantes

  • @@index([alert_date, sent]) en HealthAlerts: optimiza la consulta del cron job
  • @@index([status, category, created_at]) en MarketplaceItem: optimiza los filtros del marketplace
  • @@index([itemId]) en Favorite: optimiza el conteo de favoritos por artículo
  • @@unique([userId, itemId]) en Favorite: previene duplicados a nivel de integridad

CI/CD y Despliegue

El pipeline de despliegue se ejecuta en GitHub Actions al hacer push a main:

  1. Build: Clona el repo, instala dependencias con npm install, ejecuta npm run build (que incluye prisma generate + next build).
  2. Conexión VPN: Establece túnel OpenVPN hacia la red de la Universidad del Bío-Bío usando credenciales almacenadas en GitHub Secrets.
  3. SSH al servidor: Se conecta al servidor de producción, navega a cicd/gps, actualiza el .env, hace git fetch + git reset --hard origin/main, instala dependencias con npm ci, ejecuta npm run build, y reinicia el proceso con pm2 restart next-app.
  4. 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.