feat: abo + profile section
This commit is contained in:
parent
d408ba89dd
commit
0c78f2c869
114
controller/abonemments/AbonemmentController.js
Normal file
114
controller/abonemments/AbonemmentController.js
Normal file
@ -0,0 +1,114 @@
|
||||
const AbonemmentService = require('../../services/abonemments/AbonemmentService');
|
||||
const service = new AbonemmentService();
|
||||
|
||||
module.exports = {
|
||||
async subscribe(req, res) {
|
||||
try {
|
||||
const result = await service.subscribeOrder({
|
||||
userId: req.user?.id || null,
|
||||
items: req.body.items,
|
||||
billingInterval: req.body.billing_interval,
|
||||
intervalCount: req.body.interval_count,
|
||||
isAutoRenew: req.body.is_auto_renew,
|
||||
firstName: req.body.firstName,
|
||||
lastName: req.body.lastName,
|
||||
email: req.body.email,
|
||||
street: req.body.street,
|
||||
postalCode: req.body.postalCode,
|
||||
city: req.body.city,
|
||||
country: req.body.country,
|
||||
frequency: req.body.frequency,
|
||||
startDate: req.body.startDate,
|
||||
actorUser: req.user,
|
||||
referredBy: req.body.referred_by, // NEW: Pass referred_by from frontend
|
||||
});
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT SUBSCRIBE]', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async pause(req, res) {
|
||||
try {
|
||||
const data = await service.pause({ abonementId: req.params.id, actorUser: req.user });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT PAUSE]', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async resume(req, res) {
|
||||
try {
|
||||
const data = await service.resume({ abonementId: req.params.id, actorUser: req.user });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT RESUME]', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async cancel(req, res) {
|
||||
try {
|
||||
const data = await service.cancel({ abonementId: req.params.id, actorUser: req.user });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT CANCEL]', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async renew(req, res) {
|
||||
try {
|
||||
const data = await service.adminRenew({ abonementId: req.params.id, actorUser: req.user });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT RENEW]', err);
|
||||
return res.status(403).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async getMine(req, res) {
|
||||
try {
|
||||
const data = await service.getMyAbonements({ userId: req.user.id });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT MINE]', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal error' });
|
||||
}
|
||||
},
|
||||
|
||||
async getHistory(req, res) {
|
||||
try {
|
||||
const data = await service.getHistory({ abonementId: req.params.id });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT HISTORY]', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async adminList(req, res) {
|
||||
try {
|
||||
const data = await service.adminList({ status: req.query.status });
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT ADMIN LIST]', err);
|
||||
return res.status(403).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async getReferredSubscriptions(req, res) {
|
||||
try {
|
||||
const data = await service.getReferredSubscriptions({
|
||||
userId: req.user.id,
|
||||
email: req.user.email,
|
||||
});
|
||||
return res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[ABONEMENT REFERRED SUBSCRIPTIONS]', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -617,52 +617,68 @@ async function createDatabase() {
|
||||
object_storage_id VARCHAR(255) NULL,
|
||||
original_filename VARCHAR(255) NULL,
|
||||
state BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
pack_group VARCHAR(100) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_pack_group (pack_group)
|
||||
);
|
||||
`);
|
||||
console.log('✅ Coffee table (simplified) created/verified');
|
||||
|
||||
// --- Pools Table ---
|
||||
// --- Coffee Abonements (subscriptions) ---
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS pools (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
pool_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
pool_type ENUM('coffee', 'other') NOT NULL DEFAULT 'other',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INT NULL,
|
||||
updated_by INT NULL,
|
||||
CREATE TABLE IF NOT EXISTS coffee_abonements (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
pack_group VARCHAR(100) NOT NULL DEFAULT '',
|
||||
status ENUM('active','paused','canceled','expired') NOT NULL DEFAULT 'active',
|
||||
started_at DATETIME NOT NULL,
|
||||
ended_at DATETIME NULL,
|
||||
next_billing_at DATETIME NULL,
|
||||
billing_interval ENUM('day','week','month','year') NOT NULL,
|
||||
interval_count INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
price DECIMAL(10,2) NOT NULL,
|
||||
currency CHAR(3) NOT NULL,
|
||||
is_auto_renew TINYINT(1) NOT NULL DEFAULT 1,
|
||||
notes VARCHAR(255) NULL,
|
||||
pack_breakdown JSON NULL,
|
||||
first_name VARCHAR(100) NULL,
|
||||
last_name VARCHAR(100) NULL,
|
||||
email VARCHAR(255) NULL,
|
||||
street VARCHAR(255) NULL,
|
||||
postal_code VARCHAR(20) NULL,
|
||||
city VARCHAR(100) NULL,
|
||||
country VARCHAR(100) NULL,
|
||||
frequency VARCHAR(50) NULL,
|
||||
referred_by INT NULL, -- NEW: user_id of the logged-in user who referred the subscription
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
INDEX idx_pool_type (pool_type),
|
||||
INDEX idx_is_active (is_active)
|
||||
CONSTRAINT fk_abon_referred_by FOREIGN KEY (referred_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
INDEX idx_pack_group (pack_group),
|
||||
INDEX idx_abon_status (status),
|
||||
INDEX idx_abon_billing (next_billing_at),
|
||||
INDEX idx_abon_created (created_at)
|
||||
);
|
||||
`);
|
||||
console.log('✅ Pools table created/verified');
|
||||
console.log('✅ Coffee abonements table updated');
|
||||
|
||||
// --- Affiliates Table ---
|
||||
// --- Coffee Abonement History ---
|
||||
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),
|
||||
CREATE TABLE IF NOT EXISTS coffee_abonement_history (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
abonement_id BIGINT NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
event_at DATETIME NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
details JSON NULL,
|
||||
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)
|
||||
CONSTRAINT fk_hist_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_hist_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
INDEX idx_hist_abon (abonement_id),
|
||||
INDEX idx_hist_event_at (event_at),
|
||||
INDEX idx_hist_event_type (event_type)
|
||||
);
|
||||
`);
|
||||
console.log('✅ Affiliates table created/verified');
|
||||
console.log('✅ Coffee abonement history table created/verified');
|
||||
|
||||
// --- Matrix: Global 5-ary tree config and relations ---
|
||||
await connection.query(`
|
||||
|
||||
35
models/Abonemment.js
Normal file
35
models/Abonemment.js
Normal file
@ -0,0 +1,35 @@
|
||||
class Abonemment {
|
||||
constructor(row) {
|
||||
this.id = row.id;
|
||||
this.status = row.status;
|
||||
this.started_at = row.started_at;
|
||||
this.ended_at = row.ended_at;
|
||||
this.next_billing_at = row.next_billing_at;
|
||||
this.billing_interval = row.billing_interval;
|
||||
this.interval_count = row.interval_count;
|
||||
this.price = row.price;
|
||||
this.currency = row.currency;
|
||||
this.is_auto_renew = !!row.is_auto_renew;
|
||||
this.notes = row.notes;
|
||||
this.pack_group = row.pack_group;
|
||||
this.pack_breakdown = row.pack_breakdown ? (typeof row.pack_breakdown === 'string' ? JSON.parse(row.pack_breakdown) : row.pack_breakdown) : null;
|
||||
this.first_name = row.first_name;
|
||||
this.last_name = row.last_name;
|
||||
this.email = row.email;
|
||||
this.street = row.street;
|
||||
this.postal_code = row.postal_code;
|
||||
this.city = row.city;
|
||||
this.country = row.country;
|
||||
this.frequency = row.frequency;
|
||||
this.referred_by = row.referred_by; // NEW
|
||||
this.created_at = row.created_at;
|
||||
this.updated_at = row.updated_at;
|
||||
}
|
||||
|
||||
get isActive() { return this.status === 'active'; }
|
||||
get isPaused() { return this.status === 'paused'; }
|
||||
get isCanceled() { return this.status === 'canceled'; }
|
||||
get isExpired() { return this.status === 'expired'; }
|
||||
}
|
||||
|
||||
module.exports = Abonemment;
|
||||
353
repositories/abonemments/AbonemmentRepository.js
Normal file
353
repositories/abonemments/AbonemmentRepository.js
Normal file
@ -0,0 +1,353 @@
|
||||
const pool = require('../../database/database');
|
||||
const Abonemment = require('../../models/Abonemment');
|
||||
|
||||
class AbonemmentRepository {
|
||||
async createAbonement(referredBy, snapshot) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [res] = await conn.query(
|
||||
`INSERT INTO coffee_abonements
|
||||
(pack_group, status, started_at, ended_at, next_billing_at, billing_interval, interval_count, price, currency, is_auto_renew, notes, pack_breakdown, first_name, last_name, email, street, postal_code, city, country, frequency, referred_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||
[
|
||||
snapshot.pack_group || '',
|
||||
snapshot.status || 'active',
|
||||
snapshot.started_at,
|
||||
snapshot.ended_at || null,
|
||||
snapshot.next_billing_at || null,
|
||||
snapshot.billing_interval,
|
||||
snapshot.interval_count,
|
||||
snapshot.price,
|
||||
snapshot.currency,
|
||||
snapshot.is_auto_renew ? 1 : 0,
|
||||
snapshot.notes || null,
|
||||
snapshot.pack_breakdown ? JSON.stringify(snapshot.pack_breakdown) : null,
|
||||
snapshot.first_name || null,
|
||||
snapshot.last_name || null,
|
||||
snapshot.email || null,
|
||||
snapshot.street || null,
|
||||
snapshot.postal_code || null,
|
||||
snapshot.city || null,
|
||||
snapshot.country || null,
|
||||
snapshot.frequency || null,
|
||||
referredBy || null, // Ensure referred_by is stored
|
||||
],
|
||||
);
|
||||
const abonementId = res.insertId;
|
||||
|
||||
const historyDetails = { ...(snapshot.details || {}), pack_group: snapshot.pack_group };
|
||||
await conn.query(
|
||||
`INSERT INTO coffee_abonement_history
|
||||
(abonement_id, event_type, event_at, actor_user_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())`,
|
||||
[
|
||||
abonementId,
|
||||
'created',
|
||||
snapshot.started_at,
|
||||
referredBy || snapshot.actor_user_id || null, // Use referredBy as actor_user_id if available
|
||||
JSON.stringify(historyDetails),
|
||||
],
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
return this.getAbonementById(abonementId);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getAbonementById(id) {
|
||||
const [rows] = await pool.query(`SELECT * FROM coffee_abonements WHERE id = ?`, [id]);
|
||||
return rows[0] ? new Abonemment(rows[0]) : null;
|
||||
}
|
||||
|
||||
async listByUser(userId, { status, limit = 50, offset = 0 } = {}) {
|
||||
const params = [userId];
|
||||
let sql = `SELECT * FROM coffee_abonements WHERE user_id = ?`;
|
||||
if (status) {
|
||||
sql += ` AND status = ?`;
|
||||
params.push(status);
|
||||
}
|
||||
sql += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
||||
params.push(Number(limit), Number(offset));
|
||||
const [rows] = await pool.query(sql, params);
|
||||
return rows.map((r) => new Abonemment(r));
|
||||
}
|
||||
|
||||
async updateStatus(id, newStatus, { ended_at, updated_at = new Date() } = {}) {
|
||||
await pool.query(
|
||||
`UPDATE coffee_abonements SET status = ?, ended_at = ?, updated_at = ? WHERE id = ?`,
|
||||
[newStatus, ended_at || null, updated_at, id],
|
||||
);
|
||||
return this.getAbonementById(id);
|
||||
}
|
||||
|
||||
async updateBilling(id, next_billing_at, updated_at = new Date()) {
|
||||
await pool.query(
|
||||
`UPDATE coffee_abonements SET next_billing_at = ?, updated_at = ? WHERE id = ?`,
|
||||
[next_billing_at, updated_at, id],
|
||||
);
|
||||
return this.getAbonementById(id);
|
||||
}
|
||||
|
||||
async appendHistory(abonementId, eventType, actorUserId, details = {}, eventAt = new Date()) {
|
||||
await pool.query(
|
||||
`INSERT INTO coffee_abonement_history
|
||||
(abonement_id, event_type, event_at, actor_user_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())`,
|
||||
[abonementId, eventType, eventAt, actorUserId || null, JSON.stringify(details || {})],
|
||||
);
|
||||
}
|
||||
|
||||
async transitionStatus(id, newStatus, historyPayload = {}) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query(
|
||||
`UPDATE coffee_abonements SET status = ?, ended_at = ?, updated_at = ? WHERE id = ?`,
|
||||
[
|
||||
newStatus,
|
||||
historyPayload.ended_at || null,
|
||||
historyPayload.updated_at || new Date(),
|
||||
id,
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO coffee_abonement_history
|
||||
(abonement_id, event_type, event_at, actor_user_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())`,
|
||||
[
|
||||
id,
|
||||
historyPayload.event_type || 'updated',
|
||||
historyPayload.event_at || new Date(),
|
||||
historyPayload.actor_user_id || null,
|
||||
JSON.stringify(historyPayload.details || {}),
|
||||
],
|
||||
);
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
return this.getAbonementById(id);
|
||||
}
|
||||
|
||||
async transitionBilling(id, nextBillingAt, historyPayload = {}) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query(
|
||||
`UPDATE coffee_abonements SET next_billing_at = ?, updated_at = ? WHERE id = ?`,
|
||||
[nextBillingAt, historyPayload.updated_at || new Date(), id],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO coffee_abonement_history
|
||||
(abonement_id, event_type, event_at, actor_user_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())`,
|
||||
[
|
||||
id,
|
||||
historyPayload.event_type || 'renewed',
|
||||
historyPayload.event_at || new Date(),
|
||||
historyPayload.actor_user_id || null,
|
||||
JSON.stringify(historyPayload.details || {}),
|
||||
],
|
||||
);
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
return this.getAbonementById(id);
|
||||
}
|
||||
|
||||
async listDueForBilling(now) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM coffee_abonements
|
||||
WHERE status = 'active' AND is_auto_renew = 1 AND next_billing_at IS NOT NULL AND next_billing_at <= ?
|
||||
ORDER BY next_billing_at ASC`,
|
||||
[now],
|
||||
);
|
||||
return rows.map((r) => new Abonemment(r));
|
||||
}
|
||||
|
||||
async listActiveByProduct(productId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM coffee_abonements WHERE coffee_table_id = ? AND status = 'active'`,
|
||||
[productId],
|
||||
);
|
||||
return rows.map((r) => new Abonemment(r));
|
||||
}
|
||||
|
||||
async listHistory(abonementId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM coffee_abonement_history WHERE abonement_id = ? ORDER BY event_at ASC, id ASC`,
|
||||
[abonementId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
abonement_id: r.abonement_id,
|
||||
event_type: r.event_type,
|
||||
event_at: r.event_at,
|
||||
actor_user_id: r.actor_user_id,
|
||||
details: r.details ? JSON.parse(r.details) : {},
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async findActiveOrPausedByUserAndProduct(userId, packGroup) {
|
||||
const normalizedUserId = userId ?? null;
|
||||
const normalizedPackGroup = packGroup || '';
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM coffee_abonements
|
||||
WHERE pack_group = ? AND user_id <=> ? AND status IN ('active','paused')
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[normalizedPackGroup, normalizedUserId],
|
||||
);
|
||||
return rows[0] ? new Abonemment(rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateExistingAbonementForSubscribe(id, snapshot) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query(
|
||||
`UPDATE coffee_abonements
|
||||
SET status = 'active',
|
||||
pack_group = ?,
|
||||
started_at = IFNULL(started_at, ?),
|
||||
next_billing_at = ?,
|
||||
billing_interval = ?,
|
||||
interval_count = ?,
|
||||
price = ?,
|
||||
currency = ?,
|
||||
is_auto_renew = ?,
|
||||
notes = ?,
|
||||
recipient_email = ?,
|
||||
purchaser_user_id = IFNULL(purchaser_user_id, ?),
|
||||
pack_breakdown = ?, -- NEW
|
||||
updated_at = NOW()
|
||||
WHERE id = ?`,
|
||||
[
|
||||
snapshot.pack_group || '',
|
||||
snapshot.started_at,
|
||||
snapshot.next_billing_at,
|
||||
snapshot.billing_interval,
|
||||
snapshot.interval_count,
|
||||
snapshot.price,
|
||||
snapshot.currency,
|
||||
snapshot.is_auto_renew ? 1 : 0,
|
||||
snapshot.notes || null,
|
||||
snapshot.recipient_email || null,
|
||||
snapshot.purchaser_user_id ?? null,
|
||||
snapshot.pack_breakdown ? JSON.stringify(snapshot.pack_breakdown) : null,
|
||||
id,
|
||||
],
|
||||
);
|
||||
const historyDetails = { ...(snapshot.details || {}), reused_existing: true, pack_group: snapshot.pack_group };
|
||||
if (snapshot.recipient) historyDetails.recipient = snapshot.recipient;
|
||||
await conn.query(
|
||||
`INSERT INTO coffee_abonement_history
|
||||
(abonement_id, event_type, event_at, actor_user_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())`,
|
||||
[id, 'merged_subscribe', snapshot.started_at, snapshot.actor_user_id || null, JSON.stringify(historyDetails)],
|
||||
);
|
||||
await conn.commit();
|
||||
return this.getAbonementById(id);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: attempt insert into alias table if it exists
|
||||
async tryAliasInsert(email, abonementId, createdByUserId) {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO no_user_aboo_mails (email, abonement_id, status, source, created_by_user_id, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', 'subscribe', ?, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE status = 'pending', source = 'subscribe', created_by_user_id = VALUES(created_by_user_id), updated_at = NOW()`,
|
||||
[email, abonementId, createdByUserId || null],
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e && e.code === 'ER_NO_SUCH_TABLE')) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert/update pending ownership by email (primary table), with alias fallback
|
||||
async upsertNoUserAboMail(email, abonementId, createdByUserId) {
|
||||
const normalized = typeof email === 'string' ? email.trim().toLowerCase() : null;
|
||||
console.log('[UPSERT NO USER ABO MAIL] Normalized email:', normalized); // NEW
|
||||
console.log('[UPSERT NO USER ABO MAIL] Abonement ID:', abonementId); // NEW
|
||||
console.log('[UPSERT NO USER ABO MAIL] Created by user ID:', createdByUserId); // NEW
|
||||
|
||||
if (!normalized) {
|
||||
console.log('[UPSERT NO USER ABO MAIL] Skipping due to invalid email'); // NEW
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO no_user_abo_mails (email, abonement_id, status, source, created_by_user_id, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', 'subscribe', ?, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE status = 'pending', source = 'subscribe', created_by_user_id = VALUES(created_by_user_id), updated_at = NOW()`,
|
||||
[normalized, abonementId, createdByUserId || null],
|
||||
);
|
||||
console.log('[UPSERT NO USER ABO MAIL] Successfully inserted/updated record'); // NEW
|
||||
} catch (err) {
|
||||
console.error('[UPSERT NO USER ABO MAIL] Error inserting/updating record:', err); // NEW
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pending ownership rows for an email
|
||||
async findPendingNoUserAboMailsByEmail(email) {
|
||||
const normalized = typeof email === 'string' ? email.trim().toLowerCase() : null;
|
||||
console.log('[FIND PENDING NO USER ABO MAILS] Normalized email:', normalized); // NEW
|
||||
|
||||
if (!normalized) {
|
||||
console.log('[FIND PENDING NO USER ABO MAILS] Skipping due to invalid email'); // NEW
|
||||
return [];
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM no_user_abo_mails WHERE email = ? AND status = 'pending'`,
|
||||
[normalized],
|
||||
);
|
||||
console.log('[FIND PENDING NO USER ABO MAILS] Found rows:', rows); // NEW
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
// Mark a pending ownership record as linked
|
||||
async markNoUserAboMailLinked(id) {
|
||||
console.log('[MARK NO USER ABO MAIL LINKED] Marking record as linked for ID:', id); // NEW
|
||||
await pool.query(`UPDATE no_user_abo_mails SET status = 'linked', updated_at = NOW() WHERE id = ?`, [id]);
|
||||
console.log('[MARK NO USER ABO MAIL LINKED] Successfully marked record as linked'); // NEW
|
||||
}
|
||||
|
||||
// Link abonement to a user (on registration)
|
||||
async linkAbonementToUser(abonementId, userId) {
|
||||
await pool.query(`UPDATE coffee_abonements SET user_id = ?, updated_at = NOW() WHERE id = ?`, [userId, abonementId]);
|
||||
}
|
||||
|
||||
async findByReferredByAndEmail(referredBy, email) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM coffee_abonements
|
||||
WHERE referred_by = ? AND email = ?
|
||||
ORDER BY created_at DESC`,
|
||||
[referredBy, email],
|
||||
);
|
||||
return rows.map((row) => new Abonemment(row));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AbonemmentRepository;
|
||||
@ -19,6 +19,7 @@ const CoffeeController = require('../controller/admin/CoffeeController');
|
||||
const PoolController = require('../controller/pool/PoolController');
|
||||
const TaxController = require('../controller/tax/taxController');
|
||||
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
|
||||
|
||||
// small helpers copied from original files
|
||||
function adminOnly(req, res, next) {
|
||||
@ -147,5 +148,9 @@ router.get('/admin/affiliates', authMiddleware, adminOnly, AffiliateController.l
|
||||
// Public Affiliates Route (Active only)
|
||||
router.get('/affiliates/active', AffiliateController.listActive);
|
||||
|
||||
// export
|
||||
// Abonement GETs
|
||||
router.get('/abonements/mine', authMiddleware, AbonemmentController.getMine);
|
||||
router.get('/abonements/:id/history', authMiddleware, AbonemmentController.getHistory);
|
||||
router.get('/admin/abonements', authMiddleware, adminOnly, AbonemmentController.adminList);
|
||||
|
||||
module.exports = router;
|
||||
@ -25,6 +25,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // Ma
|
||||
const PoolController = require('../controller/pool/PoolController');
|
||||
const TaxController = require('../controller/tax/taxController');
|
||||
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
|
||||
|
||||
const multer = require('multer');
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
@ -118,6 +119,27 @@ function forceCompanyForAdmin(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// NEW: route-specific helper to ensure req.user has id/email from POST body
|
||||
function ensureUserFromBody(req, res, next) {
|
||||
try {
|
||||
const bodyUserId = req.body?.userId ?? req.body?.id;
|
||||
const bodyEmail = req.body?.email;
|
||||
|
||||
if (!req.user) req.user = {};
|
||||
if (!req.user.id && bodyUserId) req.user.id = bodyUserId;
|
||||
if (!req.user.email && bodyEmail) req.user.email = bodyEmail;
|
||||
|
||||
// keep user_type/userType normalization intact
|
||||
if (!req.user.userType && req.user.user_type) req.user.userType = req.user.user_type;
|
||||
if (!req.user.user_type && req.user.userType) req.user.user_type = req.user.userType;
|
||||
|
||||
next();
|
||||
} catch (e) {
|
||||
console.error('[ensureUserFromBody] Error:', e);
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Company-stamp POST
|
||||
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
|
||||
// Admin: create coffee product (supports multipart file 'picture')
|
||||
@ -135,6 +157,15 @@ router.post('/tax/vat-rates/import', authMiddleware, adminOnly, upload.single('f
|
||||
// NEW: Admin create affiliate with logo upload
|
||||
router.post('/admin/affiliates', authMiddleware, adminOnly, upload.single('logo'), AffiliateController.create);
|
||||
|
||||
// Abonement POSTs
|
||||
router.post('/abonements/subscribe', authMiddleware, AbonemmentController.subscribe);
|
||||
router.post('/abonements/:id/pause', authMiddleware, AbonemmentController.pause);
|
||||
router.post('/abonements/:id/resume', authMiddleware, AbonemmentController.resume);
|
||||
router.post('/abonements/:id/cancel', authMiddleware, AbonemmentController.cancel);
|
||||
router.post('/admin/abonements/:id/renew', authMiddleware, adminOnly, AbonemmentController.renew);
|
||||
// CHANGED: ensure req.user has id/email from body for this route
|
||||
router.post('/abonements/referred', authMiddleware, ensureUserFromBody, AbonemmentController.getReferredSubscriptions);
|
||||
|
||||
// Existing registration handlers (keep)
|
||||
router.post('/register/personal', (req, res) => {
|
||||
console.log('🔗 POST /register/personal route accessed');
|
||||
|
||||
@ -3,7 +3,7 @@ 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 || 'alexander.ibrahim.ai@gmail.com';
|
||||
// const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com';
|
||||
// const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%';
|
||||
|
||||
342
services/abonemments/AbonemmentService.js
Normal file
342
services/abonemments/AbonemmentService.js
Normal file
@ -0,0 +1,342 @@
|
||||
const pool = require('../../database/database');
|
||||
const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository');
|
||||
|
||||
class AbonemmentService {
|
||||
constructor() {
|
||||
this.repo = new AbonemmentRepository();
|
||||
}
|
||||
|
||||
isAdmin(user) {
|
||||
return user && ['admin', 'super_admin'].includes(user.role);
|
||||
}
|
||||
|
||||
addInterval(date, interval, count) {
|
||||
const d = new Date(date);
|
||||
if (interval === 'day') d.setDate(d.getDate() + count);
|
||||
if (interval === 'week') d.setDate(d.getDate() + 7 * count);
|
||||
if (interval === 'month') d.setMonth(d.getMonth() + count);
|
||||
if (interval === 'year') d.setFullYear(d.getFullYear() + count);
|
||||
return d;
|
||||
}
|
||||
|
||||
async getCoffeeProduct(id) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, price, currency, billing_interval, interval_count, state AS is_active, pack_group FROM coffee_table WHERE id = ?`,
|
||||
[id],
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Helper: normalize email
|
||||
normalizeEmail(email) {
|
||||
return typeof email === 'string' ? email.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
// NEW: single bundle subscribe using items array (12 packs, 120 capsules)
|
||||
async subscribeOrder({
|
||||
items,
|
||||
billingInterval,
|
||||
intervalCount,
|
||||
isAutoRenew,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
street,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
frequency,
|
||||
startDate,
|
||||
actorUser,
|
||||
referredBy, // NEW: referred_by field
|
||||
}) {
|
||||
console.log('[SUBSCRIBE ORDER] Start processing subscription order');
|
||||
console.log('[SUBSCRIBE ORDER] Payload:', {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
street,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
frequency,
|
||||
startDate,
|
||||
});
|
||||
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) throw new Error('items must be a non-empty array');
|
||||
|
||||
let totalPacks = 0;
|
||||
let totalPrice = 0;
|
||||
const breakdown = [];
|
||||
|
||||
for (const item of items) {
|
||||
const coffeeId = item?.coffeeId;
|
||||
const packs = Number(item?.quantity ?? 0);
|
||||
if (!coffeeId) throw new Error('coffeeId is required for each item');
|
||||
if (!Number.isFinite(packs) || packs <= 0) throw new Error('quantity must be a positive integer per item');
|
||||
|
||||
const product = await this.getCoffeeProduct(coffeeId);
|
||||
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`);
|
||||
|
||||
totalPacks += packs;
|
||||
totalPrice += packs * Number(product.price);
|
||||
|
||||
breakdown.push({
|
||||
coffee_table_id: coffeeId,
|
||||
packs,
|
||||
price_per_pack: Number(product.price),
|
||||
currency: product.currency,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const startDateObj = startDate ? new Date(startDate) : now;
|
||||
const nextBilling = this.addInterval(startDateObj, billingInterval || 'month', intervalCount || 1);
|
||||
|
||||
const snapshot = {
|
||||
status: 'active',
|
||||
started_at: startDateObj,
|
||||
next_billing_at: nextBilling,
|
||||
billing_interval: billingInterval || 'month',
|
||||
interval_count: intervalCount || 1,
|
||||
price: Number(totalPrice.toFixed(2)),
|
||||
currency: breakdown[0]?.currency || 'EUR',
|
||||
is_auto_renew: isAutoRenew !== false,
|
||||
actor_user_id: actorUser?.id,
|
||||
details: { origin: 'subscribe_order', total_packs: totalPacks },
|
||||
pack_breakdown: breakdown,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email: normalizedEmail,
|
||||
street,
|
||||
postal_code: postalCode,
|
||||
city,
|
||||
country,
|
||||
frequency,
|
||||
referred_by: referredBy || null, // Pass referred_by to snapshot
|
||||
};
|
||||
|
||||
return this.repo.createAbonement(referredBy, snapshot); // Pass referredBy to repository
|
||||
}
|
||||
|
||||
async subscribe({
|
||||
userId,
|
||||
coffeeId,
|
||||
billingInterval,
|
||||
intervalCount,
|
||||
isAutoRenew,
|
||||
targetUserId,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
recipientNotes,
|
||||
actorUser,
|
||||
referredBy, // NEW: referred_by field
|
||||
}) {
|
||||
console.log('[SUBSCRIBE] Start processing single subscription'); // NEW
|
||||
console.log('[SUBSCRIBE] Recipient email:', recipientEmail); // NEW
|
||||
|
||||
const normalizedRecipientEmail = this.normalizeEmail(recipientEmail);
|
||||
console.log('[SUBSCRIBE] Normalized recipient email:', normalizedRecipientEmail); // NEW
|
||||
|
||||
if (coffeeId === undefined || coffeeId === null) throw new Error('coffeeId is required');
|
||||
|
||||
const hasRecipientFields = recipientName || normalizedRecipientEmail || recipientNotes;
|
||||
if (targetUserId && hasRecipientFields) throw new Error('Provide either target_user_id or recipient fields, not both');
|
||||
if (hasRecipientFields && !normalizedRecipientEmail) throw new Error('recipient_email is required when subscribing for another person');
|
||||
|
||||
const safeUserId = userId ?? null;
|
||||
const isForMe = !targetUserId && !hasRecipientFields;
|
||||
const ownerUserId = targetUserId ?? (hasRecipientFields ? null : safeUserId);
|
||||
const purchaserUserId = isForMe ? null : actorUser?.id || null;
|
||||
|
||||
const recipientMeta = targetUserId
|
||||
? { target_user_id: targetUserId }
|
||||
: hasRecipientFields
|
||||
? { recipient_name: recipientName, recipient_email: normalizedRecipientEmail, recipient_notes: recipientNotes }
|
||||
: null;
|
||||
|
||||
const product = await this.getCoffeeProduct(coffeeId);
|
||||
if (!product || !product.is_active) throw new Error('Product not available');
|
||||
|
||||
const canonicalPackGroup = product.pack_group || `product:${coffeeId}`;
|
||||
const now = new Date();
|
||||
const nextBilling = this.addInterval(
|
||||
now,
|
||||
billingInterval || product.billing_interval,
|
||||
intervalCount || product.interval_count || 1,
|
||||
);
|
||||
|
||||
const details = { origin: 'subscribe', pack_group: canonicalPackGroup };
|
||||
if (recipientMeta) details.recipient = recipientMeta;
|
||||
|
||||
const snapshot = {
|
||||
status: 'active',
|
||||
pack_group: canonicalPackGroup,
|
||||
started_at: now,
|
||||
next_billing_at: nextBilling,
|
||||
billing_interval: billingInterval || product.billing_interval,
|
||||
interval_count: intervalCount || product.interval_count || 1,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
is_auto_renew: isAutoRenew !== false,
|
||||
actor_user_id: actorUser?.id,
|
||||
notes: recipientMeta && recipientMeta.recipient_name ? recipientMeta.recipient_name : undefined,
|
||||
recipient_email: normalizedRecipientEmail || null, // CHANGED
|
||||
details,
|
||||
recipient: recipientMeta || undefined,
|
||||
purchaser_user_id: purchaserUserId, // NEW
|
||||
referred_by: referredBy || null, // Pass referred_by to snapshot
|
||||
};
|
||||
|
||||
const existing = await this.repo.findActiveOrPausedByUserAndProduct(ownerUserId ?? null, canonicalPackGroup);
|
||||
const abonement = existing
|
||||
? await this.repo.updateExistingAbonementForSubscribe(existing.id, snapshot)
|
||||
: await this.repo.createAbonement(ownerUserId ?? null, snapshot, referredBy); // Pass referredBy to repository
|
||||
|
||||
console.log('[SUBSCRIBE] Single subscription completed successfully'); // NEW
|
||||
return abonement;
|
||||
}
|
||||
|
||||
// NEW: authorization helper
|
||||
canManageAbonement(abon, actorUser) {
|
||||
if (this.isAdmin(actorUser)) return true;
|
||||
const actorId = actorUser?.id;
|
||||
if (!actorId) return false;
|
||||
if (abon.user_id && abon.user_id === actorId) return true;
|
||||
if (!abon.user_id && abon.purchaser_user_id && abon.purchaser_user_id === actorId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async pause({ abonementId, actorUser }) {
|
||||
const abon = await this.repo.getAbonementById(abonementId);
|
||||
if (!abon) throw new Error('Not found');
|
||||
if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW
|
||||
if (!abon.isActive) throw new Error('Only active abonements can be paused');
|
||||
return this.repo.transitionStatus(abonementId, 'paused', {
|
||||
event_type: 'paused',
|
||||
actor_user_id: actorUser?.id,
|
||||
details: { pack_group: abon.pack_group },
|
||||
});
|
||||
}
|
||||
|
||||
async resume({ abonementId, actorUser }) {
|
||||
const abon = await this.repo.getAbonementById(abonementId);
|
||||
if (!abon) throw new Error('Not found');
|
||||
if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW
|
||||
if (!abon.isPaused) throw new Error('Only paused abonements can be resumed');
|
||||
return this.repo.transitionStatus(abonementId, 'active', {
|
||||
event_type: 'resumed',
|
||||
actor_user_id: actorUser?.id,
|
||||
details: { pack_group: abon.pack_group },
|
||||
});
|
||||
}
|
||||
|
||||
async cancel({ abonementId, actorUser }) {
|
||||
const abon = await this.repo.getAbonementById(abonementId);
|
||||
if (!abon) throw new Error('Not found');
|
||||
if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW
|
||||
if (abon.isCanceled) return abon;
|
||||
return this.repo.transitionStatus(abonementId, 'canceled', {
|
||||
event_type: 'canceled',
|
||||
actor_user_id: actorUser?.id,
|
||||
ended_at: new Date(),
|
||||
details: { pack_group: abon.pack_group },
|
||||
});
|
||||
}
|
||||
|
||||
async renew({ abonementId, actorUser, invoiceId }) {
|
||||
const abon = await this.repo.getAbonementById(abonementId);
|
||||
if (!abon) throw new Error('Not found');
|
||||
if (!abon.isActive) throw new Error('Only active abonements can be renewed');
|
||||
const next = this.addInterval(new Date(abon.next_billing_at || new Date()), abon.billing_interval, abon.interval_count);
|
||||
return this.repo.transitionBilling(abonementId, next, {
|
||||
event_type: 'renewed',
|
||||
actor_user_id: actorUser?.id || null,
|
||||
details: { pack_group: abon.pack_group, ...(invoiceId ? { invoiceId } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
async getMyAbonements({ userId }) {
|
||||
return this.repo.listByUser(userId);
|
||||
}
|
||||
|
||||
async getHistory({ abonementId }) {
|
||||
return this.repo.listHistory(abonementId);
|
||||
}
|
||||
|
||||
async linkGiftFlagsToUser(email, userId) {
|
||||
const normalizedEmail = this.normalizeEmail(email); // NEW
|
||||
const pending = await this.repo.findPendingNoUserAboMailsByEmail(normalizedEmail);
|
||||
for (const row of pending) {
|
||||
await this.repo.linkAbonementToUser(row.abonement_id, userId);
|
||||
await this.repo.appendHistory(
|
||||
row.abonement_id,
|
||||
'gift_linked',
|
||||
userId,
|
||||
{ email, pack_group: (await this.repo.getAbonementById(row.abonement_id))?.pack_group || null }
|
||||
);
|
||||
await this.repo.markNoUserAboMailLinked(row.id); // CHANGED
|
||||
}
|
||||
return pending.length;
|
||||
}
|
||||
|
||||
async adminList({ status }) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM coffee_abonements ${status ? 'WHERE status = ?' : ''} ORDER BY created_at DESC LIMIT 200`,
|
||||
status ? [status] : [],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async adminRenew({ abonementId, actorUser }) {
|
||||
if (!this.isAdmin(actorUser)) throw new Error('Forbidden');
|
||||
return this.renew({ abonementId, actorUser });
|
||||
}
|
||||
|
||||
async getReferredSubscriptions({ userId, email }) {
|
||||
if (!userId || !email) throw new Error('User ID and email are required');
|
||||
const rows = await this.repo.findByReferredByAndEmail(userId, email);
|
||||
|
||||
// Collect distinct coffee_table_ids from pack_breakdown
|
||||
const idsSet = new Set();
|
||||
for (const r of rows) {
|
||||
const breakdown = Array.isArray(r.pack_breakdown) ? r.pack_breakdown : [];
|
||||
for (const item of breakdown) {
|
||||
const id = item?.coffee_table_id;
|
||||
if (id !== undefined && id !== null) idsSet.add(Number(id));
|
||||
}
|
||||
}
|
||||
const ids = Array.from(idsSet);
|
||||
let nameMap = {};
|
||||
if (ids.length) {
|
||||
const [nameRows] = await pool.query(
|
||||
`SELECT id, title FROM coffee_table WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids
|
||||
);
|
||||
nameMap = (nameRows || []).reduce((acc, row) => {
|
||||
acc[Number(row.id)] = row.title; // CHANGED: use title
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Attach coffee_name to each pack_breakdown item
|
||||
const enriched = rows.map(r => {
|
||||
const breakdown = Array.isArray(r.pack_breakdown) ? r.pack_breakdown : [];
|
||||
const withNames = breakdown.map(item => ({
|
||||
...item,
|
||||
coffee_name: nameMap[Number(item.coffee_table_id)] || null,
|
||||
}));
|
||||
// Return a plain object with enriched breakdown
|
||||
return {
|
||||
...r,
|
||||
pack_breakdown: withNames,
|
||||
};
|
||||
});
|
||||
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AbonemmentService;
|
||||
Loading…
Reference in New Issue
Block a user