#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
2153 lines
80 KiB
JavaScript
Executable File
2153 lines
80 KiB
JavaScript
Executable File
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()">×</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()">×</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');
|
||
});
|