fix: add pagination to unbounded GET endpoints

All list endpoints now accept ?page and ?limit (default 50, max 200) and
return { data, total, page, limit } instead of a bare array, preventing
memory and performance failures at scale.

- GET /api/dogs: adds pagination, server-side search (?search) and sex
  filter (?sex), and a stats aggregate (total/males/females) for the
  Dashboard to avoid counting from the array
- GET /api/litters: adds pagination; also fixes N+1 query by fetching
  all puppies for the current page in a single query instead of one per
  litter
- DogList: moves search/sex filtering server-side with 300ms debounce;
  adds Prev/Next pagination controls
- LitterList: uses paginated response; adds Prev/Next pagination controls
- Dashboard: reads counts from stats/total fields instead of array length
- LitterDetail, LitterForm: switch dogs fetch to /api/dogs/all (complete
  list, no pagination, for sire/dam dropdowns)
- DogForm: updates litters fetch to use paginated response shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jason
2026-03-16 16:40:28 -05:00
parent fa7a336588
commit b8633863b0
8 changed files with 197 additions and 68 deletions

View File

@@ -64,8 +64,8 @@ function DogForm({ dog, onClose, onSave, isExternal = false }) {
const fetchLitters = async () => { const fetchLitters = async () => {
try { try {
const res = await axios.get('/api/litters') const res = await axios.get('/api/litters', { params: { limit: 200 } })
const data = res.data || [] const data = res.data.data || []
setLitters(data) setLitters(data)
setLittersAvailable(data.length > 0) setLittersAvailable(data.length > 0)
if (data.length === 0) setUseManualParents(true) if (data.length === 0) setUseManualParents(true)

View File

@@ -39,7 +39,7 @@ function LitterForm({ litter, prefill, onClose, onSave }) {
const fetchDogs = async () => { const fetchDogs = async () => {
try { try {
const res = await axios.get('/api/dogs') const res = await axios.get('/api/dogs/all')
setDogs(res.data) setDogs(res.data)
} catch (error) { } catch (error) {
console.error('Error fetching dogs:', error) console.error('Error fetching dogs:', error)

View File

@@ -21,21 +21,21 @@ function Dashboard() {
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
try { try {
const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([ const [dogsRes, littersRes, heatCyclesRes] = await Promise.all([
axios.get('/api/dogs'), axios.get('/api/dogs', { params: { page: 1, limit: 8 } }),
axios.get('/api/litters'), axios.get('/api/litters', { params: { page: 1, limit: 1 } }),
axios.get('/api/breeding/heat-cycles/active') axios.get('/api/breeding/heat-cycles/active')
]) ])
const dogs = dogsRes.data const { data: recentDogsList, stats: dogStats } = dogsRes.data
setStats({ setStats({
totalDogs: dogs.length, totalDogs: dogStats?.total ?? 0,
males: dogs.filter(d => d.sex === 'male').length, males: dogStats?.males ?? 0,
females: dogs.filter(d => d.sex === 'female').length, females: dogStats?.females ?? 0,
totalLitters: littersRes.data.length, totalLitters: littersRes.data.total,
activeHeatCycles: heatCyclesRes.data.length activeHeatCycles: heatCyclesRes.data.length
}) })
setRecentDogs(dogs.slice(0, 8)) setRecentDogs(recentDogsList)
setLoading(false) setLoading(false)
} catch (error) { } catch (error) {
console.error('Error fetching dashboard data:', error) console.error('Error fetching dashboard data:', error)

View File

@@ -1,57 +1,69 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react' import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2 } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
import DogForm from '../components/DogForm' import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge' import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
const LIMIT = 50
function DogList() { function DogList() {
const [dogs, setDogs] = useState([]) const [dogs, setDogs] = useState([])
const [filteredDogs, setFilteredDogs] = useState([]) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [sexFilter, setSexFilter] = useState('all') const [sexFilter, setSexFilter] = useState('all')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name } const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const searchTimerRef = useRef(null)
useEffect(() => { fetchDogs() }, []) useEffect(() => { fetchDogs(1, '', 'all') }, []) // eslint-disable-line
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
const fetchDogs = async () => { const fetchDogs = async (p, q, s) => {
setLoading(true)
try { try {
const res = await axios.get('/api/dogs') const params = { page: p, limit: LIMIT }
setDogs(res.data) if (q) params.search = q
setLoading(false) if (s !== 'all') params.sex = s
const res = await axios.get('/api/dogs', { params })
setDogs(res.data.data)
setTotal(res.data.total)
setPage(p)
} catch (error) { } catch (error) {
console.error('Error fetching dogs:', error) console.error('Error fetching dogs:', error)
} finally {
setLoading(false) setLoading(false)
} }
} }
const filterDogs = () => { const handleSearchChange = (value) => {
let filtered = dogs setSearch(value)
if (search) { if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
filtered = filtered.filter(dog => searchTimerRef.current = setTimeout(() => fetchDogs(1, value, sexFilter), 300)
dog.name.toLowerCase().includes(search.toLowerCase()) ||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
)
}
if (sexFilter !== 'all') {
filtered = filtered.filter(dog => dog.sex === sexFilter)
}
setFilteredDogs(filtered)
} }
const handleSave = () => { fetchDogs() } const handleSexChange = (value) => {
setSexFilter(value)
fetchDogs(1, search, value)
}
const handleClearFilters = () => {
setSearch('')
setSexFilter('all')
fetchDogs(1, '', 'all')
}
const handleSave = () => { fetchDogs(page, search, sexFilter) }
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return if (!deleteTarget) return
setDeleting(true) setDeleting(true)
try { try {
await axios.delete(`/api/dogs/${deleteTarget.id}`) await axios.delete(`/api/dogs/${deleteTarget.id}`)
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
setDeleteTarget(null) setDeleteTarget(null)
fetchDogs(page, search, sexFilter)
} catch (err) { } catch (err) {
console.error('Delete failed:', err) console.error('Delete failed:', err)
alert('Failed to delete dog. Please try again.') alert('Failed to delete dog. Please try again.')
@@ -60,6 +72,8 @@ function DogList() {
} }
} }
const totalPages = Math.ceil(total / LIMIT)
const calculateAge = (birthDate) => { const calculateAge = (birthDate) => {
if (!birthDate) return null if (!birthDate) return null
const today = new Date() const today = new Date()
@@ -85,7 +99,7 @@ function DogList() {
<div> <div>
<h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1> <h1 style={{ marginBottom: '0.25rem' }}>Dogs</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}> <p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'} {total} {total === 1 ? 'dog' : 'dogs'}
{search || sexFilter !== 'all' ? ' matching filters' : ' total'} {search || sexFilter !== 'all' ? ' matching filters' : ' total'}
</p> </p>
</div> </div>
@@ -105,11 +119,11 @@ function DogList() {
className="input" className="input"
placeholder="Search by name or registration..." placeholder="Search by name or registration..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
style={{ paddingLeft: '2.75rem' }} style={{ paddingLeft: '2.75rem' }}
/> />
</div> </div>
<select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: '140px' }}> <select className="input" value={sexFilter} onChange={(e) => handleSexChange(e.target.value)} style={{ width: '140px' }}>
<option value="all">All Dogs</option> <option value="all">All Dogs</option>
<option value="male">Males </option> <option value="male">Males </option>
<option value="female">Females </option> <option value="female">Females </option>
@@ -117,7 +131,7 @@ function DogList() {
{(search || sexFilter !== 'all') && ( {(search || sexFilter !== 'all') && (
<button <button
className="btn btn-ghost" className="btn btn-ghost"
onClick={() => { setSearch(''); setSexFilter('all') }} onClick={handleClearFilters}
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }} style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
> >
Clear Clear
@@ -127,7 +141,7 @@ function DogList() {
</div> </div>
{/* Dogs List */} {/* Dogs List */}
{filteredDogs.length === 0 ? ( {dogs.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}> <div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
<Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} /> <Dog size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
<h3 style={{ marginBottom: '0.5rem' }}> <h3 style={{ marginBottom: '0.5rem' }}>
@@ -147,7 +161,7 @@ function DogList() {
</div> </div>
) : ( ) : (
<div style={{ display: 'grid', gap: '1rem' }}> <div style={{ display: 'grid', gap: '1rem' }}>
{filteredDogs.map(dog => ( {dogs.map(dog => (
<div <div
key={dog.id} key={dog.id}
className="card" className="card"
@@ -313,6 +327,31 @@ function DogList() {
</div> </div>
)} )}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
<button
className="btn btn-ghost"
onClick={() => fetchDogs(page - 1, search, sexFilter)}
disabled={page <= 1 || loading}
style={{ padding: '0.5rem 1rem' }}
>
Previous
</button>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Page {page} of {totalPages}
</span>
<button
className="btn btn-ghost"
onClick={() => fetchDogs(page + 1, search, sexFilter)}
disabled={page >= totalPages || loading}
style={{ padding: '0.5rem 1rem' }}
>
Next
</button>
</div>
)}
{/* Add Dog Modal */} {/* Add Dog Modal */}
{showAddModal && ( {showAddModal && (
<DogForm <DogForm

View File

@@ -278,7 +278,7 @@ function LitterDetail() {
const fetchAllDogs = async () => { const fetchAllDogs = async () => {
try { try {
const res = await axios.get('/api/dogs') const res = await axios.get('/api/dogs/all')
setAllDogs(res.data) setAllDogs(res.data)
} catch (err) { console.error('Error fetching dogs:', err) } } catch (err) { console.error('Error fetching dogs:', err) }
} }

View File

@@ -4,8 +4,12 @@ import { useNavigate } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import LitterForm from '../components/LitterForm' import LitterForm from '../components/LitterForm'
const LIMIT = 50
function LitterList() { function LitterList() {
const [litters, setLitters] = useState([]) const [litters, setLitters] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editingLitter, setEditingLitter] = useState(null) const [editingLitter, setEditingLitter] = useState(null)
@@ -13,7 +17,7 @@ function LitterList() {
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
fetchLitters() fetchLitters(1)
// Auto-open form with prefill from BreedingCalendar "Record Litter" CTA // Auto-open form with prefill from BreedingCalendar "Record Litter" CTA
const stored = sessionStorage.getItem('prefillLitter') const stored = sessionStorage.getItem('prefillLitter')
if (stored) { if (stored) {
@@ -27,10 +31,12 @@ function LitterList() {
} }
}, []) }, [])
const fetchLitters = async () => { const fetchLitters = async (p = page) => {
try { try {
const res = await axios.get('/api/litters') const res = await axios.get('/api/litters', { params: { page: p, limit: LIMIT } })
setLitters(res.data) setLitters(res.data.data)
setTotal(res.data.total)
setPage(p)
} catch (error) { } catch (error) {
console.error('Error fetching litters:', error) console.error('Error fetching litters:', error)
} finally { } finally {
@@ -38,6 +44,8 @@ function LitterList() {
} }
} }
const totalPages = Math.ceil(total / LIMIT)
const handleCreate = () => { const handleCreate = () => {
setEditingLitter(null) setEditingLitter(null)
setPrefill(null) setPrefill(null)
@@ -56,14 +64,14 @@ function LitterList() {
if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return if (!window.confirm('Delete this litter record? Puppies will be unlinked but not deleted.')) return
try { try {
await axios.delete(`/api/litters/${id}`) await axios.delete(`/api/litters/${id}`)
fetchLitters() fetchLitters(page)
} catch (error) { } catch (error) {
console.error('Error deleting litter:', error) console.error('Error deleting litter:', error)
} }
} }
const handleSave = () => { const handleSave = () => {
fetchLitters() fetchLitters(page)
} }
if (loading) { if (loading) {
@@ -80,7 +88,7 @@ function LitterList() {
</button> </button>
</div> </div>
{litters.length === 0 ? ( {total === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '4rem' }}> <div className="card" style={{ textAlign: 'center', padding: '4rem' }}>
<Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} /> <Activity size={64} style={{ color: 'var(--text-secondary)', margin: '0 auto 1rem' }} />
<h2>No litters recorded yet</h2> <h2>No litters recorded yet</h2>
@@ -143,6 +151,31 @@ function LitterList() {
</div> </div>
)} )}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
<button
className="btn btn-ghost"
onClick={() => fetchLitters(page - 1)}
disabled={page <= 1 || loading}
style={{ padding: '0.5rem 1rem' }}
>
Previous
</button>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Page {page} of {totalPages}
</span>
<button
className="btn btn-ghost"
onClick={() => fetchLitters(page + 1)}
disabled={page >= totalPages || loading}
style={{ padding: '0.5rem 1rem' }}
>
Next
</button>
</div>
)}
{showForm && ( {showForm && (
<LitterForm <LitterForm
litter={editingLitter} litter={editingLitter}

View File

@@ -55,34 +55,72 @@ function attachParents(db, dogs) {
return dogs; return dogs;
} }
// ── GET dogs // ── GET dogs (paginated)
// Default: kennel dogs only (is_external = 0) // Default: kennel dogs only (is_external = 0)
// ?include_external=1 : all active dogs (kennel + external) // ?include_external=1 : all active dogs (kennel + external)
// ?external_only=1 : external dogs only // ?external_only=1 : external dogs only
// ?page=1&limit=50 : pagination
// ?search=term : filter by name or registration_number
// ?sex=male|female : filter by sex
// Response: { data, total, page, limit, stats: { total, males, females } }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true'; const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true';
const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true'; const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true';
const search = (req.query.search || '').trim();
const sex = req.query.sex === 'male' || req.query.sex === 'female' ? req.query.sex : '';
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const offset = (page - 1) * limit;
let whereClause; let baseWhere;
if (externalOnly) { if (externalOnly) {
whereClause = 'WHERE is_active = 1 AND is_external = 1'; baseWhere = 'is_active = 1 AND is_external = 1';
} else if (includeExternal) { } else if (includeExternal) {
whereClause = 'WHERE is_active = 1'; baseWhere = 'is_active = 1';
} else { } else {
whereClause = 'WHERE is_active = 1 AND is_external = 0'; baseWhere = 'is_active = 1 AND is_external = 0';
} }
const filters = [];
const params = [];
if (search) {
filters.push('(name LIKE ? OR registration_number LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (sex) {
filters.push('sex = ?');
params.push(sex);
}
const whereClause = 'WHERE ' + [baseWhere, ...filters].join(' AND ');
const total = db.prepare(`SELECT COUNT(*) as count FROM dogs ${whereClause}`).get(...params).count;
const statsWhere = externalOnly
? 'WHERE is_active = 1 AND is_external = 1'
: includeExternal
? 'WHERE is_active = 1'
: 'WHERE is_active = 1 AND is_external = 0';
const stats = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN sex = 'male' THEN 1 ELSE 0 END) as males,
SUM(CASE WHEN sex = 'female' THEN 1 ELSE 0 END) as females
FROM dogs ${statsWhere}
`).get();
const dogs = db.prepare(` const dogs = db.prepare(`
SELECT ${DOG_COLS} SELECT ${DOG_COLS}
FROM dogs FROM dogs
${whereClause} ${whereClause}
ORDER BY name ORDER BY name
`).all(); LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json(attachParents(db, dogs)); res.json({ data: attachParents(db, dogs), total, page, limit, stats });
} catch (error) { } catch (error) {
console.error('Error fetching dogs:', error); console.error('Error fetching dogs:', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });

View File

@@ -2,10 +2,18 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDatabase } = require('../db/init'); const { getDatabase } = require('../db/init');
// GET all litters // GET all litters (paginated)
// ?page=1&limit=50
// Response: { data, total, page, limit }
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const offset = (page - 1) * limit;
const total = db.prepare('SELECT COUNT(*) as count FROM litters').get().count;
const litters = db.prepare(` const litters = db.prepare(`
SELECT l.*, SELECT l.*,
s.name as sire_name, s.registration_number as sire_reg, s.name as sire_name, s.registration_number as sire_reg,
@@ -14,19 +22,30 @@ router.get('/', (req, res) => {
JOIN dogs s ON l.sire_id = s.id JOIN dogs s ON l.sire_id = s.id
JOIN dogs d ON l.dam_id = d.id JOIN dogs d ON l.dam_id = d.id
ORDER BY l.breeding_date DESC ORDER BY l.breeding_date DESC
`).all(); LIMIT ? OFFSET ?
`).all(limit, offset);
litters.forEach(litter => { if (litters.length > 0) {
litter.puppies = db.prepare(` const litterIds = litters.map(l => l.id);
SELECT * FROM dogs WHERE litter_id = ? AND is_active = 1 const placeholders = litterIds.map(() => '?').join(',');
`).all(litter.id); const allPuppies = db.prepare(`
litter.puppies.forEach(puppy => { SELECT * FROM dogs WHERE litter_id IN (${placeholders}) AND is_active = 1
puppy.photo_urls = puppy.photo_urls ? JSON.parse(puppy.photo_urls) : []; `).all(...litterIds);
});
litter.actual_puppy_count = litter.puppies.length; const puppiesByLitter = {};
allPuppies.forEach(p => {
p.photo_urls = p.photo_urls ? JSON.parse(p.photo_urls) : [];
if (!puppiesByLitter[p.litter_id]) puppiesByLitter[p.litter_id] = [];
puppiesByLitter[p.litter_id].push(p);
}); });
res.json(litters); litters.forEach(l => {
l.puppies = puppiesByLitter[l.id] || [];
l.actual_puppy_count = l.puppies.length;
});
}
res.json({ data: litters, total, page, limit });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }