const express = require('express') const multer = require('multer') const fs = require('fs') const path = require('path') const cors = require('cors') const { PrismaClient } = require('@prisma/client') // small, configurable logger used across the app const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase() // 'error'|'info'|'debug' const levels = { error: 0, info: 1, debug: 2 } const log = { error: (...args) => { if (levels[LOG_LEVEL] >= levels.error && process.env.NODE_ENV !== 'test') console.error(new Date().toISOString(), '[ERROR]', ...args) }, info: (...args) => { if (levels[LOG_LEVEL] >= levels.info && process.env.NODE_ENV !== 'test') console.log(new Date().toISOString(), '[INFO]', ...args) }, debug: (...args) => { if (levels[LOG_LEVEL] >= levels.debug && process.env.NODE_ENV !== 'test') console.log(new Date().toISOString(), '[DEBUG]', ...args) }, } // store runtime data under DATA_DIR env if provided, else backend/data const DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : path.join(__dirname, 'data') const UPLOAD_DIR = path.join(DATA_DIR, 'uploads') // ensure data and upload dirs exist if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }) // ensure DATABASE_URL points to the DB under DATA_DIR for Prisma at runtime if (!process.env.DATABASE_URL) { process.env.DATABASE_URL = `file:${path.join(DATA_DIR, 'sqlite.db')}` } // instantiate Prisma client AFTER DATA_DIR / DATABASE_URL are ready const prisma = new PrismaClient() const upload = multer({ dest: UPLOAD_DIR }) // local storage for uploads const app = express() app.use(cors()) app.use(express.json()) // minimal request logger: only log responses with status >= 400 app.use((req, res, next) => { const start = Date.now() res.on('finish', () => { const elapsed = Date.now() - start if (res.statusCode >= 400) { log.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${elapsed}ms`) } }) next() }) // serve uploaded files statically at /uploads/ app.use('/uploads', express.static(UPLOAD_DIR)) // small helper to parse numeric id param and return early 400 if invalid const parseId = (req, res) => { const id = Number(req.params.id) if (!Number.isFinite(id) || Number.isNaN(id)) { res.status(400).json({ error: 'invalid id' }) return null } return id } // file upload -> returns { url } app.post('/api/upload', upload.single('file'), async (req, res) => { if (!req.file) { log.error('upload failed: no file in request') return res.status(400).json({ error: 'file required' }) } // minimal debug log for successful upload (visible when LOG_LEVEL=debug) log.debug('upload saved', req.file.filename) const url = `/uploads/${req.file.filename}` res.json({ url }) }) // COUNTERS CRUD app.get('/api/counters', async (req, res) => { const rows = await prisma.counter.findMany({ orderBy: { position: 'asc' }, }) res.json(rows) }) app.post('/api/counters', async (req, res) => { let { name = null, value = 0, imageUrl = null, position = 0 } = req.body const finalName = typeof name === 'string' && name.trim() ? name.trim() : 'New Counter' try { const row = await prisma.counter.create({ data: { name: finalName, value, imageUrl, position }, }) log.debug('counter created', row.name ?? null) res.status(201).json(row) } catch (err) { log.error('create counter failed', err && err.message) res.status(500).json({ error: 'create failed' }) } }) app.get('/api/counters/:id', async (req, res) => { const id = parseId(req, res) if (id === null) return const row = await prisma.counter.findUnique({ where: { id } }) if (!row) return res.status(404).json({ error: 'not found' }) res.json(row) }) app.patch('/api/counters/reorder', async (req, res) => { const updates = req.body if (!Array.isArray(updates)) return res.status(400).json({ error: 'expected array' }) try { const ops = updates.map((u) => prisma.counter.update({ where: { id: Number(u.id) }, data: { position: Number(u.position) }, }) ) await prisma.$transaction(ops) const rows = await prisma.counter.findMany({ orderBy: { position: 'asc' } }) log.debug('reorder completed', updates.length) res.json(rows) } catch (err) { log.error('reorder failed', err && err.message) res.status(500).json({ error: 'reorder failed' }) } }) app.patch('/api/counters/:id', async (req, res) => { const id = parseId(req, res) if (id === null) return const { value, name, imageUrl, position } = req.body try { const row = await prisma.counter.update({ where: { id }, data: { value, name, imageUrl, position }, }) log.debug('counter updated', row.id, row.name ?? null) res.json(row) } catch (err) { log.error('update failed', id, err && err.message) res.status(404).json({ error: 'not found or invalid update' }) } }) app.delete('/api/counters/:id', async (req, res) => { const id = parseId(req, res) if (id === null) return try { const deleted = await prisma.counter.delete({ where: { id } }) log.debug('counter deleted', deleted.id, deleted.name ?? null) res.status(204).end() } catch (err) { log.error('delete failed', id, err && err.message) res.status(500).json({ error: 'delete failed' }) } }) // Serve built frontend (production) if it exists const FRONTEND_DIST = path.join(__dirname, '..', 'frontend', 'dist') if (fs.existsSync(FRONTEND_DIST)) { app.use(express.static(FRONTEND_DIST)) // SPA fallback: serve index.html for any non-API route app.get('/*path', (req, res) => { if (req.path.startsWith('/api')) return res.status(404).end() res.sendFile(path.join(FRONTEND_DIST, 'index.html')) }) } // graceful shutdown: ensure Prisma disconnects process.on('SIGINT', async () => { log.info('shutting down') try { await prisma.$disconnect() } catch {} process.exit(0) }) process.on('SIGTERM', async () => { log.info('shutting down') try { await prisma.$disconnect() } catch {} process.exit(0) }) const PORT = process.env.PORT || 3000 app.listen(PORT, () => { log.info(`Server listening on port ${PORT}`) })