MVP
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import morgan from 'morgan';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT ?? 5000);
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
|
||||
const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
|
||||
|
||||
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:3000')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 250,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
}),
|
||||
);
|
||||
app.use(morgan('combined'));
|
||||
app.use(express.json());
|
||||
|
||||
type DashboardSummary = {
|
||||
totalFirearms: number;
|
||||
totalAmmoRounds: number;
|
||||
firearmsInvestment: string;
|
||||
ammoInvestment: string;
|
||||
configuredCalibers: number;
|
||||
};
|
||||
|
||||
type FirearmRow = {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
caliber: string;
|
||||
serial_number: string;
|
||||
purchase_price: string;
|
||||
acquired_on: string | null;
|
||||
image_url: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type CaliberRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
type AmmoInventoryRow = {
|
||||
caliber_id: string;
|
||||
caliber_name: string;
|
||||
rounds_on_hand: number;
|
||||
cost_per_round: string;
|
||||
};
|
||||
|
||||
type FirearmInput = {
|
||||
manufacturer?: unknown;
|
||||
model?: unknown;
|
||||
category?: unknown;
|
||||
caliber?: unknown;
|
||||
serialNumber?: unknown;
|
||||
purchasePrice?: unknown;
|
||||
acquiredOn?: unknown;
|
||||
imageUrl?: unknown;
|
||||
notes?: unknown;
|
||||
};
|
||||
|
||||
type CaliberInput = {
|
||||
name?: unknown;
|
||||
};
|
||||
|
||||
type AmmoAdjustmentInput = {
|
||||
rounds?: unknown;
|
||||
costPerRound?: unknown;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: string | number | null): number => Number(value ?? 0);
|
||||
|
||||
const getString = (value: unknown, fieldName: string, required = true): string => {
|
||||
if (typeof value !== 'string') {
|
||||
if (required) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed && required) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const getOptionalString = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const getNumber = (value: unknown, fieldName: string): number => {
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${fieldName} must be a valid number`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizeFirearm = (row: FirearmRow) => ({
|
||||
id: row.id,
|
||||
manufacturer: row.manufacturer,
|
||||
model: row.model,
|
||||
category: row.category,
|
||||
caliber: row.caliber,
|
||||
serialNumber: row.serial_number,
|
||||
purchasePrice: formatCurrency(row.purchase_price),
|
||||
acquiredOn: row.acquired_on,
|
||||
imageUrl: row.image_url,
|
||||
notes: row.notes,
|
||||
});
|
||||
|
||||
const normalizeCaliber = (row: CaliberRow) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: row.is_default,
|
||||
isActive: row.is_active,
|
||||
});
|
||||
|
||||
const normalizeAmmoInventory = (row: AmmoInventoryRow) => ({
|
||||
caliberId: row.caliber_id,
|
||||
caliber: row.caliber_name,
|
||||
roundsOnHand: row.rounds_on_hand,
|
||||
costPerRound: formatCurrency(row.cost_per_round),
|
||||
totalValue: formatCurrency(row.cost_per_round) * row.rounds_on_hand,
|
||||
});
|
||||
|
||||
const ensureSchema = async () => {
|
||||
await pool.query(`
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calibers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(40) NOT NULL UNIQUE,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS firearms (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
manufacturer VARCHAR(120) NOT NULL,
|
||||
model VARCHAR(120) NOT NULL,
|
||||
category VARCHAR(80) NOT NULL,
|
||||
caliber VARCHAR(40) NOT NULL,
|
||||
serial_number VARCHAR(120) NOT NULL UNIQUE,
|
||||
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
acquired_on DATE,
|
||||
image_url TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ammo_inventory (
|
||||
caliber_id UUID PRIMARY KEY REFERENCES calibers(id) ON DELETE CASCADE,
|
||||
rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0),
|
||||
cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS image_url TEXT;
|
||||
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS acquired_on DATE;
|
||||
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_firearms_caliber ON firearms(caliber);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibers_active ON calibers(is_active);
|
||||
`);
|
||||
|
||||
for (const caliber of defaultCalibers) {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO calibers (name, is_default, is_active)
|
||||
VALUES ($1, TRUE, TRUE)
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET is_default = TRUE
|
||||
`,
|
||||
[caliber],
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
DELETE FROM firearms
|
||||
WHERE serial_number IN ('G19-EXAMPLE-001', 'R1022-EXAMPLE-002', 'M590A1-EXAMPLE-003')
|
||||
`,
|
||||
);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||
SELECT c.id, 0, 0
|
||||
FROM calibers c
|
||||
WHERE c.is_active = TRUE
|
||||
ON CONFLICT (caliber_id) DO NOTHING
|
||||
`);
|
||||
};
|
||||
|
||||
app.get('/health', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT NOW() AS now');
|
||||
res.json({ status: 'ok', database: 'connected', timestamp: result.rows[0].now });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api', (_req, res) => {
|
||||
res.json({
|
||||
name: 'Arsenal IQ API',
|
||||
version: '2.0.0',
|
||||
resources: ['/api/dashboard', '/api/firearms', '/api/calibers', '/api/ammo'],
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/dashboard', async (_req, res, next) => {
|
||||
try {
|
||||
const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([
|
||||
pool.query<DashboardSummary>(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM firearms) AS "totalFirearms",
|
||||
COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory), 0) AS "totalAmmoRounds",
|
||||
COALESCE((SELECT SUM(purchase_price) FROM firearms), 0) AS "firearmsInvestment",
|
||||
COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory), 0) AS "ammoInvestment",
|
||||
(SELECT COUNT(*)::int FROM calibers WHERE is_active = TRUE) AS "configuredCalibers"
|
||||
`),
|
||||
pool.query<FirearmRow>(`
|
||||
SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||
FROM firearms
|
||||
ORDER BY acquired_on DESC NULLS LAST, created_at DESC
|
||||
`),
|
||||
pool.query<CaliberRow>(`
|
||||
SELECT id, name, is_default, is_active
|
||||
FROM calibers
|
||||
ORDER BY is_active DESC, is_default DESC, name ASC
|
||||
`),
|
||||
pool.query<AmmoInventoryRow>(`
|
||||
SELECT
|
||||
ai.caliber_id,
|
||||
c.name AS caliber_name,
|
||||
ai.rounds_on_hand,
|
||||
ai.cost_per_round
|
||||
FROM ammo_inventory ai
|
||||
INNER JOIN calibers c ON c.id = ai.caliber_id
|
||||
WHERE c.is_active = TRUE
|
||||
ORDER BY c.name ASC
|
||||
`),
|
||||
]);
|
||||
|
||||
const summary = summaryResult.rows[0];
|
||||
|
||||
res.json({
|
||||
summary: {
|
||||
totalFirearms: summary.totalFirearms,
|
||||
totalAmmoRounds: summary.totalAmmoRounds,
|
||||
firearmsInvestment: formatCurrency(summary.firearmsInvestment),
|
||||
ammoInvestment: formatCurrency(summary.ammoInvestment),
|
||||
configuredCalibers: summary.configuredCalibers,
|
||||
},
|
||||
firearms: firearmsResult.rows.map(normalizeFirearm),
|
||||
calibers: calibersResult.rows.map(normalizeCaliber),
|
||||
ammoInventory: ammoResult.rows.map(normalizeAmmoInventory),
|
||||
defaultCalibers,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/firearms', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query<FirearmRow>(`
|
||||
SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||
FROM firearms
|
||||
ORDER BY acquired_on DESC NULLS LAST, created_at DESC
|
||||
`);
|
||||
|
||||
res.json(result.rows.map(normalizeFirearm));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/firearms', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body as FirearmInput;
|
||||
const manufacturer = getString(body.manufacturer, 'manufacturer');
|
||||
const model = getString(body.model, 'model');
|
||||
const category = getString(body.category, 'category');
|
||||
const caliber = getString(body.caliber, 'caliber');
|
||||
const serialNumber = getString(body.serialNumber, 'serialNumber');
|
||||
const purchasePrice = getNumber(body.purchasePrice, 'purchasePrice');
|
||||
const acquiredOn = getOptionalString(body.acquiredOn);
|
||||
const imageUrl = getOptionalString(body.imageUrl);
|
||||
const notes = getOptionalString(body.notes);
|
||||
|
||||
const result = await pool.query<FirearmRow>(
|
||||
`
|
||||
INSERT INTO firearms (
|
||||
manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||
`,
|
||||
[manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes],
|
||||
);
|
||||
|
||||
res.status(201).json(normalizeFirearm(result.rows[0]));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/firearms/:id', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body as FirearmInput;
|
||||
const manufacturer = getString(body.manufacturer, 'manufacturer');
|
||||
const model = getString(body.model, 'model');
|
||||
const category = getString(body.category, 'category');
|
||||
const caliber = getString(body.caliber, 'caliber');
|
||||
const serialNumber = getString(body.serialNumber, 'serialNumber');
|
||||
const purchasePrice = getNumber(body.purchasePrice, 'purchasePrice');
|
||||
const acquiredOn = getOptionalString(body.acquiredOn);
|
||||
const imageUrl = getOptionalString(body.imageUrl);
|
||||
const notes = getOptionalString(body.notes);
|
||||
|
||||
const result = await pool.query<FirearmRow>(
|
||||
`
|
||||
UPDATE firearms
|
||||
SET
|
||||
manufacturer = $2,
|
||||
model = $3,
|
||||
category = $4,
|
||||
caliber = $5,
|
||||
serial_number = $6,
|
||||
purchase_price = $7,
|
||||
acquired_on = $8,
|
||||
image_url = $9,
|
||||
notes = $10,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||
`,
|
||||
[req.params.id, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ error: 'Firearm not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(normalizeFirearm(result.rows[0]));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/firearms/:id', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM firearms WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ error: 'Firearm not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/calibers', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query<CaliberRow>(`
|
||||
SELECT id, name, is_default, is_active
|
||||
FROM calibers
|
||||
ORDER BY is_active DESC, is_default DESC, name ASC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
configured: result.rows.filter((row) => row.is_active).map(normalizeCaliber),
|
||||
availableDefaults: defaultCalibers.filter(
|
||||
(name) => !result.rows.some((row) => row.name === name && row.is_active),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/calibers', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body as CaliberInput;
|
||||
const name = getString(body.name, 'name');
|
||||
|
||||
const result = await pool.query<CaliberRow>(
|
||||
`
|
||||
INSERT INTO calibers (name, is_default, is_active)
|
||||
VALUES ($1, $2, TRUE)
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET is_active = TRUE
|
||||
RETURNING id, name, is_default, is_active
|
||||
`,
|
||||
[name, defaultCalibers.includes(name)],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||
VALUES ($1, 0, 0)
|
||||
ON CONFLICT (caliber_id) DO NOTHING
|
||||
`,
|
||||
[result.rows[0].id],
|
||||
);
|
||||
|
||||
res.status(201).json(normalizeCaliber(result.rows[0]));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/calibers/:id', async (req, res, next) => {
|
||||
try {
|
||||
const isActive = Boolean(req.body?.isActive);
|
||||
const result = await pool.query<CaliberRow>(
|
||||
`
|
||||
UPDATE calibers
|
||||
SET is_active = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, name, is_default, is_active
|
||||
`,
|
||||
[req.params.id, isActive],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ error: 'Caliber not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||
VALUES ($1, 0, 0)
|
||||
ON CONFLICT (caliber_id) DO NOTHING
|
||||
`,
|
||||
[req.params.id],
|
||||
);
|
||||
}
|
||||
|
||||
res.json(normalizeCaliber(result.rows[0]));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/ammo', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query<AmmoInventoryRow>(`
|
||||
SELECT
|
||||
ai.caliber_id,
|
||||
c.name AS caliber_name,
|
||||
ai.rounds_on_hand,
|
||||
ai.cost_per_round
|
||||
FROM ammo_inventory ai
|
||||
INNER JOIN calibers c ON c.id = ai.caliber_id
|
||||
WHERE c.is_active = TRUE
|
||||
ORDER BY c.name ASC
|
||||
`);
|
||||
|
||||
res.json(result.rows.map(normalizeAmmoInventory));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/ammo/:caliberId', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body as AmmoAdjustmentInput;
|
||||
const rounds = getNumber(body.rounds, 'rounds');
|
||||
const costPerRound = getNumber(body.costPerRound, 'costPerRound');
|
||||
|
||||
const result = await pool.query<AmmoInventoryRow>(
|
||||
`
|
||||
UPDATE ammo_inventory
|
||||
SET
|
||||
rounds_on_hand = GREATEST(0, rounds_on_hand + $2),
|
||||
cost_per_round = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE caliber_id = $1
|
||||
RETURNING
|
||||
caliber_id,
|
||||
(
|
||||
SELECT name
|
||||
FROM calibers
|
||||
WHERE id = ammo_inventory.caliber_id
|
||||
) AS caliber_name,
|
||||
rounds_on_hand,
|
||||
cost_per_round
|
||||
`,
|
||||
[req.params.caliberId, rounds, costPerRound],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ error: 'Ammo inventory not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(normalizeAmmoInventory(result.rows[0]));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(error);
|
||||
res.status(500).json({
|
||||
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : error.message,
|
||||
});
|
||||
});
|
||||
|
||||
void ensureSchema()
|
||||
.then(() => {
|
||||
app.listen(port, () => {
|
||||
console.log(`Arsenal IQ API listening on http://localhost:${port}`);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to initialize schema', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user