feat: REST API v1 (Bearer JWT, CRUD entries + admin companies)
This commit is contained in:
@@ -103,6 +103,10 @@ async function start() {
|
||||
authLimiter,
|
||||
}));
|
||||
|
||||
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
|
||||
const { createApiRouter } = require('./src/api/index');
|
||||
app.use('/api/v1', createApiRouter({ auth, q }));
|
||||
|
||||
// ── Все маршруты ниже требуют авторизации (auth.middleware) ────────────────
|
||||
app.use(auth.middleware);
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* src/api/index.js — REST API v1.
|
||||
*
|
||||
* Монтируется в server.js ДО общего auth.middleware:
|
||||
* app.use('/api/v1', createApiRouter({ auth, q }))
|
||||
*
|
||||
* Аутентификация: только Bearer JWT (без сессий, без CSRF).
|
||||
* Ответы: JSON везде.
|
||||
* Переиспользует: src/queries.js, src/auth.js (middleware для валидации токена).
|
||||
*
|
||||
* Эндпоинты:
|
||||
* GET /api/v1/entries — список записей компании
|
||||
* POST /api/v1/entries — создать запись
|
||||
* PATCH /api/v1/entries/:id — обновить запись
|
||||
* DELETE /api/v1/entries/:id — удалить запись
|
||||
* GET /api/v1/companies — все компании (admin)
|
||||
* PATCH /api/v1/companies/:id/limit — установить лимит (admin)
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireBearer } = require('./middleware/bearerAuth');
|
||||
const { createEntriesRouter } = require('./routes/entries');
|
||||
const { createAdminRouter } = require('./routes/admin');
|
||||
|
||||
function createApiRouter({ auth, q }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Все /api/v1/* требуют Bearer — 401 JSON если нет токена
|
||||
router.use(requireBearer);
|
||||
// Валидация токена — переиспользуем auth.middleware из src/auth.js
|
||||
router.use(auth.middleware);
|
||||
|
||||
router.use('/entries', createEntriesRouter({ q }));
|
||||
router.use('/', createAdminRouter({ q }));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createApiRouter };
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Middleware для API: требует Bearer-токен в Authorization.
|
||||
* Если токена нет — сразу 401 JSON (без редиректа на /login).
|
||||
* Валидацию токена делает следующий в цепочке auth.middleware.
|
||||
*/
|
||||
function requireBearer(req, res, next) {
|
||||
const header = (req.headers.authorization || '').trim();
|
||||
if (!/^Bearer\s+\S/i.test(header)) {
|
||||
return res.status(401).json({ error: 'Bearer token required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireBearer };
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
|
||||
// Отдельный requireAdmin для API — возвращает JSON вместо plain text
|
||||
function apiRequireAdmin(req, res, next) {
|
||||
if (!req.user || !req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin role required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function createAdminRouter({ q }) {
|
||||
const router = express.Router();
|
||||
const json = express.json({ limit: '8kb' });
|
||||
|
||||
// GET /api/v1/companies — все компании (admin only)
|
||||
router.get('/companies', apiRequireAdmin, async (req, res) => {
|
||||
try {
|
||||
const companies = await q.getAllCompanies();
|
||||
res.json({ companies });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/v1/companies/:id/limit — установить лимит (admin only)
|
||||
router.patch('/companies/:id/limit', apiRequireAdmin, json, async (req, res) => {
|
||||
const limit = parseInt((req.body || {}).limit, 10);
|
||||
if (isNaN(limit) || limit < 0) {
|
||||
return res.status(400).json({ error: 'limit must be non-negative integer' });
|
||||
}
|
||||
try {
|
||||
await q.setLimit(req.params.id, limit);
|
||||
res.json({ ok: true, limit });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createAdminRouter };
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
|
||||
function createEntriesRouter({ q }) {
|
||||
const router = express.Router();
|
||||
const json = express.json({ limit: '32kb' });
|
||||
|
||||
// ── Хелпер: маппинг сообщений ошибок на HTTP-коды ───────────────────────
|
||||
function handleQueryError(e, res) {
|
||||
const msg = e.message || '';
|
||||
if (
|
||||
msg.includes('Лимит исчерпан') ||
|
||||
msg.includes('Такой адрес уже существует') ||
|
||||
msg.includes('Пересечение с существующей записью')
|
||||
) {
|
||||
return res.status(409).json({ error: msg });
|
||||
}
|
||||
if (msg.includes('Запись не найдена')) {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
return res.status(500).json({ error: msg });
|
||||
}
|
||||
|
||||
// GET /api/v1/entries — список записей текущей компании
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||
const entries = await q.listEntries(company.id);
|
||||
res.json({ entries });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/entries — создать запись
|
||||
router.post('/', json, async (req, res) => {
|
||||
const { value, comment } = req.body || {};
|
||||
if (!value) return res.status(400).json({ error: 'value required' });
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email);
|
||||
res.status(201).json({ entry, wasNormalized });
|
||||
} catch (e) {
|
||||
handleQueryError(e, res);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/v1/entries/:id — обновить запись
|
||||
router.patch('/:id', json, async (req, res) => {
|
||||
const { value, comment } = req.body || {};
|
||||
if (!value) return res.status(400).json({ error: 'value required' });
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
|
||||
res.json({ entry, wasNormalized });
|
||||
} catch (e) {
|
||||
handleQueryError(e, res);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/entries/:id — удалить запись
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||
await q.deleteEntry(req.params.id, company.id, req.user.email);
|
||||
res.status(204).send();
|
||||
} catch (e) {
|
||||
handleQueryError(e, res);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createEntriesRouter };
|
||||
Reference in New Issue
Block a user