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 })); // 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(` NocoDB Photo Uploader

📷 NocoDB Photo Uploader

Items with Issues

Loading...

📁 Click to add photos

Each click adds more photos - they accumulate until you upload

Scan

Align the barcode in the frame. Scans close automatically on success.
`); }); // 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'); });