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(`
📁 Click to add photos
Each click adds more photos - they accumulate until you upload