195 lines
6.4 KiB
JavaScript
195 lines
6.4 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')
|
|
|
|
// 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/<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}`)
|
|
}) |