petly
Web application for pet care management. Combines clinical history with alerts, a forum, service and lost-pet maps, and a community marketplace.
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
| Module | What it does | How it’s built |
|---|---|---|
| Health | Medication, vaccine, and alert tracking with automatic email reminders | Daily cron job via GitHub Actions + SSH + Resend, Prisma transactions |
| Forum | Community with 3 categories, 7 subforums, topics, and gamified posts | USER/MODERATOR/ADMIN roles, 10s anti-spam cooldown, temporary suspensions |
| Find | Lost/found pet reports with interactive map and sighting system | Full-screen Mapbox GL (createPortal), reverse geocoding, API with 9 endpoints |
| Marketplace | Buy & sell articles with categories, favorites, and distance filters | PostGIS ST_DWithin, soft delete with states, automatic geocoding |
| Timeline | Chronological timeline with milestones and multiple photos per entry | CUID-based IDs, M:N relationship with Milestones |
| Services | Geolocated directory of vets and stores with user reviews | Manual Haversine formula, admin-only CRUD, full-screen map |
| Pets | Up to 10 pets per user with global active pet selection | Zustand + localStorage, square crop via Canvas API, species badges |
| Badges | 14+ unlockable achievements that users can display on the forum | Idempotent upsert, decoupled via assignBadge(), dual earned/locked query |
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router, React Server Components) |
| Language | TypeScript 5.7 |
| Styles | Tailwind CSS 3.4 + shadcn/ui (CSS variables, neutral theme) |
| Animations | Framer Motion |
| Authentication | Supabase Auth (SSR, cookie-based sessions + Bearer token) |
| Database | PostgreSQL + Prisma ORM + Prisma Accelerate (connection pooling) |
| Storage | Supabase Storage (pet images, profile, timeline, marketplace, find) |
| Maps | Mapbox GL JS + Mapbox Geocoding API v6 |
| Resend (health alerts, authentication emails) | |
| Client state | Zustand 5 (4 stores with localStorage persistence) |
| Validation | Zod + React Hook Form + TanStack Form |
| Deployment | PM2 on Ubuntu, CI/CD with GitHub Actions + OpenVPN |
| Image processing | Sharp (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):
FoundReportsmodel with M:1 relationship toMissingPets, 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
createPortaltodocument.body, avoiding hydration conflicts with Next.js nested layouts useUserLocationhook 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/findsharing 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/USEDcondition, 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_DWithinwith 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:
Favoritemodel 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
Saleentity is created (1:1 withMarketplaceItem) recording final price, buyer, date, and notes - The complete operation —state change + Sale creation +
MARKETPLACE_SALEbadge 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_PUBLISHbadge 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()inauth.middleware.tssupports 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.
activePetandlocationpersist in localStorage to survive page refreshes;healthanduserProfileare 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:
| Type | Folder | Max width | JPEG quality | Max size |
|---|---|---|---|---|
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 |
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:
- Installs OpenVPN and connects to the UBB VPN
- Connects via SSH to the production server
- Runs
curl -X POST http://localhost:3000/api/cron/health-alertswith theCRON_SECRETas 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
| Model | Purpose | Key Relationships |
|---|---|---|
users | User profile (RLS on Supabase) | 1:N with Pets, Posts, MarketplaceItems, MissingPets, FoundReports, Reviews |
Pets | User’s pets | 1:N with Medications, Vaccinations, TimelineEntries, MissingPets |
MissingPets | Lost pet reports | N:1 with Pets, N:1 with users (reporter), 1:N with FoundReports |
FoundReports | Lost pet sightings | N:1 with MissingPets, N:1 with users (helper) |
MarketplaceItem | Article listings | N:1 with users (seller), 1:1 with Sale, 1:N with Favorite |
Sale | Completed sale records | 1:1 with MarketplaceItem (@unique), N:1 with users |
Favorite | Article favorites | N:1 with users + MarketplaceItem, @@unique([userId, itemId]) |
Badge / UserBadge | Badges and their assignment | @@unique([userId, badgeId]) |
TimelineEntries / TimelineEntryPhotos | Timeline with photos | CUID IDs, 1:N with photos, M:N with Milestones |
Relevant Indexes
@@index([alert_date, sent])onHealthAlerts— optimizes the cron job query@@index([status, category, created_at])onMarketplaceItem— optimizes marketplace filters@@index([itemId])onFavorite— optimizes favorites count per article@@unique([userId, itemId])onFavorite— prevents duplicates at the integrity level
CI/CD and Deployment
The deployment pipeline runs on GitHub Actions on push to main:
- Build: Clones the repo, installs dependencies with
npm install, runsnpm run build(which includesprisma generate+next build). - VPN connection: Establishes an OpenVPN tunnel to the Universidad del Bío-Bío network using credentials stored in GitHub Secrets.
- SSH to server: Connects to the production server, navigates to
cicd/gps, updates.env, runsgit fetch+git reset --hard origin/main, installs dependencies withnpm ci, runsnpm run build, and restarts the process withpm2 restart next-app. - 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.