chanmged app.ts based on Onyxoasis's recommendations
This commit is contained in:
+134
-575
@@ -7,77 +7,20 @@ import express from 'express';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import morgan from 'morgan';
|
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();
|
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 = {
|
type AuthContext = {
|
||||||
user: UserRow;
|
user: UserRow;
|
||||||
session: SessionRow;
|
session: SessionRow;
|
||||||
@@ -106,10 +49,8 @@ const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase
|
|||||||
const allowDemoAccount = (process.env.ALLOW_DEMO_ACCOUNT ?? 'false').toLowerCase() === 'true';
|
const allowDemoAccount = (process.env.ALLOW_DEMO_ACCOUNT ?? 'false').toLowerCase() === 'true';
|
||||||
const demoAccountPassword = process.env.DEMO_ACCOUNT_PASSWORD ?? 'demo1234';
|
const demoAccountPassword = process.env.DEMO_ACCOUNT_PASSWORD ?? 'demo1234';
|
||||||
const demoAccountName = process.env.DEMO_ACCOUNT_NAME ?? 'Demo User';
|
const demoAccountName = process.env.DEMO_ACCOUNT_NAME ?? 'Demo User';
|
||||||
const { Pool } = pg;
|
const db = new ArsenalIqClient({
|
||||||
const pool = databaseUrl
|
databaseUrl,
|
||||||
? new Pool({ connectionString: databaseUrl })
|
|
||||||
: new Pool({
|
|
||||||
host: postgresHost,
|
host: postgresHost,
|
||||||
port: postgresPort,
|
port: postgresPort,
|
||||||
database: postgresDatabase,
|
database: postgresDatabase,
|
||||||
@@ -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) => {
|
const ensureProfileDefaults = async (profileId: string) => {
|
||||||
|
<<<<<<< HEAD
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE calibers
|
`UPDATE calibers
|
||||||
SET name = '12 Gauge - Sporting',
|
SET name = '12 Gauge - Sporting',
|
||||||
@@ -431,33 +220,15 @@ const ensureProfileDefaults = async (profileId: string) => {
|
|||||||
[profileId, caliberResult.rows[0].id],
|
[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 getUserProfiles = async (userId: string) => db.getUserProfiles(userId);
|
||||||
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) =>
|
||||||
};
|
db.ensureDefaultProfile(userId, userName, defaultCalibers);
|
||||||
|
|
||||||
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 ensureDemoAccount = async () => {
|
const ensureDemoAccount = async () => {
|
||||||
if (!allowDemoAccount) {
|
if (!allowDemoAccount) {
|
||||||
@@ -465,23 +236,15 @@ const ensureDemoAccount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(demoAccountPassword, 10);
|
const passwordHash = await bcrypt.hash(demoAccountPassword, 10);
|
||||||
const existing = await pool.query<UserRow>('SELECT id, email, name FROM users WHERE email = $1', [demoAccountEmail]);
|
const existing = await db.findUserByEmail(demoAccountEmail);
|
||||||
|
|
||||||
let user: UserRow;
|
let user: UserRow;
|
||||||
|
|
||||||
if ((existing.rowCount ?? 0) > 0) {
|
if (existing) {
|
||||||
user = existing.rows[0];
|
user = existing;
|
||||||
await pool.query('UPDATE users SET name = $2, password_hash = $3 WHERE id = $1', [
|
await db.updateUserPasswordAndName(user.id, demoAccountName, passwordHash);
|
||||||
user.id,
|
|
||||||
demoAccountName,
|
|
||||||
passwordHash,
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
const created = await pool.query<UserRow>(
|
user = await db.createUser(demoAccountEmail, passwordHash, demoAccountName);
|
||||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name',
|
|
||||||
[demoAccountEmail, passwordHash, demoAccountName],
|
|
||||||
);
|
|
||||||
user = created.rows[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await ensureDefaultProfile(user.id, demoAccountName);
|
const profile = await ensureDefaultProfile(user.id, demoAccountName);
|
||||||
@@ -492,100 +255,49 @@ const createSession = async (userId: string, activeProfileId: string) => {
|
|||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
const expiresAt = new Date(Date.now() + sessionHours * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + sessionHours * 60 * 60 * 1000);
|
||||||
|
const session = await db.createSession(userId, activeProfileId, tokenHash, expiresAt.toISOString());
|
||||||
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 {
|
return {
|
||||||
token,
|
token,
|
||||||
session: result.rows[0],
|
session,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSessionFromToken = async (rawToken: string) => {
|
const getSessionFromToken = async (rawToken: string) => {
|
||||||
const tokenHash = hashToken(rawToken);
|
const tokenHash = hashToken(rawToken);
|
||||||
const result = await pool.query<SessionRow & { email: string; name: string }>(
|
const sessionData = await db.getSessionByTokenHash(tokenHash);
|
||||||
`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) {
|
if (!sessionData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: result.rows[0].user_id,
|
id: sessionData.user_id,
|
||||||
email: result.rows[0].email,
|
email: sessionData.email,
|
||||||
name: result.rows[0].name,
|
name: sessionData.name,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: result.rows[0].id,
|
id: sessionData.id,
|
||||||
user_id: result.rows[0].user_id,
|
user_id: sessionData.user_id,
|
||||||
active_profile_id: result.rows[0].active_profile_id,
|
active_profile_id: sessionData.active_profile_id,
|
||||||
expires_at: result.rows[0].expires_at,
|
expires_at: sessionData.expires_at,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAuthProvider = async (providerKey: string) => {
|
const getAuthProvider = async (providerKey: string) => db.getAuthProvider(providerKey);
|
||||||
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) => {
|
const buildDashboard = async (profileId: string) => {
|
||||||
await ensureProfileDefaults(profileId);
|
await ensureProfileDefaults(profileId);
|
||||||
|
|
||||||
const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([
|
const [summary, firearms, calibers, ammoInventory] = await Promise.all([
|
||||||
pool.query<DashboardSummaryRow>(
|
db.getDashboardSummary(profileId),
|
||||||
`SELECT
|
db.listFirearms(profileId),
|
||||||
(SELECT COUNT(*)::int FROM firearms WHERE profile_id = $1) AS "totalFirearms",
|
db.listCalibers(profileId),
|
||||||
COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory WHERE profile_id = $1), 0) AS "totalAmmoRounds",
|
db.listAmmoInventory(profileId),
|
||||||
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 {
|
return {
|
||||||
summary: {
|
summary: {
|
||||||
totalFirearms: Number(summary.totalFirearms || 0),
|
totalFirearms: Number(summary.totalFirearms || 0),
|
||||||
@@ -594,9 +306,9 @@ const buildDashboard = async (profileId: string) => {
|
|||||||
ammoInvestment: formatCurrency(summary.ammoInvestment),
|
ammoInvestment: formatCurrency(summary.ammoInvestment),
|
||||||
configuredCalibers: Number(summary.configuredCalibers || 0),
|
configuredCalibers: Number(summary.configuredCalibers || 0),
|
||||||
},
|
},
|
||||||
firearms: firearmsResult.rows.map(normalizeFirearm),
|
firearms: firearms.map(normalizeFirearm),
|
||||||
calibers: calibersResult.rows.map(normalizeCaliber),
|
calibers: calibers.map(normalizeCaliber),
|
||||||
ammoInventory: ammoResult.rows.map(normalizeAmmoInventory),
|
ammoInventory: ammoInventory.map(normalizeAmmoInventory),
|
||||||
defaultCalibers,
|
defaultCalibers,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -605,17 +317,11 @@ const resolveProfileId = async (req: express.Request) => {
|
|||||||
const requestedProfileId = String(req.header('x-profile-id') || '');
|
const requestedProfileId = String(req.header('x-profile-id') || '');
|
||||||
|
|
||||||
if (requestedProfileId && isUuid(requestedProfileId)) {
|
if (requestedProfileId && isUuid(requestedProfileId)) {
|
||||||
const result = await pool.query<ProfileRow>(
|
const profile = await db.getProfileForUser(requestedProfileId, req.auth!.user.id);
|
||||||
'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2',
|
|
||||||
[requestedProfileId, req.auth!.user.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((result.rowCount ?? 0) > 0) {
|
if (profile) {
|
||||||
await ensureProfileDefaults(requestedProfileId);
|
await ensureProfileDefaults(requestedProfileId);
|
||||||
await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [
|
await db.setSessionActiveProfile(req.auth!.session.id, requestedProfileId);
|
||||||
requestedProfileId,
|
|
||||||
req.auth!.session.id,
|
|
||||||
]);
|
|
||||||
req.auth!.session.active_profile_id = requestedProfileId;
|
req.auth!.session.active_profile_id = requestedProfileId;
|
||||||
return 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);
|
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', [
|
await db.setSessionActiveProfile(req.auth!.session.id, fallbackProfile.id);
|
||||||
fallbackProfile.id,
|
|
||||||
req.auth!.session.id,
|
|
||||||
]);
|
|
||||||
req.auth!.session.active_profile_id = fallbackProfile.id;
|
req.auth!.session.active_profile_id = fallbackProfile.id;
|
||||||
return 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<string, unknown> | undefined)?.manufacturer, 'manufacturer'),
|
||||||
|
model: getString((body as Record<string, unknown> | undefined)?.model, 'model'),
|
||||||
|
category: getString((body as Record<string, unknown> | undefined)?.category, 'category'),
|
||||||
|
caliber: getString((body as Record<string, unknown> | undefined)?.caliber, 'caliber'),
|
||||||
|
serialNumber: getString((body as Record<string, unknown> | undefined)?.serialNumber, 'serialNumber'),
|
||||||
|
purchasePrice: getNumber((body as Record<string, unknown> | undefined)?.purchasePrice, 'purchasePrice'),
|
||||||
|
acquiredOn: getOptionalString((body as Record<string, unknown> | undefined)?.acquiredOn),
|
||||||
|
imageUrl: getOptionalString((body as Record<string, unknown> | undefined)?.imageUrl),
|
||||||
|
notes: getOptionalString((body as Record<string, unknown> | undefined)?.notes),
|
||||||
|
});
|
||||||
|
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
@@ -690,8 +405,8 @@ app.use(express.json());
|
|||||||
|
|
||||||
app.get('/health', async (_req, res, next) => {
|
app.get('/health', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query('SELECT NOW() AS now');
|
const timestamp = await db.getNow();
|
||||||
res.json({ status: 'ok', database: 'connected', timestamp: result.rows[0].now });
|
res.json({ status: 'ok', database: 'connected', timestamp });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.get('/api/auth/providers', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query<ProviderConfigRow>(
|
const providers = await db.listEnabledAuthProviders();
|
||||||
`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(
|
res.json(
|
||||||
result.rows.map((provider) => ({
|
providers.map((provider) => ({
|
||||||
providerKey: provider.provider_key,
|
providerKey: provider.provider_key,
|
||||||
displayName: provider.display_name,
|
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 password = getString(req.body?.password, 'password');
|
||||||
const name = getString(req.body?.name, 'name');
|
const name = getString(req.body?.name, 'name');
|
||||||
|
|
||||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
if (await db.userExistsByEmail(email)) {
|
||||||
|
|
||||||
if ((existing.rowCount ?? 0) > 0) {
|
|
||||||
res.status(409).json({ error: 'An account with that email already exists' });
|
res.status(409).json({ error: 'An account with that email already exists' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const userResult = await pool.query<UserRow>(
|
const user = await db.createUser(email, passwordHash, name);
|
||||||
'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 profile = await ensureDefaultProfile(user.id, user.name);
|
||||||
const { token, session } = await createSession(user.id, profile.id);
|
const { token, session } = await createSession(user.id, profile.id);
|
||||||
|
|
||||||
@@ -807,18 +509,13 @@ app.post('/api/auth/login', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const email = normalizeEmail(getString(req.body?.email, 'email'));
|
const email = normalizeEmail(getString(req.body?.email, 'email'));
|
||||||
const password = getString(req.body?.password, 'password');
|
const password = getString(req.body?.password, 'password');
|
||||||
const result = await pool.query<UserRow & { password_hash: string | null }>(
|
const user = await db.findUserWithPasswordByEmail(email);
|
||||||
'SELECT id, email, name, password_hash FROM users WHERE email = $1',
|
|
||||||
[email],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((result.rowCount ?? 0) === 0) {
|
if (!user) {
|
||||||
res.status(401).json({ error: 'Invalid credentials' });
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = result.rows[0];
|
|
||||||
|
|
||||||
if (!user.password_hash) {
|
if (!user.password_hash) {
|
||||||
res.status(401).json({ error: 'This account uses SSO. Use your identity provider to sign in.' });
|
res.status(401).json({ error: 'This account uses SSO. Use your identity provider to sign in.' });
|
||||||
return;
|
return;
|
||||||
@@ -848,7 +545,7 @@ app.post('/api/auth/login', async (req, res, next) => {
|
|||||||
|
|
||||||
app.post('/api/auth/logout', requireAuth, async (req, res, next) => {
|
app.post('/api/auth/logout', requireAuth, async (req, res, next) => {
|
||||||
try {
|
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();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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 state = crypto.randomBytes(24).toString('hex');
|
||||||
const redirectUri = `${apiBaseUrl}/auth/sso/${provider.provider_key}/callback`;
|
const redirectUri = `${apiBaseUrl}/auth/sso/${provider.provider_key}/callback`;
|
||||||
await pool.query(
|
await db.createOauthState(provider.provider_key, state, redirectUri);
|
||||||
'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);
|
const url = new URL(provider.authorization_endpoint);
|
||||||
url.searchParams.set('client_id', provider.client_id);
|
url.searchParams.set('client_id', provider.client_id);
|
||||||
@@ -920,27 +614,21 @@ app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateResult = await pool.query<{ redirect_uri: string }>(
|
const oauthState = await db.getOauthState(provider.provider_key, state);
|
||||||
'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2',
|
|
||||||
[provider.provider_key, state],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((stateResult.rowCount ?? 0) === 0) {
|
if (!oauthState) {
|
||||||
res.status(400).send('Invalid or expired SSO state');
|
res.status(400).send('Invalid or expired SSO state');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [
|
await db.deleteOauthState(provider.provider_key, state);
|
||||||
provider.provider_key,
|
|
||||||
state,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!provider.client_id || !provider.client_secret || !provider.token_endpoint) {
|
if (!provider.client_id || !provider.client_secret || !provider.token_endpoint) {
|
||||||
res.status(400).send('Provider is missing token configuration');
|
res.status(400).send('Provider is missing token configuration');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = stateResult.rows[0].redirect_uri;
|
const redirectUri = oauthState.redirect_uri;
|
||||||
const tokenResponse = await axios.post(
|
const tokenResponse = await axios.post(
|
||||||
provider.token_endpoint,
|
provider.token_endpoint,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
@@ -979,36 +667,28 @@ app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const identityResult = await pool.query<{ user_id: string }>(
|
let userId = await db.findIdentityUserId(provider.provider_key, subject);
|
||||||
'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) {
|
if (!userId) {
|
||||||
const existingUser = await pool.query<UserRow>('SELECT id, email, name FROM users WHERE email = $1', [email]);
|
const existingUser = await db.findUserByEmail(email);
|
||||||
|
|
||||||
if ((existingUser.rowCount ?? 0) > 0) {
|
if (existingUser) {
|
||||||
userId = existingUser.rows[0].id;
|
userId = existingUser.id;
|
||||||
} else {
|
} else {
|
||||||
const createdUser = await pool.query<UserRow>(
|
const createdUser = await db.createUser(email, null, name);
|
||||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, NULL, $2) RETURNING id, email, name',
|
userId = createdUser.id;
|
||||||
[email, name],
|
|
||||||
);
|
|
||||||
userId = createdUser.rows[0].id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await db.createIdentity(userId, provider.provider_key, subject, email);
|
||||||
`INSERT INTO auth_identities (user_id, provider_key, provider_subject, email)
|
}
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (provider_key, provider_subject) DO NOTHING`,
|
const user = await db.getUserById(userId);
|
||||||
[userId, provider.provider_key, subject, email],
|
|
||||||
);
|
if (!user) {
|
||||||
|
res.status(500).send('Unable to load SSO user');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 profile = await ensureDefaultProfile(user.id, user.name);
|
||||||
const { token } = await createSession(user.id, profile.id);
|
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 {
|
try {
|
||||||
res.status(403).json({ error: 'Multiple profiles are disabled. Each user has a single arsenal.' });
|
res.status(403).json({ error: 'Multiple profiles are disabled. Each user has a single arsenal.' });
|
||||||
} catch (error) {
|
} 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) => {
|
app.get('/api/settings/auth-providers', requireAuth, async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query<ProviderConfigRow>(
|
const providers = await db.listAuthProviders();
|
||||||
`SELECT provider_key, display_name, protocol, client_id, client_secret,
|
res.json(providers.map(serializeProvider));
|
||||||
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -1069,34 +743,18 @@ app.put('/api/settings/auth-providers/:providerKey', requireAuth, async (req, re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await db.updateAuthProvider(providerKey, {
|
||||||
`UPDATE auth_provider_configs
|
displayName: String(req.body?.displayName || existing.display_name),
|
||||||
SET display_name = $2,
|
protocol: String(req.body?.protocol || existing.protocol),
|
||||||
protocol = $3,
|
clientId: String(req.body?.clientId ?? existing.client_id ?? ''),
|
||||||
client_id = $4,
|
clientSecret: typeof req.body?.clientSecret === 'string' ? req.body.clientSecret : (existing.client_secret ?? ''),
|
||||||
client_secret = $5,
|
authorizationEndpoint: String(req.body?.authorizationEndpoint ?? existing.authorization_endpoint ?? ''),
|
||||||
authorization_endpoint = $6,
|
tokenEndpoint: String(req.body?.tokenEndpoint ?? existing.token_endpoint ?? ''),
|
||||||
token_endpoint = $7,
|
userinfoEndpoint: String(req.body?.userinfoEndpoint ?? existing.userinfo_endpoint ?? ''),
|
||||||
userinfo_endpoint = $8,
|
issuer: String(req.body?.issuer ?? existing.issuer ?? ''),
|
||||||
issuer = $9,
|
scopes: String(req.body?.scopes ?? existing.scopes ?? 'openid profile email'),
|
||||||
scopes = $10,
|
enabled: Boolean(req.body?.enabled),
|
||||||
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);
|
const updated = await getAuthProvider(providerKey);
|
||||||
res.json(serializeProvider(updated!));
|
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) => {
|
app.get('/api/firearms', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const result = await pool.query<FirearmRow>(
|
const firearms = await db.listFirearms(profileId);
|
||||||
`SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
res.json(firearms.map(normalizeFirearm));
|
||||||
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) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.post('/api/firearms', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const manufacturer = getString(req.body?.manufacturer, 'manufacturer');
|
const firearm = getFirearmInput(req.body);
|
||||||
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)) {
|
if (!firearmCategories.includes(firearm.category)) {
|
||||||
res.status(400).json({ error: 'Unsupported firearm category' });
|
res.status(400).json({ error: 'Unsupported firearm category' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query<FirearmRow>(
|
const created = await db.createFirearm(profileId, firearm);
|
||||||
`INSERT INTO firearms (profile_id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes)
|
res.status(201).json(normalizeFirearm(created));
|
||||||
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) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.put('/api/firearms/:id', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const manufacturer = getString(req.body?.manufacturer, 'manufacturer');
|
const firearm = getFirearmInput(req.body);
|
||||||
const model = getString(req.body?.model, 'model');
|
const updated = await db.updateFirearm(req.params.id, profileId, firearm);
|
||||||
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>(
|
if (!updated) {
|
||||||
`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' });
|
res.status(404).json({ error: 'Firearm not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(normalizeFirearm(result.rows[0]));
|
res.json(normalizeFirearm(updated));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.delete('/api/firearms/:id', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const result = await pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [
|
const deleted = await db.deleteFirearm(req.params.id, profileId);
|
||||||
req.params.id,
|
|
||||||
profileId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ((result.rowCount ?? 0) === 0) {
|
if (!deleted) {
|
||||||
res.status(404).json({ error: 'Firearm not found' });
|
res.status(404).json({ error: 'Firearm not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1233,19 +843,11 @@ app.delete('/api/firearms/:id', requireAuth, async (req, res, next) => {
|
|||||||
app.get('/api/calibers', requireAuth, async (req, res, next) => {
|
app.get('/api/calibers', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const result = await pool.query<CaliberRow>(
|
const calibers = await db.listCalibers(profileId);
|
||||||
`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({
|
res.json({
|
||||||
configured: result.rows.filter((row) => row.is_active).map(normalizeCaliber),
|
configured: calibers.filter((row) => row.is_active).map(normalizeCaliber),
|
||||||
availableDefaults: defaultCalibers.filter(
|
availableDefaults: defaultCalibers.filter((name) => !calibers.some((row) => row.name === name && row.is_active)),
|
||||||
(name) => !result.rows.some((row) => row.name === name && row.is_active),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -1256,24 +858,11 @@ app.post('/api/calibers', requireAuth, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const name = getString(req.body?.name, 'name');
|
const name = getString(req.body?.name, 'name');
|
||||||
|
const caliber = await db.upsertCaliber(profileId, name, defaultCalibers.includes(name));
|
||||||
|
|
||||||
const result = await pool.query<CaliberRow>(
|
await db.ensureAmmoInventory(profileId, caliber.id);
|
||||||
`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(
|
res.status(201).json(normalizeCaliber(caliber));
|
||||||
`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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -1283,29 +872,18 @@ app.patch('/api/calibers/:id', requireAuth, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const isActive = Boolean(req.body?.isActive);
|
const isActive = Boolean(req.body?.isActive);
|
||||||
const result = await pool.query<CaliberRow>(
|
const caliber = await db.updateCaliberActive(req.params.id, profileId, isActive);
|
||||||
`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) {
|
if (!caliber) {
|
||||||
res.status(404).json({ error: 'Caliber not found' });
|
res.status(404).json({ error: 'Caliber not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
await pool.query(
|
await db.ensureAmmoInventory(profileId, req.params.id);
|
||||||
`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]));
|
res.json(normalizeCaliber(caliber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.get('/api/ammo', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const result = await pool.query<AmmoInventoryRow>(
|
const ammoInventory = await db.listAmmoInventory(profileId);
|
||||||
`SELECT ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round
|
res.json(ammoInventory.map(normalizeAmmoInventory));
|
||||||
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -1334,27 +904,14 @@ app.patch('/api/ammo/:caliberId', requireAuth, async (req, res, next) => {
|
|||||||
const profileId = await resolveProfileId(req);
|
const profileId = await resolveProfileId(req);
|
||||||
const rounds = getNumber(req.body?.rounds, 'rounds');
|
const rounds = getNumber(req.body?.rounds, 'rounds');
|
||||||
const costPerRound = req.body?.costPerRound == null ? null : getNumber(req.body?.costPerRound, 'costPerRound');
|
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<AmmoInventoryRow>(
|
if (!ammoInventory) {
|
||||||
`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' });
|
res.status(404).json({ error: 'Ammo inventory not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(normalizeAmmoInventory(result.rows[0]));
|
res.json(normalizeAmmoInventory(ammoInventory));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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 () => {
|
.then(async () => {
|
||||||
await ensureDemoAccount();
|
await ensureDemoAccount();
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
@@ -1380,5 +938,6 @@ void ensureSchema()
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to initialize schema', error);
|
console.error('Failed to initialize schema', error);
|
||||||
|
void db.close();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<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 this.ensureAmmoInventory(profileId, caliberResult.rows[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserProfiles(userId: string) {
|
||||||
|
const result = await this.pool.query<ProfileRow>(
|
||||||
|
'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<ProfileRow>(
|
||||||
|
'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<UserRow>('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<UserWithPasswordRow>(
|
||||||
|
'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<UserRow>(
|
||||||
|
'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<UserRow>('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<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, expiresAtIso],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionByTokenHash(tokenHash: string) {
|
||||||
|
const result = await this.pool.query<SessionWithUserRow>(
|
||||||
|
`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<ProfileRow>(
|
||||||
|
'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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEnabledAuthProviders() {
|
||||||
|
const result = await this.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`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAuthProviders() {
|
||||||
|
const result = await this.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`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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<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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFirearms(profileId: string) {
|
||||||
|
const result = await this.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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFirearm(profileId: string, firearm: FirearmMutation) {
|
||||||
|
const result = await this.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,
|
||||||
|
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<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`,
|
||||||
|
[
|
||||||
|
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<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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertCaliber(profileId: string, name: string, isDefault: boolean) {
|
||||||
|
const result = await this.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, isDefault],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCaliberActive(id: string, profileId: string, isActive: boolean) {
|
||||||
|
const result = await this.pool.query<CaliberRow>(
|
||||||
|
`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<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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAmmoInventory(profileId: string, caliberId: string, rounds: number, costPerRound: number | null) {
|
||||||
|
const result = await this.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, 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user