feat: add city avatar upload functionality and enhance city management features

This commit is contained in:
2026-09-17 19:54:09 +03:00
parent d13cc92d82
commit acec6b4fc0
11 changed files with 292 additions and 26 deletions

View 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;

View File

@@ -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();

View File

@@ -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.'
});
}

View 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
});

View File

@@ -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;