Files
platform/services/inventory/server.js
Yusuf Suleman 72747668f9 fix: remaining security and deployment hardening (#6 #7 #10)
#7 Transport Security:
- Removed legacy _ssl_ctx alias from config.py
- proxy.py now uses _internal_ssl_ctx directly (explicitly scoped)
- No global TLS bypass remains

#10 Deployment Hardening:
- Inventory Dockerfile: non-root (node user), health check, production deps
- Budget Dockerfile: non-root (node user), health check, npm ci, multi-stage ready
- Frontend-v2 Dockerfile: multi-stage build, non-root (node user), health check
- Added /health endpoints to inventory and budget (before auth middleware)
- All 6 containers now run as non-root with health checks

All services verified: gateway, trips, fitness, inventory, budget, frontend
2026-03-29 09:35:39 -05:00

2153 lines
80 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const axios = require('axios');
const FormData = require('form-data');
const app = express();
const port = process.env.PORT || 3000;
const config = {
ncodbUrl: process.env.NOCODB_URL || 'https://noco.quadjourney.com',
ncodbPublicUrl: process.env.NOCODB_PUBLIC_URL || process.env.NOCODB_URL || 'https://noco.quadjourney.com',
apiToken: process.env.NOCODB_API_TOKEN || '',
baseId: process.env.NOCODB_BASE_ID || 'pava9q9zccyihpt',
tableId: process.env.NOCODB_TABLE_ID || 'mash7c5nx4unukc',
columnName: process.env.NOCODB_COLUMN_NAME || 'photos',
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL || '',
discordUsername: process.env.DISCORD_USERNAME || 'NocoDB Uploader',
discordAvatarUrl: process.env.DISCORD_AVATAR_URL || '',
publicAppUrl: process.env.PUBLIC_APP_URL || `http://localhost:${process.env.EXTERNAL_PORT || port}`,
sendToPhoneToken: process.env.SEND_TO_PHONE_TOKEN || '',
workspaceId: '' // fetched at startup
};
app.use(cors());
app.use(express.json());
// Allow form-encoded payloads from NocoDB webhook buttons
app.use(express.urlencoded({ extended: true }));
// Health check (before auth middleware)
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// API key auth middleware — require X-API-Key header on all routes
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
if (SERVICE_API_KEY) {
app.use((req, res, next) => {
const key = req.headers['x-api-key'] || req.query.api_key;
if (key !== SERVICE_API_KEY) {
return res.status(401).json({ error: 'Unauthorized: invalid API key' });
}
next();
});
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }
});
function extractRowFromPayload(body = {}) {
if (!body || typeof body !== 'object') return null;
// Common NocoDB webhook shapes
if (body.data?.rows?.length) return body.data.rows[0];
if (body.data?.records?.length) return body.data.records[0];
if (body.data?.previous_rows?.length) return body.data.previous_rows[0];
if (Array.isArray(body.rows) && body.rows.length) return body.rows[0];
if (body.row) return body.row;
return null;
}
// Optional per-request shared secret for send-to-phone endpoint
function isAuthorized(req) {
if (!config.sendToPhoneToken) return true;
const provided =
(req.query && (req.query.token || req.query.auth)) ||
(req.body && (req.body.token || req.body.auth)) ||
req.headers['x-send-to-phone-token'];
return provided === config.sendToPhoneToken;
}
function buildDeepLink(rowId, itemName) {
const base = (config.publicAppUrl || '').replace(/\/$/, '') || `http://localhost:${port}`;
const params = new URLSearchParams({ rowId: String(rowId) });
if (itemName) {
params.set('item', itemName);
}
return `${base}?${params.toString()}`;
}
const safeField = (value, fallback = 'Unknown') => (value ? String(value) : fallback);
async function fetchRowByFilter(rowId) {
const headers = {
'xc-token': config.apiToken,
'Accept': 'application/json'
};
// Try row_id first (common UUID field), then Id as string
const queries = [
{ where: `(row_id,eq,${rowId})`, limit: 1 },
{ where: `(Id,eq,${rowId})`, limit: 1 }
];
for (const params of queries) {
try {
const resp = await axios.get(
`${config.ncodbUrl}/api/v1/db/data/noco/${config.baseId}/${config.tableId}`,
{ headers, params }
);
const list = resp.data?.list || [];
if (list.length > 0) return list[0];
} catch (err) {
// continue to next strategy
}
}
return null;
}
async function fetchRowFlexible(rowId) {
const headers = {
'xc-token': config.apiToken,
'Accept': 'application/json'
};
// First try direct PK fetch
try {
const resp = await axios.get(
`${config.ncodbUrl}/api/v1/db/data/noco/${config.baseId}/${config.tableId}/${rowId}`,
{ headers }
);
return resp.data;
} catch (err) {
const code = err.response?.data?.error;
const status = err.response?.status;
// If PK fails (common when Noco button sends row_id UUID), try filtering
if (code === 'INVALID_PK_VALUE' || status === 404 || status === 400) {
const byFilter = await fetchRowByFilter(rowId);
if (byFilter) return byFilter;
}
throw err;
}
}
// Search for records by item name
app.get('/search-records', async (req, res) => {
try {
if (!config.apiToken) {
return res.status(500).json({ error: 'NocoDB API token not configured' });
}
const searchTerm = req.query.q;
if (!searchTerm) {
return res.status(400).json({ error: 'Search term required' });
}
const term = searchTerm.replace(/[%_]/g, ''); // sanitize wildcards
// Server-side search across key fields using NocoDB like filter
const where = [
`(Item,like,%${term}%)`,
`(Order Number,like,%${term}%)`,
`(SKU,like,%${term}%)`,
`(Serial Numbers,like,%${term}%)`,
`(Name,like,%${term}%)`,
`(Tracking Number,like,%${term}%)`
].join('~or');
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
{
headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' },
params: { where, limit: 200, sort: '-Id' }
}
);
const rows = response.data.list || [];
const searchResults = rows.map(row => {
let displayText = row.Item || row.Name || 'Unknown Item';
if (row['Order Number']) displayText += ' | Order: ' + row['Order Number'];
if (row['Serial Numbers']) displayText += ' | SN: ' + row['Serial Numbers'];
if (row.SKU) displayText += ' | SKU: ' + row.SKU;
return {
id: row.Id,
item: displayText,
received: row.Received || 'Unknown Status'
};
});
res.json({ results: searchResults });
} catch (error) {
console.error('Search error:', error.response?.data || error.message);
res.status(500).json({ error: 'Search failed', details: error.message });
}
});
// Test endpoint
app.get('/test', (req, res) => {
res.json({ message: 'Server is working!', timestamp: new Date() });
});
// Get single item details
app.get('/item-details/:id', async (req, res) => {
try {
const itemId = req.params.id;
console.log('Fetching details for item ID:', itemId);
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + itemId,
{
headers: {
'xc-token': config.apiToken,
'Accept': 'application/json'
}
}
);
const nocodbRowUrl = config.workspaceId
? `${config.ncodbPublicUrl}/dashboard/#/${config.workspaceId}/${config.baseId}/${config.tableId}?rowId=${itemId}`
: config.ncodbPublicUrl;
res.json({ success: true, item: response.data, nocodb_url: nocodbRowUrl });
} catch (error) {
console.error('Error fetching item details:', error.message);
res.status(500).json({ error: 'Failed to fetch item details', details: error.message });
}
});
// Send a Discord notification with a deep link for mobile upload
app.all('/send-to-phone', async (req, res) => {
try {
if (!config.discordWebhookUrl) {
return res.status(500).json({ error: 'Discord webhook not configured' });
}
if (!config.apiToken) {
return res.status(500).json({ error: 'NocoDB API token not configured' });
}
if (!isAuthorized(req)) {
return res.status(403).json({ error: 'Unauthorized' });
}
const payloadRow = extractRowFromPayload(req.body);
let rowId = req.body?.rowId || req.body?.id || req.query.rowId || req.query.id;
if (!rowId && payloadRow) {
rowId = payloadRow.Id || payloadRow.id || payloadRow.row_id || payloadRow.rowId;
}
const primaryId = payloadRow?.Id || rowId; // Prefer numeric Id when available
console.log('Send-to-phone request payload:', {
rowId,
primaryId,
query: req.query,
body: req.body,
payloadRow
});
if (!rowId) {
return res.status(400).json({ error: 'Missing rowId/id' });
}
// Fetch row to confirm and enrich the message (with fallback for row_id UUIDs)
const item = payloadRow || await fetchRowFlexible(primaryId);
const itemName = item.Item || item.Name || '';
const orderNumber = item['Order Number'] || '';
const sku = item.SKU || '';
const serials = item['Serial Numbers'] || '';
const status = item.Received || item.received || 'Unknown';
const deepLink = buildDeepLink(primaryId, itemName);
const contentLines = [
`📲 Upload photos from your phone`,
`Item: ${safeField(itemName, 'Unknown')}`,
`Row ID: ${primaryId}`,
`Link: ${deepLink}`
];
const embedFields = [
{ name: 'Row ID', value: String(primaryId), inline: true }
];
if (orderNumber) embedFields.push({ name: 'Order #', value: String(orderNumber), inline: true });
if (sku) embedFields.push({ name: 'SKU', value: String(sku), inline: true });
if (serials) embedFields.push({ name: 'Serials', value: String(serials).slice(0, 1000) });
if (status) embedFields.push({ name: 'Status', value: String(status), inline: true });
const payload = {
content: contentLines.join('\n'),
username: config.discordUsername || undefined,
avatar_url: config.discordAvatarUrl || undefined,
embeds: [
{
title: itemName || 'Item',
description: 'Tap the link to open the uploader on your phone.',
url: deepLink,
fields: embedFields,
footer: { text: 'NocoDB Photo Uploader' }
}
]
};
await axios.post(config.discordWebhookUrl, payload);
res.json({
success: true,
message: 'Sent to Discord',
link: deepLink,
item: {
id: rowId,
name: itemName,
orderNumber,
sku,
serials,
status
}
});
} catch (error) {
console.error('Send-to-phone error:', error.response?.data || error.message);
res.status(500).json({ error: 'Failed to send link', details: error.message });
}
});
// Summary endpoint — server-side filtered, fast single query
app.get('/summary', async (req, res) => {
try {
if (!config.apiToken) {
return res.status(500).json({ error: 'NocoDB API token not configured' });
}
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
{
headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' },
params: {
where: '(Received,eq,Issues)~or(Received,eq,Issue)~or(Received,eq,Needs Review)',
limit: 200
}
}
);
const rows = response.data.list || [];
const mapItem = (row) => ({
id: row.Id,
item: row.Item || row.Name || 'Unknown',
orderNumber: row['Order Number'] || '',
serialNumbers: row['Serial Numbers'] || '',
sku: row.SKU || '',
received: row.Received || row.received || '',
trackingNumber: row['Tracking Number'] || '',
notes: row.Notes || ''
});
const issues = [];
const needsReview = [];
for (const row of rows) {
const val = (row.Received || row.received || '').toLowerCase();
if (val === 'issues' || val === 'issue') issues.push(mapItem(row));
else if (val === 'needs review') needsReview.push(mapItem(row));
}
res.json({
issues,
issueCount: issues.length,
needsReview,
reviewCount: needsReview.length
});
} catch (error) {
console.error('Summary fetch error:', error.message);
res.status(500).json({ error: 'Failed to fetch summary', details: error.message });
}
});
// Debug endpoint to check NocoDB connection and data
app.get('/debug-nocodb', async (req, res) => {
try {
console.log('Debug: Testing NocoDB connection...');
console.log('Config:', {
url: config.ncodbUrl,
baseId: config.baseId,
tableId: config.tableId,
hasToken: !!config.apiToken
});
// Fetch all records with pagination
let allRows = [];
let offset = 0;
const limit = 1000;
let hasMore = true;
while (hasMore && offset < 10000) {
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
{
headers: {
'xc-token': config.apiToken,
'Accept': 'application/json'
},
params: {
limit: limit,
offset: offset
}
}
);
const pageRows = response.data.list || [];
allRows = allRows.concat(pageRows);
hasMore = pageRows.length === limit;
offset += limit;
}
res.json({
success: true,
totalRowsRetrieved: allRows.length,
sampleRow: allRows[0] || null,
fieldNames: allRows.length > 0 ? Object.keys(allRows[0]) : [],
firstThreeRows: allRows.slice(0, 3),
message: `Successfully retrieved ${allRows.length} total records`
});
} catch (error) {
console.error('Debug error:', error.message);
res.status(500).json({
error: 'Failed to connect to NocoDB',
message: error.message,
responseData: error.response?.data,
status: error.response?.status
});
}
});
// Get items with issues (full details)
app.get('/issues', async (req, res) => {
try {
if (!config.apiToken) {
return res.status(500).json({ error: 'NocoDB API token not configured' });
}
let allRows = [];
let offset = 0;
const limit = 1000;
let hasMore = true;
while (hasMore && offset < 10000) {
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
{ headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' }, params: { limit, offset } }
);
const pageRows = response.data.list || [];
allRows = allRows.concat(pageRows);
hasMore = pageRows.length === limit;
offset += limit;
}
const issues = allRows.filter(row => {
const val = (row.Received || row.received || '').toLowerCase();
return val === 'issues' || val === 'issue';
}).map(row => ({
id: row.Id,
item: row.Item || row.Name || 'Unknown',
orderNumber: row['Order Number'] || '',
serialNumbers: row['Serial Numbers'] || '',
sku: row.SKU || '',
received: row.Received || row.received || '',
trackingNumber: row['Tracking Number'] || '',
notes: row.Notes || ''
}));
res.json({ issues, count: issues.length });
} catch (error) {
console.error('Issues fetch error:', error.message);
res.status(500).json({ error: 'Failed to fetch issues', details: error.message });
}
});
// Get count of rows that have issues
app.get('/needs-review-count', async (req, res) => {
try {
if (!config.apiToken) {
return res.status(500).json({ error: 'NocoDB API token not configured' });
}
console.log('Fetching issues count...');
// Fetch all records with pagination
let allRows = [];
let offset = 0;
const limit = 1000;
let hasMore = true;
while (hasMore && offset < 10000) {
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
{
headers: {
'xc-token': config.apiToken,
'Accept': 'application/json'
},
params: {
limit: limit,
offset: offset
}
}
);
const pageRows = response.data.list || [];
allRows = allRows.concat(pageRows);
hasMore = pageRows.length === limit;
offset += limit;
}
console.log('API response received, total rows:', allRows.length);
const issuesCount = allRows.filter(row => {
const receivedValue = row.Received || row.received;
return receivedValue === 'Issues' || receivedValue === 'Issue';
}).length;
console.log('Found ' + issuesCount + ' items with issues');
res.json({ count: issuesCount });
} catch (error) {
console.error('Error fetching issues count:', error.message);
res.status(500).json({ error: 'Failed to fetch count', details: error.message });
}
});
app.get('/', (req, res) => {
res.send(`<!DOCTYPE html>
<html>
<head>
<title>NocoDB Photo Uploader</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 10px;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Ccircle cx='30' cy='30' r='4'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
pointer-events: none;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 30px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.2);
width: 100%;
max-width: 800px;
margin: 0 auto;
border: 1px solid rgba(255,255,255,0.18);
transition: all 0.3s ease;
}
.container:hover {
transform: translateY(-2px);
box-shadow: 0 25px 50px rgba(0,0,0,0.15), 0 0 0 1px rgba(255,255,255,0.2);
}
h1 {
font-size: clamp(1.8rem, 5vw, 2.5rem);
margin-bottom: 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
text-align: center;
}
.issues-counter {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
padding: 20px;
border-radius: 16px;
margin-bottom: 25px;
text-align: center;
box-shadow: 0 8px 25px rgba(255,107,107,0.3);
border: none;
transition: transform 0.2s ease;
}
.issues-counter:hover {
transform: translateY(-1px);
}
.issues-counter h3 {
margin: 0 0 8px 0;
color: white;
font-weight: 600;
}
.issues-counter .count {
font-size: 28px;
font-weight: 800;
color: white;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.config-info {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
padding: 20px;
border-radius: 16px;
margin-bottom: 30px;
box-shadow: 0 8px 25px rgba(116,185,255,0.3);
}
.config-info h3 {
color: white;
margin-bottom: 10px;
font-weight: 600;
}
.config-info p {
color: rgba(255,255,255,0.9);
margin: 5px 0;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #2d3748;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-container {
position: relative;
margin-bottom: 10px;
}
.search-input {
width: 100%;
padding: 16px 20px;
border: 2px solid rgba(102, 126, 234, 0.1);
border-radius: 12px;
box-sizing: border-box;
font-size: 16px;
background: rgba(255,255,255,0.8);
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.search-input:focus {
outline: none;
border-color: #667eea;
background: rgba(255,255,255,0.95);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(10px);
border: 2px solid rgba(102, 126, 234, 0.2);
border-top: none;
border-radius: 0 0 12px 12px;
max-height: 250px;
overflow-y: auto;
z-index: 100;
display: none;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.search-result-item {
padding: 15px 20px;
cursor: pointer;
border-bottom: 1px solid rgba(102, 126, 234, 0.1);
transition: all 0.2s ease;
}
.search-result-item:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
transform: translateX(5px);
}
.search-result-item:last-child {
border-bottom: none;
}
.scan-buttons {
display: flex;
gap: 10px;
margin-top: 8px;
flex-wrap: wrap;
}
.scan-btn {
flex: 1 1 140px;
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
color: white;
border: none;
border-radius: 10px;
padding: 12px 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 20px rgba(0, 184, 148, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.scan-btn:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(0, 184, 148, 0.35);
}
.scan-btn:active {
transform: translateY(0);
}
.result-id {
font-weight: 700;
color: #667eea;
font-size: 14px;
}
.result-item {
color: #2d3748;
font-weight: 600;
margin: 4px 0;
}
.result-status {
font-size: 12px;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input[type="text"] {
width: 100%;
padding: 16px 20px;
border: 2px solid rgba(102, 126, 234, 0.1);
border-radius: 12px;
box-sizing: border-box;
font-size: 16px;
background: rgba(255,255,255,0.8);
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
background: rgba(255,255,255,0.95);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.drop-zone {
border: 3px dashed rgba(102, 126, 234, 0.3);
padding: 50px 30px;
text-align: center;
cursor: pointer;
border-radius: 20px;
margin: 25px 0;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.drop-zone::before {
content: '📸';
font-size: 3rem;
display: block;
margin-bottom: 15px;
opacity: 0.7;
}
.drop-zone:hover {
border-color: #667eea;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
}
.drop-zone p:first-of-type {
font-size: 1.2rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 8px;
}
.upload-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 18px 35px;
border: none;
border-radius: 12px;
width: 100%;
cursor: pointer;
font-size: 18px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
text-transform: uppercase;
letter-spacing: 1px;
}
.upload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(102, 126, 234, 0.4);
}
.upload-btn:active {
transform: translateY(0);
}
#fileInput { display: none; }
.preview-container { margin-top: 20px; }
.preview-image {
width: 100%;
height: 280px;
object-fit: cover;
margin: 15px 0 5px 0;
border-radius: 16px;
border: none;
box-shadow: 0 15px 35px rgba(0,0,0,0.1), 0 0 0 1px rgba(102, 126, 234, 0.1);
display: block;
transition: all 0.3s ease;
}
.preview-image:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 25px 50px rgba(0,0,0,0.15), 0 0 0 1px rgba(102, 126, 234, 0.2);
}
.file-preview {
display: block;
margin: 25px 0;
position: relative;
width: 100%;
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-name {
font-size: 16px;
color: #4a5568;
margin-top: 12px;
word-wrap: break-word;
font-weight: 600;
padding: 15px 20px;
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
border-left: 4px solid #667eea;
}
.remove-btn {
position: absolute;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
border: none;
border-radius: 50%;
width: 45px;
height: 45px;
cursor: pointer;
font-size: 24px;
box-shadow: 0 8px 20px rgba(255, 107, 107, 0.4);
z-index: 10;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
transform: scale(1.1);
box-shadow: 0 10px 25px rgba(255, 107, 107, 0.5);
}
.file-count {
font-weight: 700;
color: white;
margin-bottom: 20px;
font-size: 18px;
padding: 12px 20px;
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
border-radius: 25px;
display: inline-block;
box-shadow: 0 8px 20px rgba(0, 184, 148, 0.3);
animation: slideIn 0.3s ease;
}
.status {
margin-top: 25px;
padding: 18px 25px;
border-radius: 12px;
text-align: center;
font-size: 16px;
font-weight: 600;
backdrop-filter: blur(10px);
animation: slideIn 0.3s ease;
}
.status.success {
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
color: white;
box-shadow: 0 8px 25px rgba(0, 184, 148, 0.3);
}
.status.error {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.3);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Modal styles for item details */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 20px auto;
padding: 0;
border: 1px solid #888;
width: calc(100% - 20px);
max-width: 800px;
max-height: calc(100vh - 40px);
overflow: hidden;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.modal-header {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px 10px 0 0;
position: relative;
}
.modal-header h2 {
margin: 0;
font-size: 24px;
}
.close {
color: white;
position: absolute;
right: 20px;
top: 20px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover { opacity: 0.8; }
.modal-body {
padding: 20px;
max-height: calc(100vh - 140px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.detail-row {
display: flex;
padding: 12px 0;
border-bottom: 1px solid #eee;
flex-wrap: wrap;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-weight: bold;
width: 150px;
color: #555;
flex-shrink: 0;
}
.detail-value {
flex: 1;
color: #333;
word-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.detail-value.empty {
color: #999;
font-style: italic;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.status-badge.closed {
background: #d4edda;
color: #155724;
}
.status-badge.issues {
background: #f8d7da;
color: #721c24;
}
.status-badge.open {
background: #fff3cd;
color: #856404;
}
.select-button {
background: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin-top: 20px;
width: 100%;
}
.select-button:hover {
background: #45a049;
}
/* Scanner modal */
.scanner-modal {
display: none;
position: fixed;
z-index: 1200;
left: 0; top: 0; right:0; bottom:0;
background: rgba(0,0,0,0.6);
align-items: center;
justify-content: center;
padding: 15px;
}
.scanner-content {
background: #fff;
max-width: 420px;
width: 100%;
border-radius: 16px;
padding: 18px;
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
}
.scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.scanner-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.scanner-area {
border: 2px dashed rgba(102,126,234,0.25);
border-radius: 12px;
min-height: 280px;
overflow: hidden;
}
.scanner-hint {
margin-top: 8px;
font-size: 14px;
color: #555;
}
/* Modern theme overrides (light, Jotform-inspired) */
.modern-theme {
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(255, 138, 101, 0.15) 0, transparent 25%), radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.12) 0, transparent 22%), #f7f7fb;
color: #1f2937;
}
.modern-theme body::before { display: none; }
.modern-theme .container {
background: #ffffff;
border: 1px solid #e5e7eb;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.1);
}
.modern-theme h1 {
background: linear-gradient(135deg, #ff7a59, #10b981);
}
.modern-theme .issues-counter {
background: linear-gradient(135deg, #ff7a59, #f97316);
box-shadow: 0 10px 30px rgba(249, 115, 22, 0.25);
}
.modern-theme .search-input,
.modern-theme input[type="text"],
.modern-theme input[type="number"] {
background: #fff;
color: #111827;
border-color: #e5e7eb;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.04);
}
.modern-theme .search-input:focus,
.modern-theme input[type="text"]:focus,
.modern-theme input[type="number"]:focus {
border-color: #ff7a59;
box-shadow: 0 0 0 4px rgba(255, 122, 89, 0.2);
}
.modern-theme .search-results {
background: #ffffff;
border-color: #e5e7eb;
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
}
.modern-theme .search-result-item:hover {
background: linear-gradient(135deg, rgba(255, 122, 89, 0.08), rgba(16, 185, 129, 0.08));
}
.modern-theme .drop-zone {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(255, 122, 89, 0.08));
border-color: rgba(255, 122, 89, 0.35);
}
.modern-theme .upload-btn {
background: linear-gradient(135deg, #ff7a59, #f97316);
box-shadow: 0 10px 25px rgba(249, 115, 22, 0.25);
}
.modern-theme .scan-btn {
background: linear-gradient(135deg, #10b981, #0ea271);
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.25);
}
.modern-theme .file-name {
background: rgba(255, 122, 89, 0.08);
border-left-color: #ff7a59;
color: #1f2937;
}
.modern-theme .status.success { background: linear-gradient(135deg, #10b981, #0ea271); }
.modern-theme .status.error { background: linear-gradient(135deg, #f43f5e, #f97316); }
/* Mobile optimizations */
@media (max-width: 768px) {
body {
padding: 5px;
}
.container {
padding: 20px;
border-radius: 16px;
}
.config-info {
font-size: 14px;
padding: 10px;
}
.preview-image {
height: 200px;
}
input[type="text"], .search-input {
padding: 12px;
font-size: 16px; /* Prevents zoom on iOS */
}
.drop-zone {
padding: 30px 15px;
}
.remove-btn {
width: 40px;
height: 40px;
font-size: 22px;
top: 20px;
right: 10px;
}
.modal-content {
width: calc(100% - 10px);
margin: 5px auto;
max-height: calc(100vh - 10px);
}
.modal-header {
padding: 15px;
}
.modal-header h2 {
font-size: 20px;
}
.modal-body {
padding: 15px;
max-height: calc(100vh - 100px);
}
.detail-label {
width: 100%;
margin-bottom: 5px;
}
.detail-value {
width: 100%;
padding-left: 0;
}
.detail-row {
flex-direction: column;
}
}
/* Tablet styles */
@media (min-width: 769px) and (max-width: 1024px) {
.container {
max-width: 700px;
}
}
/* Ensure inputs don't zoom on mobile */
@media (max-width: 768px) {
input[type="text"],
input[type="file"],
select,
textarea {
font-size: 16px !important;
}
}
</style>
</head>
<body>
<script>
(function() {
const params = new URLSearchParams(window.location.search);
if (params.get('theme') === 'modern') {
document.body.classList.add('modern-theme');
}
})();
</script>
<div class="container">
<h1>📷 NocoDB Photo Uploader</h1>
<div class="issues-counter">
<h3>Items with Issues</h3>
<div class="count" id="issuesCount">Loading...</div>
</div>
<div class="form-group">
<label>Search for Item:</label>
<div class="search-container">
<input type="text" id="searchInput" class="search-input" placeholder="Type item name to search..." />
<div id="searchResults" class="search-results"></div>
</div>
<div class="scan-buttons">
<button class="scan-btn" onclick="openScanner('search')">📷 Scan for Search</button>
</div>
</div>
<div class="form-group">
<label>Row ID:</label>
<input type="text" id="rowId" placeholder="Enter row ID or search above" />
</div>
<div id="nocoLinkBox" style="display:none; margin-bottom:15px;">
<a id="nocoLink" href="#" target="_blank" rel="noopener" style="color:#2563eb;font-weight:700;">Open in NocoDB</a>
</div>
<div class="form-group">
<label>Item Details:</label>
<div id="editForm"></div>
</div>
<div class="drop-zone" onclick="document.getElementById('fileInput').click()">
<p>📁 Click to add photos</p>
<p style="font-size: 14px; color: #888;">Each click adds more photos - they accumulate until you upload</p>
<input type="file" id="fileInput" multiple accept="image/*" />
</div>
<div id="previewContainer"></div>
<button class="upload-btn" onclick="uploadPhotos()">Upload Photos</button>
<div id="status"></div>
</div>
<!-- Item Details Modal -->
<div id="itemModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Item Details</h2>
<span class="close" onclick="closeModal()">&times;</span>
</div>
<div class="modal-body" id="modalBody">
<!-- Details will be inserted here -->
</div>
</div>
</div>
<!-- Scanner Modal -->
<div id="scannerModal" class="scanner-modal">
<div class="scanner-content">
<div class="scanner-header">
<h3 id="scannerTitle">Scan</h3>
<button class="scanner-close" onclick="closeScanner()">&times;</button>
</div>
<div id="scannerArea" class="scanner-area"></div>
<div class="scanner-hint">Align the barcode in the frame. Scans close automatically on success.</div>
</div>
</div>
<script>
let selectedFiles = [];
let scannerInstance = null;
let scannerTarget = null;
let detectorStream = null;
let detectorInterval = null;
let detectorRunning = false;
const nocoLinkBase = '${(config.ncodbPublicUrl || '').endsWith('/') ? config.ncodbPublicUrl.slice(0, -1) : (config.ncodbPublicUrl || '')}/#/nc/${config.baseId}/${config.tableId}';
const fieldDefs = [
{ key: 'Id', label: 'ID', readonly: true },
{ key: 'Item', label: 'Item' },
{ key: 'Order Number', label: 'Order Number' },
{ key: 'Serial Numbers', label: 'Serial Numbers' },
{ key: 'SKU', label: 'SKU' },
{ key: 'Vendor', label: 'Vendor' },
{ key: 'Date', label: 'Date' },
{ key: 'Tracking Number', label: 'Tracking Number' },
{ key: 'Received', label: 'Status/Received' },
{ key: 'QTY', label: 'Quantity' },
{ key: 'Price Per Item', label: 'Price Per Item' },
{ key: 'Tax', label: 'Tax' },
{ key: 'Total', label: 'Total' },
{ key: 'Name', label: 'Name' }
];
// Prefill from query params (rowId/item) for deep links
(function prefillFromQuery() {
try {
const params = new URLSearchParams(window.location.search);
const rowId = params.get('rowId') || params.get('id');
const itemName = params.get('item');
if (rowId) {
selectItem(rowId, itemName || '');
showItemDetails(rowId);
}
} catch (e) {
console.warn('Prefill error:', e);
}
})();
// Load issues count
loadIssuesCount();
renderEditForm({});
// Search functionality
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', function(e) {
clearTimeout(searchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('searchResults').style.display = 'none';
return;
}
searchTimeout = setTimeout(() => searchItems(query), 300);
});
// Hide search results when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.search-container')) {
document.getElementById('searchResults').style.display = 'none';
}
});
async function searchItems(query) {
try {
const response = await fetch('/search-records?q=' + encodeURIComponent(query));
const data = await response.json();
const resultsDiv = document.getElementById('searchResults');
resultsDiv.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = 'search-result-item';
const resultId = document.createElement('div');
resultId.className = 'result-id';
resultId.textContent = 'ID: ' + item.id;
const resultItem = document.createElement('div');
resultItem.className = 'result-item';
resultItem.textContent = item.item;
const resultStatus = document.createElement('div');
resultStatus.className = 'result-status';
resultStatus.textContent = 'Status: ' + item.received;
itemDiv.appendChild(resultId);
itemDiv.appendChild(resultItem);
itemDiv.appendChild(resultStatus);
itemDiv.onclick = () => {
selectItem(item.id, item.item);
showItemDetails(item.id);
};
resultsDiv.appendChild(itemDiv);
});
resultsDiv.style.display = 'block';
} else {
resultsDiv.innerHTML = '<div class="search-result-item">No results found</div>';
resultsDiv.style.display = 'block';
}
} catch (error) {
console.error('Search error:', error);
}
}
function selectItem(id, itemName) {
document.getElementById('rowId').value = id;
document.getElementById('searchInput').value = itemName;
document.getElementById('searchResults').style.display = 'none';
updateNocoLink(id);
// Clear search box so next search can start fresh
document.getElementById('searchInput').value = '';
}
function renderEditForm(item = {}) {
const container = document.getElementById('editForm');
if (!container) return;
container.innerHTML = '';
fieldDefs.forEach(def => {
const wrap = document.createElement('div');
wrap.style.marginBottom = '15px';
const label = document.createElement('label');
label.textContent = def.label;
label.style.display = 'block';
label.style.marginBottom = '6px';
label.style.fontWeight = '600';
const input = document.createElement('input');
input.id = 'edit-' + def.key;
input.type = (def.key === 'Price Per Item' || def.key === 'Tax' || def.key === 'Total' || def.key === 'QTY') ? 'number' : 'text';
input.value = item[def.key] !== undefined && item[def.key] !== null ? item[def.key] : '';
input.style.width = '100%';
input.style.padding = '12px';
input.style.borderRadius = '10px';
input.style.border = '2px solid rgba(102, 126, 234, 0.1)';
input.style.background = 'rgba(255,255,255,0.8)';
if (def.readonly) {
input.readOnly = true;
input.style.opacity = '0.7';
}
wrap.appendChild(label);
wrap.appendChild(input);
// Inline scan buttons for specific fields
if (def.key === 'Serial Numbers' || def.key === 'Tracking Number') {
const btnRow = document.createElement('div');
btnRow.className = 'scan-buttons';
btnRow.style.marginTop = '8px';
const btn = document.createElement('button');
btn.className = 'scan-btn';
btn.type = 'button';
btn.textContent = def.key === 'Serial Numbers' ? '🔍 Scan Serial' : '🧭 Scan Tracking';
btn.onclick = () => openScanner(def.key === 'Serial Numbers' ? 'serial' : 'tracking');
btnRow.appendChild(btn);
wrap.appendChild(btnRow);
}
container.appendChild(wrap);
});
}
function collectEditValues() {
const values = {};
fieldDefs.forEach(def => {
if (def.readonly) return;
const el = document.getElementById('edit-' + def.key);
if (!el) return;
values[def.key] = el.value ?? '';
});
return values;
}
async function showItemDetails(id) {
try {
const response = await fetch('/item-details/' + id);
const data = await response.json();
if (data.success && data.item) {
renderEditForm(data.item);
const modal = document.getElementById('itemModal');
const modalBody = document.getElementById('modalBody');
const modalTitle = document.getElementById('modalTitle');
// Set title
modalTitle.textContent = data.item.Item || data.item.Name || 'Item Details';
// Build details HTML
let detailsHTML = '';
// Define the order and labels for fields
const fieldOrder = [
{ key: 'Id', label: 'ID' },
{ key: 'Item', label: 'Item' },
{ key: 'Name', label: 'Name' },
{ key: 'Received', label: 'Status' },
{ key: 'Order Number', label: 'Order Number' },
{ key: 'SKU', label: 'SKU' },
{ key: 'Serial Numbers', label: 'Serial Numbers' },
{ key: 'QTY', label: 'Quantity' },
{ key: 'Price Per Item', label: 'Price Per Item' },
{ key: 'Tax', label: 'Tax' },
{ key: 'Total', label: 'Total' },
{ key: 'Vendor', label: 'Vendor' },
{ key: 'Date', label: 'Date' },
{ key: 'Tracking Number', label: 'Tracking Number' },
{ key: 'CreatedAt', label: 'Created' },
{ key: 'UpdatedAt', label: 'Updated' }
];
fieldOrder.forEach(field => {
const value = data.item[field.key];
if (field.key === 'CreatedAt' || field.key === 'UpdatedAt') {
// Skip timestamp fields or format them nicely
if (value) {
const date = new Date(value);
detailsHTML += '<div class="detail-row">';
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
detailsHTML += '<div class="detail-value">' + date.toLocaleString() + '</div>';
detailsHTML += '</div>';
}
} else if (field.key === 'Received') {
// Special formatting for status
detailsHTML += '<div class="detail-row">';
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
detailsHTML += '<div class="detail-value">';
const status = value || 'Unknown';
const statusClass = status.toLowerCase() === 'closed' ? 'closed' :
status.toLowerCase() === 'issues' ? 'issues' : 'open';
detailsHTML += '<span class="status-badge ' + statusClass + '">' + status + '</span>';
detailsHTML += '</div></div>';
} else if (value !== null && value !== undefined && value !== '') {
detailsHTML += '<div class="detail-row">';
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
detailsHTML += '<div class="detail-value">' + value + '</div>';
detailsHTML += '</div>';
} else if (field.key === 'Item' || field.key === 'Name' || field.key === 'Order Number') {
// Show important fields even if empty
detailsHTML += '<div class="detail-row">';
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
detailsHTML += '<div class="detail-value empty">Not provided</div>';
detailsHTML += '</div>';
}
});
modalBody.innerHTML = detailsHTML;
modal.style.display = 'none'; // keep hidden
updateNocoLink(id);
}
} catch (error) {
console.error('Error fetching item details:', error);
}
}
function selectAndClose(id, itemName) {
selectItem(id, itemName);
closeModal();
}
function closeModal() {
document.getElementById('itemModal').style.display = 'none';
}
function updateNocoLink(id) {
const box = document.getElementById('nocoLinkBox');
const link = document.getElementById('nocoLink');
if (!box || !link) return;
if (!id) {
box.style.display = 'none';
link.href = '#';
return;
}
link.href = nocoLinkBase + '?rowId=' + encodeURIComponent(id);
box.style.display = 'block';
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('itemModal');
if (event.target == modal) {
modal.style.display = 'none';
}
}
async function loadIssuesCount() {
try {
const response = await fetch('/needs-review-count');
const data = await response.json();
document.getElementById('issuesCount').textContent = data.count;
} catch (error) {
document.getElementById('issuesCount').textContent = 'Error';
}
}
document.getElementById('fileInput').addEventListener('change', function(e) {
const newFiles = Array.from(e.target.files);
selectedFiles = [...selectedFiles, ...newFiles];
this.value = '';
displayPreviews();
});
function displayPreviews() {
const container = document.getElementById('previewContainer');
container.innerHTML = '';
if (selectedFiles.length > 0) {
const fileCount = document.createElement('div');
fileCount.className = 'file-count';
fileCount.textContent = selectedFiles.length + ' file(s) selected';
container.appendChild(fileCount);
selectedFiles.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className = 'file-preview';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.className = 'preview-image';
img.src = URL.createObjectURL(file);
fileDiv.appendChild(img);
}
const fileName = document.createElement('div');
fileName.className = 'file-name';
fileName.textContent = file.name;
fileDiv.appendChild(fileName);
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.textContent = '×';
removeBtn.onclick = () => removeFile(index);
fileDiv.appendChild(removeBtn);
container.appendChild(fileDiv);
});
}
}
function removeFile(index) {
selectedFiles.splice(index, 1);
displayPreviews();
}
// --- Scanner handling ---
function ensureScannerScript() {
return new Promise((resolve, reject) => {
if (window.Html5Qrcode) return resolve();
const script = document.createElement('script');
script.src = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load scanner lib'));
document.head.appendChild(script);
});
}
async function startBarcodeDetector(target) {
if (!('BarcodeDetector' in window)) return false;
const formats = ['qr_code','code_128','code_39','codabar','ean_13','ean_8','upc_a','upc_e','itf','data_matrix','pdf417'];
let detector;
try {
detector = new BarcodeDetector({ formats });
} catch (e) {
return false;
}
const area = document.getElementById('scannerArea');
area.innerHTML = '';
const video = document.createElement('video');
video.setAttribute('playsinline', 'true');
video.style.width = '100%';
video.style.borderRadius = '8px';
area.appendChild(video);
try {
detectorStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
} catch (e) {
console.warn('Camera permission error:', e);
return false;
}
video.srcObject = detectorStream;
await video.play();
detectorRunning = true;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const scanFrame = async () => {
if (!detectorRunning) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
try {
const barcodes = await detector.detect(canvas);
if (barcodes && barcodes.length > 0) {
const text = barcodes[0].rawValue || barcodes[0].rawdata || '';
if (text) {
onScanSuccess(text);
return;
}
}
} catch (e) {
// ignore detection errors per frame
}
}
detectorInterval = requestAnimationFrame(scanFrame);
};
detectorInterval = requestAnimationFrame(scanFrame);
return true;
}
function stopBarcodeDetector() {
detectorRunning = false;
if (detectorInterval) {
cancelAnimationFrame(detectorInterval);
detectorInterval = null;
}
if (detectorStream) {
detectorStream.getTracks().forEach(t => t.stop());
detectorStream = null;
}
const area = document.getElementById('scannerArea');
if (area) area.innerHTML = '';
}
async function openScanner(target) {
scannerTarget = target;
document.getElementById('scannerModal').style.display = 'flex';
document.getElementById('scannerTitle').textContent =
target === 'serial' ? 'Scan Serial' : target === 'tracking' ? 'Scan Tracking' : 'Scan for Search';
try {
// Try native detector first
const detectorStarted = await startBarcodeDetector(target);
if (detectorStarted) return;
// Fallback to html5-qrcode
await ensureScannerScript();
if (scannerInstance) {
await scannerInstance.stop();
}
scannerInstance = new Html5Qrcode('scannerArea');
await scannerInstance.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 260, height: 260 } },
onScanSuccess,
onScanError
);
} catch (err) {
console.error('Scanner error:', err);
alert('Camera not available: ' + err.message);
closeScanner();
}
}
async function closeScanner() {
document.getElementById('scannerModal').style.display = 'none';
stopBarcodeDetector();
if (scannerInstance) {
try { await scannerInstance.stop(); } catch (e) {}
try { scannerInstance.clear(); } catch (e) {}
scannerInstance = null;
}
document.getElementById('scannerArea').innerHTML = '';
}
function onScanSuccess(decodedText) {
if (!decodedText) return;
const trimmed = decodedText.trim();
if (scannerTarget === 'serial') {
const serialInput = document.getElementById('edit-Serial Numbers');
if (serialInput) {
if (serialInput.value) {
serialInput.value = serialInput.value + ', ' + trimmed;
} else {
serialInput.value = trimmed;
}
}
} else if (scannerTarget === 'tracking') {
const trackingInput = document.getElementById('edit-Tracking Number');
if (trackingInput) {
trackingInput.value = trimmed;
}
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.value = trimmed;
searchItems(trimmed);
}
} else {
const searchInput = document.getElementById('searchInput');
searchInput.value = trimmed;
searchItems(trimmed);
}
closeScanner();
}
function onScanError(err) {
// Quietly ignore; library calls this frequently during scan
return;
}
async function uploadPhotos() {
const rowId = document.getElementById('rowId').value;
const fieldUpdates = collectEditValues();
const hasFields = Object.keys(fieldUpdates).length > 0;
if (!rowId || (!selectedFiles.length && !hasFields)) {
document.getElementById('status').innerHTML = '<div class="status error">Please enter row ID and select files or fields to update</div>';
return;
}
const formData = new FormData();
formData.append('rowId', rowId);
formData.append('fields', JSON.stringify(fieldUpdates));
selectedFiles.forEach(file => formData.append('photos', file));
try {
const response = await fetch('/upload', { method: 'POST', body: formData });
const result = await response.json();
if (response.ok) {
document.getElementById('status').innerHTML = '<div class="status success">Upload successful!</div>';
loadIssuesCount(); // Refresh count
// Clear all fields and files
selectedFiles = [];
document.getElementById('fileInput').value = '';
document.getElementById('rowId').value = '';
document.getElementById('searchInput').value = ''; // Clear search field
document.getElementById('searchResults').style.display = 'none'; // Hide search results
document.getElementById('previewContainer').innerHTML = '';
renderEditForm({}); // Clear form
} else {
throw new Error(result.error);
}
} catch (error) {
document.getElementById('status').innerHTML = '<div class="status error">Error: ' + error.message + '</div>';
}
}
</script>
</body>
</html>`);
});
// Proxy NocoDB images
app.get('/noco-image/*', async (req, res) => {
try {
const imagePath = req.params[0];
if (!imagePath) return res.status(400).json({ error: 'Missing path' });
const imageUrl = config.ncodbUrl + '/' + imagePath;
const response = await axios.get(imageUrl, {
headers: { 'xc-token': config.apiToken },
responseType: 'stream',
timeout: 15000
});
res.set('Content-Type', response.headers['content-type'] || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
response.data.pipe(res);
} catch (error) {
console.error('Image proxy error:', error.message);
res.status(404).json({ error: 'Image not found' });
}
});
// Create new record
app.post('/create', async (req, res) => {
try {
const fields = req.body || {};
if (!fields.Item) {
return res.status(400).json({ error: 'Item name is required' });
}
// Only send known fields, skip junk
const allowed = ['Item', 'Order Number', 'Serial Numbers', 'SKU', 'Received',
'Price Per Item', 'Tax', 'Total', 'QTY', 'Notes',
'Tracking Number', 'Source', 'Platform', 'Category'];
const record = {};
for (const key of allowed) {
if (fields[key] !== undefined && fields[key] !== null && fields[key] !== '') {
record[key] = fields[key];
}
}
// Default status
if (!record.Received) record.Received = 'Pending';
const createResp = await axios.post(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
record,
{ headers: { 'Content-Type': 'application/json', 'xc-token': config.apiToken } }
);
const newId = createResp.data?.Id || createResp.data?.id;
console.log('Created new record:', newId, record.Item);
res.json({ success: true, id: newId, item: createResp.data });
} catch (error) {
console.error('Create error:', error.response?.data || error.message);
res.status(500).json({ error: 'Create failed', details: error.message });
}
});
// Update item fields
app.patch('/item/:id', async (req, res) => {
try {
const rowId = req.params.id;
const fields = req.body;
if (!rowId || !fields || Object.keys(fields).length === 0) {
return res.status(400).json({ error: 'Missing rowId or fields' });
}
await axios.patch(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
fields,
{
headers: {
'Content-Type': 'application/json',
'xc-token': config.apiToken
}
}
);
res.json({ success: true });
} catch (error) {
console.error('Update error:', error.response?.data || error.message);
res.status(500).json({ error: 'Update failed', details: error.message });
}
});
app.post('/duplicate/:id', async (req, res) => {
try {
const rowId = req.params.id;
// Fetch original record
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
{ headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' } }
);
const original = response.data;
// Strip fields that shouldn't be duplicated
const skip = ['Id', 'id', 'CreatedAt', 'UpdatedAt', 'nc_', 'photos', 'Photos', 'row_id',
'Print', 'Print SKU', 'send to phone'];
const newRecord = {};
for (const [key, value] of Object.entries(original)) {
if (skip.some(s => key === s || key.startsWith(s))) continue;
if (value && typeof value === 'object' && !Array.isArray(value)) continue; // skip webhook buttons etc
if (value !== null && value !== undefined) {
newRecord[key] = value;
}
}
// Create new record
const createResp = await axios.post(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
newRecord,
{ headers: { 'Content-Type': 'application/json', 'xc-token': config.apiToken } }
);
console.log('Duplicated record', rowId, '-> new ID:', createResp.data?.Id || createResp.data?.id);
res.json({ success: true, newId: createResp.data?.Id || createResp.data?.id, item: createResp.data });
} catch (error) {
console.error('Duplicate error:', error.response?.data || error.message);
res.status(500).json({ error: 'Duplicate failed', details: error.message });
}
});
app.post('/upload', upload.array('photos'), async (req, res) => {
try {
const { rowId } = req.body;
const files = req.files || [];
const rawFields = req.body.fields;
let fieldUpdates = {};
if (rawFields) {
try { fieldUpdates = JSON.parse(rawFields); } catch (e) { fieldUpdates = {}; }
}
if (!rowId || !config.apiToken) {
return res.status(400).json({ error: 'Missing required data' });
}
const hasFiles = files.length > 0;
const hasFields = fieldUpdates && Object.keys(fieldUpdates).length > 0;
if (!hasFiles && !hasFields) {
return res.status(400).json({ error: 'No files or fields to update' });
}
const uploadedFiles = [];
for (let file of files) {
const formData = new FormData();
formData.append('file', file.buffer, {
filename: file.originalname,
contentType: file.mimetype
});
const uploadResponse = await axios.post(
config.ncodbUrl + '/api/v1/db/storage/upload',
formData,
{
headers: {
...formData.getHeaders(),
'xc-token': config.apiToken
}
}
);
uploadedFiles.push(...uploadResponse.data);
}
const updateData = { ...fieldUpdates };
if (hasFiles) {
updateData[config.columnName] = uploadedFiles;
}
await axios.patch(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
updateData,
{
headers: {
'Content-Type': 'application/json',
'xc-token': config.apiToken
}
}
);
res.json({ success: true, message: 'Upload successful' });
} catch (error) {
const status = error.response?.status;
const data = error.response?.data;
const details = typeof data === 'string' ? data.slice(0, 500) : data || error.message;
console.error('Upload error:', status || '', details);
res.status(500).json({ error: 'Upload failed', status, details });
}
});
// Recent items — sorted by last updated
app.get('/recent', async (req, res) => {
try {
if (!config.apiToken) {
return res.status(500).json({ error: 'NocoDB API token not configured' });
}
const limit = Math.min(parseInt(req.query.limit) || 30, 100);
const response = await axios.get(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
{
headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' },
params: { limit, sort: '-UpdatedAt' }
}
);
const items = (response.data.list || []).map(row => ({
id: row.Id,
item: row.Item || row.Name || 'Unknown',
received: row.Received || '',
updatedAt: row.UpdatedAt || ''
}));
res.json({ items, count: items.length });
} catch (error) {
console.error('Recent fetch error:', error.message);
res.status(500).json({ error: 'Failed to fetch recent items', details: error.message });
}
});
// Upload from Immich — server-to-server, no browser involved
app.post('/upload-from-immich', async (req, res) => {
try {
const { rowId, assetIds, deleteAfter } = req.body;
const immichUrl = process.env.IMMICH_URL;
const immichApiKey = process.env.IMMICH_API_KEY;
if (!rowId || !assetIds?.length) {
return res.status(400).json({ error: 'Missing rowId or assetIds' });
}
if (!immichUrl || !immichApiKey) {
return res.status(500).json({ error: 'Immich not configured on server' });
}
const uploaded = [];
for (const assetId of assetIds) {
try {
// Download original from Immich
const imgRes = await axios.get(`${immichUrl}/api/assets/${assetId}/original`, {
headers: { 'x-api-key': immichApiKey },
responseType: 'arraybuffer'
});
// Get filename from Immich
const assetInfo = await axios.get(`${immichUrl}/api/assets/${assetId}`, {
headers: { 'x-api-key': immichApiKey }
});
const filename = assetInfo.data.originalFileName || `immich-${assetId}.jpg`;
const mimetype = assetInfo.data.originalMimeType || 'image/jpeg';
// Upload to NocoDB storage
const formData = new FormData();
formData.append('file', Buffer.from(imgRes.data), {
filename,
contentType: mimetype
});
const uploadResponse = await axios.post(
config.ncodbUrl + '/api/v1/db/storage/upload',
formData,
{
headers: {
...formData.getHeaders(),
'xc-token': config.apiToken
}
}
);
uploaded.push(...uploadResponse.data);
console.log(`Uploaded ${filename} from Immich to NocoDB`);
} catch (e) {
console.error(`Failed to upload asset ${assetId}:`, e.message);
}
}
if (uploaded.length > 0) {
// Attach to row
await axios.patch(
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
{ [config.columnName]: uploaded },
{
headers: {
'Content-Type': 'application/json',
'xc-token': config.apiToken
}
}
);
}
// Delete from Immich if requested
const deletedIds = [];
if (deleteAfter && uploaded.length > 0) {
try {
await axios.delete(`${immichUrl}/api/assets`, {
headers: { 'x-api-key': immichApiKey, 'Content-Type': 'application/json' },
data: { ids: assetIds.slice(0, uploaded.length), force: false }
});
deletedIds.push(...assetIds.slice(0, uploaded.length));
console.log(`Trashed ${deletedIds.length} assets from Immich`);
} catch (e) {
console.error('Failed to delete from Immich:', e.message);
}
}
res.json({ success: true, uploadedCount: uploaded.length, deletedCount: deletedIds.length });
} catch (error) {
console.error('Immich upload error:', error.message);
res.status(500).json({ error: 'Upload failed', details: error.message });
}
});
// Fetch workspace ID for NocoDB deep links
(async () => {
try {
const res = await axios.get(config.ncodbUrl + '/api/v1/db/meta/projects/', {
headers: { 'xc-token': config.apiToken }
});
const base = (res.data.list || []).find(b => b.id === config.baseId);
if (base && base.fk_workspace_id) {
config.workspaceId = base.fk_workspace_id;
console.log(' Workspace ID: ' + config.workspaceId);
}
} catch (e) {
console.log(' Could not fetch workspace ID:', e.message);
}
})();
app.listen(port, () => {
const externalPort = process.env.EXTERNAL_PORT || port;
console.log('📷 NocoDB Photo Uploader running on port ' + port);
console.log('🔧 Configuration:');
console.log(' NocoDB URL: ' + config.ncodbUrl);
console.log(' Base ID: ' + config.baseId);
console.log(' Table ID: ' + config.tableId);
console.log(' Column Name: ' + config.columnName);
console.log(' API Token: ' + (config.apiToken ? '✅ Set' : '❌ Missing'));
console.log('');
console.log('🌐 Open http://localhost:' + externalPort + ' to use the uploader');
console.log('🔧 Test endpoint: http://localhost:' + externalPort + '/test');
console.log('🎨 Modern theme preview: http://localhost:' + externalPort + '/preview');
});
// Preview route that enables modern theme without affecting default
app.get('/preview', (req, res) => {
res.redirect('/?theme=modern');
});