Initial app

This commit is contained in:
blaisadmin
2026-04-06 23:36:12 -04:00
parent 9405327f74
commit 68ab8b12d2
20 changed files with 5626 additions and 152 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
EXPOSE 5000
CMD ["npm", "run", "dev"]
+1860
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "flockpal-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --import tsx src/app.ts",
"build": "tsc",
"start": "node dist/app.js"
},
"dependencies": {
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.21.2",
"express-rate-limit": "7.5.0",
"helmet": "8.1.0",
"morgan": "1.10.0",
"pg": "8.13.1",
"zod": "3.24.1"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/express": "4.17.21",
"@types/morgan": "1.9.9",
"@types/node": "22.10.2",
"@types/pg": "8.11.10",
"tsx": "4.19.2",
"typescript": "5.7.2"
}
}
+424
View File
@@ -0,0 +1,424 @@
import cors from 'cors';
import dotenv from 'dotenv';
import express, { type NextFunction, type Request, type Response } from 'express';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import morgan from 'morgan';
import pg from 'pg';
import { z } from 'zod';
dotenv.config();
const app = express();
const port = Number(process.env.PORT ?? 5000);
const { Pool } = pg;
const pool = new Pool({
host: process.env.POSTGRES_HOST ?? 'localhost',
port: Number(process.env.POSTGRES_PORT ?? 5432),
database: process.env.POSTGRES_DB ?? 'flockpal',
user: process.env.POSTGRES_USER ?? 'flockpal',
password: process.env.POSTGRES_PASSWORD ?? 'flockpal_dev_password',
});
const defaultAllowedOrigins = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:5173',
'http://127.0.0.1:5173',
];
const allowedOrigins = Array.from(
new Set(
[process.env.FRONTEND_URL, process.env.FRONTEND_URLS]
.filter(Boolean)
.flatMap((value) => (value ?? '').split(','))
.map((origin) => origin.trim())
.filter(Boolean)
.concat(defaultAllowedOrigins),
),
);
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80),
species: z.string().trim().min(1).max(120),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
});
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema,
notes: z.string().trim().max(280).optional().or(z.literal('')),
});
const vetVisitSchema = z.object({
visitedOn: dateStringSchema,
clinicName: z.string().trim().min(1).max(160),
reason: z.string().trim().min(1).max(160),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
type BirdRow = {
id: string;
name: string;
tag_id: string;
species: string;
date_of_birth: string | null;
gotcha_day: string | null;
created_at: string;
latest_weight_grams: string | null;
latest_recorded_on: string | null;
};
type WeightRow = {
id: string;
bird_id: string;
weight_grams: string;
recorded_on: string;
notes: string | null;
};
type VetVisitRow = {
id: string;
bird_id: string;
visited_on: string;
clinic_name: string;
reason: string;
notes: string | null;
};
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(
cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error('Origin not allowed'));
},
}),
);
app.use(
rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: true,
legacyHeaders: false,
}),
);
app.use(express.json({ limit: '300kb' }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
const emptyToNull = (value?: string) => {
const trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null;
};
const normalizeBird = (row: BirdRow) => ({
id: row.id,
name: row.name,
tagId: row.tag_id,
species: row.species,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
createdAt: row.created_at,
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
latestRecordedOn: row.latest_recorded_on,
});
const normalizeWeight = (row: WeightRow) => ({
id: row.id,
birdId: row.bird_id,
weightGrams: Number(row.weight_grams),
recordedOn: row.recorded_on,
notes: row.notes,
});
const normalizeVetVisit = (row: VetVisitRow) => ({
id: row.id,
birdId: row.bird_id,
visitedOn: row.visited_on,
clinicName: row.clinic_name,
reason: row.reason,
notes: row.notes,
});
const ensureSchema = async () => {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS birds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80) NOT NULL UNIQUE,
species VARCHAR(120) NOT NULL,
date_of_birth DATE,
gotcha_day DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE birds
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE;
CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
recorded_on DATE NOT NULL,
notes VARCHAR(280),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (bird_id, recorded_on)
);
CREATE TABLE IF NOT EXISTS vet_visits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
visited_on DATE NOT NULL,
clinic_name VARCHAR(160) NOT NULL,
reason VARCHAR(160) NOT NULL,
notes VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on
ON weight_records (bird_id, recorded_on DESC);
CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on
ON vet_visits (bird_id, visited_on DESC);
`);
};
const getBirdById = async (birdId: string) => {
const result = await pool.query<BirdRow>(
`SELECT
birds.id,
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE birds.id = $1`,
[birdId],
);
return result.rows[0] ?? null;
};
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true });
});
app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await pool.query<BirdRow>(`
SELECT
birds.id,
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
ORDER BY birds.name ASC
`);
res.json({ birds: result.rows.map(normalizeBird) });
} catch (error) {
next(error);
}
});
app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
return;
}
try {
const result = await pool.query<BirdRow>(
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[
parsed.data.name,
parsed.data.tagId,
parsed.data.species,
emptyToNull(parsed.data.dateOfBirth),
emptyToNull(parsed.data.gotchaDay),
],
);
res.status(201).json({ bird: normalizeBird(result.rows[0]) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That tag ID is already in use.' });
return;
}
next(error);
}
});
app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]);
if (!result.rowCount) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/birds/:birdId/weights', async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 365);
const result = await pool.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
FROM weight_records
WHERE bird_id = $1
AND recorded_on >= CURRENT_DATE - (($2::int - 1) * INTERVAL '1 day')
ORDER BY recorded_on ASC`,
[req.params.birdId, days],
);
res.json({ weights: result.rows.map(normalizeWeight) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/weights', async (req: Request, res: Response, next: NextFunction) => {
const parsed = weightSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
return;
}
try {
const birdLookup = await pool.query<{ id: string }>('SELECT id FROM birds WHERE id = $1', [req.params.birdId]);
if (!birdLookup.rowCount) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const result = await pool.query<WeightRow>(
`INSERT INTO weight_records (bird_id, weight_grams, recorded_on, notes)
VALUES ($1, $2, $3, $4)
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
[req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)],
);
res.status(201).json({ weight: normalizeWeight(result.rows[0]) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
return;
}
next(error);
}
});
app.get('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await pool.query<VetVisitRow>(
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
FROM vet_visits
WHERE bird_id = $1
ORDER BY visited_on DESC, created_at DESC`,
[req.params.birdId],
);
res.json({ vetVisits: result.rows.map(normalizeVetVisit) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, next: NextFunction) => {
const parsed = vetVisitSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const result = await pool.query<VetVisitRow>(
`INSERT INTO vet_visits (bird_id, visited_on, clinic_name, reason, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`,
[
req.params.birdId,
parsed.data.visitedOn,
parsed.data.clinicName,
parsed.data.reason,
emptyToNull(parsed.data.notes),
],
);
res.status(201).json({ vetVisit: normalizeVetVisit(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
});
const start = async () => {
await ensureSchema();
app.listen(port, () => {
console.log(`FlockPal backend listening on port ${port}`);
});
};
start().catch((error) => {
console.error('Failed to start backend', error);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}