feat: implement affiliate management with CRUD operations and S3 integration
This commit is contained in:
parent
328b18d8a8
commit
85fd8b16fb
276
controller/affiliate/AffiliateController.js
Normal file
276
controller/affiliate/AffiliateController.js
Normal 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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -644,6 +644,26 @@ async function createDatabase() {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ Pools table created/verified');
|
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 ---
|
// --- Matrix: Global 5-ary tree config and relations ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS matrix_config (
|
CREATE TABLE IF NOT EXISTS matrix_config (
|
||||||
|
|||||||
62
models/Affiliate.js
Normal file
62
models/Affiliate.js
Normal 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;
|
||||||
202
repositories/affiliate/AffiliateRepository.js
Normal file
202
repositories/affiliate/AffiliateRepository.js
Normal 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;
|
||||||
@ -6,6 +6,7 @@ const AdminUserController = require('../controller/admin/AdminUserController');
|
|||||||
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
||||||
const CompanyStampController = require('../controller/companyStamp/CompanyStampController');
|
const CompanyStampController = require('../controller/companyStamp/CompanyStampController');
|
||||||
const CoffeeController = require('../controller/admin/CoffeeController');
|
const CoffeeController = require('../controller/admin/CoffeeController');
|
||||||
|
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||||
|
|
||||||
// Helper middlewares for company-stamp
|
// Helper middlewares for company-stamp
|
||||||
function adminOnly(req, res, next) {
|
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);
|
router.delete('/company-stamps/:id', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.delete);
|
||||||
// Admin: delete coffee product
|
// Admin: delete coffee product
|
||||||
router.delete('/admin/coffee/:id', authMiddleware, adminOnly, CoffeeController.remove);
|
router.delete('/admin/coffee/:id', authMiddleware, adminOnly, CoffeeController.remove);
|
||||||
|
// Admin: delete affiliate
|
||||||
|
router.delete('/admin/affiliates/:id', authMiddleware, adminOnly, AffiliateController.delete);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // <-
|
|||||||
const CoffeeController = require('../controller/admin/CoffeeController');
|
const CoffeeController = require('../controller/admin/CoffeeController');
|
||||||
const PoolController = require('../controller/pool/PoolController');
|
const PoolController = require('../controller/pool/PoolController');
|
||||||
const TaxController = require('../controller/tax/taxController');
|
const TaxController = require('../controller/tax/taxController');
|
||||||
|
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||||
|
|
||||||
// small helpers copied from original files
|
// small helpers copied from original files
|
||||||
function adminOnly(req, res, next) {
|
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
|
// NEW: Admin list vacancies for a matrix
|
||||||
router.get('/admin/matrix/vacancies', authMiddleware, adminOnly, MatrixController.listVacancies);
|
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
|
// export
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@ -9,6 +9,10 @@ const AdminUserController = require('../controller/admin/AdminUserController');
|
|||||||
const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add
|
const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add
|
||||||
const PoolController = require('../controller/pool/PoolController'); // <-- new
|
const PoolController = require('../controller/pool/PoolController'); // <-- new
|
||||||
const MatrixController = require('../controller/matrix/MatrixController'); // <-- 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
|
// Helper middlewares for company-stamp
|
||||||
function adminOnly(req, res, next) {
|
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);
|
router.patch('/admin/matrix/:id/deactivate', authMiddleware, adminOnly, MatrixController.deactivate);
|
||||||
// NEW: activate a matrix instance (admin-only)
|
// NEW: activate a matrix instance (admin-only)
|
||||||
router.patch('/admin/matrix/:id/activate', authMiddleware, adminOnly, MatrixController.activate);
|
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
|
// Personal profile (self-service) - no admin guard
|
||||||
router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic);
|
router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const CompanyStampController = require('../controller/companyStamp/CompanyStampC
|
|||||||
const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations
|
const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations
|
||||||
const PoolController = require('../controller/pool/PoolController');
|
const PoolController = require('../controller/pool/PoolController');
|
||||||
const TaxController = require('../controller/tax/taxController');
|
const TaxController = require('../controller/tax/taxController');
|
||||||
|
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||||
|
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
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);
|
router.post('/admin/pools', authMiddleware, adminOnly, PoolController.create);
|
||||||
// NEW: import VAT rates CSV
|
// NEW: import VAT rates CSV
|
||||||
router.post('/tax/vat-rates/import', authMiddleware, adminOnly, upload.single('file'), TaxController.importVatRatesCsv);
|
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)
|
// Existing registration handlers (keep)
|
||||||
router.post('/register/personal', (req, res) => {
|
router.post('/register/personal', (req, res) => {
|
||||||
|
|||||||
@ -3,13 +3,13 @@ const UnitOfWork = require('../database/UnitOfWork');
|
|||||||
const argon2 = require('argon2');
|
const argon2 = require('argon2');
|
||||||
|
|
||||||
async function createAdminUser() {
|
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 || 'loki.aahi@gmail.com';
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@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 || 'W.profit-planet.com.2025';
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%';
|
// const adminPassword = process.env.ADMIN_PASSWORD || 'loki!123';
|
||||||
const firstName = process.env.ADMIN_FIRST_NAME || 'Admin';
|
const firstName = process.env.ADMIN_FIRST_NAME || 'Lokii';
|
||||||
const lastName = process.env.ADMIN_LAST_NAME || 'User';
|
const lastName = process.env.ADMIN_LAST_NAME || 'Aahi';
|
||||||
|
|
||||||
const uow = new UnitOfWork(); // No need to pass pool
|
const uow = new UnitOfWork(); // No need to pass pool
|
||||||
await uow.start();
|
await uow.start();
|
||||||
|
|||||||
152
services/affiliate/AffiliateService.js
Normal file
152
services/affiliate/AffiliateService.js
Normal 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 });
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user