diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..43ab29f --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,41 @@ +name: Deploy to Server + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Install SSH Key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan 192.168.30.114 >> ~/.ssh/known_hosts + + - name: Deploy via SSH + run: | + ssh deployuser@192.168.30.114 << 'EOF' + set -euo pipefail + APP_DIR=/opt/Train-ID + SERVICE=train-id + if [ ! -d "$APP_DIR" ]; then + sudo mkdir -p "$APP_DIR" + sudo chown "$USER":"$USER" "$APP_DIR" + git clone https://git.hudsonriggs.systems/HRiggs/Train-ID.git "$APP_DIR" + fi + cd "$APP_DIR" + git pull origin main + # Install Node.js deps and build + npm ci || npm install + npm run build + # Ensure systemd service exists and restart + if systemctl list-unit-files | grep -q "${SERVICE}.service"; then + sudo systemctl restart "$SERVICE" + else + echo "Warning: ${SERVICE}.service not found; start your process manager manually." + fi + EOF diff --git a/PDF Trainz Inv List.pdf b/PDF Trainz Inv List.pdf deleted file mode 100644 index 62ad57b..0000000 Binary files a/PDF Trainz Inv List.pdf and /dev/null differ diff --git a/README.md b/README.md index 3701c3b..c66ca49 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,140 @@ # Train-ID +Simple Node/Express app to identify and inventory model trains from photos using OpenAI Vision, store results in MySQL, and export PDFs/XLSX. + +## Requirements +- Node.js 20+ and npm +- MySQL 8+ +- OpenAI API key + +## Environment variables +Create a `.env` in the project root on the server: + +``` +PORT=3000 +# OpenAI +OPENAI_API_KEY=sk-... +# or alternative variable name also supported by code: +# openapi_key=sk-... + +# MySQL connection +db_ip=127.0.0.1 +db_port=3306 +db_user=trainid +db_pass=changeme +db_name=trainid +``` + +## Local development +``` +npm install +npm run build +npm start +# or for auto-reload on TypeScript build changes: +npm run dev +``` +Visit `http://localhost:3000`. + +## API overview +- POST `/api/upload` with `multipart/form-data` field `image` → analyzes photo, inserts record +- GET `/api/items` → list items (optional `?q=` to search model/SKU) +- GET `/api/items/:id/pdf` → generate/download PDF for one item +- GET `/api/export/xlsx` → download XLSX export of inventory with embedded thumbnails +- DELETE `/api/items/:id` → delete one +- DELETE `/api/items` → wipe all + +## Debian 13 (Trixie) LXC install +These steps assume a fresh Debian 13 LXC and deployment directory `/opt/Train-ID` with a system user `deployuser` that has passwordless sudo for service management. + +1) Base packages and Node.js +``` +sudo apt update +sudo apt install -y curl ca-certificates gnupg build-essential pkg-config +# Node 20 using Nodesource or Debian repo (choose one). Nodesource example: +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +node -v && npm -v +``` + +2) MySQL server (or connect to external MySQL) +``` +sudo apt install -y mariadb-server mariadb-client +sudo systemctl enable --now mariadb +sudo mysql -e "CREATE DATABASE IF NOT EXISTS trainid CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +sudo mysql -e "CREATE USER IF NOT EXISTS 'trainid'@'%' IDENTIFIED BY 'changeme';" +sudo mysql -e "GRANT ALL PRIVILEGES ON trainid.* TO 'trainid'@'%'; FLUSH PRIVILEGES;" +``` +If using external MySQL, skip install and set `db_ip` etc. in `.env`. + +3) App checkout and build +``` +sudo mkdir -p /opt/Train-ID +sudo chown $USER:$USER /opt/Train-ID +git clone https://git.hudsonriggs.systems/HRiggs/Train-ID.git /opt/Train-ID +cd /opt/Train-ID +cp .env.example .env || true # if you keep a template in the future +# create .env as per above +npm ci || npm install +npm run build +``` + +4) Systemd service +Create `/etc/systemd/system/train-id.service`: +``` +[Unit] +Description=Train-ID API +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/Train-ID +EnvironmentFile=/opt/Train-ID/.env +ExecStart=/usr/bin/node /opt/Train-ID/dist/server.js +Restart=always +RestartSec=5 +User=www-data +Group=www-data + +[Install] +WantedBy=multi-user.target +``` +Then enable and start: +``` +sudo systemctl daemon-reload +sudo systemctl enable --now train-id +sudo systemctl status train-id --no-pager +``` + +5) Reverse proxy (optional, for port 80/443) +Example Nginx site (`/etc/nginx/sites-available/train-id`): +``` +server { + listen 80; + server_name _; + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` +Enable and reload: +``` +sudo apt install -y nginx +sudo ln -s /etc/nginx/sites-available/train-id /etc/nginx/sites-enabled/train-id +sudo nginx -t && sudo systemctl reload nginx +``` + +## CI/CD deploy +The repo includes `.gitea/workflows/deploy.yaml` which, on push to `main`, SSHes to `192.168.30.114`, pulls latest, installs dependencies, builds, and restarts the `train-id` service. Set `DEPLOY_KEY` in repository secrets to a private SSH key authorized for `deployuser@192.168.30.114`. + +If your service name or directory differ, update `SERVICE`/`APP_DIR` in the workflow accordingly. + +``` + +echo "deployuser ALL=(root) NOPASSWD: /bin/systemctl restart train-id.service" | sudo tee /etc/sudoers.d/train-id >/dev/null +sudo chmod 440 /etc/sudoers.d/train-id +sudo visudo -cf /etc/sudoers.d/train-id +``` \ No newline at end of file diff --git a/image.png b/image.png deleted file mode 100644 index 160d55a..0000000 Binary files a/image.png and /dev/null differ diff --git a/src/db/client.ts b/src/db/client.ts index a3ec540..2f56734 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -22,13 +22,20 @@ async function ensureSchema() { description text not null, item_condition varchar(50) not null, has_box boolean not null, - image_path text not null, + image_path text null, + image_data longblob null, + image_mime varchar(100) null, created_at timestamp default current_timestamp ) engine=InnoDB; `; await pool.query(sql); // backfill for older tables without sku await pool.query(`alter table items add column if not exists sku varchar(64) null` as any); + // backfill nullable image_path + await pool.query(`alter table items modify column image_path text null` as any); + // add image_data and image_mime if missing + await pool.query(`alter table items add column if not exists image_data longblob null` as any); + await pool.query(`alter table items add column if not exists image_mime varchar(100) null` as any); } let schemaEnsured: Promise | null = null; @@ -41,9 +48,20 @@ export const db = { async insertItem(item: Omit) { await getReady(); const [result] = await pool.execute( - `insert into items (manufacturer, model, sku, quantity, description, item_condition, has_box, image_path) - values (?,?,?,?,?,?,?,?)`, - [item.manufacturer, item.model, item.sku || null, item.quantity, item.description, item.condition, item.hasBox, item.imagePath] + `insert into items (manufacturer, model, sku, quantity, description, item_condition, has_box, image_path, image_data, image_mime) + values (?,?,?,?,?,?,?,?,?,?)`, + [ + item.manufacturer, + item.model, + item.sku || null, + item.quantity, + item.description, + item.condition, + item.hasBox, + item.imagePath || null, + (item as any).imageData || null, + (item as any).imageMime || null + ] ); const insertId = (result as any).insertId as number; const [rows] = await pool.query( @@ -79,6 +97,27 @@ export const db = { if (row) { row.condition = row.cond; delete row.cond; } return row; }, + async getItemWithImage(id: number) { + await getReady(); + const [rows] = await pool.query( + `select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, image_data as imageData, image_mime as imageMime, created_at as createdAt + from items where id = ?`, + [id] + ); + const row = (rows as any[])[0] || null; + if (row) { row.condition = row.cond; delete row.cond; } + return row; + }, + async listItemsWithImage() { + await getReady(); + const [rows] = await pool.query( + `select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, image_data as imageData, image_mime as imageMime, created_at as createdAt + from items order by id asc` + ); + const arr = rows as any[]; + for (const r of arr) { r.condition = r.cond; delete r.cond; } + return arr; + }, async deleteItem(id: number) { await getReady(); const [res] = await pool.query('delete from items where id = ?', [id]); diff --git a/src/routes/api.ts b/src/routes/api.ts index 2472172..0e19dd1 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -21,6 +21,10 @@ router.post('/upload', async (req, res) => { const analysis = await analyzeImageToMetadata(savePath); + const imageBuffer = await fs.readFile(savePath); + const imageMime: string = file.mimetype || 'application/octet-stream'; + + // Insert with image bytes and mime; do not persist path const saved = await db.insertItem({ manufacturer: analysis.manufacturer, model: analysis.model, @@ -29,8 +33,13 @@ router.post('/upload', async (req, res) => { description: analysis.description, condition: analysis.condition, hasBox: analysis.hasBox, - imagePath: savePath, - }); + imagePath: null as any, + imageData: imageBuffer as any, + imageMime: imageMime as any + } as any); + + // Remove uploaded file after DB storage + try { await fs.unlink(savePath); } catch {} res.json({ item: saved }); } catch (err: any) { @@ -53,7 +62,7 @@ router.get('/items', async (req, res) => { router.get('/items/:id/pdf', async (req, res) => { try { const id = Number(req.params.id); - const item = await db.getItem(id); + const item = await db.getItemWithImage(id); if (!item) return res.status(404).json({ error: 'Not found' }); const pdfPath = await generatePdfForItem(item); res.setHeader('Content-Type', 'application/pdf'); @@ -79,11 +88,41 @@ router.get('/export/xlsx', async (_req, res) => { { header: 'Condition', key: 'condition', width: 14 }, { header: 'Description', key: 'description', width: 50 }, { header: 'Has Box', key: 'hasBox', width: 10 }, - { header: 'Image Path', key: 'imagePath', width: 50 }, + { header: 'Image', key: 'image', width: 25 }, { header: 'Created', key: 'createdAt', width: 24 } ]; - const items = await db.listItems(); - for (const it of items) ws.addRow(it); + const items = await db.listItemsWithImage(); + let rowIndex = 2; // 1-based, row 1 is header + for (const it of items) { + // Add row without imagePath, we have an Image column placeholder + const row = ws.addRow({ + id: it.id, + manufacturer: it.manufacturer, + model: it.model, + sku: it.sku, + quantity: it.quantity, + condition: it.condition, + description: it.description, + hasBox: it.hasBox ? 'Yes' : 'No', + image: '', + createdAt: it.createdAt + }); + // Adjust row height for image display + row.height = 90; + // Embed image if available + const buf = (it as any).imageData as unknown as Uint8Array | null; + const mime: string | null = (it as any).imageMime || null; + if (buf && mime && (mime.includes('png') || mime.includes('jpeg') || mime.includes('jpg'))) { + const imgId = wb.addImage({ buffer: Buffer.from(buf as any) as any, extension: mime.includes('png') ? 'png' : 'jpeg' }); + // Place image in the Image column (index 9, zero-based col 8) anchored to this row + const colIndexZeroBased = 8; // 0-based; column I (Image) since A=0 + ws.addImage(imgId, { + tl: { col: colIndexZeroBased, row: rowIndex - 1 }, + ext: { width: 120, height: 80 } + }); + } + rowIndex++; + } res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', 'attachment; filename="train_inventory.xlsx"'); await wb.xlsx.write(res); diff --git a/src/services/openaiVision.ts b/src/services/openaiVision.ts index 8225cbc..abda7a2 100644 --- a/src/services/openaiVision.ts +++ b/src/services/openaiVision.ts @@ -11,7 +11,10 @@ export type TrainItem = { description: string; // 1 sentence condition: string; // e.g., New, Excellent, Good, Fair, Poor hasBox: boolean; - imagePath: string; + // Prefer in-DB storage; path retained for legacy/backfill + imagePath?: string; + imageData?: Buffer; + imageMime?: string; id?: number; }; diff --git a/src/services/pdf.ts b/src/services/pdf.ts index 2e534fd..508d059 100644 --- a/src/services/pdf.ts +++ b/src/services/pdf.ts @@ -39,7 +39,12 @@ export async function generatePdfForItem(item: TrainItem & { id: number }): Prom try { doc.moveDown(); doc.text('Photo:', { continued: false }); - doc.image(item.imagePath, { width: 300 }); + const imageBuffer: Buffer | undefined = (item as any).imageData; + if (imageBuffer && imageBuffer.length > 0) { + doc.image(imageBuffer, { width: 300 }); + } else if (item.imagePath) { + doc.image(item.imagePath, { width: 300 }); + } } catch {} doc.end();