diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/app.ts b/backend/src/app.ts index 1d4e086..f752fd4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,77 +7,20 @@ import express from 'express'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import morgan from 'morgan'; -import pg from 'pg'; + +import { + ArsenalIqClient, + type AmmoInventoryRow, + type CaliberRow, + type FirearmMutation, + type FirearmRow, + type ProviderConfigRow, + type SessionRow, + type UserRow, +} from './clients/arsenalIqClient.js'; 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; @@ -106,16 +49,14 @@ const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase const allowDemoAccount = (process.env.ALLOW_DEMO_ACCOUNT ?? 'false').toLowerCase() === 'true'; const demoAccountPassword = process.env.DEMO_ACCOUNT_PASSWORD ?? 'demo1234'; const demoAccountName = process.env.DEMO_ACCOUNT_NAME ?? 'Demo User'; -const { Pool } = pg; -const pool = databaseUrl - ? new Pool({ connectionString: databaseUrl }) - : new Pool({ - host: postgresHost, - port: postgresPort, - database: postgresDatabase, - user: postgresUser, - password: postgresPassword, - }); +const db = new ArsenalIqClient({ + databaseUrl, + host: postgresHost, + port: postgresPort, + database: postgresDatabase, + user: postgresUser, + password: postgresPassword, +}); const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge - Birdshot', '12 Gauge - Buckshot', '12 Gauge - Slug', '12 Gauge - Sporting', '.45 ACP']; const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other']; @@ -230,160 +171,8 @@ const decodeJwtPayload = (token: string) => { } }; -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) => { +<<<<<<< HEAD await pool.query( `UPDATE calibers SET name = '12 Gauge - Sporting', @@ -431,33 +220,15 @@ const ensureProfileDefaults = async (profileId: string) => { [profileId, caliberResult.rows[0].id], ); } +======= + await db.ensureProfileDefaults(profileId, defaultCalibers); +>>>>>>> bcd4459 (chanmged app.ts based on Onyxoasis's recommendations) }; -const getUserProfiles = async (userId: string) => { - const result = await pool.query( - 'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC', - [userId], - ); +const getUserProfiles = async (userId: string) => db.getUserProfiles(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( - '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 ensureDefaultProfile = async (userId: string, userName: string) => + db.ensureDefaultProfile(userId, userName, defaultCalibers); const ensureDemoAccount = async () => { if (!allowDemoAccount) { @@ -465,23 +236,15 @@ const ensureDemoAccount = async () => { } const passwordHash = await bcrypt.hash(demoAccountPassword, 10); - const existing = await pool.query('SELECT id, email, name FROM users WHERE email = $1', [demoAccountEmail]); + const existing = await db.findUserByEmail(demoAccountEmail); let user: UserRow; - if ((existing.rowCount ?? 0) > 0) { - user = existing.rows[0]; - await pool.query('UPDATE users SET name = $2, password_hash = $3 WHERE id = $1', [ - user.id, - demoAccountName, - passwordHash, - ]); + if (existing) { + user = existing; + await db.updateUserPasswordAndName(user.id, demoAccountName, passwordHash); } else { - const created = await pool.query( - 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name', - [demoAccountEmail, passwordHash, demoAccountName], - ); - user = created.rows[0]; + user = await db.createUser(demoAccountEmail, passwordHash, demoAccountName); } const profile = await ensureDefaultProfile(user.id, demoAccountName); @@ -492,100 +255,49 @@ 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( - `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()], - ); + const session = await db.createSession(userId, activeProfileId, tokenHash, expiresAt.toISOString()); return { token, - session: result.rows[0], + session, }; }; const getSessionFromToken = async (rawToken: string) => { const tokenHash = hashToken(rawToken); - const result = await pool.query( - `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], - ); + const sessionData = await db.getSessionByTokenHash(tokenHash); - if ((result.rowCount ?? 0) === 0) { + if (!sessionData) { return null; } return { user: { - id: result.rows[0].user_id, - email: result.rows[0].email, - name: result.rows[0].name, + id: sessionData.user_id, + email: sessionData.email, + name: sessionData.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, + id: sessionData.id, + user_id: sessionData.user_id, + active_profile_id: sessionData.active_profile_id, + expires_at: sessionData.expires_at, }, }; }; -const getAuthProvider = async (providerKey: string) => { - const result = await pool.query( - `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 getAuthProvider = async (providerKey: string) => db.getAuthProvider(providerKey); const buildDashboard = async (profileId: string) => { await ensureProfileDefaults(profileId); - const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([ - pool.query( - `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( - `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( - `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( - `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, firearms, calibers, ammoInventory] = await Promise.all([ + db.getDashboardSummary(profileId), + db.listFirearms(profileId), + db.listCalibers(profileId), + db.listAmmoInventory(profileId), ]); - const summary = summaryResult.rows[0]; - return { summary: { totalFirearms: Number(summary.totalFirearms || 0), @@ -594,9 +306,9 @@ const buildDashboard = async (profileId: string) => { ammoInvestment: formatCurrency(summary.ammoInvestment), configuredCalibers: Number(summary.configuredCalibers || 0), }, - firearms: firearmsResult.rows.map(normalizeFirearm), - calibers: calibersResult.rows.map(normalizeCaliber), - ammoInventory: ammoResult.rows.map(normalizeAmmoInventory), + firearms: firearms.map(normalizeFirearm), + calibers: calibers.map(normalizeCaliber), + ammoInventory: ammoInventory.map(normalizeAmmoInventory), defaultCalibers, }; }; @@ -605,17 +317,11 @@ const resolveProfileId = async (req: express.Request) => { const requestedProfileId = String(req.header('x-profile-id') || ''); if (requestedProfileId && isUuid(requestedProfileId)) { - const result = await pool.query( - 'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2', - [requestedProfileId, req.auth!.user.id], - ); + const profile = await db.getProfileForUser(requestedProfileId, req.auth!.user.id); - if ((result.rowCount ?? 0) > 0) { + if (profile) { await ensureProfileDefaults(requestedProfileId); - await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [ - requestedProfileId, - req.auth!.session.id, - ]); + await db.setSessionActiveProfile(req.auth!.session.id, requestedProfileId); req.auth!.session.active_profile_id = requestedProfileId; return requestedProfileId; } @@ -627,10 +333,7 @@ const resolveProfileId = async (req: express.Request) => { } 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, - ]); + await db.setSessionActiveProfile(req.auth!.session.id, fallbackProfile.id); req.auth!.session.active_profile_id = fallbackProfile.id; return fallbackProfile.id; }; @@ -664,6 +367,18 @@ const requireAuth = async (req: express.Request, res: express.Response, next: ex } }; +const getFirearmInput = (body: unknown): FirearmMutation => ({ + manufacturer: getString((body as Record | undefined)?.manufacturer, 'manufacturer'), + model: getString((body as Record | undefined)?.model, 'model'), + category: getString((body as Record | undefined)?.category, 'category'), + caliber: getString((body as Record | undefined)?.caliber, 'caliber'), + serialNumber: getString((body as Record | undefined)?.serialNumber, 'serialNumber'), + purchasePrice: getNumber((body as Record | undefined)?.purchasePrice, 'purchasePrice'), + acquiredOn: getOptionalString((body as Record | undefined)?.acquiredOn), + imageUrl: getOptionalString((body as Record | undefined)?.imageUrl), + notes: getOptionalString((body as Record | undefined)?.notes), +}); + app.use(helmet()); app.use( cors({ @@ -690,8 +405,8 @@ 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 }); + const timestamp = await db.getNow(); + res.json({ status: 'ok', database: 'connected', timestamp }); } catch (error) { next(error); } @@ -745,16 +460,10 @@ app.post('/api/auth/demo', async (_req, res, next) => { app.get('/api/auth/providers', async (_req, res, next) => { try { - const result = await pool.query( - `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`, - ); + const providers = await db.listEnabledAuthProviders(); res.json( - result.rows.map((provider) => ({ + providers.map((provider) => ({ providerKey: provider.provider_key, displayName: provider.display_name, })), @@ -775,20 +484,13 @@ app.post('/api/auth/register', async (req, res, next) => { 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) { + if (await db.userExistsByEmail(email)) { 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( - 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name', - [email, passwordHash, name], - ); - - const user = userResult.rows[0]; + const user = await db.createUser(email, passwordHash, name); const profile = await ensureDefaultProfile(user.id, user.name); const { token, session } = await createSession(user.id, profile.id); @@ -807,18 +509,13 @@ 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( - 'SELECT id, email, name, password_hash FROM users WHERE email = $1', - [email], - ); + const user = await db.findUserWithPasswordByEmail(email); - if ((result.rowCount ?? 0) === 0) { + if (!user) { 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; @@ -848,7 +545,7 @@ app.post('/api/auth/login', async (req, res, next) => { 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]); + await db.deleteSession(req.auth!.session.id); res.status(204).send(); } catch (error) { next(error); @@ -886,10 +583,7 @@ app.get('/api/auth/sso/:providerKey/start', async (req, res, next) => { 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], - ); + await db.createOauthState(provider.provider_key, state, redirectUri); const url = new URL(provider.authorization_endpoint); url.searchParams.set('client_id', provider.client_id); @@ -920,27 +614,21 @@ app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => { 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], - ); + const oauthState = await db.getOauthState(provider.provider_key, state); - if ((stateResult.rowCount ?? 0) === 0) { + if (!oauthState) { 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, - ]); + await db.deleteOauthState(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 redirectUri = oauthState.redirect_uri; const tokenResponse = await axios.post( provider.token_endpoint, new URLSearchParams({ @@ -979,36 +667,28 @@ app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => { 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; + let userId = await db.findIdentityUserId(provider.provider_key, subject); if (!userId) { - const existingUser = await pool.query('SELECT id, email, name FROM users WHERE email = $1', [email]); + const existingUser = await db.findUserByEmail(email); - if ((existingUser.rowCount ?? 0) > 0) { - userId = existingUser.rows[0].id; + if (existingUser) { + userId = existingUser.id; } else { - const createdUser = await pool.query( - 'INSERT INTO users (email, password_hash, name) VALUES ($1, NULL, $2) RETURNING id, email, name', - [email, name], - ); - userId = createdUser.rows[0].id; + const createdUser = await db.createUser(email, null, name); + userId = createdUser.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], - ); + await db.createIdentity(userId, provider.provider_key, subject, email); + } + + const user = await db.getUserById(userId); + + if (!user) { + res.status(500).send('Unable to load SSO user'); + return; } - const userResult = await pool.query('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); @@ -1027,7 +707,7 @@ app.get('/api/profiles', requireAuth, async (req, res, next) => { } }); -app.post('/api/profiles', requireAuth, async (req, res, next) => { +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) { @@ -1046,14 +726,8 @@ app.post('/api/profiles/select', requireAuth, async (req, res, next) => { app.get('/api/settings/auth-providers', requireAuth, async (_req, res, next) => { try { - const result = await pool.query( - `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)); + const providers = await db.listAuthProviders(); + res.json(providers.map(serializeProvider)); } catch (error) { next(error); } @@ -1069,34 +743,18 @@ app.put('/api/settings/auth-providers/:providerKey', requireAuth, async (req, re 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), - ], - ); + await db.updateAuthProvider(providerKey, { + displayName: String(req.body?.displayName || existing.display_name), + protocol: String(req.body?.protocol || existing.protocol), + clientId: String(req.body?.clientId ?? existing.client_id ?? ''), + clientSecret: typeof req.body?.clientSecret === 'string' ? req.body.clientSecret : (existing.client_secret ?? ''), + authorizationEndpoint: String(req.body?.authorizationEndpoint ?? existing.authorization_endpoint ?? ''), + tokenEndpoint: String(req.body?.tokenEndpoint ?? existing.token_endpoint ?? ''), + userinfoEndpoint: String(req.body?.userinfoEndpoint ?? existing.userinfo_endpoint ?? ''), + issuer: String(req.body?.issuer ?? existing.issuer ?? ''), + scopes: String(req.body?.scopes ?? existing.scopes ?? 'openid profile email'), + enabled: Boolean(req.body?.enabled), + }); const updated = await getAuthProvider(providerKey); res.json(serializeProvider(updated!)); @@ -1125,15 +783,8 @@ app.get('/api/dashboard', requireAuth, async (req, res, next) => { app.get('/api/firearms', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); - const result = await pool.query( - `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)); + const firearms = await db.listFirearms(profileId); + res.json(firearms.map(normalizeFirearm)); } catch (error) { next(error); } @@ -1142,29 +793,15 @@ app.get('/api/firearms', requireAuth, async (req, res, next) => { 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); + const firearm = getFirearmInput(req.body); - if (!firearmCategories.includes(category)) { + if (!firearmCategories.includes(firearm.category)) { res.status(400).json({ error: 'Unsupported firearm category' }); return; } - const result = await pool.query( - `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])); + const created = await db.createFirearm(profileId, firearm); + res.status(201).json(normalizeFirearm(created)); } catch (error) { next(error); } @@ -1173,39 +810,15 @@ app.post('/api/firearms', requireAuth, async (req, res, next) => { 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 firearm = getFirearmInput(req.body); + const updated = await db.updateFirearm(req.params.id, profileId, firearm); - const result = await pool.query( - `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) { + if (!updated) { res.status(404).json({ error: 'Firearm not found' }); return; } - res.json(normalizeFirearm(result.rows[0])); + res.json(normalizeFirearm(updated)); } catch (error) { next(error); } @@ -1214,12 +827,9 @@ app.put('/api/firearms/:id', requireAuth, async (req, res, next) => { 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, - ]); + const deleted = await db.deleteFirearm(req.params.id, profileId); - if ((result.rowCount ?? 0) === 0) { + if (!deleted) { res.status(404).json({ error: 'Firearm not found' }); return; } @@ -1233,19 +843,11 @@ app.delete('/api/firearms/:id', requireAuth, async (req, res, next) => { app.get('/api/calibers', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); - const result = await pool.query( - `SELECT id, name, is_default, is_active - FROM calibers - WHERE profile_id = $1 - ORDER BY is_active DESC, is_default DESC, name ASC`, - [profileId], - ); + const calibers = await db.listCalibers(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), - ), + configured: calibers.filter((row) => row.is_active).map(normalizeCaliber), + availableDefaults: defaultCalibers.filter((name) => !calibers.some((row) => row.name === name && row.is_active)), }); } catch (error) { next(error); @@ -1256,24 +858,11 @@ app.post('/api/calibers', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const name = getString(req.body?.name, 'name'); + const caliber = await db.upsertCaliber(profileId, name, defaultCalibers.includes(name)); - const result = await pool.query( - `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 db.ensureAmmoInventory(profileId, caliber.id); - 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])); + res.status(201).json(normalizeCaliber(caliber)); } catch (error) { next(error); } @@ -1283,29 +872,18 @@ 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( - `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], - ); + const caliber = await db.updateCaliberActive(req.params.id, profileId, isActive); - if ((result.rowCount ?? 0) === 0) { + if (!caliber) { 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], - ); + await db.ensureAmmoInventory(profileId, req.params.id); } - res.json(normalizeCaliber(result.rows[0])); + res.json(normalizeCaliber(caliber)); } catch (error) { next(error); } @@ -1314,16 +892,8 @@ app.patch('/api/calibers/:id', requireAuth, async (req, res, next) => { app.get('/api/ammo', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); - const result = await pool.query( - `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)); + const ammoInventory = await db.listAmmoInventory(profileId); + res.json(ammoInventory.map(normalizeAmmoInventory)); } catch (error) { next(error); } @@ -1334,27 +904,14 @@ app.patch('/api/ammo/:caliberId', requireAuth, async (req, res, next) => { 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 ammoInventory = await db.updateAmmoInventory(profileId, req.params.caliberId, rounds, costPerRound); - const result = await pool.query( - `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) { + if (!ammoInventory) { res.status(404).json({ error: 'Ammo inventory not found' }); return; } - res.json(normalizeAmmoInventory(result.rows[0])); + res.json(normalizeAmmoInventory(ammoInventory)); } catch (error) { next(error); } @@ -1371,7 +928,8 @@ app.use((error: Error, _req: express.Request, res: express.Response, _next: expr }); }); -void ensureSchema() +void db + .ensureSchema() .then(async () => { await ensureDemoAccount(); app.listen(port, () => { @@ -1380,5 +938,6 @@ void ensureSchema() }) .catch((error) => { console.error('Failed to initialize schema', error); + void db.close(); process.exit(1); }); diff --git a/backend/src/clients/arsenalIqClient.ts b/backend/src/clients/arsenalIqClient.ts new file mode 100644 index 0000000..ae9b43a --- /dev/null +++ b/backend/src/clients/arsenalIqClient.ts @@ -0,0 +1,686 @@ +import pg from 'pg'; + +export type UserRow = { + id: string; + email: string; + name: string; +}; + +export type ProfileRow = { + id: string; + name: string; +}; + +export type SessionRow = { + id: string; + user_id: string; + active_profile_id: string | null; + expires_at: string; +}; + +export 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; +}; + +export type DashboardSummaryRow = { + totalFirearms: number; + totalAmmoRounds: number; + firearmsInvestment: string; + ammoInvestment: string; + configuredCalibers: number; +}; + +export 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; +}; + +export type CaliberRow = { + id: string; + name: string; + is_default: boolean; + is_active: boolean; +}; + +export type AmmoInventoryRow = { + caliber_id: string; + caliber_name: string; + rounds_on_hand: number; + cost_per_round: string; +}; + +type SessionWithUserRow = SessionRow & { + email: string; + name: string; +}; + +type UserWithPasswordRow = UserRow & { + password_hash: string | null; +}; + +export type AuthProviderUpdate = { + displayName: string; + protocol: string; + clientId: string; + clientSecret: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + issuer: string; + scopes: string; + enabled: boolean; +}; + +export type FirearmMutation = { + manufacturer: string; + model: string; + category: string; + caliber: string; + serialNumber: string; + purchasePrice: number; + acquiredOn: string | null; + imageUrl: string | null; + notes: string | null; +}; + +export type ClientConfig = { + databaseUrl: string; + host: string; + port: number; + database: string; + user: string; + password: string; +}; + +export class ArsenalIqClient { + private pool: pg.Pool; + + constructor(config: ClientConfig) { + const { Pool } = pg; + this.pool = config.databaseUrl + ? new Pool({ connectionString: config.databaseUrl }) + : new Pool({ + host: config.host, + port: config.port, + database: config.database, + user: config.user, + password: config.password, + }); + } + + async getNow() { + const result = await this.pool.query<{ now: string }>('SELECT NOW() AS now'); + return result.rows[0].now; + } + + async ensureSchema() { + await this.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); + `); + + await this.seedAuthProviders(); + } + + async ensureProfileDefaults(profileId: string, defaultCalibers: string[]) { + for (const caliber of defaultCalibers) { + const caliberResult = await this.pool.query( + `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 this.ensureAmmoInventory(profileId, caliberResult.rows[0].id); + } + } + + async getUserProfiles(userId: string) { + const result = await this.pool.query( + 'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC', + [userId], + ); + + return result.rows; + } + + async ensureDefaultProfile(userId: string, userName: string, defaultCalibers: string[]) { + const profiles = await this.getUserProfiles(userId); + + if (profiles.length > 0) { + await this.ensureProfileDefaults(profiles[0].id, defaultCalibers); + return profiles[0]; + } + + const created = await this.pool.query( + 'INSERT INTO profiles (user_id, name) VALUES ($1, $2) RETURNING id, name', + [userId, `${userName.split(' ')[0] || 'Primary'} Arsenal`], + ); + + await this.ensureProfileDefaults(created.rows[0].id, defaultCalibers); + return created.rows[0]; + } + + async findUserByEmail(email: string) { + const result = await this.pool.query('SELECT id, email, name FROM users WHERE email = $1', [email]); + return result.rows[0] ?? null; + } + + async findUserWithPasswordByEmail(email: string) { + const result = await this.pool.query( + 'SELECT id, email, name, password_hash FROM users WHERE email = $1', + [email], + ); + + return result.rows[0] ?? null; + } + + async userExistsByEmail(email: string) { + const result = await this.pool.query<{ id: string }>('SELECT id FROM users WHERE email = $1', [email]); + return (result.rowCount ?? 0) > 0; + } + + async updateUserPasswordAndName(userId: string, name: string, passwordHash: string) { + await this.pool.query('UPDATE users SET name = $2, password_hash = $3 WHERE id = $1', [ + userId, + name, + passwordHash, + ]); + } + + async createUser(email: string, passwordHash: string | null, name: string) { + const result = await this.pool.query( + 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name', + [email, passwordHash, name], + ); + + return result.rows[0]; + } + + async getUserById(userId: string) { + const result = await this.pool.query('SELECT id, email, name FROM users WHERE id = $1', [userId]); + return result.rows[0] ?? null; + } + + async createSession(userId: string, activeProfileId: string, tokenHash: string, expiresAtIso: string) { + const result = await this.pool.query( + `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, expiresAtIso], + ); + + return result.rows[0]; + } + + async getSessionByTokenHash(tokenHash: string) { + const result = await this.pool.query( + `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], + ); + + return result.rows[0] ?? null; + } + + async deleteSession(sessionId: string) { + await this.pool.query('DELETE FROM auth_sessions WHERE id = $1', [sessionId]); + } + + async setSessionActiveProfile(sessionId: string, profileId: string) { + await this.pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [profileId, sessionId]); + } + + async getProfileForUser(profileId: string, userId: string) { + const result = await this.pool.query( + 'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2', + [profileId, userId], + ); + + return result.rows[0] ?? null; + } + + async getAuthProvider(providerKey: string) { + const result = await this.pool.query( + `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; + } + + async listEnabledAuthProviders() { + const result = await this.pool.query( + `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`, + ); + + return result.rows; + } + + async listAuthProviders() { + const result = await this.pool.query( + `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`, + ); + + return result.rows; + } + + async updateAuthProvider(providerKey: string, update: AuthProviderUpdate) { + await this.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, + update.displayName, + update.protocol, + update.clientId, + update.clientSecret, + update.authorizationEndpoint, + update.tokenEndpoint, + update.userinfoEndpoint, + update.issuer, + update.scopes, + update.enabled, + ], + ); + } + + async createOauthState(providerKey: string, stateCode: string, redirectUri: string) { + await this.pool.query( + 'INSERT INTO oauth_states (provider_key, state_code, redirect_uri) VALUES ($1, $2, $3)', + [providerKey, stateCode, redirectUri], + ); + } + + async getOauthState(providerKey: string, stateCode: string) { + const result = await this.pool.query<{ redirect_uri: string }>( + 'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2', + [providerKey, stateCode], + ); + + return result.rows[0] ?? null; + } + + async deleteOauthState(providerKey: string, stateCode: string) { + await this.pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [ + providerKey, + stateCode, + ]); + } + + async findIdentityUserId(providerKey: string, subject: string) { + const result = await this.pool.query<{ user_id: string }>( + 'SELECT user_id FROM auth_identities WHERE provider_key = $1 AND provider_subject = $2', + [providerKey, subject], + ); + + return result.rows[0]?.user_id ?? null; + } + + async createIdentity(userId: string, providerKey: string, subject: string, email: string) { + await this.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, providerKey, subject, email], + ); + } + + async getDashboardSummary(profileId: string) { + const result = await this.pool.query( + `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], + ); + + return result.rows[0]; + } + + async listFirearms(profileId: string) { + const result = await this.pool.query( + `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], + ); + + return result.rows; + } + + async createFirearm(profileId: string, firearm: FirearmMutation) { + const result = await this.pool.query( + `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, + firearm.manufacturer, + firearm.model, + firearm.category, + firearm.caliber, + firearm.serialNumber, + firearm.purchasePrice, + firearm.acquiredOn, + firearm.imageUrl, + firearm.notes, + ], + ); + + return result.rows[0]; + } + + async updateFirearm(id: string, profileId: string, firearm: FirearmMutation) { + const result = await this.pool.query( + `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`, + [ + id, + profileId, + firearm.manufacturer, + firearm.model, + firearm.category, + firearm.caliber, + firearm.serialNumber, + firearm.purchasePrice, + firearm.acquiredOn, + firearm.imageUrl, + firearm.notes, + ], + ); + + return result.rows[0] ?? null; + } + + async deleteFirearm(id: string, profileId: string) { + const result = await this.pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [id, profileId]); + return (result.rowCount ?? 0) > 0; + } + + async listCalibers(profileId: string) { + const result = await this.pool.query( + `SELECT id, name, is_default, is_active + FROM calibers + WHERE profile_id = $1 + ORDER BY is_active DESC, is_default DESC, name ASC`, + [profileId], + ); + + return result.rows; + } + + async upsertCaliber(profileId: string, name: string, isDefault: boolean) { + const result = await this.pool.query( + `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, isDefault], + ); + + return result.rows[0]; + } + + async updateCaliberActive(id: string, profileId: string, isActive: boolean) { + const result = await this.pool.query( + `UPDATE calibers + SET is_active = $3 + WHERE id = $1 AND profile_id = $2 + RETURNING id, name, is_default, is_active`, + [id, profileId, isActive], + ); + + return result.rows[0] ?? null; + } + + async ensureAmmoInventory(profileId: string, caliberId: string) { + await this.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, caliberId], + ); + } + + async listAmmoInventory(profileId: string) { + const result = await this.pool.query( + `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], + ); + + return result.rows; + } + + async updateAmmoInventory(profileId: string, caliberId: string, rounds: number, costPerRound: number | null) { + const result = await this.pool.query( + `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, caliberId, rounds, costPerRound], + ); + + return result.rows[0] ?? null; + } + + async close() { + await this.pool.end(); + } + + private async seedAuthProviders() { + 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 this.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, + ], + ); + } + } +}