feat: add city avatar upload functionality and enhance city management features
This commit is contained in:
6
backend/migrations/011_city_avatars.sql
Normal file
6
backend/migrations/011_city_avatars.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- A city may use a raster coat of arms or another custom image instead of the
|
||||
-- text/emoji emblem. The file itself is stored under public/uploads/cities.
|
||||
ALTER TABLE cities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(255);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cities_avatar ON cities(avatar_url)
|
||||
WHERE avatar_url IS NOT NULL;
|
||||
@@ -1,5 +1,9 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const cityUploadDir = path.join(__dirname, '../../public/uploads/cities');
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
@@ -25,7 +29,7 @@ class CitiesController {
|
||||
async getAllCities(req, res) {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, slug, name, emblem, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
SELECT id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
FROM cities
|
||||
WHERE is_active = true
|
||||
ORDER BY name COLLATE "ru-RU-x-icu"
|
||||
@@ -46,7 +50,7 @@ class CitiesController {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO cities (name, slug, emblem, center_lat, center_lon, schedule_source_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, slug, name, emblem, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
`, [
|
||||
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
|
||||
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
|
||||
@@ -60,6 +64,42 @@ class CitiesController {
|
||||
res.status(500).json({ error: 'Не удалось добавить город.' });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadCityAvatar(req, res) {
|
||||
const cityId = Number(req.params.cityId);
|
||||
if (!Number.isInteger(cityId) || cityId <= 0) {
|
||||
if (req.file) await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'Выберите изображение.' });
|
||||
|
||||
try {
|
||||
const current = await pool.query('SELECT avatar_url FROM cities WHERE id = $1', [cityId]);
|
||||
if (!current.rows.length) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(404).json({ error: 'Город не найден.' });
|
||||
}
|
||||
|
||||
const avatarUrl = `/uploads/cities/${req.file.filename}`;
|
||||
const result = await pool.query(`
|
||||
UPDATE cities SET avatar_url = $1 WHERE id = $2
|
||||
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
`, [avatarUrl, cityId]);
|
||||
|
||||
const previous = current.rows[0].avatar_url;
|
||||
if (previous?.startsWith('/uploads/cities/')) {
|
||||
await fs.unlink(path.join(cityUploadDir, path.basename(previous))).catch(error => {
|
||||
logger.warn('Failed to remove previous city avatar', { cityId, message: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Изображение города сохранено.', city: result.rows[0] });
|
||||
} catch (error) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
logger.error('Error uploading city avatar', { cityId, message: error.message });
|
||||
res.status(500).json({ error: 'Не удалось сохранить изображение города.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CitiesController();
|
||||
|
||||
@@ -99,15 +99,13 @@ app.use((err, req, res, next) => {
|
||||
// Multer errors
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: 'File too large',
|
||||
message: 'Maximum file size is 5MB'
|
||||
error: 'Размер файла не должен превышать 5 МБ.'
|
||||
});
|
||||
}
|
||||
|
||||
if (err.message && err.message.includes('Only image files are allowed')) {
|
||||
if (err.message && (err.message.includes('Only image files are allowed') || err.message.includes('Only JPEG, PNG, and WEBP images are allowed'))) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid file type',
|
||||
message: err.message
|
||||
error: 'Поддерживаются только изображения JPG, PNG и WEBP.'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
30
backend/src/middleware/cityUpload.js
Normal file
30
backend/src/middleware/cityUpload.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads/cities');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const allowedExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, callback) => callback(null, uploadDir),
|
||||
filename: (req, file, callback) => {
|
||||
const cityId = Number(req.params.cityId);
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
callback(null, `city-${cityId}-${Date.now()}${extension}`);
|
||||
}
|
||||
});
|
||||
|
||||
function fileFilter(_req, file, callback) {
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
if (allowedExtensions.has(extension) && allowedMimeTypes.has(file.mimetype)) return callback(null, true);
|
||||
return callback(new Error('Only JPEG, PNG, and WEBP images are allowed'));
|
||||
}
|
||||
|
||||
module.exports = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
const express = require('express');
|
||||
const citiesController = require('../controllers/citiesController');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const cityUpload = require('../middleware/cityUpload');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', citiesController.getAllCities.bind(citiesController));
|
||||
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
|
||||
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user