From 85fd8b16fb911b3d3542d060d4e674cce679f28a Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sat, 6 Dec 2025 20:25:34 +0100 Subject: [PATCH] feat: implement affiliate management with CRUD operations and S3 integration --- controller/affiliate/AffiliateController.js | 276 ++++++++++++++++++ database/createDb.js | 20 ++ models/Affiliate.js | 62 ++++ repositories/affiliate/AffiliateRepository.js | 202 +++++++++++++ routes/deleteRoutes.js | 3 + routes/getRoutes.js | 7 + routes/patchRoutes.js | 8 + routes/postRoutes.js | 3 + scripts/createAdminUser.js | 12 +- services/affiliate/AffiliateService.js | 152 ++++++++++ 10 files changed, 739 insertions(+), 6 deletions(-) create mode 100644 controller/affiliate/AffiliateController.js create mode 100644 models/Affiliate.js create mode 100644 repositories/affiliate/AffiliateRepository.js create mode 100644 services/affiliate/AffiliateService.js diff --git a/controller/affiliate/AffiliateController.js b/controller/affiliate/AffiliateController.js new file mode 100644 index 0000000..8b94372 --- /dev/null +++ b/controller/affiliate/AffiliateController.js @@ -0,0 +1,276 @@ +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' }); + } +}; diff --git a/database/createDb.js b/database/createDb.js index 8df6584..28584ad 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -644,6 +644,26 @@ async function createDatabase() { `); console.log('✅ Pools table created/verified'); + // --- Affiliates Table --- + await connection.query(` + CREATE TABLE IF NOT EXISTS affiliates ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + url VARCHAR(512) NOT NULL, + object_storage_id VARCHAR(255) NULL, + original_filename VARCHAR(255) NULL, + category VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + commission_rate VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_category (category), + INDEX idx_is_active (is_active) + ); + `); + console.log('✅ Affiliates table created/verified'); + // --- Matrix: Global 5-ary tree config and relations --- await connection.query(` CREATE TABLE IF NOT EXISTS matrix_config ( diff --git a/models/Affiliate.js b/models/Affiliate.js new file mode 100644 index 0000000..25d569f --- /dev/null +++ b/models/Affiliate.js @@ -0,0 +1,62 @@ +class Affiliate { + constructor({ + id = null, + name = '', + description = '', + url = '', + object_storage_id = null, + original_filename = null, + category = '', + is_active = true, + commission_rate = null, + created_at = null, + updated_at = null + }) { + this.id = id; + this.name = name; + this.description = description; + this.url = url; + this.object_storage_id = object_storage_id; + this.original_filename = original_filename; + this.category = category; + this.is_active = is_active; + this.commission_rate = commission_rate; + this.created_at = created_at; + this.updated_at = updated_at; + } + + static fromDbRow(row) { + if (!row) return null; + return new Affiliate({ + id: row.id, + name: row.name, + description: row.description, + url: row.url, + object_storage_id: row.object_storage_id, + original_filename: row.original_filename, + category: row.category, + is_active: row.is_active === 1 || row.is_active === true, + commission_rate: row.commission_rate, + created_at: row.created_at, + updated_at: row.updated_at + }); + } + + toJSON() { + return { + id: this.id, + name: this.name, + description: this.description, + url: this.url, + object_storage_id: this.object_storage_id, + original_filename: this.original_filename, + category: this.category, + is_active: this.is_active, + commission_rate: this.commission_rate, + created_at: this.created_at, + updated_at: this.updated_at + }; + } +} + +module.exports = Affiliate; diff --git a/repositories/affiliate/AffiliateRepository.js b/repositories/affiliate/AffiliateRepository.js new file mode 100644 index 0000000..ed7d798 --- /dev/null +++ b/repositories/affiliate/AffiliateRepository.js @@ -0,0 +1,202 @@ +const Affiliate = require('../../models/Affiliate'); +const { logger } = require('../../middleware/logger'); + +class AffiliateRepository { + constructor(db) { + this.db = db; + } + + /** + * Get all affiliates + */ + async getAll() { + const query = ` + SELECT * FROM affiliates + ORDER BY created_at DESC + `; + + try { + const [rows] = await this.db.query(query); + return rows.map(row => Affiliate.fromDbRow(row)); + } catch (error) { + logger.error('[AffiliateRepository] Error getting all affiliates:', error); + throw error; + } + } + + /** + * Get affiliate by ID + */ + async getById(id) { + const query = `SELECT * FROM affiliates WHERE id = ?`; + + try { + const [rows] = await this.db.query(query, [id]); + return rows.length > 0 ? Affiliate.fromDbRow(rows[0]) : null; + } catch (error) { + logger.error(`[AffiliateRepository] Error getting affiliate by ID ${id}:`, error); + throw error; + } + } + + /** + * Get active affiliates only + */ + async getActive() { + const query = ` + SELECT * FROM affiliates + WHERE is_active = 1 + ORDER BY created_at DESC + `; + + try { + const [rows] = await this.db.query(query); + return rows.map(row => Affiliate.fromDbRow(row)); + } catch (error) { + logger.error('[AffiliateRepository] Error getting active affiliates:', error); + throw error; + } + } + + /** + * Get affiliates by category + */ + async getByCategory(category) { + const query = ` + SELECT * FROM affiliates + WHERE category = ? + ORDER BY created_at DESC + `; + + try { + const [rows] = await this.db.query(query, [category]); + return rows.map(row => Affiliate.fromDbRow(row)); + } catch (error) { + logger.error(`[AffiliateRepository] Error getting affiliates by category ${category}:`, error); + throw error; + } + } + + /** + * Create new affiliate + */ + async create({ name, description, url, object_storage_id, original_filename, category, is_active, commission_rate }) { + const query = ` + INSERT INTO affiliates (name, description, url, object_storage_id, original_filename, category, is_active, commission_rate) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + try { + const [result] = await this.db.query(query, [ + name, + description || null, + url, + object_storage_id || null, + original_filename || null, + category, + is_active !== undefined ? is_active : true, + commission_rate || null + ]); + + logger.info(`[AffiliateRepository] Created affiliate with ID: ${result.insertId}`); + return await this.getById(result.insertId); + } catch (error) { + logger.error('[AffiliateRepository] Error creating affiliate:', error); + throw error; + } + } + + /** + * Update affiliate + */ + async update(id, { name, description, url, object_storage_id, original_filename, category, is_active, commission_rate }) { + const updates = []; + const values = []; + + if (name !== undefined) { + updates.push('name = ?'); + values.push(name); + } + if (description !== undefined) { + updates.push('description = ?'); + values.push(description); + } + if (url !== undefined) { + updates.push('url = ?'); + values.push(url); + } + if (object_storage_id !== undefined) { + updates.push('object_storage_id = ?'); + values.push(object_storage_id); + } + if (original_filename !== undefined) { + updates.push('original_filename = ?'); + values.push(original_filename); + } + if (category !== undefined) { + updates.push('category = ?'); + values.push(category); + } + if (is_active !== undefined) { + updates.push('is_active = ?'); + values.push(is_active); + } + if (commission_rate !== undefined) { + updates.push('commission_rate = ?'); + values.push(commission_rate); + } + + if (updates.length === 0) { + logger.warn(`[AffiliateRepository] No fields to update for affiliate ID ${id}`); + return await this.getById(id); + } + + updates.push('updated_at = NOW()'); + values.push(id); + + const query = `UPDATE affiliates SET ${updates.join(', ')} WHERE id = ?`; + + try { + await this.db.query(query, values); + logger.info(`[AffiliateRepository] Updated affiliate ID: ${id}`); + return await this.getById(id); + } catch (error) { + logger.error(`[AffiliateRepository] Error updating affiliate ID ${id}:`, error); + throw error; + } + } + + /** + * Delete affiliate + */ + async delete(id) { + const query = `DELETE FROM affiliates WHERE id = ?`; + + try { + const [result] = await this.db.query(query, [id]); + logger.info(`[AffiliateRepository] Deleted affiliate ID: ${id}`); + return result.affectedRows > 0; + } catch (error) { + logger.error(`[AffiliateRepository] Error deleting affiliate ID ${id}:`, error); + throw error; + } + } + + /** + * Update affiliate status (active/inactive) + */ + async updateStatus(id, is_active) { + const query = `UPDATE affiliates SET is_active = ?, updated_at = NOW() WHERE id = ?`; + + try { + await this.db.query(query, [is_active, id]); + logger.info(`[AffiliateRepository] Updated status for affiliate ID ${id} to ${is_active}`); + return await this.getById(id); + } catch (error) { + logger.error(`[AffiliateRepository] Error updating status for affiliate ID ${id}:`, error); + throw error; + } + } +} + +module.exports = AffiliateRepository; diff --git a/routes/deleteRoutes.js b/routes/deleteRoutes.js index f550b17..3202ba7 100644 --- a/routes/deleteRoutes.js +++ b/routes/deleteRoutes.js @@ -6,6 +6,7 @@ const AdminUserController = require('../controller/admin/AdminUserController'); const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); const CoffeeController = require('../controller/admin/CoffeeController'); +const AffiliateController = require('../controller/affiliate/AffiliateController'); // Helper middlewares for company-stamp function adminOnly(req, res, next) { @@ -31,5 +32,7 @@ router.delete('/document-templates/:id', authMiddleware, DocumentTemplateControl router.delete('/company-stamps/:id', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.delete); // Admin: delete coffee product router.delete('/admin/coffee/:id', authMiddleware, adminOnly, CoffeeController.remove); +// Admin: delete affiliate +router.delete('/admin/affiliates/:id', authMiddleware, adminOnly, AffiliateController.delete); module.exports = router; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 8ee90b1..8737f8d 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -18,6 +18,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // <- const CoffeeController = require('../controller/admin/CoffeeController'); const PoolController = require('../controller/pool/PoolController'); const TaxController = require('../controller/tax/taxController'); +const AffiliateController = require('../controller/affiliate/AffiliateController'); // small helpers copied from original files function adminOnly(req, res, next) { @@ -140,5 +141,11 @@ router.get('/tax/vat-history/:countryCode', authMiddleware, adminOnly, TaxContro // NEW: Admin list vacancies for a matrix router.get('/admin/matrix/vacancies', authMiddleware, adminOnly, MatrixController.listVacancies); +// Affiliate Management Routes (Admin) +router.get('/admin/affiliates', authMiddleware, adminOnly, AffiliateController.list); + +// Public Affiliates Route (Active only) +router.get('/affiliates/active', AffiliateController.listActive); + // export module.exports = router; \ No newline at end of file diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js index f69a047..e4480e8 100644 --- a/routes/patchRoutes.js +++ b/routes/patchRoutes.js @@ -9,6 +9,10 @@ const AdminUserController = require('../controller/admin/AdminUserController'); const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add const PoolController = require('../controller/pool/PoolController'); // <-- new const MatrixController = require('../controller/matrix/MatrixController'); // <-- new +const AffiliateController = require('../controller/affiliate/AffiliateController'); // <-- new + +const multer = require('multer'); +const upload = multer({ storage: multer.memoryStorage() }); // Helper middlewares for company-stamp function adminOnly(req, res, next) { @@ -44,6 +48,10 @@ router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolControlle router.patch('/admin/matrix/:id/deactivate', authMiddleware, adminOnly, MatrixController.deactivate); // NEW: activate a matrix instance (admin-only) router.patch('/admin/matrix/:id/activate', authMiddleware, adminOnly, MatrixController.activate); +// NEW: Update affiliate (with optional logo upload) +router.patch('/admin/affiliates/:id', authMiddleware, adminOnly, upload.single('logo'), AffiliateController.update); +// NEW: Update affiliate status only +router.patch('/admin/affiliates/:id/status', authMiddleware, adminOnly, AffiliateController.updateStatus); // Personal profile (self-service) - no admin guard router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic); diff --git a/routes/postRoutes.js b/routes/postRoutes.js index 1e3d079..dd61525 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -24,6 +24,7 @@ const CompanyStampController = require('../controller/companyStamp/CompanyStampC const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations const PoolController = require('../controller/pool/PoolController'); const TaxController = require('../controller/tax/taxController'); +const AffiliateController = require('../controller/affiliate/AffiliateController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -131,6 +132,8 @@ router.post('/admin/matrix/assign-vacancy', authMiddleware, adminOnly, MatrixCon router.post('/admin/pools', authMiddleware, adminOnly, PoolController.create); // NEW: import VAT rates CSV router.post('/tax/vat-rates/import', authMiddleware, adminOnly, upload.single('file'), TaxController.importVatRatesCsv); +// NEW: Admin create affiliate with logo upload +router.post('/admin/affiliates', authMiddleware, adminOnly, upload.single('logo'), AffiliateController.create); // Existing registration handlers (keep) router.post('/register/personal', (req, res) => { diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index 1783d0b..08f4711 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,13 +3,13 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { - // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; + const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; - const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; - // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; - const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%'; - const firstName = process.env.ADMIN_FIRST_NAME || 'Admin'; - const lastName = process.env.ADMIN_LAST_NAME || 'User'; + // const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; + const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; + // const adminPassword = process.env.ADMIN_PASSWORD || 'loki!123'; + const firstName = process.env.ADMIN_FIRST_NAME || 'Lokii'; + const lastName = process.env.ADMIN_LAST_NAME || 'Aahi'; const uow = new UnitOfWork(); // No need to pass pool await uow.start(); diff --git a/services/affiliate/AffiliateService.js b/services/affiliate/AffiliateService.js new file mode 100644 index 0000000..68ee837 --- /dev/null +++ b/services/affiliate/AffiliateService.js @@ -0,0 +1,152 @@ +const db = require('../../database/database'); +const { logger } = require('../../middleware/logger'); + +exports.list = async () => { + const [rows] = await db.query('SELECT * FROM affiliates ORDER BY created_at DESC'); + return rows.map(r => ({ + id: r.id, + name: r.name, + description: r.description, + url: r.url, + object_storage_id: r.object_storage_id, + original_filename: r.original_filename, + category: r.category, + is_active: !!r.is_active, + commission_rate: r.commission_rate, + created_at: r.created_at, + updated_at: r.updated_at + })); +}; + +exports.get = async (id) => { + const [rows] = await db.query('SELECT * FROM affiliates WHERE id = ?', [id]); + if (!rows[0]) return null; + const r = rows[0]; + return { + id: r.id, + name: r.name, + description: r.description, + url: r.url, + object_storage_id: r.object_storage_id, + original_filename: r.original_filename, + category: r.category, + is_active: !!r.is_active, + commission_rate: r.commission_rate, + created_at: r.created_at, + updated_at: r.updated_at + }; +}; + +exports.create = async ({ name, description, url, category, is_active, commission_rate, object_storage_id, original_filename }) => { + // Validation + if (!name || name.trim() === '') { + const e = new Error('Affiliate name is required'); + e.code = 'VALIDATION_ERROR'; + throw e; + } + if (!url || url.trim() === '') { + const e = new Error('Affiliate URL is required'); + e.code = 'VALIDATION_ERROR'; + throw e; + } + if (!category || category.trim() === '') { + const e = new Error('Affiliate category is required'); + e.code = 'VALIDATION_ERROR'; + throw e; + } + + // Validate URL format + try { + new URL(url); + } catch (err) { + const e = new Error('Invalid URL format'); + e.code = 'VALIDATION_ERROR'; + throw e; + } + + const [result] = await db.query( + `INSERT INTO affiliates (name, description, url, category, is_active, commission_rate, object_storage_id, original_filename) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + name.trim(), + description ? description.trim() : null, + url.trim(), + category.trim(), + is_active !== undefined ? is_active : true, + commission_rate || null, + object_storage_id || null, + original_filename || null + ] + ); + logger.info('[AffiliateService.create] created', { id: result.insertId }); + return await exports.get(result.insertId); +}; + +exports.update = async (id, { name, description, url, category, is_active, commission_rate, object_storage_id, original_filename }) => { + const updates = []; + const values = []; + + if (name !== undefined) { + updates.push('name = ?'); + values.push(name.trim()); + } + if (description !== undefined) { + updates.push('description = ?'); + values.push(description ? description.trim() : null); + } + if (url !== undefined) { + // Validate URL format + try { + new URL(url); + } catch (err) { + const e = new Error('Invalid URL format'); + e.code = 'VALIDATION_ERROR'; + throw e; + } + updates.push('url = ?'); + values.push(url.trim()); + } + if (category !== undefined) { + updates.push('category = ?'); + values.push(category.trim()); + } + if (is_active !== undefined) { + updates.push('is_active = ?'); + values.push(is_active); + } + if (commission_rate !== undefined) { + updates.push('commission_rate = ?'); + values.push(commission_rate); + } + if (object_storage_id !== undefined) { + updates.push('object_storage_id = ?'); + values.push(object_storage_id); + } + if (original_filename !== undefined) { + updates.push('original_filename = ?'); + values.push(original_filename); + } + + if (updates.length === 0) { + logger.warn('[AffiliateService.update] no fields to update', { id }); + return await exports.get(id); + } + + updates.push('updated_at = NOW()'); + values.push(id); + + await db.query(`UPDATE affiliates SET ${updates.join(', ')} WHERE id = ?`, values); + logger.info('[AffiliateService.update] updated', { id }); + return await exports.get(id); +}; + +exports.updateStatus = async (id, is_active) => { + await db.query('UPDATE affiliates SET is_active = ?, updated_at = NOW() WHERE id = ?', [is_active, id]); + logger.info('[AffiliateService.updateStatus] updated', { id, is_active }); + return await exports.get(id); +}; + +exports.delete = async (id) => { + await db.query('DELETE FROM affiliates WHERE id = ?', [id]); + logger.info('[AffiliateService.delete] deleted', { id }); +};