ES
petly — banner
← Back to projects

petly

· 10 min read

Web application for pet care management. Combines clinical history with alerts, a forum, service and lost-pet maps, and a community marketplace.

Next.js TypeScript TailwindCSS Supabase Prisma ORM Mapbox

Summary

petly centralizes into a single platform the information that pet owners typically have scattered: vaccine and medication history, dose reminders, veterinary service locations, lost pet reports, an article marketplace, and a community with a gamified forum. It went from planning to production in three months (May–July 2025).

The system supports multiple pets per user, automatic email alerts, geolocation with Mapbox GL, and a badge system with 14+ unlockable achievements.


Modules

ModuleWhat it doesHow it’s built
HealthMedication, vaccine, and alert tracking with automatic email remindersDaily cron job via GitHub Actions + SSH + Resend, Prisma transactions
ForumCommunity with 3 categories, 7 subforums, topics, and gamified postsUSER/MODERATOR/ADMIN roles, 10s anti-spam cooldown, temporary suspensions
FindLost/found pet reports with interactive map and sighting systemFull-screen Mapbox GL (createPortal), reverse geocoding, API with 9 endpoints
MarketplaceBuy & sell articles with categories, favorites, and distance filtersPostGIS ST_DWithin, soft delete with states, automatic geocoding
TimelineChronological timeline with milestones and multiple photos per entryCUID-based IDs, M:N relationship with Milestones
ServicesGeolocated directory of vets and stores with user reviewsManual Haversine formula, admin-only CRUD, full-screen map
PetsUp to 10 pets per user with global active pet selectionZustand + localStorage, square crop via Canvas API, species badges
Badges14+ unlockable achievements that users can display on the forumIdempotent upsert, decoupled via assignBadge(), dual earned/locked query

Tech Stack

LayerTechnology
FrameworkNext.js 15 (App Router, React Server Components)
LanguageTypeScript 5.7
StylesTailwind CSS 3.4 + shadcn/ui (CSS variables, neutral theme)
AnimationsFramer Motion
AuthenticationSupabase Auth (SSR, cookie-based sessions + Bearer token)
DatabasePostgreSQL + Prisma ORM + Prisma Accelerate (connection pooling)
StorageSupabase Storage (pet images, profile, timeline, marketplace, find)
MapsMapbox GL JS + Mapbox Geocoding API v6
EmailResend (health alerts, authentication emails)
Client stateZustand 5 (4 stores with localStorage persistence)
ValidationZod + React Hook Form + TanStack Form
DeploymentPM2 on Ubuntu, CI/CD with GitHub Actions + OpenVPN
Image processingSharp (server-side resize and compression)

My Contribution

Within the 5-person team, I was responsible for the complete development (backend + frontend) of two modules: Find and Marketplace.

Find — Lost and Found Pets

Module for reporting lost and found pets on an interactive map with Mapbox GL. Owners can register their pet’s disappearance by geolocating the exact point on the map, and other users can report sightings to collaborate with the search.

Reports and geolocation:

  • Creation of reports with coordinates obtained from the map, resolved to a readable address (street, city, region, country) via the Mapbox Geocoding API v6 in the corresponding language
  • Graceful degradation on external API failure: if geocoding doesn’t respond, address fields are left empty but the report is still created
  • Duplicate prevention: controller validation that prevents creating an active report for a pet that already has an unresolved one
  • Mark as found with automatic resolution of all active reports in a single operation

Sighting system (found reports):

  • FoundReports model with M:1 relationship to MissingPets, recording the helper user, photos, description, and the sighting’s own location
  • The owner can review, accept, or delete false sightings from their dashboard
  • Multiple photos per report stored as a string array in PostgreSQL referencing Supabase Storage URLs

Interactive map:

  • Full-screen map rendered with createPortal to document.body, avoiding hydration conflicts with Next.js nested layouts
  • useUserLocation hook that provides the user’s initial position with fallback to Concepción, Chile
  • Markers with pet photo, description, and report date

API:

  • 9 endpoints under /api/find sharing the same base route differentiated by query params (?mode=recent|all|pets|my|others|found)
  • Endpoints cover queries for recent reports, all active, user’s pets, own reports, others’ reports, and received sightings
  • Full CRUD operations with Zod validation in server/validations/find.validation.ts

Marketplace — Article Buy & Sell

Buy-and-sell platform for pet articles with geospatial search. Users publish articles with categories, buyers filter by geographic proximity using PostGIS, and the system records every completed transaction.

Listings:

  • 7 article categories (food, toys, walking accessories, health/hygiene, travel, bed/rest, other) and 16 pet species as destination filter
  • NEW/USED condition, price with 2 decimal places, and multiple photos per listing
  • Soft delete with 3 states (ACTIVE, SOLD, REMOVED) allowing pause and republish of articles without losing history
  • Automatic geocoding that resolves coordinates to city, region, and country when creating or updating a listing

