const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); const CoffeeService = require('../../services/subscriptions/CoffeeService'); const { logger } = require('../../middleware/logger'); function buildPictureUrlFromKey(key) { const endpoint = process.env.EXOSCALE_ENDPOINT || ''; const bucket = process.env.EXOSCALE_BUCKET || ''; // If using S3-compatible endpoint with virtual-hosted-style, construct URL accordingly if (endpoint.startsWith('http')) { return `${endpoint.replace(/\/$/, '')}/${bucket}/${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) => 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'; const is_featured = req.body.is_featured === 'true' || req.body.is_featured === true ? true : false; // Fixed billing defaults const billing_interval = 'month'; const interval_count = 1; const state = req.body.state === 'false' || req.body.state === false ? false : true; // default available // If files uploaded, push to Exoscale and store all uploaded images let object_storage_id = null; let original_filename = null; 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']; 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, 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({ title, description, price: Number(price), currency, is_featured, billing_interval, interval_count, object_storage_id, original_filename, images, state, }); 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 (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.create] cleanup failed', { msg: cleanupErr.message }); } if (e.code === 'VALIDATION_ERROR') return res.status(400).json({ error: e.message, fields: e.errors }); res.status(500).json({ error: 'Failed to create product' }); } }; exports.update = async (req, res) => { try { const id = parseInt(req.params.id, 10); const { title, description, price } = req.body; const currency = req.body.currency; const is_featured = req.body.is_featured === undefined ? undefined : (req.body.is_featured === 'true' || req.body.is_featured === true); const state = req.body.state === undefined ? undefined : (req.body.state === 'false' || req.body.state === false ? false : true); const removePicture = req.body.removePicture === 'true'; let object_storage_id; let original_filename; let uploadedKey = null; if (req.file) { 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, credentials: { accessKeyId: process.env.EXOSCALE_ACCESS_KEY, secretAccessKey: process.env.EXOSCALE_SECRET_KEY, }, }); const key = `coffee/products/${Date.now()}_${req.file.originalname}`; await s3.send(new PutObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key, Body: req.file.buffer, ContentType: req.file.mimetype, ACL: 'public-read' })); object_storage_id = key; original_filename = req.file.originalname; uploadedKey = key; logger.info('[CoffeeController.update] uploaded new picture', { id, key }); } const current = await CoffeeService.get(id); if (!current) return res.status(404).json({ error: 'Not found' }); // If removePicture requested and no new file uploaded, clear existing object_storage_id if (removePicture && !object_storage_id) { object_storage_id = null; original_filename = null; // Delete previous object if exists if (current && current.object_storage_id) { try { const s3del = 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, }, }); await s3del.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: current.object_storage_id })); logger.info('[CoffeeController.update] removed existing picture', { id }); } catch (delErr) { logger.warn('[CoffeeController.update] remove existing picture failed', { id, msg: delErr.message }); } } } const updated = await CoffeeService.update(id, { title: title ?? current.title, description: description ?? current.description, price: price !== undefined ? Number(price) : current.price, currency: currency !== undefined ? currency : current.currency, is_featured: is_featured !== undefined ? is_featured : !!current.is_featured, object_storage_id: object_storage_id !== undefined ? object_storage_id : current.object_storage_id, original_filename: original_filename !== undefined ? original_filename : current.original_filename, state: state !== undefined ? state : !!current.state, }); res.json({ ...updated, pictureUrl: updated?.object_storage_id ? buildPictureUrlFromKey(updated.object_storage_id) : '' }); } catch (e) { logger.error('[CoffeeController.update] error', { msg: e.message }); // best-effort cleanup of newly uploaded object on failure try { if (object_storage_id) { 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, }, }); await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: object_storage_id })); } } catch (cleanupErr) { logger.warn('[CoffeeController.update] cleanup failed', { msg: cleanupErr.message }); } if (e.code === 'VALIDATION_ERROR') return res.status(400).json({ error: e.message, fields: e.errors }); res.status(500).json({ error: 'Failed to update product' }); } }; exports.setState = async (req, res) => { try { const id = parseInt(req.params.id, 10); const { state } = req.body; // boolean const updated = await CoffeeService.setState(id, !!state); res.json({ ...updated, pictureUrl: updated?.object_storage_id ? buildPictureUrlFromKey(updated.object_storage_id) : '' }); } catch (e) { res.status(400).json({ error: e.message }); } }; exports.remove = async (req, res) => { try { const id = parseInt(req.params.id, 10); // fetch current for object_storage_id const current = await CoffeeService.get(id); const ok = await CoffeeService.delete(id); if (!ok) return res.status(404).json({ error: 'Not found' }); // best-effort delete object from storage try { if (current && current.object_storage_id) { 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, }, }); await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: current.object_storage_id })); } } catch (cleanupErr) { logger.warn('[CoffeeController.remove] storage delete failed', { msg: cleanupErr.message }); } res.status(204).end(); } catch (e) { res.status(500).json({ error: 'Failed to delete product' }); } }; exports.listActive = async (req, res) => { try { const rows = await CoffeeService.listActive(); 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' }); } };