Files
tally-counter/backend/server.js

189 lines
6.1 KiB
JavaScript

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')
const prisma = new PrismaClient()
// 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 all runtime data under 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 })
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/<filename>
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}`)
})