Filters and spatial search:

  • Distance radius filtering using PostGIS ST_DWithin with raw SQL ($queryRaw), querying coordinates in SRID 4326
  • Combinable filters by category, species, price range, sorting, and pagination
  • Endpoint for in-use cities and pet categories to populate frontend filters without hardcoded data

Favorites system:

  • Favorite model with @@unique([userId, itemId]) constraint at the database level preventing duplicates even under race conditions
  • Cascading deletion when removing an article, maintaining referential integrity

Sale flow:

  • When marking as sold, a Sale entity is created (1:1 with MarketplaceItem) recording final price, buyer, date, and notes
  • The complete operation —state change + Sale creation + MARKETPLACE_SALE badge assignment— runs within a Prisma transaction ($transaction)
  • Sales are recorded for auditing and statistics

Frontend:

  • Organized in 4 tabs: browse listings with filters, favorites, listing form, and manage my articles (edit, sell, republish, delete)
  • On mobile, filters are displayed in a side drawer (Sheet) to avoid cluttering the screen
  • MARKETPLACE_PUBLISH badge automatically awarded on the user’s first listing

Architecture

Server-side (layers)

app/api/*  →  server/controllers/*  →  server/services/*  →  Prisma (lib/db.ts)
                   ↑                          ↑
           server/middlewares/         server/validations/*
           (auth, roles)               (Zod schemas)
  • Controllers: Handle HTTP requests, delegate to services, and return NextResponse. Each domain has its own dedicated controller.
  • Services: Contain pure business logic, with no HTTP dependencies. They receive validated DTOs and operate on Prisma.
  • Validations: Zod schemas for each operation. Validate body, query params, and path params.
  • Auth middleware: authenticateUser() in auth.middleware.ts supports both authentication modes (Supabase SSR cookie and Bearer token for external API calls). Returns the user with their Prisma role (USER | MODERATOR | ADMIN).

Client-side

Pages (Server Components)  →  Client Components  →  Hooks  →  Stores (Zustand)  →  API Routes
  • Server Components for initial data fetching (protected layout fetches the user profile and passes it as a prop).
  • Client Components for interactivity (maps, forms, drawers, tabs).
  • Hooks encapsulate reusable logic (image uploads with crop, forms, geolocation).
  • Zustand with 4 stores. activePet and location persist in localStorage to survive page refreshes; health and userProfile are in-memory.

Authentication

Supabase SSR with Next.js middleware that automatically refreshes sessions. The /auth/callback exchanges the verification code for a session and assigns the WELCOME badge. Protected routes redirect to /sign-in if there’s no session; authenticated users at / are redirected to /home.


Technical Highlights

Image Upload Pipeline

The /api/upload endpoint processes images with sharp before uploading them to Supabase Storage. It supports 6 upload types with independent configurations:

TypeFolderMax widthJPEG qualityMax size
petpets1024px80%5 MB
profileprofile400px90%2 MB
timeline_phototimeline1920px85%5 MB
findfind1024px80%5 MB
marketplacemarketplace1024px80%5 MB
userusers512px85%3 MB

Processing resizes without upscaling (only reduces if exceeding the maximum), converts to JPEG with the configured quality, validates MIME type and size, and returns the public Supabase Storage URL. The DELETE endpoint extracts the relative path from the public URL to delete the file from the bucket.

On the client side, usePetImageUpload performs a square crop via Canvas API before sending the image, ensuring pet photos are always square without depending on the server.

Health Alert Cron Job

Medication and vaccine alerts are dispatched via a cron job that doesn’t depend on Vercel Cron or a server-side scheduler. Instead, GitHub Actions runs a workflow daily at 9:00 UTC that:

  1. Installs OpenVPN and connects to the UBB VPN
  2. Connects via SSH to the production server
  3. Runs curl -X POST http://localhost:3000/api/cron/health-alerts with the CRON_SECRET as Bearer token

This architecture avoids exposing the cron endpoint to the internet and requires no external scheduling services. The endpoint processes all pending alerts for the day, sends personalized HTML emails with Resend, marks alerts as sent, and returns success/error metrics.

Badge System

14+ badges that are automatically unlocked as a side effect of actions across different modules. Each badge-awarding service calls assignBadge(userId, badgeKey), which uses upsert to be idempotent. The system is decoupled: domain services don’t know the badge logic, they only invoke a utility function.

Badges are queried in two modes: earned (join with UserBadge) and locked (badges the user doesn’t have yet). The userProfile.ts store exposes a useSelectedBadges() selector to get the badges the user chose to display on the forum.

Dual Authentication Middleware

The Supabase middleware (utils/supabase/middleware.ts) supports two authentication modes simultaneously: cookies (for browser) and Bearer token (for API calls from the cron job or external clients). It detects the mode based on the presence of the Authorization header and builds the Supabase client with the corresponding strategy.


Database

Engine: PostgreSQL with Prisma ORM and the Accelerate extension for connection pooling and edge caching.

Main Models

ModelPurposeKey Relationships
usersUser profile (RLS on Supabase)1:N with Pets, Posts, MarketplaceItems, MissingPets, FoundReports, Reviews
PetsUser’s pets1:N with Medications, Vaccinations, TimelineEntries, MissingPets
MissingPetsLost pet reportsN:1 with Pets, N:1 with users (reporter), 1:N with FoundReports
FoundReportsLost pet sightingsN:1 with MissingPets, N:1 with users (helper)
MarketplaceItemArticle listingsN:1 with users (seller), 1:1 with Sale, 1:N with Favorite
SaleCompleted sale records1:1 with MarketplaceItem (@unique), N:1 with users
FavoriteArticle favoritesN:1 with users + MarketplaceItem, @@unique([userId, itemId])
Badge / UserBadgeBadges and their assignment@@unique([userId, badgeId])
TimelineEntries / TimelineEntryPhotosTimeline with photosCUID IDs, 1:N with photos, M:N with Milestones

Relevant Indexes

  • @@index([alert_date, sent]) on HealthAlerts — optimizes the cron job query
  • @@index([status, category, created_at]) on MarketplaceItem — optimizes marketplace filters
  • @@index([itemId]) on Favorite — optimizes favorites count per article
  • @@unique([userId, itemId]) on Favorite — prevents duplicates at the integrity level

CI/CD and Deployment

The deployment pipeline runs on GitHub Actions on push to main:

  1. Build: Clones the repo, installs dependencies with npm install, runs npm run build (which includes prisma generate + next build).
  2. VPN connection: Establishes an OpenVPN tunnel to the Universidad del Bío-Bío network using credentials stored in GitHub Secrets.
  3. SSH to server: Connects to the production server, navigates to cicd/gps, updates .env, runs git fetch + git reset --hard origin/main, installs dependencies with npm ci, runs npm run build, and restarts the process with pm2 restart next-app.
  4. PM2 manages the Next.js process in production with 1 instance, auto-restart enabled, and a 1 GB memory limit.

Database migrations are not run in CI; they are applied manually with npm run deploy (prisma migrate deploy) or locally during development.


Learnings

Geolocation and maps: Integrating Mapbox GL into a Next.js app with Server Components presented hydration challenges. I solved it by rendering the map with createPortal outside the layout tree and sharing the user’s position through Zustand stores, without prop drilling.

Spatial queries with PostGIS: Distance radius search required writing raw SQL within Prisma ($queryRaw) to use ST_DWithin, since Prisma has no native support for geospatial types. The trade-off between accuracy (Haversine) and performance (PostGIS spatial indexes) was deliberate.

Transactions and badges: Operations that create a resource and award a badge had to be atomic to avoid inconsistencies. Prisma’s callback-style $transaction ensures that if the badge award fails, the resource isn’t created either, and vice versa.

Reverse geocoding as an external dependency: The Mapbox Geocoding API is an external point of failure. If the API fails, address fields are left null and the rest of the flow continues, so a Mapbox outage never blocks report or listing creation.

Teamwork with module-based architecture: The separation into controllers, services, and validations by domain allowed 5 developers to work in parallel without merge conflicts. Each module is self-contained: it has its types (types/), hooks (hooks/), components (components/<module>/), and endpoints (app/api/<module>/).


References

  • Next.js — React framework with App Router, Server Components, and SSR.
  • Supabase — SSR authentication, PostgreSQL database, and file storage.
  • Prisma — Type-safe ORM with Accelerate for connection pooling.
  • Tailwind CSS — Utility-first CSS framework with variables and dark theme.
  • shadcn/ui — React components based on Radix UI and Tailwind.
  • Mapbox GL JS — Interactive maps and reverse geocoding.
  • Zustand — Global state with localStorage persistence.
  • Resend — Transactional email sending and alerts.
  • PM2 — Process manager for Node.js in production.
  • Sharp — Server-side image processing and optimization.
  • Zod — Schema validation with TypeScript type inference.
  • Framer Motion — Declarative animations for React.
  • GitHub Actions — CI/CD with SSH deployment and cron jobs.

Project developed together with Rocío Rivas, Álvaro Loyola, Anaís Saldías, and Nicolás Ibieta.