chanmged app.ts based on Onyxoasis's recommendations
This commit is contained in:
+135
-576
File diff suppressed because it is too large
Load Diff
@@ -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