visual improvements
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --import tsx src/app.ts",
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js"
|
||||
},
|
||||
|
||||
+100
-23
@@ -39,6 +39,11 @@ const allowedOrigins = Array.from(
|
||||
);
|
||||
|
||||
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
const photoDataUrlSchema = z
|
||||
.string()
|
||||
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
|
||||
.max(1_500_000);
|
||||
|
||||
const birdSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -46,6 +51,8 @@ const birdSchema = z.object({
|
||||
species: z.string().trim().min(1).max(120),
|
||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||
chartColor: chartColorSchema.optional(),
|
||||
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
const weightSchema = z.object({
|
||||
@@ -68,6 +75,8 @@ type BirdRow = {
|
||||
species: string;
|
||||
date_of_birth: string | null;
|
||||
gotcha_day: string | null;
|
||||
chart_color: string;
|
||||
photo_data_url: string | null;
|
||||
created_at: string;
|
||||
latest_weight_grams: string | null;
|
||||
latest_recorded_on: string | null;
|
||||
@@ -111,7 +120,7 @@ app.use(
|
||||
legacyHeaders: false,
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '300kb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
const emptyToNull = (value?: string) => {
|
||||
@@ -126,11 +135,27 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
species: row.species,
|
||||
dateOfBirth: row.date_of_birth,
|
||||
gotchaDay: row.gotcha_day,
|
||||
chartColor: row.chart_color,
|
||||
photoDataUrl: row.photo_data_url,
|
||||
createdAt: row.created_at,
|
||||
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
|
||||
latestRecordedOn: row.latest_recorded_on,
|
||||
});
|
||||
|
||||
const birdSelectFields = `
|
||||
birds.id,
|
||||
birds.name,
|
||||
birds.tag_id,
|
||||
birds.species,
|
||||
birds.date_of_birth::text,
|
||||
birds.gotcha_day::text,
|
||||
birds.chart_color,
|
||||
birds.photo_data_url,
|
||||
birds.created_at,
|
||||
latest.weight_grams AS latest_weight_grams,
|
||||
latest.recorded_on::text AS latest_recorded_on
|
||||
`;
|
||||
|
||||
const normalizeWeight = (row: WeightRow) => ({
|
||||
id: row.id,
|
||||
birdId: row.bird_id,
|
||||
@@ -159,12 +184,16 @@ const ensureSchema = async () => {
|
||||
species VARCHAR(120) NOT NULL,
|
||||
date_of_birth DATE,
|
||||
gotcha_day DATE,
|
||||
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
photo_data_url TEXT,
|
||||
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;
|
||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
ADD COLUMN IF NOT EXISTS photo_data_url TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weight_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -197,15 +226,7 @@ const ensureSchema = async () => {
|
||||
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
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
@@ -229,15 +250,7 @@ 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
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
@@ -265,15 +278,17 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
|
||||
|
||||
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`,
|
||||
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, 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),
|
||||
parsed.data.chartColor ?? '#cb3a35',
|
||||
emptyToNull(parsed.data.photoDataUrl),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -288,6 +303,68 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/birds/:birdId', 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>(
|
||||
`UPDATE birds
|
||||
SET name = $2,
|
||||
tag_id = $3,
|
||||
species = $4,
|
||||
date_of_birth = $5,
|
||||
gotcha_day = $6,
|
||||
chart_color = $7,
|
||||
photo_data_url = $8
|
||||
WHERE id = $1
|
||||
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
WHERE bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) AS latest_weight_grams,
|
||||
(
|
||||
SELECT recorded_on::text
|
||||
FROM weight_records
|
||||
WHERE bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) AS latest_recorded_on`,
|
||||
[
|
||||
req.params.birdId,
|
||||
parsed.data.name,
|
||||
parsed.data.tagId,
|
||||
parsed.data.species,
|
||||
emptyToNull(parsed.data.dateOfBirth),
|
||||
emptyToNull(parsed.data.gotchaDay),
|
||||
parsed.data.chartColor ?? '#cb3a35',
|
||||
emptyToNull(parsed.data.photoDataUrl),
|
||||
],
|
||||
);
|
||||
|
||||
if (!result.rowCount) {
|
||||
res.status(404).json({ error: 'Bird not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.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]);
|
||||
|
||||
Reference in New Issue
Block a user