394 lines
9.2 KiB
Markdown
394 lines
9.2 KiB
Markdown
# Arsenal IQ
|
||
|
||
Arsenal IQ is a self-hosted ERP-style inventory app for recording firearms and ammunition. It tracks serial numbers, acquisition cost, estimated value, ammunition on hand by caliber, and ammo cost, with a React frontend and a Docker Compose deployment model that supports both local development and Traefik-backed production.
|
||
|
||
## Stack
|
||
|
||
- Frontend: React + TypeScript + Vite
|
||
- Backend: Node.js + Express + TypeScript
|
||
- Database: PostgreSQL 16
|
||
- Runtime: Docker Compose
|
||
- Proxy: optional Traefik production override
|
||
|
||
## Features
|
||
|
||
- Firearm registry with manufacturer, model, serial number, caliber, category, storage location, and cost basis
|
||
- Ammunition lot tracking with caliber, grain, quantity on hand, vendor, cost per round, and reorder thresholds
|
||
- Dashboard summaries for inventory counts, invested value, and low-stock alerts
|
||
- Seed data so the UI is useful immediately after first startup
|
||
|
||
## Quick start
|
||
|
||
1. Copy the environment file.
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
2. Create the shared Traefik Docker network if you do not already have it.
|
||
|
||
```bash
|
||
docker network create traefik_proxy
|
||
```
|
||
|
||
3. Build and start the local development stack.
|
||
|
||
```bash
|
||
docker compose up --build
|
||
```
|
||
|
||
## Local endpoints
|
||
|
||
- Frontend: `http://localhost:3000`
|
||
- API: `http://localhost:5000/api`
|
||
- Health check: `http://localhost:5000/health`
|
||
- PostgreSQL: `localhost:5432`
|
||
|
||
## Development storage
|
||
|
||
This project now uses local bind mounts instead of named Docker volumes:
|
||
|
||
- PostgreSQL data: `./data/postgres`
|
||
- Backend dependencies: `./backend/node_modules`
|
||
- Frontend dependencies: `./frontend/node_modules`
|
||
|
||
On first start, the backend and frontend containers will install dependencies into those local `node_modules` folders before launching.
|
||
|
||
## Production with Traefik
|
||
|
||
For production, use the dedicated production compose file so Traefik labels and external networking are only enabled there.
|
||
|
||
```bash
|
||
docker compose -f docker-compose.prod.yml up --build -d
|
||
```
|
||
|
||
Configure these values in `.env` for your production domain names:
|
||
|
||
```env
|
||
TRAEFIK_NETWORK=traefik_proxy
|
||
TRAEFIK_ENTRYPOINT=websecure
|
||
TRAEFIK_WEB_HOST=arsenal.example.com
|
||
TRAEFIK_API_HOST=api.arsenal.example.com
|
||
```
|
||
|
||
If the frontend should call the API through Traefik in production, set:
|
||
|
||
```env
|
||
VITE_API_BASE_URL=https://api.arsenal.example.com/api
|
||
FRONTEND_URL=https://arsenal.example.com
|
||
```
|
||
|
||
To disable self-service account creation and allow only existing users or SSO sign-in, set:
|
||
|
||
```env
|
||
ALLOW_REGISTRATION=false
|
||
```
|
||
|
||
## API
|
||
|
||
The app includes an Express API in [backend/src/app.ts](/home/corey/github/Arsenal_IQ/backend/src/app.ts). In local development, the frontend calls:
|
||
|
||
- Base URL: `http://localhost:5000/api`
|
||
- Health check: `http://localhost:5000/health`
|
||
|
||
### Authentication model
|
||
|
||
- Authenticated requests use `Authorization: Bearer <token>`
|
||
- The token is returned by local login/register or after a successful SSO callback
|
||
- The app is currently single-profile per user, even though profile endpoints still exist for compatibility
|
||
- Most inventory routes also accept `x-profile-id`, but in the current app this resolves to the user’s single arsenal/profile
|
||
|
||
### Environment flags
|
||
|
||
- `ALLOW_REGISTRATION=true|false`
|
||
- Controls whether `POST /api/auth/register` is available
|
||
- When `false`, the login UI hides self-service account creation
|
||
- `ALLOW_DEMO_ACCOUNT=true|false`
|
||
- Enables a backend-managed demo account and a demo sign-in button on the login page
|
||
- `DEMO_ACCOUNT_EMAIL`
|
||
- `DEMO_ACCOUNT_PASSWORD`
|
||
- `DEMO_ACCOUNT_NAME`
|
||
|
||
Database note:
|
||
|
||
- In Docker Compose, the backend uses `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD`
|
||
- This avoids malformed `DATABASE_URL` issues when the database password contains URL-sensitive characters
|
||
|
||
### Response shape notes
|
||
|
||
- Validation and business-rule errors generally return:
|
||
|
||
```json
|
||
{ "error": "Human readable message" }
|
||
```
|
||
|
||
- Successful login/register responses return:
|
||
|
||
```json
|
||
{
|
||
"token": "session-token",
|
||
"user": {
|
||
"id": "uuid",
|
||
"email": "owner@example.com",
|
||
"name": "Owner Name"
|
||
},
|
||
"profiles": [
|
||
{
|
||
"id": "uuid",
|
||
"name": "Owner"
|
||
}
|
||
],
|
||
"activeProfileId": "uuid"
|
||
}
|
||
```
|
||
|
||
### Core routes
|
||
|
||
#### Service and discovery
|
||
|
||
- `GET /health`
|
||
- Returns service/database health
|
||
- `GET /api`
|
||
- Returns API metadata and `allowRegistration`
|
||
|
||
Example:
|
||
|
||
```json
|
||
{
|
||
"name": "Arsenal IQ API",
|
||
"version": "3.0.0",
|
||
"allowRegistration": true,
|
||
"resources": [
|
||
"/api/auth/login",
|
||
"/api/auth/register",
|
||
"/api/dashboard",
|
||
"/api/firearms",
|
||
"/api/calibers",
|
||
"/api/ammo"
|
||
]
|
||
}
|
||
```
|
||
|
||
#### Authentication
|
||
|
||
- `GET /api/auth/providers`
|
||
- Public list of enabled SSO providers for the login page
|
||
- `POST /api/auth/register`
|
||
- Creates a local account when registration is enabled
|
||
- `POST /api/auth/login`
|
||
- Signs in with local email/password
|
||
- `POST /api/auth/demo`
|
||
- Signs in to the configured demo account when demo mode is enabled
|
||
- `POST /api/auth/logout`
|
||
- Invalidates the current session token
|
||
- `GET /api/auth/me`
|
||
- Returns the current authenticated user and active profile
|
||
- `GET /api/auth/sso/:providerKey/start`
|
||
- Starts the OIDC login flow and returns an authorization URL
|
||
- `GET /api/auth/sso/:providerKey/callback`
|
||
- Handles the provider callback, creates or links a user, then redirects back to the frontend with a token
|
||
|
||
Register request:
|
||
|
||
```json
|
||
{
|
||
"name": "Owner Name",
|
||
"email": "owner@example.com",
|
||
"password": "change-me"
|
||
}
|
||
```
|
||
|
||
Login request:
|
||
|
||
```json
|
||
{
|
||
"email": "owner@example.com",
|
||
"password": "change-me"
|
||
}
|
||
```
|
||
|
||
SSO behavior:
|
||
|
||
- If the SSO provider returns an email that matches an existing user, the SSO identity is linked to that user
|
||
- If the email does not exist yet, a new user is created automatically
|
||
- If the account was created via SSO only, local password login is rejected for that user
|
||
|
||
#### Profiles
|
||
|
||
- `GET /api/profiles`
|
||
- Returns the user’s profile list and active profile ID
|
||
- `POST /api/profiles`
|
||
- Currently disabled and returns `403`
|
||
- `POST /api/profiles/select`
|
||
- Returns the active profile ID
|
||
|
||
Note:
|
||
|
||
- The backend still exposes these endpoints, but the product now behaves as one user per arsenal/profile
|
||
|
||
#### Dashboard
|
||
|
||
- `GET /api/dashboard`
|
||
- Returns the current user, active profile, firearms, calibers, ammo inventory, defaults, and summary metrics used by the React UI
|
||
|
||
Summary fields:
|
||
|
||
- `totalFirearms`
|
||
- `totalAmmoRounds`
|
||
- `firearmsInvestment`
|
||
- `ammoInvestment`
|
||
- `configuredCalibers`
|
||
|
||
#### Firearms
|
||
|
||
- `GET /api/firearms`
|
||
- Lists firearms for the current profile
|
||
- `POST /api/firearms`
|
||
- Creates a firearm
|
||
- `PUT /api/firearms/:id`
|
||
- Updates a firearm
|
||
- `DELETE /api/firearms/:id`
|
||
- Deletes a firearm
|
||
|
||
Firearm body:
|
||
|
||
```json
|
||
{
|
||
"manufacturer": "Glock",
|
||
"model": "19",
|
||
"category": "Handgun",
|
||
"caliber": "9mm",
|
||
"serialNumber": "ABC123",
|
||
"purchasePrice": 550,
|
||
"acquiredOn": "2025-06-01",
|
||
"imageUrl": "",
|
||
"notes": "Carry pistol"
|
||
}
|
||
```
|
||
|
||
Valid firearm categories:
|
||
|
||
- `Handgun`
|
||
- `Rifle`
|
||
- `Shotgun`
|
||
- `PCC`
|
||
- `Other`
|
||
|
||
Firearm response:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"manufacturer": "Glock",
|
||
"model": "19",
|
||
"category": "Handgun",
|
||
"caliber": "9mm",
|
||
"serialNumber": "ABC123",
|
||
"purchasePrice": 550,
|
||
"acquiredOn": "2025-06-01",
|
||
"imageUrl": null,
|
||
"notes": "Carry pistol"
|
||
}
|
||
```
|
||
|
||
#### Calibers
|
||
|
||
- `GET /api/calibers`
|
||
- Returns configured active calibers plus unused default caliber names
|
||
- `POST /api/calibers`
|
||
- Adds or re-enables a caliber for the current profile
|
||
- `PATCH /api/calibers/:id`
|
||
- Enables or disables a caliber
|
||
|
||
Create caliber request:
|
||
|
||
```json
|
||
{
|
||
"name": "300 BLK"
|
||
}
|
||
```
|
||
|
||
Toggle caliber request:
|
||
|
||
```json
|
||
{
|
||
"isActive": false
|
||
}
|
||
```
|
||
|
||
Caliber response:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"name": "9mm",
|
||
"isDefault": true,
|
||
"isActive": true
|
||
}
|
||
```
|
||
|
||
#### Ammo
|
||
|
||
- `GET /api/ammo`
|
||
- Returns active caliber inventory rows for the current profile
|
||
- `PATCH /api/ammo/:caliberId`
|
||
- Adjusts rounds on hand and optionally updates cost per round
|
||
|
||
Ammo patch request:
|
||
|
||
```json
|
||
{
|
||
"rounds": 250,
|
||
"costPerRound": 0.24
|
||
}
|
||
```
|
||
|
||
Notes:
|
||
|
||
- Positive `rounds` adds to the current count
|
||
- Negative `rounds` removes from the current count
|
||
- The backend clamps the final total to `0`
|
||
- If `costPerRound` is omitted or `null`, the existing value is preserved
|
||
|
||
Ammo response:
|
||
|
||
```json
|
||
{
|
||
"caliberId": "uuid",
|
||
"caliber": "9mm",
|
||
"roundsOnHand": 750,
|
||
"costPerRound": 0.24,
|
||
"totalValue": 180
|
||
}
|
||
```
|
||
|
||
#### Auth provider settings
|
||
|
||
- `GET /api/settings/auth-providers`
|
||
- Returns the full editable provider configuration for the authenticated settings page
|
||
- `PUT /api/settings/auth-providers/:providerKey`
|
||
- Updates an auth provider config such as Google, Entra, or another OIDC-compatible provider
|
||
|
||
Provider update request:
|
||
|
||
```json
|
||
{
|
||
"displayName": "Google",
|
||
"protocol": "oidc",
|
||
"clientId": "your-client-id",
|
||
"clientSecret": "your-client-secret",
|
||
"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",
|
||
"scopes": "openid profile email",
|
||
"enabled": true
|
||
}
|
||
```
|
||
|
||
### Current limitations
|
||
|
||
- There is no password reset or account recovery flow yet
|
||
- There is no API versioning beyond the current route structure
|
||
- Profile endpoints remain present, but multiple profiles are intentionally disabled in the product
|