Zipfelzwerg
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
9f53ffbbdd
commit
baf53e36c1
@ -12,16 +12,61 @@ function buildPictureUrlFromKey(key) {
|
|||||||
return key; // fallback: store key only
|
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) => {
|
exports.list = async (req, res) => {
|
||||||
const rows = await CoffeeService.list();
|
const rows = await CoffeeService.list();
|
||||||
const items = (rows || []).map(r => ({
|
const items = (rows || []).map((r) => toCoffeeResponse(r));
|
||||||
...r,
|
|
||||||
pictureUrl: r.object_storage_id ? buildPictureUrlFromKey(r.object_storage_id) : ''
|
|
||||||
}));
|
|
||||||
res.json(items);
|
res.json(items);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.create = async (req, res) => {
|
exports.create = async (req, res) => {
|
||||||
|
let uploadedKeys = [];
|
||||||
try {
|
try {
|
||||||
const { title, description, price } = req.body;
|
const { title, description, price } = req.body;
|
||||||
const currency = req.body.currency || 'EUR';
|
const currency = req.body.currency || 'EUR';
|
||||||
@ -31,19 +76,23 @@ exports.create = async (req, res) => {
|
|||||||
const interval_count = 1;
|
const interval_count = 1;
|
||||||
const state = req.body.state === 'false' || req.body.state === false ? false : true; // default available
|
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 object_storage_id = null;
|
||||||
let original_filename = null;
|
let original_filename = null;
|
||||||
let uploadedKey = null;
|
let images = [];
|
||||||
if (req.file) {
|
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'];
|
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
|
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({
|
const s3 = new S3Client({
|
||||||
region: process.env.EXOSCALE_REGION,
|
region: process.env.EXOSCALE_REGION,
|
||||||
endpoint: process.env.EXOSCALE_ENDPOINT,
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
||||||
@ -52,18 +101,38 @@ exports.create = async (req, res) => {
|
|||||||
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const key = `coffee/products/${Date.now()}_${req.file.originalname}`;
|
|
||||||
await s3.send(new PutObjectCommand({
|
for (let i = 0; i < incomingFiles.length; i += 1) {
|
||||||
Bucket: process.env.EXOSCALE_BUCKET,
|
const file = incomingFiles[i];
|
||||||
Key: key,
|
if (!allowedMime.includes(file.mimetype)) {
|
||||||
Body: req.file.buffer,
|
return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP' });
|
||||||
ContentType: req.file.mimetype,
|
}
|
||||||
ACL: 'public-read'
|
if (file.size > maxBytes) {
|
||||||
}));
|
return res.status(400).json({ error: 'Image exceeds 10MB limit' });
|
||||||
object_storage_id = key;
|
}
|
||||||
original_filename = req.file.originalname;
|
|
||||||
uploadedKey = key;
|
const key = `coffee/products/${Date.now()}_${i}_${file.originalname}`;
|
||||||
logger.info('[CoffeeController.create] uploaded picture', { key });
|
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,
|
||||||
|
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({
|
const created = await CoffeeService.create({
|
||||||
@ -76,17 +145,15 @@ exports.create = async (req, res) => {
|
|||||||
interval_count,
|
interval_count,
|
||||||
object_storage_id,
|
object_storage_id,
|
||||||
original_filename,
|
original_filename,
|
||||||
|
images,
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
res.status(201).json({
|
res.status(201).json(toCoffeeResponse(created));
|
||||||
...created,
|
|
||||||
pictureUrl: created.object_storage_id ? buildPictureUrlFromKey(created.object_storage_id) : ''
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('[CoffeeController.create] error', { msg: e.message, stack: e.stack?.split('\n')[0] });
|
logger.error('[CoffeeController.create] error', { msg: e.message, stack: e.stack?.split('\n')[0] });
|
||||||
// best-effort cleanup of uploaded object on failure
|
// best-effort cleanup of uploaded object on failure
|
||||||
try {
|
try {
|
||||||
if (object_storage_id) {
|
if (uploadedKeys.length) {
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
region: process.env.EXOSCALE_REGION,
|
region: process.env.EXOSCALE_REGION,
|
||||||
endpoint: process.env.EXOSCALE_ENDPOINT,
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
||||||
@ -95,7 +162,9 @@ exports.create = async (req, res) => {
|
|||||||
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
|
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) {
|
} catch (cleanupErr) {
|
||||||
logger.warn('[CoffeeController.create] cleanup failed', { msg: cleanupErr.message });
|
logger.warn('[CoffeeController.create] cleanup failed', { msg: cleanupErr.message });
|
||||||
@ -251,13 +320,181 @@ exports.remove = async (req, res) => {
|
|||||||
exports.listActive = async (req, res) => {
|
exports.listActive = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await CoffeeService.listActive();
|
const rows = await CoffeeService.listActive();
|
||||||
const items = (rows || []).map(r => ({
|
const items = (rows || []).map((r) => toCoffeeResponse(r));
|
||||||
...r,
|
|
||||||
pictureUrl: r.object_storage_id ? buildPictureUrlFromKey(r.object_storage_id) : ''
|
|
||||||
}));
|
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('[CoffeeController.listActive] error', { msg: e.message });
|
logger.error('[CoffeeController.listActive] error', { msg: e.message });
|
||||||
res.status(500).json({ error: 'Failed to fetch active coffee products' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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) {
|
static async remove(req, res) {
|
||||||
try {
|
try {
|
||||||
const deleted = await MailTemplateService.remove(req.params.id);
|
const deleted = await MailTemplateService.remove(req.params.id);
|
||||||
|
|||||||
@ -1135,6 +1135,21 @@ const createDatabase = async () => {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ Coffee table (simplified) created/verified');
|
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) ---
|
// --- Coffee shipping fees (fixed package sizes) ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS coffee_shipping_fees (
|
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_active', 'is_active');
|
||||||
await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_archived', 'is_archived');
|
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, '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_company', 'company_id');
|
||||||
await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active');
|
await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active');
|
||||||
console.log('🚀 Performance indexes created/verified');
|
console.log('🚀 Performance indexes created/verified');
|
||||||
|
|||||||
@ -2,16 +2,204 @@ const db = require('../../database/database');
|
|||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
class CoffeeRepository {
|
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) {
|
async listAll(conn) {
|
||||||
const cx = conn || db;
|
const cx = conn || db;
|
||||||
const [rows] = await cx.query('SELECT * FROM coffee_table ORDER BY id DESC');
|
const [rows] = await cx.query('SELECT * FROM coffee_table ORDER BY id DESC');
|
||||||
return rows || [];
|
return this._attachPictures(rows || [], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id, conn) {
|
async getById(id, conn) {
|
||||||
const cx = conn || db;
|
const cx = conn || db;
|
||||||
const [rows] = await cx.query('SELECT * FROM coffee_table WHERE id = ? LIMIT 1', [id]);
|
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) {
|
async create(data, conn) {
|
||||||
@ -35,8 +223,32 @@ class CoffeeRepository {
|
|||||||
data.state
|
data.state
|
||||||
];
|
];
|
||||||
const [result] = await cx.query(sql, params);
|
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 });
|
logger.info('[CoffeeRepository.create] insert', { id: result.insertId });
|
||||||
return { id: result.insertId, ...data };
|
return this.getById(createdId, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id, data, conn) {
|
async update(id, data, conn) {
|
||||||
@ -80,7 +292,33 @@ class CoffeeRepository {
|
|||||||
async listActive(conn) {
|
async listActive(conn) {
|
||||||
const cx = conn || db;
|
const cx = conn || db;
|
||||||
const [rows] = await cx.query('SELECT * FROM coffee_table WHERE state = TRUE ORDER BY id DESC');
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -183,6 +183,21 @@ class MailTemplateRepository {
|
|||||||
return this.getById(id);
|
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) {
|
async delete(id) {
|
||||||
const [result] = await db.query('DELETE FROM mail_templates WHERE id = ?', [id]);
|
const [result] = await db.query('DELETE FROM mail_templates WHERE id = ?', [id]);
|
||||||
return Number(result?.affectedRows || 0) > 0;
|
return Number(result?.affectedRows || 0) > 0;
|
||||||
|
|||||||
@ -143,7 +143,9 @@ router.get('/company-stamps/all', authMiddleware, adminOnly, forceCompanyForAdmi
|
|||||||
// Admin: coffee products
|
// Admin: coffee products
|
||||||
router.get('/admin/coffee', authMiddleware, adminOnly, CoffeeController.list);
|
router.get('/admin/coffee', authMiddleware, adminOnly, CoffeeController.list);
|
||||||
router.get('/admin/coffee/active', authMiddleware, adminOnly, CoffeeController.listActive);
|
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/active', authMiddleware, CoffeeController.listActive);
|
||||||
|
router.get('/coffee/:id/pictures', authMiddleware, CoffeeController.getPictures);
|
||||||
|
|
||||||
|
|
||||||
// Matrix GETs
|
// Matrix GETs
|
||||||
|
|||||||
@ -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', authMiddleware, adminOnly, MailTemplatesController.update);
|
||||||
router.patch('/admin/mail-templates/:id/activate', authMiddleware, adminOnly, MailTemplatesController.activate);
|
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/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-verification/:id', authMiddleware, adminOnly, AdminUserController.updateUserVerification);
|
||||||
router.patch('/admin/update-user-profile/:id', authMiddleware, adminOnly, AdminUserController.updateUserProfile);
|
router.patch('/admin/update-user-profile/:id', authMiddleware, adminOnly, AdminUserController.updateUserProfile);
|
||||||
router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUserController.updateUserStatus);
|
router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUserController.updateUserStatus);
|
||||||
// Admin: set state for coffee product
|
// Admin: set state for coffee product
|
||||||
router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
|
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
|
// NEW: Admin pool active status update
|
||||||
router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolController.updateActive);
|
router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolController.updateActive);
|
||||||
// NEW: Admin update pool linked subscription
|
// NEW: Admin update pool linked subscription
|
||||||
|
|||||||
@ -152,8 +152,8 @@ function ensureUserFromBody(req, res, next) {
|
|||||||
|
|
||||||
// Company-stamp POST
|
// Company-stamp POST
|
||||||
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
|
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
|
||||||
// Admin: create coffee product (supports multipart file 'picture')
|
// Admin: create coffee product (supports multipart files 'pictures' and legacy 'picture')
|
||||||
router.post('/admin/coffee', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.create);
|
router.post('/admin/coffee', authMiddleware, adminOnly, upload.any(), CoffeeController.create);
|
||||||
// NEW: add user into matrix
|
// NEW: add user into matrix
|
||||||
router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added
|
router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added
|
||||||
// NEW: remove matrix user and create vacancy
|
// NEW: remove matrix user and create vacancy
|
||||||
|
|||||||
@ -99,6 +99,30 @@ class CoffeeService {
|
|||||||
async listActive() {
|
async listActive() {
|
||||||
return CoffeeRepository.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();
|
module.exports = new CoffeeService();
|
||||||
|
|||||||
@ -107,6 +107,17 @@ class MailTemplateService {
|
|||||||
return MailTemplateRepository.archive(numericId, userId);
|
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) {
|
async remove(id) {
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user