const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); const AffiliateService = require('../../services/affiliate/AffiliateService'); const { logger } = require('../../middleware/logger'); function buildLogoUrlFromKey(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 } exports.list = async (req, res) => { try { const rows = await AffiliateService.list(); const items = (rows || []).map(r => ({ ...r, logoUrl: r.object_storage_id ? buildLogoUrlFromKey(r.object_storage_id) : '' })); res.json({ success: true, data: items }); } catch (e) { logger.error('[AffiliateController.list] error', { msg: e.message }); res.status(500).json({ error: 'Failed to load affiliates' }); } }; exports.listActive = async (req, res) => { try { const rows = await AffiliateService.list(); const activeItems = (rows || []).filter(r => r.is_active).map(r => ({ id: r.id, name: r.name, description: r.description, url: r.url, category: r.category, commission_rate: r.commission_rate, logoUrl: r.object_storage_id ? buildLogoUrlFromKey(r.object_storage_id) : '' })); res.json({ success: true, data: activeItems }); } catch (e) { logger.error('[AffiliateController.listActive] error', { msg: e.message }); res.status(500).json({ error: 'Failed to load affiliates' }); } }; exports.create = async (req, res) => { try { const { name, description, url, category, is_active, commission_rate } = req.body; // Validate required fields if (!name || !url || !category) { return res.status(400).json({ error: 'Missing required fields: name, url, and category are required' }); } // If file uploaded, push to Exoscale and set object_storage_id let object_storage_id = null; let original_filename = null; let uploadedKey = null; if (req.file) { const allowedMime = ['image/jpeg','image/png','image/webp','image/svg+xml']; if (!allowedMime.includes(req.file.mimetype)) { return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP, SVG' }); } const maxBytes = 5 * 1024 * 1024; // 5MB if (req.file.size > maxBytes) { return res.status(400).json({ error: 'Image exceeds 5MB 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 = `affiliates/${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('[AffiliateController.create] uploaded logo', { key }); } const created = await AffiliateService.create({ name, description, url, category, is_active: is_active !== undefined ? (is_active === 'true' || is_active === true) : true, commission_rate: commission_rate || null, object_storage_id, original_filename, }); res.status(201).json({ ...created, logoUrl: created.object_storage_id ? buildLogoUrlFromKey(created.object_storage_id) : '' }); } catch (e) { logger.error('[AffiliateController.create] error', { msg: e.message, stack: e.stack?.split('\n')[0] }); // best-effort cleanup of 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('[AffiliateController.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 affiliate' }); } }; exports.update = async (req, res) => { try { const id = parseInt(req.params.id, 10); const { name, description, url, category, is_active, commission_rate } = req.body; const removeLogo = req.body.removeLogo === 'true'; let object_storage_id; let original_filename; let uploadedKey = null; if (req.file) { const allowedMime = ['image/jpeg','image/png','image/webp','image/svg+xml']; if (!allowedMime.includes(req.file.mimetype)) { return res.status(400).json({ error: 'Invalid image type. Allowed: JPG, PNG, WebP, SVG' }); } const maxBytes = 5 * 1024 * 1024; // 5MB if (req.file.size > maxBytes) { return res.status(400).json({ error: 'Image exceeds 5MB 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 = `affiliates/${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('[AffiliateController.update] uploaded new logo', { id, key }); } const current = await AffiliateService.get(id); if (!current) return res.status(404).json({ error: 'Not found' }); // If removeLogo requested and no new file uploaded, clear existing object_storage_id if (removeLogo && !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('[AffiliateController.update] removed existing logo', { id }); } catch (delErr) { logger.warn('[AffiliateController.update] remove existing logo failed', { id, msg: delErr.message }); } } } const updated = await AffiliateService.update(id, { name: name ?? current.name, description: description ?? current.description, url: url ?? current.url, category: category ?? current.category, is_active: is_active !== undefined ? (is_active === 'true' || is_active === true) : !!current.is_active, commission_rate: commission_rate !== undefined ? commission_rate : current.commission_rate, object_storage_id: object_storage_id !== undefined ? object_storage_id : current.object_storage_id, original_filename: original_filename !== undefined ? original_filename : current.original_filename, }); res.json({ ...updated, logoUrl: updated?.object_storage_id ? buildLogoUrlFromKey(updated.object_storage_id) : '' }); } catch (e) { logger.error('[AffiliateController.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('[AffiliateController.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 affiliate' }); } }; exports.updateStatus = async (req, res) => { try { const id = parseInt(req.params.id, 10); const { is_active } = req.body; // boolean const updated = await AffiliateService.updateStatus(id, !!is_active); res.json({ ...updated, logoUrl: updated?.object_storage_id ? buildLogoUrlFromKey(updated.object_storage_id) : '' }); } catch (e) { res.status(400).json({ error: e.message }); } }; exports.delete = async (req, res) => { try { const id = parseInt(req.params.id, 10); const current = await AffiliateService.get(id); if (!current) return res.status(404).json({ error: 'Not found' }); // Delete S3 object if exists if (current.object_storage_id) { 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, }, }); await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: current.object_storage_id })); logger.info('[AffiliateController.delete] deleted logo', { id, key: current.object_storage_id }); } catch (delErr) { logger.warn('[AffiliateController.delete] delete logo failed', { id, msg: delErr.message }); } } await AffiliateService.delete(id); res.json({ message: 'Affiliate deleted successfully' }); } catch (e) { logger.error('[AffiliateController.delete] error', { msg: e.message }); res.status(500).json({ error: 'Failed to delete affiliate' }); } };