feat: implement affiliate management with CRUD operations and S3 integration

This commit is contained in:
seaznCode 2025-12-06 20:25:34 +01:00
parent 328b18d8a8
commit 85fd8b16fb
10 changed files with 739 additions and 6 deletions

View File

@ -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' });
}
};

View File

@ -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 (

62
models/Affiliate.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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) => {

View File

@ -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();

View File

@ -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 });
};