1280 lines
40 KiB
TypeScript
1280 lines
40 KiB
TypeScript
import crypto from 'crypto';
|
|
import axios from 'axios';
|
|
import bcrypt from 'bcryptjs';
|
|
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 pg from 'pg';
|
|
|
|
dotenv.config();
|
|
|
|
type UserRow = {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
};
|
|
|
|
type ProfileRow = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
type SessionRow = {
|
|
id: string;
|
|
user_id: string;
|
|
active_profile_id: string | null;
|
|
expires_at: string;
|
|
};
|
|
|
|
type ProviderConfigRow = {
|
|
provider_key: string;
|
|
display_name: string;
|
|
protocol: string;
|
|
client_id: string | null;
|
|
client_secret: string | null;
|
|
authorization_endpoint: string | null;
|
|
token_endpoint: string | null;
|
|
userinfo_endpoint: string | null;
|
|
issuer: string | null;
|
|
scopes: string;
|
|
enabled: boolean;
|
|
};
|
|
|
|
type DashboardSummaryRow = {
|
|
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 AuthContext = {
|
|
user: UserRow;
|
|
session: SessionRow;
|
|
token: string;
|
|
};
|
|
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
auth?: AuthContext;
|
|
}
|
|
}
|
|
}
|
|
|
|
const app = express();
|
|
const port = Number(process.env.PORT ?? 5000);
|
|
const databaseUrl =
|
|
process.env.DATABASE_URL ||
|
|
`postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:5432/${process.env.POSTGRES_DB}`;
|
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
|
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:5000/api';
|
|
const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false';
|
|
const { Pool } = pg;
|
|
const pool = new Pool({ connectionString: databaseUrl });
|
|
|
|
const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
|
|
const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other'];
|
|
const sessionHours = 24 * 7;
|
|
|
|
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:3000,http://localhost:5173')
|
|
.split(',')
|
|
.map((origin) => origin.trim())
|
|
.filter(Boolean);
|
|
|
|
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 normalizeEmail = (email: string) => email.trim().toLowerCase();
|
|
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
|
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
|
const isUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
|
|
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: Number(row.rounds_on_hand || 0),
|
|
costPerRound: formatCurrency(row.cost_per_round),
|
|
totalValue: Number(row.rounds_on_hand || 0) * formatCurrency(row.cost_per_round),
|
|
});
|
|
|
|
const serializeProvider = (provider: ProviderConfigRow) => ({
|
|
providerKey: provider.provider_key,
|
|
displayName: provider.display_name,
|
|
protocol: provider.protocol,
|
|
clientId: provider.client_id ?? '',
|
|
clientSecret: provider.client_secret ?? '',
|
|
authorizationEndpoint: provider.authorization_endpoint ?? '',
|
|
tokenEndpoint: provider.token_endpoint ?? '',
|
|
userinfoEndpoint: provider.userinfo_endpoint ?? '',
|
|
issuer: provider.issuer ?? '',
|
|
scopes: provider.scopes,
|
|
enabled: provider.enabled,
|
|
});
|
|
|
|
const decodeJwtPayload = (token: string) => {
|
|
const segments = token.split('.');
|
|
|
|
if (segments.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const payload = segments[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
|
|
|
try {
|
|
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const ensureSchema = async () => {
|
|
await pool.query(`
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
email VARCHAR(255) UNIQUE NOT NULL,
|
|
password_hash VARCHAR(255),
|
|
name VARCHAR(255) NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (user_id, name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
active_profile_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
|
|
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_provider_configs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
provider_key VARCHAR(100) NOT NULL UNIQUE,
|
|
display_name VARCHAR(255) NOT NULL,
|
|
protocol VARCHAR(50) NOT NULL DEFAULT 'oidc',
|
|
client_id TEXT,
|
|
client_secret TEXT,
|
|
authorization_endpoint TEXT,
|
|
token_endpoint TEXT,
|
|
userinfo_endpoint TEXT,
|
|
issuer TEXT,
|
|
scopes TEXT NOT NULL DEFAULT 'openid profile email',
|
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_identities (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE,
|
|
provider_subject TEXT NOT NULL,
|
|
email VARCHAR(255),
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (provider_key, provider_subject)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS oauth_states (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE,
|
|
state_code VARCHAR(255) NOT NULL UNIQUE,
|
|
redirect_uri TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS calibers (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
name VARCHAR(40) NOT NULL,
|
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (profile_id, name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS firearms (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
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,
|
|
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
|
acquired_on DATE,
|
|
image_url TEXT,
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ammo_inventory (
|
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
caliber_id UUID NOT NULL 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 TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (profile_id, caliber_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON auth_sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_calibers_profile_id ON calibers(profile_id);
|
|
CREATE INDEX IF NOT EXISTS idx_firearms_profile_id ON firearms(profile_id);
|
|
`);
|
|
|
|
const providers = [
|
|
{
|
|
providerKey: 'google',
|
|
displayName: 'Google',
|
|
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
|
userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
|
|
issuer: 'https://accounts.google.com',
|
|
},
|
|
{
|
|
providerKey: 'entra',
|
|
displayName: 'Microsoft Entra ID',
|
|
authorizationEndpoint: '',
|
|
tokenEndpoint: '',
|
|
userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo',
|
|
issuer: '',
|
|
},
|
|
{
|
|
providerKey: 'oidc',
|
|
displayName: 'Custom OIDC',
|
|
authorizationEndpoint: '',
|
|
tokenEndpoint: '',
|
|
userinfoEndpoint: '',
|
|
issuer: '',
|
|
},
|
|
];
|
|
|
|
for (const provider of providers) {
|
|
await pool.query(
|
|
`INSERT INTO auth_provider_configs
|
|
(provider_key, display_name, protocol, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled)
|
|
VALUES ($1, $2, 'oidc', $3, $4, $5, $6, 'openid profile email', FALSE)
|
|
ON CONFLICT (provider_key) DO NOTHING`,
|
|
[
|
|
provider.providerKey,
|
|
provider.displayName,
|
|
provider.authorizationEndpoint,
|
|
provider.tokenEndpoint,
|
|
provider.userinfoEndpoint,
|
|
provider.issuer,
|
|
],
|
|
);
|
|
}
|
|
};
|
|
|
|
const ensureProfileDefaults = async (profileId: string) => {
|
|
for (const caliber of defaultCalibers) {
|
|
const caliberResult = await pool.query<CaliberRow>(
|
|
`INSERT INTO calibers (profile_id, name, is_default, is_active)
|
|
VALUES ($1, $2, TRUE, TRUE)
|
|
ON CONFLICT (profile_id, name) DO UPDATE
|
|
SET is_default = TRUE
|
|
RETURNING id, name, is_default, is_active`,
|
|
[profileId, caliber],
|
|
);
|
|
|
|
await pool.query(
|
|
`INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round)
|
|
VALUES ($1, $2, 0, 0)
|
|
ON CONFLICT (profile_id, caliber_id) DO NOTHING`,
|
|
[profileId, caliberResult.rows[0].id],
|
|
);
|
|
}
|
|
};
|
|
|
|
const getUserProfiles = async (userId: string) => {
|
|
const result = await pool.query<ProfileRow>(
|
|
'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC',
|
|
[userId],
|
|
);
|
|
|
|
return result.rows;
|
|
};
|
|
|
|
const ensureDefaultProfile = async (userId: string, userName: string) => {
|
|
const profiles = await getUserProfiles(userId);
|
|
|
|
if (profiles.length > 0) {
|
|
await ensureProfileDefaults(profiles[0].id);
|
|
return profiles[0];
|
|
}
|
|
|
|
const created = await pool.query<ProfileRow>(
|
|
'INSERT INTO profiles (user_id, name) VALUES ($1, $2) RETURNING id, name',
|
|
[userId, `${userName.split(' ')[0] || 'Primary'} Arsenal`],
|
|
);
|
|
|
|
await ensureProfileDefaults(created.rows[0].id);
|
|
return created.rows[0];
|
|
};
|
|
|
|
const createSession = async (userId: string, activeProfileId: string) => {
|
|
const token = createSessionToken();
|
|
const tokenHash = hashToken(token);
|
|
const expiresAt = new Date(Date.now() + sessionHours * 60 * 60 * 1000);
|
|
|
|
const result = await pool.query<SessionRow>(
|
|
`INSERT INTO auth_sessions (user_id, active_profile_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, user_id, active_profile_id, expires_at`,
|
|
[userId, activeProfileId, tokenHash, expiresAt.toISOString()],
|
|
);
|
|
|
|
return {
|
|
token,
|
|
session: result.rows[0],
|
|
};
|
|
};
|
|
|
|
const getSessionFromToken = async (rawToken: string) => {
|
|
const tokenHash = hashToken(rawToken);
|
|
const result = await pool.query<SessionRow & { email: string; name: string }>(
|
|
`SELECT s.id, s.user_id, s.active_profile_id, s.expires_at, u.email, u.name
|
|
FROM auth_sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = $1 AND s.expires_at > NOW()`,
|
|
[tokenHash],
|
|
);
|
|
|
|
if ((result.rowCount ?? 0) === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
user: {
|
|
id: result.rows[0].user_id,
|
|
email: result.rows[0].email,
|
|
name: result.rows[0].name,
|
|
},
|
|
session: {
|
|
id: result.rows[0].id,
|
|
user_id: result.rows[0].user_id,
|
|
active_profile_id: result.rows[0].active_profile_id,
|
|
expires_at: result.rows[0].expires_at,
|
|
},
|
|
};
|
|
};
|
|
|
|
const getAuthProvider = async (providerKey: string) => {
|
|
const result = await pool.query<ProviderConfigRow>(
|
|
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
|
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
|
|
FROM auth_provider_configs
|
|
WHERE provider_key = $1`,
|
|
[providerKey],
|
|
);
|
|
|
|
return result.rows[0] ?? null;
|
|
};
|
|
|
|
const buildDashboard = async (profileId: string) => {
|
|
await ensureProfileDefaults(profileId);
|
|
|
|
const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([
|
|
pool.query<DashboardSummaryRow>(
|
|
`SELECT
|
|
(SELECT COUNT(*)::int FROM firearms WHERE profile_id = $1) AS "totalFirearms",
|
|
COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory WHERE profile_id = $1), 0) AS "totalAmmoRounds",
|
|
COALESCE((SELECT SUM(purchase_price) FROM firearms WHERE profile_id = $1), 0) AS "firearmsInvestment",
|
|
COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory WHERE profile_id = $1), 0) AS "ammoInvestment",
|
|
(SELECT COUNT(*)::int FROM calibers WHERE profile_id = $1 AND is_active = TRUE) AS "configuredCalibers"`,
|
|
[profileId],
|
|
),
|
|
pool.query<FirearmRow>(
|
|
`SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
|
FROM firearms
|
|
WHERE profile_id = $1
|
|
ORDER BY acquired_on DESC NULLS LAST, created_at DESC`,
|
|
[profileId],
|
|
),
|
|
pool.query<CaliberRow>(
|
|
`SELECT id, name, is_default, is_active
|
|
FROM calibers
|
|
WHERE profile_id = $1
|
|
ORDER BY is_active DESC, is_default DESC, name ASC`,
|
|
[profileId],
|
|
),
|
|
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 ai.profile_id = $1 AND c.profile_id = $1 AND c.is_active = TRUE
|
|
ORDER BY c.name ASC`,
|
|
[profileId],
|
|
),
|
|
]);
|
|
|
|
const summary = summaryResult.rows[0];
|
|
|
|
return {
|
|
summary: {
|
|
totalFirearms: Number(summary.totalFirearms || 0),
|
|
totalAmmoRounds: Number(summary.totalAmmoRounds || 0),
|
|
firearmsInvestment: formatCurrency(summary.firearmsInvestment),
|
|
ammoInvestment: formatCurrency(summary.ammoInvestment),
|
|
configuredCalibers: Number(summary.configuredCalibers || 0),
|
|
},
|
|
firearms: firearmsResult.rows.map(normalizeFirearm),
|
|
calibers: calibersResult.rows.map(normalizeCaliber),
|
|
ammoInventory: ammoResult.rows.map(normalizeAmmoInventory),
|
|
defaultCalibers,
|
|
};
|
|
};
|
|
|
|
const resolveProfileId = async (req: express.Request) => {
|
|
const requestedProfileId = String(req.header('x-profile-id') || '');
|
|
|
|
if (requestedProfileId && isUuid(requestedProfileId)) {
|
|
const result = await pool.query<ProfileRow>(
|
|
'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2',
|
|
[requestedProfileId, req.auth!.user.id],
|
|
);
|
|
|
|
if ((result.rowCount ?? 0) > 0) {
|
|
await ensureProfileDefaults(requestedProfileId);
|
|
await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [
|
|
requestedProfileId,
|
|
req.auth!.session.id,
|
|
]);
|
|
req.auth!.session.active_profile_id = requestedProfileId;
|
|
return requestedProfileId;
|
|
}
|
|
}
|
|
|
|
if (req.auth!.session.active_profile_id) {
|
|
await ensureProfileDefaults(req.auth!.session.active_profile_id);
|
|
return req.auth!.session.active_profile_id;
|
|
}
|
|
|
|
const fallbackProfile = await ensureDefaultProfile(req.auth!.user.id, req.auth!.user.name);
|
|
await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [
|
|
fallbackProfile.id,
|
|
req.auth!.session.id,
|
|
]);
|
|
req.auth!.session.active_profile_id = fallbackProfile.id;
|
|
return fallbackProfile.id;
|
|
};
|
|
|
|
const requireAuth = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
try {
|
|
const header = req.header('authorization');
|
|
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
|
|
|
if (!token) {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
return;
|
|
}
|
|
|
|
const sessionData = await getSessionFromToken(token);
|
|
|
|
if (!sessionData) {
|
|
res.status(401).json({ error: 'Session expired or invalid' });
|
|
return;
|
|
}
|
|
|
|
req.auth = {
|
|
user: sessionData.user,
|
|
session: sessionData.session,
|
|
token,
|
|
};
|
|
|
|
next();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
app.use(helmet());
|
|
app.use(
|
|
cors({
|
|
origin(origin, callback) {
|
|
if (!origin || allowedOrigins.includes(origin)) {
|
|
callback(null, true);
|
|
return;
|
|
}
|
|
|
|
callback(new Error('Origin not allowed by CORS'));
|
|
},
|
|
}),
|
|
);
|
|
app.use(
|
|
rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 250,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
}),
|
|
);
|
|
app.use(morgan('combined'));
|
|
app.use(express.json());
|
|
|
|
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: '3.0.0',
|
|
allowRegistration,
|
|
resources: [
|
|
'/api/auth/login',
|
|
'/api/auth/register',
|
|
'/api/dashboard',
|
|
'/api/firearms',
|
|
'/api/calibers',
|
|
'/api/ammo',
|
|
],
|
|
});
|
|
});
|
|
|
|
app.get('/api/auth/providers', async (_req, res, next) => {
|
|
try {
|
|
const result = await pool.query<ProviderConfigRow>(
|
|
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
|
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
|
|
FROM auth_provider_configs
|
|
WHERE enabled = TRUE
|
|
ORDER BY display_name ASC`,
|
|
);
|
|
|
|
res.json(
|
|
result.rows.map((provider) => ({
|
|
providerKey: provider.provider_key,
|
|
displayName: provider.display_name,
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/register', async (req, res, next) => {
|
|
try {
|
|
if (!allowRegistration) {
|
|
res.status(403).json({ error: 'Registration is disabled' });
|
|
return;
|
|
}
|
|
|
|
const email = normalizeEmail(getString(req.body?.email, 'email'));
|
|
const password = getString(req.body?.password, 'password');
|
|
const name = getString(req.body?.name, 'name');
|
|
|
|
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
|
|
|
if ((existing.rowCount ?? 0) > 0) {
|
|
res.status(409).json({ error: 'An account with that email already exists' });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
const userResult = await pool.query<UserRow>(
|
|
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name',
|
|
[email, passwordHash, name],
|
|
);
|
|
|
|
const user = userResult.rows[0];
|
|
const profile = await ensureDefaultProfile(user.id, user.name);
|
|
const { token, session } = await createSession(user.id, profile.id);
|
|
|
|
res.status(201).json({
|
|
token,
|
|
user,
|
|
profiles: [profile],
|
|
activeProfileId: session.active_profile_id,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/login', async (req, res, next) => {
|
|
try {
|
|
const email = normalizeEmail(getString(req.body?.email, 'email'));
|
|
const password = getString(req.body?.password, 'password');
|
|
const result = await pool.query<UserRow & { password_hash: string | null }>(
|
|
'SELECT id, email, name, password_hash FROM users WHERE email = $1',
|
|
[email],
|
|
);
|
|
|
|
if ((result.rowCount ?? 0) === 0) {
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
return;
|
|
}
|
|
|
|
const user = result.rows[0];
|
|
|
|
if (!user.password_hash) {
|
|
res.status(401).json({ error: 'This account uses SSO. Use your identity provider to sign in.' });
|
|
return;
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
|
|
if (!valid) {
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
return;
|
|
}
|
|
|
|
const profile = await ensureDefaultProfile(user.id, user.name);
|
|
const { token, session } = await createSession(user.id, profile.id);
|
|
const profiles = await getUserProfiles(user.id);
|
|
|
|
res.json({
|
|
token,
|
|
user: { id: user.id, email: user.email, name: user.name },
|
|
profiles,
|
|
activeProfileId: session.active_profile_id,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/logout', requireAuth, async (req, res, next) => {
|
|
try {
|
|
await pool.query('DELETE FROM auth_sessions WHERE id = $1', [req.auth!.session.id]);
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/me', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const activeProfileId = await resolveProfileId(req);
|
|
const profiles = await getUserProfiles(req.auth!.user.id);
|
|
|
|
res.json({
|
|
user: req.auth!.user,
|
|
profiles,
|
|
activeProfileId,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/sso/:providerKey/start', async (req, res, next) => {
|
|
try {
|
|
const provider = await getAuthProvider(req.params.providerKey);
|
|
|
|
if (!provider?.enabled) {
|
|
res.status(404).json({ error: 'SSO provider is not enabled' });
|
|
return;
|
|
}
|
|
|
|
if (!provider.client_id || !provider.authorization_endpoint) {
|
|
res.status(400).json({ error: 'SSO provider is not fully configured' });
|
|
return;
|
|
}
|
|
|
|
const state = crypto.randomBytes(24).toString('hex');
|
|
const redirectUri = `${apiBaseUrl}/auth/sso/${provider.provider_key}/callback`;
|
|
await pool.query(
|
|
'INSERT INTO oauth_states (provider_key, state_code, redirect_uri) VALUES ($1, $2, $3)',
|
|
[provider.provider_key, state, redirectUri],
|
|
);
|
|
|
|
const url = new URL(provider.authorization_endpoint);
|
|
url.searchParams.set('client_id', provider.client_id);
|
|
url.searchParams.set('redirect_uri', redirectUri);
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('scope', provider.scopes || 'openid profile email');
|
|
url.searchParams.set('state', state);
|
|
|
|
if (provider.provider_key === 'google') {
|
|
url.searchParams.set('access_type', 'offline');
|
|
url.searchParams.set('prompt', 'select_account');
|
|
}
|
|
|
|
res.json({ authorizationUrl: url.toString() });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => {
|
|
try {
|
|
const provider = await getAuthProvider(req.params.providerKey);
|
|
const code = String(req.query.code || '');
|
|
const state = String(req.query.state || '');
|
|
|
|
if (!provider?.enabled || !code || !state) {
|
|
res.status(400).send('Invalid SSO callback');
|
|
return;
|
|
}
|
|
|
|
const stateResult = await pool.query<{ redirect_uri: string }>(
|
|
'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2',
|
|
[provider.provider_key, state],
|
|
);
|
|
|
|
if ((stateResult.rowCount ?? 0) === 0) {
|
|
res.status(400).send('Invalid or expired SSO state');
|
|
return;
|
|
}
|
|
|
|
await pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [
|
|
provider.provider_key,
|
|
state,
|
|
]);
|
|
|
|
if (!provider.client_id || !provider.client_secret || !provider.token_endpoint) {
|
|
res.status(400).send('Provider is missing token configuration');
|
|
return;
|
|
}
|
|
|
|
const redirectUri = stateResult.rows[0].redirect_uri;
|
|
const tokenResponse = await axios.post(
|
|
provider.token_endpoint,
|
|
new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
client_id: provider.client_id,
|
|
client_secret: provider.client_secret,
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
|
);
|
|
|
|
const accessToken = String(tokenResponse.data.access_token || '');
|
|
const idToken = tokenResponse.data.id_token ? String(tokenResponse.data.id_token) : '';
|
|
let userinfo: Record<string, unknown> = {};
|
|
|
|
if (provider.userinfo_endpoint && accessToken) {
|
|
const userinfoResponse = await axios.get(provider.userinfo_endpoint, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
userinfo = userinfoResponse.data as Record<string, unknown>;
|
|
} else if (idToken) {
|
|
userinfo = decodeJwtPayload(idToken) ?? {};
|
|
}
|
|
|
|
const subject = String(userinfo.sub || '');
|
|
const email = normalizeEmail(String(userinfo.email || ''));
|
|
const name =
|
|
String(userinfo.name || '').trim() ||
|
|
`${String(userinfo.given_name || '').trim()} ${String(userinfo.family_name || '').trim()}`.trim() ||
|
|
email ||
|
|
`${provider.display_name} User`;
|
|
|
|
if (!subject || !email) {
|
|
res.status(400).send('Provider did not return a subject and email');
|
|
return;
|
|
}
|
|
|
|
const identityResult = await pool.query<{ user_id: string }>(
|
|
'SELECT user_id FROM auth_identities WHERE provider_key = $1 AND provider_subject = $2',
|
|
[provider.provider_key, subject],
|
|
);
|
|
|
|
let userId = identityResult.rows[0]?.user_id;
|
|
|
|
if (!userId) {
|
|
const existingUser = await pool.query<UserRow>('SELECT id, email, name FROM users WHERE email = $1', [email]);
|
|
|
|
if ((existingUser.rowCount ?? 0) > 0) {
|
|
userId = existingUser.rows[0].id;
|
|
} else {
|
|
const createdUser = await pool.query<UserRow>(
|
|
'INSERT INTO users (email, password_hash, name) VALUES ($1, NULL, $2) RETURNING id, email, name',
|
|
[email, name],
|
|
);
|
|
userId = createdUser.rows[0].id;
|
|
}
|
|
|
|
await pool.query(
|
|
`INSERT INTO auth_identities (user_id, provider_key, provider_subject, email)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (provider_key, provider_subject) DO NOTHING`,
|
|
[userId, provider.provider_key, subject, email],
|
|
);
|
|
}
|
|
|
|
const userResult = await pool.query<UserRow>('SELECT id, email, name FROM users WHERE id = $1', [userId]);
|
|
const user = userResult.rows[0];
|
|
const profile = await ensureDefaultProfile(user.id, user.name);
|
|
const { token } = await createSession(user.id, profile.id);
|
|
|
|
res.redirect(`${frontendUrl}/?token=${encodeURIComponent(token)}`);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/profiles', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profiles = await getUserProfiles(req.auth!.user.id);
|
|
res.json({ profiles, activeProfileId: await resolveProfileId(req) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/profiles', requireAuth, async (req, res, next) => {
|
|
try {
|
|
res.status(403).json({ error: 'Multiple profiles are disabled. Each user has a single arsenal.' });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/profiles/select', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const activeProfileId = await resolveProfileId(req);
|
|
res.json({ activeProfileId });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/settings/auth-providers', requireAuth, async (_req, res, next) => {
|
|
try {
|
|
const result = await pool.query<ProviderConfigRow>(
|
|
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
|
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
|
|
FROM auth_provider_configs
|
|
ORDER BY display_name ASC`,
|
|
);
|
|
|
|
res.json(result.rows.map(serializeProvider));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.put('/api/settings/auth-providers/:providerKey', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const providerKey = getString(req.params.providerKey, 'providerKey');
|
|
const existing = await getAuthProvider(providerKey);
|
|
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Provider not found' });
|
|
return;
|
|
}
|
|
|
|
await pool.query(
|
|
`UPDATE auth_provider_configs
|
|
SET display_name = $2,
|
|
protocol = $3,
|
|
client_id = $4,
|
|
client_secret = $5,
|
|
authorization_endpoint = $6,
|
|
token_endpoint = $7,
|
|
userinfo_endpoint = $8,
|
|
issuer = $9,
|
|
scopes = $10,
|
|
enabled = $11,
|
|
updated_at = NOW()
|
|
WHERE provider_key = $1`,
|
|
[
|
|
providerKey,
|
|
String(req.body?.displayName || existing.display_name),
|
|
String(req.body?.protocol || existing.protocol),
|
|
String(req.body?.clientId ?? existing.client_id ?? ''),
|
|
typeof req.body?.clientSecret === 'string' ? req.body.clientSecret : (existing.client_secret ?? ''),
|
|
String(req.body?.authorizationEndpoint ?? existing.authorization_endpoint ?? ''),
|
|
String(req.body?.tokenEndpoint ?? existing.token_endpoint ?? ''),
|
|
String(req.body?.userinfoEndpoint ?? existing.userinfo_endpoint ?? ''),
|
|
String(req.body?.issuer ?? existing.issuer ?? ''),
|
|
String(req.body?.scopes ?? existing.scopes ?? 'openid profile email'),
|
|
Boolean(req.body?.enabled),
|
|
],
|
|
);
|
|
|
|
const updated = await getAuthProvider(providerKey);
|
|
res.json(serializeProvider(updated!));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/dashboard', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const activeProfileId = await resolveProfileId(req);
|
|
const profiles = await getUserProfiles(req.auth!.user.id);
|
|
const dashboard = await buildDashboard(activeProfileId);
|
|
|
|
res.json({
|
|
user: req.auth!.user,
|
|
profiles,
|
|
activeProfileId,
|
|
...dashboard,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/firearms', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const result = await pool.query<FirearmRow>(
|
|
`SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
|
FROM firearms
|
|
WHERE profile_id = $1
|
|
ORDER BY acquired_on DESC NULLS LAST, created_at DESC`,
|
|
[profileId],
|
|
);
|
|
|
|
res.json(result.rows.map(normalizeFirearm));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/firearms', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const manufacturer = getString(req.body?.manufacturer, 'manufacturer');
|
|
const model = getString(req.body?.model, 'model');
|
|
const category = getString(req.body?.category, 'category');
|
|
const caliber = getString(req.body?.caliber, 'caliber');
|
|
const serialNumber = getString(req.body?.serialNumber, 'serialNumber');
|
|
const purchasePrice = getNumber(req.body?.purchasePrice, 'purchasePrice');
|
|
const acquiredOn = getOptionalString(req.body?.acquiredOn);
|
|
const imageUrl = getOptionalString(req.body?.imageUrl);
|
|
const notes = getOptionalString(req.body?.notes);
|
|
|
|
if (!firearmCategories.includes(category)) {
|
|
res.status(400).json({ error: 'Unsupported firearm category' });
|
|
return;
|
|
}
|
|
|
|
const result = await pool.query<FirearmRow>(
|
|
`INSERT INTO firearms (profile_id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`,
|
|
[profileId, 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', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const manufacturer = getString(req.body?.manufacturer, 'manufacturer');
|
|
const model = getString(req.body?.model, 'model');
|
|
const category = getString(req.body?.category, 'category');
|
|
const caliber = getString(req.body?.caliber, 'caliber');
|
|
const serialNumber = getString(req.body?.serialNumber, 'serialNumber');
|
|
const purchasePrice = getNumber(req.body?.purchasePrice, 'purchasePrice');
|
|
const acquiredOn = getOptionalString(req.body?.acquiredOn);
|
|
const imageUrl = getOptionalString(req.body?.imageUrl);
|
|
const notes = getOptionalString(req.body?.notes);
|
|
|
|
const result = await pool.query<FirearmRow>(
|
|
`UPDATE firearms
|
|
SET manufacturer = $3,
|
|
model = $4,
|
|
category = $5,
|
|
caliber = $6,
|
|
serial_number = $7,
|
|
purchase_price = $8,
|
|
acquired_on = $9,
|
|
image_url = $10,
|
|
notes = $11,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1 AND profile_id = $2
|
|
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`,
|
|
[req.params.id, profileId, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes],
|
|
);
|
|
|
|
if ((result.rowCount ?? 0) === 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', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const result = await pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [
|
|
req.params.id,
|
|
profileId,
|
|
]);
|
|
|
|
if ((result.rowCount ?? 0) === 0) {
|
|
res.status(404).json({ error: 'Firearm not found' });
|
|
return;
|
|
}
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/calibers', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const result = await pool.query<CaliberRow>(
|
|
`SELECT id, name, is_default, is_active
|
|
FROM calibers
|
|
WHERE profile_id = $1
|
|
ORDER BY is_active DESC, is_default DESC, name ASC`,
|
|
[profileId],
|
|
);
|
|
|
|
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', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const name = getString(req.body?.name, 'name');
|
|
|
|
const result = await pool.query<CaliberRow>(
|
|
`INSERT INTO calibers (profile_id, name, is_default, is_active)
|
|
VALUES ($1, $2, $3, TRUE)
|
|
ON CONFLICT (profile_id, name) DO UPDATE
|
|
SET is_active = TRUE
|
|
RETURNING id, name, is_default, is_active`,
|
|
[profileId, name, defaultCalibers.includes(name)],
|
|
);
|
|
|
|
await pool.query(
|
|
`INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round)
|
|
VALUES ($1, $2, 0, 0)
|
|
ON CONFLICT (profile_id, caliber_id) DO NOTHING`,
|
|
[profileId, result.rows[0].id],
|
|
);
|
|
|
|
res.status(201).json(normalizeCaliber(result.rows[0]));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.patch('/api/calibers/:id', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const isActive = Boolean(req.body?.isActive);
|
|
const result = await pool.query<CaliberRow>(
|
|
`UPDATE calibers
|
|
SET is_active = $3
|
|
WHERE id = $1 AND profile_id = $2
|
|
RETURNING id, name, is_default, is_active`,
|
|
[req.params.id, profileId, isActive],
|
|
);
|
|
|
|
if ((result.rowCount ?? 0) === 0) {
|
|
res.status(404).json({ error: 'Caliber not found' });
|
|
return;
|
|
}
|
|
|
|
if (isActive) {
|
|
await pool.query(
|
|
`INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round)
|
|
VALUES ($1, $2, 0, 0)
|
|
ON CONFLICT (profile_id, caliber_id) DO NOTHING`,
|
|
[profileId, req.params.id],
|
|
);
|
|
}
|
|
|
|
res.json(normalizeCaliber(result.rows[0]));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/ammo', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
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 ai.profile_id = $1 AND c.profile_id = $1 AND c.is_active = TRUE
|
|
ORDER BY c.name ASC`,
|
|
[profileId],
|
|
);
|
|
|
|
res.json(result.rows.map(normalizeAmmoInventory));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.patch('/api/ammo/:caliberId', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const profileId = await resolveProfileId(req);
|
|
const rounds = getNumber(req.body?.rounds, 'rounds');
|
|
const costPerRound = req.body?.costPerRound == null ? null : getNumber(req.body?.costPerRound, 'costPerRound');
|
|
|
|
const result = await pool.query<AmmoInventoryRow>(
|
|
`UPDATE ammo_inventory ai
|
|
SET rounds_on_hand = GREATEST(0, ai.rounds_on_hand + $3),
|
|
cost_per_round = CASE WHEN $4::numeric IS NULL THEN ai.cost_per_round ELSE $4 END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
FROM calibers c
|
|
WHERE ai.caliber_id = $2
|
|
AND ai.profile_id = $1
|
|
AND c.id = ai.caliber_id
|
|
AND c.profile_id = $1
|
|
RETURNING ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round`,
|
|
[profileId, req.params.caliberId, rounds, costPerRound],
|
|
);
|
|
|
|
if ((result.rowCount ?? 0) === 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);
|
|
});
|