Initial app
This commit is contained in:
@@ -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"]
|
||||
Generated
+1860
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user