dev #23

Merged
Seazn merged 16 commits from dev into main 2026-05-21 17:34:48 +00:00
10 changed files with 601 additions and 42 deletions
Showing only changes of commit baf53e36c1 - Show all commits

View File

@ -12,16 +12,61 @@ function buildPictureUrlFromKey(key) {
return key; // fallback: store key only
}
function normalizePictureFiles(req) {
const files = [];
// upload.any() -> req.files is usually an array
if (Array.isArray(req.files)) {
files.push(...req.files);
}
// Defensive fallback for potential object-shaped middleware output
if (req.files && !Array.isArray(req.files) && typeof req.files === 'object') {
for (const value of Object.values(req.files)) {
if (Array.isArray(value)) files.push(...value);
else if (value) files.push(value);
}
}
if (req.file) files.push(req.file);
return files.filter(Boolean);
}
function toCoffeeResponse(row) {
const pictures = Array.isArray(row?.pictures) && row.pictures.length
? row.pictures.map((p) => ({
...p,
url: p.object_storage_id ? buildPictureUrlFromKey(p.object_storage_id) : ''
}))
: (row?.object_storage_id
? [{
id: null,
coffee_id: row.id,
object_storage_id: row.object_storage_id,
original_filename: row.original_filename || null,
sort_order: 0,
created_at: row.created_at || null,
url: buildPictureUrlFromKey(row.object_storage_id)
}]
: []);
const pictureUrls = pictures.map((p) => p.url).filter(Boolean);
return {
...row,
pictureUrl: pictureUrls[0] || '',
pictureUrls,
pictures,
};
}
exports.list = async (req, res) => {
const rows = await CoffeeService.list();
const items = (rows || []).map(r => ({
...r,
pictureUrl: r.object_storage_id ? buildPictureUrlFromKey(r.object_storage_id) : ''
}));
const items = (rows || []).map((r) => toCoffeeResponse(r));
res.json(items);
};
exports.create = async (req, res) => {
let uploadedKeys = [];
try {
const { title, description, price } = req.body;
const currency = req.body.currency || 'EUR';
@ -31,19 +76,23 @@ exports.create = async (req, res) => {
const interval_count = 1;
const state = req.body.state === 'false' || req.body.state === false ? false : true; // default available
// If file uploaded, push to Exoscale and set object_storage_id
// If files uploaded, push to Exoscale and store all uploaded images
let object_storage_id = null;
let original_filename = null;
let uploadedKey = null;
if (req.file) {
let images = [];
const incomingFiles = normalizePictureFiles(req);
logger.info('[CoffeeController.create] incoming multipart files', {
count: incomingFiles.length,
fields: incomingFiles.map((f) => f?.fieldname).filter(Boolean),
});
if (incomingFiles.length > 10) {
return res.status(400).json({ error: 'Maximum 10 images allowed' });
}
if (incomingFiles.length) {
const allowedMime = ['image/jpeg','image/png','image/webp'];
if (!allowedMime.includes(req.file.mimetype)) {
return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
}
const maxBytes = 10 * 1024 * 1024; // 10MB
if (req.file.size > maxBytes) {
return res.status(400).json({ error: 'Image exceeds 10MB limit' });
}
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
@ -52,18 +101,38 @@ exports.create = async (req, res) => {
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
const key = `coffee/products/${Date.now()}_${req.file.originalname}`;
for (let i = 0; i < incomingFiles.length; i += 1) {
const file = incomingFiles[i];
if (!allowedMime.includes(file.mimetype)) {
return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
}
if (file.size > maxBytes) {
return res.status(400).json({ error: 'Image exceeds 10MB limit' });
}
const key = `coffee/products/${Date.now()}_${i}_${file.originalname}`;
await s3.send(new PutObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
Body: file.buffer,
ContentType: file.mimetype,
ACL: 'public-read'
}));
object_storage_id = key;
original_filename = req.file.originalname;
uploadedKey = key;
logger.info('[CoffeeController.create] uploaded picture', { key });
uploadedKeys.push(key);
images.push({
object_storage_id: key,
original_filename: file.originalname,
sort_order: i,
});
}
if (images[0]) {
object_storage_id = images[0].object_storage_id;
original_filename = images[0].original_filename;
}
logger.info('[CoffeeController.create] uploaded pictures', { count: images.length });
}
const created = await CoffeeService.create({
@ -76,17 +145,15 @@ exports.create = async (req, res) => {
interval_count,
object_storage_id,
original_filename,
images,
state,
});
res.status(201).json({
...created,
pictureUrl: created.object_storage_id ? buildPictureUrlFromKey(created.object_storage_id) : ''
});
res.status(201).json(toCoffeeResponse(created));
} catch (e) {
logger.error('[CoffeeController.create] error', { msg: e.message, stack: e.stack?.split('\n')[0] });
// best-effort cleanup of uploaded object on failure
try {
if (object_storage_id) {
if (uploadedKeys.length) {
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
@ -95,7 +162,9 @@ exports.create = async (req, res) => {
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: object_storage_id }));
for (const key of uploadedKeys) {
await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key }));
}
}
} catch (cleanupErr) {
logger.warn('[CoffeeController.create] cleanup failed', { msg: cleanupErr.message });
@ -251,13 +320,181 @@ exports.remove = async (req, res) => {
exports.listActive = async (req, res) => {
try {
const rows = await CoffeeService.listActive();
const items = (rows || []).map(r => ({
...r,
pictureUrl: r.object_storage_id ? buildPictureUrlFromKey(r.object_storage_id) : ''
}));
const items = (rows || []).map((r) => toCoffeeResponse(r));
res.json(items);
} catch (e) {
logger.error('[CoffeeController.listActive] error', { msg: e.message });
res.status(500).json({ error: 'Failed to fetch active coffee products' });
}
};
exports.getPictures = async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) {
return res.status(400).json({ error: 'Invalid id' });
}
const row = await CoffeeService.get(id);
if (!row) {
return res.status(404).json({ error: 'Not found' });
}
const normalized = toCoffeeResponse(row);
return res.json({
coffeeId: id,
pictures: normalized.pictures,
pictureUrls: normalized.pictureUrls,
pictureUrl: normalized.pictureUrl,
});
} catch (e) {
logger.error('[CoffeeController.getPictures] error', { msg: e.message });
return res.status(500).json({ error: 'Failed to fetch coffee pictures' });
}
};
exports.editPictures = async (req, res) => {
let uploadedKeys = [];
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) {
return res.status(400).json({ error: 'Invalid id' });
}
const parseBoolean = (value, fallback = false) => {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
};
const parseIds = (value) => {
if (value === undefined || value === null || value === '') return [];
if (Array.isArray(value)) return value.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0);
const text = String(value).trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0);
}
} catch (_) {
// Fall through to CSV parsing.
}
return text
.split(',')
.map((x) => Number(String(x).trim()))
.filter((x) => Number.isFinite(x) && x > 0);
};
const replaceAll = parseBoolean(req.body?.replaceAll, false);
const removePictureIds = parseIds(req.body?.removePictureIds);
const incomingFiles = normalizePictureFiles(req);
if (incomingFiles.length > 10) {
return res.status(400).json({ error: 'Maximum 10 images allowed per edit request' });
}
const images = [];
if (incomingFiles.length) {
const allowedMime = ['image/jpeg','image/png','image/webp'];
const maxBytes = 10 * 1024 * 1024; // 10MB
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
credentials: {
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
for (let i = 0; i < incomingFiles.length; i += 1) {
const file = incomingFiles[i];
if (!allowedMime.includes(file.mimetype)) {
return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
}
if (file.size > maxBytes) {
return res.status(400).json({ error: 'Image exceeds 10MB limit' });
}
const key = `coffee/products/${Date.now()}_${i}_${file.originalname}`;
await s3.send(new PutObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ACL: 'public-read'
}));
uploadedKeys.push(key);
images.push({
object_storage_id: key,
original_filename: file.originalname,
});
}
}
const result = await CoffeeService.editPictures(id, {
replaceAll,
removePictureIds,
images,
});
if (!result || !result.updated) {
return res.status(404).json({ error: 'Not found' });
}
const deletedKeys = (result.deleted || [])
.map((x) => x?.object_storage_id)
.filter(Boolean);
if (deletedKeys.length) {
try {
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
credentials: {
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
for (const key of deletedKeys) {
await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key }));
}
} catch (cleanupErr) {
logger.warn('[CoffeeController.editPictures] cleanup failed', { msg: cleanupErr.message });
}
}
return res.json(toCoffeeResponse(result.updated));
} catch (e) {
logger.error('[CoffeeController.editPictures] error', { msg: e.message });
try {
if (uploadedKeys.length) {
const s3 = new S3Client({
region: process.env.EXOSCALE_REGION,
endpoint: process.env.EXOSCALE_ENDPOINT,
credentials: {
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
},
});
for (const key of uploadedKeys) {
await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key }));
}
}
} catch (cleanupErr) {
logger.warn('[CoffeeController.editPictures] rollback cleanup failed', { msg: cleanupErr.message });
}
return res.status(500).json({ error: 'Failed to edit coffee pictures' });
}
};

View File

@ -71,6 +71,18 @@ class MailTemplatesController {
}
}
static async unarchive(req, res) {
try {
const data = await MailTemplateService.unarchive(req.params.id, MailTemplatesController._currentUserId(req));
if (!data) {
return res.status(404).json({ success: false, message: 'Mail template not found' });
}
return res.json({ success: true, data });
} catch (error) {
return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to unarchive mail template' });
}
}
static async remove(req, res) {
try {
const deleted = await MailTemplateService.remove(req.params.id);

View File

@ -1135,6 +1135,21 @@ const createDatabase = async () => {
`);
console.log('✅ Coffee table (simplified) created/verified');
await connection.query(`
CREATE TABLE IF NOT EXISTS coffee_table_images (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
coffee_id BIGINT NOT NULL,
object_storage_id VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_coffee_table_images_coffee FOREIGN KEY (coffee_id) REFERENCES coffee_table(id) ON DELETE CASCADE ON UPDATE CASCADE,
INDEX idx_coffee_table_images_coffee_sort (coffee_id, sort_order),
INDEX idx_coffee_table_images_key (object_storage_id)
);
`);
console.log('✅ Coffee table images created/verified');
// --- Coffee shipping fees (fixed package sizes) ---
await connection.query(`
CREATE TABLE IF NOT EXISTS coffee_shipping_fees (
@ -1834,6 +1849,8 @@ const createDatabase = async () => {
await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_active', 'is_active');
await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_archived', 'is_archived');
await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_type_active', 'template_type, is_active');
await ensureIndex(connection, 'coffee_table_images', 'idx_coffee_table_images_coffee_sort', 'coffee_id, sort_order');
await ensureIndex(connection, 'coffee_table_images', 'idx_coffee_table_images_key', 'object_storage_id');
await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_company', 'company_id');
await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active');
console.log('🚀 Performance indexes created/verified');

View File

@ -2,16 +2,204 @@ const db = require('../../database/database');
const { logger } = require('../../middleware/logger');
class CoffeeRepository {
async _syncSortOrders(coffeeId, conn) {
const cx = conn || db;
const [rows] = await cx.query(
`SELECT id
FROM coffee_table_images
WHERE coffee_id = ?
ORDER BY sort_order ASC, id ASC`,
[coffeeId]
);
for (let i = 0; i < (rows || []).length; i += 1) {
await cx.query('UPDATE coffee_table_images SET sort_order = ? WHERE id = ?', [i, rows[i].id]);
}
}
async ensureLegacyPictureRow(coffeeId, conn) {
const cx = conn || db;
const [coffeeRows] = await cx.query(
`SELECT object_storage_id, original_filename
FROM coffee_table
WHERE id = ?
LIMIT 1`,
[coffeeId]
);
const coffee = coffeeRows?.[0];
if (!coffee || !coffee.object_storage_id) return;
const [countRows] = await cx.query(
`SELECT COUNT(*) AS c
FROM coffee_table_images
WHERE coffee_id = ?`,
[coffeeId]
);
const count = Number(countRows?.[0]?.c || 0);
if (count > 0) return;
await cx.query(
`INSERT INTO coffee_table_images (coffee_id, object_storage_id, original_filename, sort_order)
VALUES (?, ?, ?, 0)`,
[coffeeId, coffee.object_storage_id, coffee.original_filename || null]
);
}
async listPicturesByCoffeeId(coffeeId, conn) {
const cx = conn || db;
const [rows] = await cx.query(
`SELECT id, coffee_id, object_storage_id, original_filename, sort_order, created_at
FROM coffee_table_images
WHERE coffee_id = ?
ORDER BY sort_order ASC, id ASC`,
[coffeeId]
);
return rows || [];
}
async deleteAllPicturesByCoffeeId(coffeeId, conn) {
const cx = conn || db;
const toDelete = await this.listPicturesByCoffeeId(coffeeId, cx);
if (!toDelete.length) return [];
await cx.query('DELETE FROM coffee_table_images WHERE coffee_id = ?', [coffeeId]);
return toDelete;
}
async deletePicturesByIds(coffeeId, pictureIds, conn) {
const cx = conn || db;
const ids = Array.isArray(pictureIds)
? pictureIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0)
: [];
if (!ids.length) return [];
const placeholders = ids.map(() => '?').join(',');
const [rows] = await cx.query(
`SELECT id, coffee_id, object_storage_id, original_filename, sort_order, created_at
FROM coffee_table_images
WHERE coffee_id = ?
AND id IN (${placeholders})`,
[coffeeId, ...ids]
);
if (!(rows || []).length) return [];
const deletePlaceholders = rows.map(() => '?').join(',');
await cx.query(
`DELETE FROM coffee_table_images
WHERE coffee_id = ?
AND id IN (${deletePlaceholders})`,
[coffeeId, ...rows.map((r) => r.id)]
);
return rows;
}
async addPictures(coffeeId, images, conn) {
const cx = conn || db;
const existing = await this.listPicturesByCoffeeId(coffeeId, cx);
let nextSort = existing.length;
for (const img of (images || [])) {
await cx.query(
`INSERT INTO coffee_table_images (coffee_id, object_storage_id, original_filename, sort_order)
VALUES (?, ?, ?, ?)`,
[
coffeeId,
img.object_storage_id,
img.original_filename || null,
nextSort,
]
);
nextSort += 1;
}
}
async syncPrimaryPictureFromGallery(coffeeId, conn) {
const cx = conn || db;
const [rows] = await cx.query(
`SELECT object_storage_id, original_filename
FROM coffee_table_images
WHERE coffee_id = ?
ORDER BY sort_order ASC, id ASC
LIMIT 1`,
[coffeeId]
);
const first = rows?.[0];
await cx.query(
`UPDATE coffee_table
SET object_storage_id = ?,
original_filename = ?,
updated_at = NOW()
WHERE id = ?`,
[first?.object_storage_id || null, first?.original_filename || null, coffeeId]
);
}
async _attachPictures(rows, conn) {
const cx = conn || db;
if (!Array.isArray(rows) || !rows.length) return rows || [];
const ids = rows.map((r) => Number(r.id)).filter((id) => Number.isFinite(id));
if (!ids.length) return rows;
const placeholders = ids.map(() => '?').join(',');
const [pictureRows] = await cx.query(
`SELECT id, coffee_id, object_storage_id, original_filename, sort_order, created_at
FROM coffee_table_images
WHERE coffee_id IN (${placeholders})
ORDER BY coffee_id ASC, sort_order ASC, id ASC`,
ids
);
const grouped = new Map();
for (const p of (pictureRows || [])) {
const key = Number(p.coffee_id);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push({
id: Number(p.id),
coffee_id: Number(p.coffee_id),
object_storage_id: p.object_storage_id,
original_filename: p.original_filename,
sort_order: Number(p.sort_order || 0),
created_at: p.created_at,
});
}
for (const row of rows) {
const coffeeId = Number(row.id);
const fromTable = grouped.get(coffeeId) || [];
if (fromTable.length) {
row.pictures = fromTable;
} else if (row.object_storage_id) {
row.pictures = [{
id: null,
coffee_id: coffeeId,
object_storage_id: row.object_storage_id,
original_filename: row.original_filename || null,
sort_order: 0,
created_at: row.created_at || null,
}];
} else {
row.pictures = [];
}
}
return rows;
}
async listAll(conn) {
const cx = conn || db;
const [rows] = await cx.query('SELECT * FROM coffee_table ORDER BY id DESC');
return rows || [];
return this._attachPictures(rows || [], cx);
}
async getById(id, conn) {
const cx = conn || db;
const [rows] = await cx.query('SELECT * FROM coffee_table WHERE id = ? LIMIT 1', [id]);
return rows && rows[0] ? rows[0] : null;
if (!rows || !rows[0]) return null;
const hydrated = await this._attachPictures([rows[0]], cx);
return hydrated[0] || null;
}
async create(data, conn) {
@ -35,8 +223,32 @@ class CoffeeRepository {
data.state
];
const [result] = await cx.query(sql, params);
const createdId = result.insertId;
const pictures = Array.isArray(data.images) ? [...data.images] : [];
if (!pictures.length && data.object_storage_id) {
pictures.push({
object_storage_id: data.object_storage_id,
original_filename: data.original_filename || null,
sort_order: 0,
});
}
for (const picture of pictures) {
await cx.query(
`INSERT INTO coffee_table_images (coffee_id, object_storage_id, original_filename, sort_order)
VALUES (?, ?, ?, ?)`,
[
createdId,
picture.object_storage_id,
picture.original_filename || null,
Number.isFinite(Number(picture.sort_order)) ? Number(picture.sort_order) : 0,
]
);
}
logger.info('[CoffeeRepository.create] insert', { id: result.insertId });
return { id: result.insertId, ...data };
return this.getById(createdId, cx);
}
async update(id, data, conn) {
@ -80,7 +292,33 @@ class CoffeeRepository {
async listActive(conn) {
const cx = conn || db;
const [rows] = await cx.query('SELECT * FROM coffee_table WHERE state = TRUE ORDER BY id DESC');
return rows || [];
return this._attachPictures(rows || [], cx);
}
async editPictures(coffeeId, { replaceAll = false, removePictureIds = [], images = [] } = {}, conn) {
const cx = conn || db;
await this.ensureLegacyPictureRow(coffeeId, cx);
let deleted = [];
if (replaceAll) {
deleted = await this.deleteAllPicturesByCoffeeId(coffeeId, cx);
} else if (Array.isArray(removePictureIds) && removePictureIds.length) {
deleted = await this.deletePicturesByIds(coffeeId, removePictureIds, cx);
}
if (Array.isArray(images) && images.length) {
await this.addPictures(coffeeId, images, cx);
}
await this._syncSortOrders(coffeeId, cx);
await this.syncPrimaryPictureFromGallery(coffeeId, cx);
const updated = await this.getById(coffeeId, cx);
return {
updated,
deleted,
};
}
}

View File

@ -183,6 +183,21 @@ class MailTemplateRepository {
return this.getById(id);
}
async unarchive(id, userId) {
const [result] = await db.query(
`UPDATE mail_templates
SET is_archived = 0,
archived_at = NULL,
updated_by = ?,
updated_at = NOW()
WHERE id = ?`,
[userId || null, id]
);
if (!result?.affectedRows) return null;
return this.getById(id);
}
async delete(id) {
const [result] = await db.query('DELETE FROM mail_templates WHERE id = ?', [id]);
return Number(result?.affectedRows || 0) > 0;

View File

@ -143,7 +143,9 @@ router.get('/company-stamps/all', authMiddleware, adminOnly, forceCompanyForAdmi
// Admin: coffee products
router.get('/admin/coffee', authMiddleware, adminOnly, CoffeeController.list);
router.get('/admin/coffee/active', authMiddleware, adminOnly, CoffeeController.listActive);
router.get('/admin/coffee/:id/pictures', authMiddleware, adminOnly, CoffeeController.getPictures);
router.get('/coffee/active', authMiddleware, CoffeeController.listActive);
router.get('/coffee/:id/pictures', authMiddleware, CoffeeController.getPictures);
// Matrix GETs

View File

@ -40,11 +40,14 @@ router.patch('/admin/unarchive-user/:id', authMiddleware, adminOnly, AdminUserCo
router.patch('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.update);
router.patch('/admin/mail-templates/:id/activate', authMiddleware, adminOnly, MailTemplatesController.activate);
router.patch('/admin/mail-templates/:id/archive', authMiddleware, adminOnly, MailTemplatesController.archive);
router.patch('/admin/mail-templates/:id/unarchive', authMiddleware, adminOnly, MailTemplatesController.unarchive);
router.patch('/admin/update-verification/:id', authMiddleware, adminOnly, AdminUserController.updateUserVerification);
router.patch('/admin/update-user-profile/:id', authMiddleware, adminOnly, AdminUserController.updateUserProfile);
router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUserController.updateUserStatus);
// Admin: set state for coffee product
router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
// Admin: edit coffee gallery pictures (upload/remove/replace)
router.patch('/admin/coffee/:id/pictures', authMiddleware, adminOnly, upload.any(), CoffeeController.editPictures);
// NEW: Admin pool active status update
router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolController.updateActive);
// NEW: Admin update pool linked subscription

View File

@ -152,8 +152,8 @@ function ensureUserFromBody(req, res, next) {
// Company-stamp POST
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
// Admin: create coffee product (supports multipart file 'picture')
router.post('/admin/coffee', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.create);
// Admin: create coffee product (supports multipart files 'pictures' and legacy 'picture')
router.post('/admin/coffee', authMiddleware, adminOnly, upload.any(), CoffeeController.create);
// NEW: add user into matrix
router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added
// NEW: remove matrix user and create vacancy

View File

@ -99,6 +99,30 @@ class CoffeeService {
async listActive() {
return CoffeeRepository.listActive();
}
async editPictures(id, payload = {}) {
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) {
throw new Error('Invalid coffee id');
}
const uow = new UnitOfWork();
try {
await uow.start();
const existing = await CoffeeRepository.getById(numericId, uow.connection);
if (!existing) {
await uow.rollback();
return null;
}
const result = await CoffeeRepository.editPictures(numericId, payload, uow.connection);
await uow.commit();
return result;
} catch (e) {
try { await uow.rollback(e); } catch(_) {}
throw e;
}
}
}
module.exports = new CoffeeService();

View File

@ -107,6 +107,17 @@ class MailTemplateService {
return MailTemplateRepository.archive(numericId, userId);
}
async unarchive(id, userId = null) {
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('Invalid id');
error.status = 400;
throw error;
}
return MailTemplateRepository.unarchive(numericId, userId);
}
async remove(id) {
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) {