CentralBackend/services/ReferralService.js
2025-09-07 12:44:01 +02:00

576 lines
21 KiB
JavaScript

const ReferralTokenRepository = require('../repositories/ReferralTokenRepository');
const PersonalUserRepository = require('../repositories/PersonalUserRepository');
const CompanyUserRepository = require('../repositories/CompanyUserRepository');
const UserStatusService = require('./UserStatusService');
const UserSettingsRepository = require('../repositories/UserSettingsRepository');
const MailService = require('./MailService');
const { logger } = require('../middleware/logger');
class ReferralService {
// --- Token creation and management ---
static async createReferralToken({ userId, expiresInDays, maxUses, unitOfWork }) {
logger.info('ReferralService:createReferralToken:start', { userId, expiresInDays, maxUses });
const normalizeUnlimited = (val, label) => {
if (val === undefined || val === null || val === '') return -1;
if (typeof val === 'string') {
const trimmed = val.trim().toLowerCase();
if (['unlimited','∞','-1'].includes(trimmed)) return -1;
const num = Number(val);
if (!Number.isNaN(num)) val = num;
}
// Treat 0 as unlimited (frontend currently sends 0 for unlimited)
if (val === 0) {
logger.debug(`ReferralService:createReferralToken:interpreting_0_as_unlimited`, { field: label });
return -1;
}
return val;
};
expiresInDays = normalizeUnlimited(expiresInDays, 'expiresInDays');
maxUses = normalizeUnlimited(maxUses, 'maxUses');
if (!(expiresInDays === -1 || (Number.isInteger(expiresInDays) && expiresInDays >= 1 && expiresInDays <= 7))) {
throw new Error('Expiry must be between 1 and 7 days or unlimited');
}
if (!(maxUses === -1 || (Number.isInteger(maxUses) && maxUses >= 1))) {
throw new Error('maxUses must be a positive integer or unlimited');
}
const unlimited = maxUses === -1;
if (unlimited) logger.info('ReferralService:createReferralToken:unlimited', { userId });
const UNLIMITED_EXPIRY_DATE = new Date('2999-12-31T23:59:59Z');
const expiresAt = (expiresInDays === -1)
? UNLIMITED_EXPIRY_DATE
: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
const repo = new ReferralTokenRepository(unitOfWork);
const token = await repo.createToken({
createdByUserId: userId,
expiresAt,
maxUses
});
logger.info('ReferralService:createReferralToken:success', {
token: token.token,
unlimited,
storedMaxUses: token.maxUses,
storedUsesRemaining: token.usesRemaining
});
return this.presentToken({
id: token.id,
token: token.token,
created_by_user_id: token.createdByUserId,
expires_at: token.expiresAt,
status: token.status,
max_uses: token.maxUses,
uses_remaining: token.usesRemaining
});
}
static async getUserReferralTokens(userId, unitOfWork) {
logger.info('ReferralService:getUserReferralTokens:start', { userId });
const repo = new ReferralTokenRepository(unitOfWork);
const rows = await repo.getTokensByUser(userId);
const base = process.env.REFERRAL_PUBLIC_BASE_URL || 'https://profit-planet.partners/register?ref=';
const tokens = rows.map(r => {
const max = r.max_uses;
const remaining = r.uses_remaining;
const isUnlimited = (max === -1) || (remaining === -1);
let used;
if (isUnlimited) {
used = 0;
} else if (typeof r.used_count === 'number') {
used = r.used_count;
} else if (Number.isFinite(max) && Number.isFinite(remaining)) {
used = max - remaining;
} else {
used = 0;
}
if (!Number.isFinite(used) || used < 0) used = 0;
if (!isUnlimited && used > max) used = max;
const maxUsesCanonical = isUnlimited ? null : max;
const usage = isUnlimited ? 'unlimited' : `${used}/${maxUsesCanonical}`;
return {
id: r.id,
token: r.token,
link: `${base}${r.token}`,
status: r.status,
created: r.created_at ? new Date(r.created_at).toISOString() : null,
expires: isUnlimited ? null : (r.expires_at ? new Date(r.expires_at).toISOString() : null),
used,
maxUses: maxUsesCanonical,
isUnlimited,
usage,
progress: usage,
// Aliases (defensive)
useCount: used,
usedCount: used
};
});
tokens.slice(0, 5).forEach(t => {
logger.debug('ReferralService:getUserReferralTokens:canonical', {
id: t.id, usage: t.usage, used: t.used, maxUses: t.maxUses, isUnlimited: t.isUnlimited
});
});
logger.info('ReferralService:getUserReferralTokens:success', { count: tokens.length });
return tokens;
}
static async getUserReferralStats(userId, unitOfWork) {
logger.info('ReferralService:getUserReferralStats:start', { userId });
const repo = new ReferralTokenRepository(unitOfWork);
const stats = await repo.getStatsByUser(userId);
logger.info('ReferralService:getUserReferralStats:success', { stats });
// Pass through all fields including new ones
return stats;
}
static async deactivateReferralToken(userId, tokenId, unitOfWork) {
logger.info('ReferralService:deactivateReferralToken:start', { userId, tokenId });
const repo = new ReferralTokenRepository(unitOfWork);
await repo.deactivateToken(tokenId, userId);
logger.info('ReferralService:deactivateReferralToken:success', { userId, tokenId });
}
static async countActiveReferralTokens(userId, unitOfWork) {
logger.info('ReferralService:countActiveReferralTokens:start', { userId });
const repo = new ReferralTokenRepository(unitOfWork);
const count = await repo.countActiveTokensByUser(userId);
logger.info('ReferralService:countActiveReferralTokens:success', { userId, count });
return count;
}
static presentToken(row) {
if (!row) return row;
// Raw DB fields
const maxNum = (row.max_uses !== undefined ? row.max_uses : row.maxUses);
const remNum = (row.uses_remaining !== undefined ? row.uses_remaining : row.usesRemaining);
const createdRaw = row.created_at || row.createdAt;
const expiresRaw = row.expires_at || row.expiresAt;
const createdDate = createdRaw ? new Date(createdRaw) : null;
const createdValid = createdDate && !isNaN(createdDate.getTime());
const createdISO = createdValid ? createdDate.toISOString() : null;
const expiresDate = expiresRaw ? new Date(expiresRaw) : null;
const expiresValid = expiresDate && !isNaN(expiresDate.getTime());
const unlimited = (maxNum === -1) || (remNum === -1);
const maxDisplay = row.max_uses_display || row.max_uses_label || (unlimited ? 'unlimited' : String(maxNum));
const remDisplay = row.uses_remaining_display || row.uses_remaining_label || (unlimited ? 'unlimited' : String(remNum));
// Numeric total & used
let totalCount = unlimited ? -1 : (
typeof row.total_count === 'number'
? row.total_count
: (Number.isFinite(maxNum) ? maxNum : 0)
);
let usedCount = unlimited ? 0 : (
typeof row.used_count === 'number'
? row.used_count
: (Number.isFinite(maxNum) && Number.isFinite(remNum) ? (maxNum - remNum) : 0)
);
if (!unlimited) {
if (!Number.isFinite(usedCount) || usedCount < 0) usedCount = 0;
if (Number.isFinite(totalCount) && usedCount > totalCount) usedCount = totalCount;
}
const remainingCount = unlimited
? -1
: (Number.isFinite(remNum)
? remNum
: (Number.isFinite(totalCount) ? (totalCount - usedCount) : 0));
// Build usage string deterministically (do NOT trust usage_display if present)
const usageString = unlimited
? 'unlimited'
: (totalCount > 0 ? `${usedCount}/${totalCount}` : '0/0');
const infinitySymbol = '∞';
const denominatorDisplay = unlimited ? infinitySymbol : String(totalCount);
const numeratorDisplay = unlimited ? '0' : String(usedCount);
// Expiry display (Never for unlimited OR far future year >= 2999)
const expiresAtISO = unlimited
? null
: (expiresValid ? expiresDate.toISOString() : null);
const expiresAtDisplay = unlimited
? 'Never'
: (expiresValid ? expiresDate.toISOString() : 'Unknown');
// Numeric maxUses for FE (for unlimited keep -1 sentinel)
const maxUsesNumber = unlimited ? -1 : (Number.isFinite(totalCount) ? totalCount : 0);
// NEW: derive referrer identity (email & name) early so it can be inserted into tokenObj
const referrerEmailDerived =
row.referrer_email ||
row.referrerEmail ||
row.email ||
row.created_by_email ||
row.createdByEmail ||
null;
// Try to build a display name if first/last name fields exist (silent if not)
const refFirst = row.referrer_first_name || row.first_name || row.firstName;
const refLast = row.referrer_last_name || row.last_name || row.lastName;
let referrerNameDerived = row.referrer_name || row.referrerName || null;
if (!referrerNameDerived && (refFirst || refLast)) {
referrerNameDerived = [refFirst, refLast].filter(Boolean).join(' ').trim() || null;
}
if (!referrerNameDerived) {
// fallback to email for display if no name
referrerNameDerived = referrerEmailDerived || null;
}
const tokenObj = {
id: row.id,
token: row.token,
status: row.status,
// Core time fields
createdAt: createdISO,
created_at: createdISO,
createdAtISO: createdISO,
createdAtEpoch: createdValid ? createdDate.getTime() : null,
expiresAt: expiresAtISO,
expires_at: expiresAtISO,
expiresAtISO,
expiresAtEpoch: (expiresAtISO ? expiresDate.getTime() : null),
expiresAtDisplay,
// Display & numeric usage
usage: usageString,
usageDisplay: usageString,
progress: usageString,
// Display values
maxUsesDisplay: unlimited ? 'unlimited' : String(maxUsesNumber),
usesRemainingDisplay: unlimited ? 'unlimited' : String(remainingCount),
// Primary numeric values (camelCase)
maxUses: maxUsesNumber,
usesRemaining: remainingCount,
used: usedCount,
isUnlimited: unlimited,
// Raw helpers
maxUsesRaw: maxUsesNumber,
usesRemainingRaw: remainingCount,
usesUsedRaw: usedCount,
// Additional alias set to satisfy unknown FE expectations
limit: maxUsesNumber,
total: maxUsesNumber,
totalCount: maxUsesNumber,
max: maxUsesNumber,
capacity: maxUsesNumber,
remaining: remainingCount,
remainingCount,
left: remainingCount,
usedCount,
currentUses: usedCount,
progressUsed: usedCount,
totalUses: maxUsesNumber,
remainingUses: remainingCount,
usedUses: usedCount,
// Snake_case (legacy)
max_uses: maxUsesNumber,
uses_remaining: remainingCount,
// Displays for numerator/denominator parts
numeratorDisplay,
denominatorDisplay,
infinitySymbol,
// Structured usage object
usageData: {
used: usedCount,
remaining: remainingCount,
max: maxUsesNumber,
limit: maxUsesNumber,
total: maxUsesNumber,
isUnlimited: unlimited,
display: usageString,
numerator: numeratorDisplay,
denominator: denominatorDisplay
},
// Keep original creation if present (for FE that lists columns generically)
created_by_user_id: row.created_by_user_id || row.createdByUserId,
createdByUserId: row.created_by_user_id || row.createdByUserId,
// Count of individual usage rows
usageCount: row.usage_count,
// NEW: referrer identity fields (added near end to avoid interfering with existing logic)
referrerEmail: referrerEmailDerived,
referrerName: referrerNameDerived,
// legacy / alias fields some FE might probe
ownerEmail: referrerEmailDerived,
createdByEmail: referrerEmailDerived,
userEmail: referrerEmailDerived
};
if (!tokenObj.referrerEmail) {
logger.debug('ReferralService:presentToken:referrer_email_missing', { tokenId: row.id, hasRawField: !!row.referrer_email });
}
return tokenObj;
}
// --- Registration via referral ---
static evaluateTokenRecord(rec) {
if (!rec) return { valid: false, reason: 'not_found' };
// Defensive normalization
const rawMax = (rec.max_uses === null || rec.max_uses === undefined) ? -1 : rec.max_uses;
const rawRemaining = (rec.uses_remaining === null || rec.uses_remaining === undefined) ? -1 : rec.uses_remaining;
const isUnlimited = (rawMax === -1) || (rawRemaining === -1);
// Diagnostics
logger.debug('ReferralService:evaluateTokenRecord:raw', {
status: rec.status,
expires_at: rec.expires_at,
max_uses: rawMax,
uses_remaining: rawRemaining,
isUnlimited
});
if (rec.status !== 'active') return { valid: false, reason: 'inactive' };
const now = Date.now();
const expMs = rec.expires_at ? new Date(rec.expires_at).getTime() : NaN;
if (!isUnlimited) {
if (isNaN(expMs) || expMs <= now) return { valid: false, reason: 'expired' };
if (rawRemaining === 0) return { valid: false, reason: 'exhausted' };
if (rawRemaining < 0) {
// Inconsistent negative (not -1 sentinel) -> treat as internal issue; allow but log
logger.warn('ReferralService:evaluateTokenRecord:negative_remaining_non_unlimited', {
uses_remaining: rawRemaining, max_uses: rawMax
});
}
}
return {
valid: true,
reason: null,
isUnlimited,
usesRemaining: isUnlimited ? null : rawRemaining
};
}
static async getReferrerInfo(token, unitOfWork) {
logger.info('ReferralService:getReferrerInfo:start', { token });
const repo = new ReferralTokenRepository(unitOfWork);
const raw = await repo.getReferrerInfoByToken(token);
if (!raw) {
logger.warn('ReferralService:getReferrerInfo:not_found', { token });
return { valid: false, reason: 'not_found' };
}
const evalResult = this.evaluateTokenRecord(raw);
if (!evalResult.valid) {
logger.warn('ReferralService:getReferrerInfo:invalid', {
token, reason: evalResult.reason, max_uses: raw.max_uses, uses_remaining: raw.uses_remaining
});
return { valid: false, reason: evalResult.reason };
}
const normalized = this.presentToken(raw);
const refEmail = normalized.referrerEmail || raw.referrer_email || raw.email || null;
const refName = normalized.referrerName || refEmail;
if (!refEmail) {
logger.warn('ReferralService:getReferrerInfo:referrer_email_unresolved', { token, tokenId: raw.id });
} else {
logger.debug('ReferralService:getReferrerInfo:referrer_email_resolved', { token, refEmail });
}
return {
valid: true,
referrerId: raw.referrer_id || normalized.referrerId || null,
referrerName: refName,
referrerEmail: refEmail,
isUnlimited: normalized.isUnlimited,
usesRemaining: normalized.isUnlimited ? -1 : normalized.usesRemaining,
maxUses: normalized.maxUses,
usage: normalized.usage
};
}
static async registerPersonalWithReferral(registrationData, refToken, unitOfWork) {
logger.info('ReferralService:registerPersonalWithReferral:start', { refToken });
const repo = new ReferralTokenRepository(unitOfWork);
const raw = await repo.getReferrerInfoByToken(refToken);
const evalResult = this.evaluateTokenRecord(raw);
if (!evalResult.valid) {
logger.warn('ReferralService:registerPersonalWithReferral:token_invalid', {
refToken, reason: evalResult.reason, max_uses: raw && raw.max_uses, uses_remaining: raw && raw.uses_remaining
});
throw new Error(evalResult.reason || 'invalid_token');
}
const personalRepo = new PersonalUserRepository(unitOfWork);
const user = await personalRepo.create(registrationData);
await UserStatusService.initializeUserStatus(user.id, 'personal', unitOfWork, 'inactive');
if (UserSettingsRepository) {
const settingsRepo = new UserSettingsRepository(unitOfWork);
await settingsRepo.createDefaultSettings(user.id, unitOfWork);
} else if (unitOfWork.connection) {
await unitOfWork.connection.query(`
INSERT INTO user_settings (user_id) VALUES (?)
ON DUPLICATE KEY UPDATE user_id = user_id
`, [user.id]);
}
await repo.markReferralTokenUsed(raw.token_id, user.id, unitOfWork);
await MailService.sendRegistrationEmail({
email: registrationData.email,
firstName: registrationData.firstName,
lastName: registrationData.lastName,
userType: 'personal'
});
logger.info('ReferralService:registerPersonalWithReferral:success', { userId: user.id, email: user.email });
return user;
}
static async registerCompanyWithReferral(registrationData, refToken, unitOfWork) {
logger.info('ReferralService:registerCompanyWithReferral:start', { refToken });
const repo = new ReferralTokenRepository(unitOfWork);
const raw = await repo.getReferrerInfoByToken(refToken);
const evalResult = this.evaluateTokenRecord(raw);
if (!evalResult.valid) {
logger.warn('ReferralService:registerCompanyWithReferral:token_invalid', {
refToken, reason: evalResult.reason, max_uses: raw && raw.max_uses, uses_remaining: raw && raw.uses_remaining
});
throw new Error(evalResult.reason || 'invalid_token');
}
const companyRepo = new CompanyUserRepository(unitOfWork);
const { companyEmail, password, companyName, companyPhone, contactPersonName, contactPersonPhone } = registrationData;
const user = await companyRepo.create({
companyEmail,
password,
companyName,
companyPhone,
contactPersonName,
contactPersonPhone
});
await UserStatusService.initializeUserStatus(user.id, 'company', unitOfWork, 'inactive');
if (UserSettingsRepository) {
const settingsRepo = new UserSettingsRepository(unitOfWork);
await settingsRepo.createDefaultSettings(user.id, unitOfWork);
} else if (unitOfWork.connection) {
await unitOfWork.connection.query(`
INSERT INTO user_settings (user_id) VALUES (?)
ON DUPLICATE KEY UPDATE user_id = user_id
`, [user.id]);
}
await repo.markReferralTokenUsed(raw.token_id, user.id, unitOfWork);
await MailService.sendRegistrationEmail({
email: registrationData.companyEmail,
companyName: registrationData.companyName,
userType: 'company'
});
logger.info('ReferralService:registerCompanyWithReferral:success', { userId: user.id, email: user.email });
return user;
}
// --- Referral usage processing ---
static async processReferral(userId, referralEmail, unitOfWork) {
logger.info('ReferralService:processReferral:start', { userId, referralEmail });
try {
const referrer = await this.findReferrerByEmail(referralEmail, unitOfWork);
if (!referrer) {
logger.warn('ReferralService:processReferral:referrer_not_found', { referralEmail });
return false;
}
const referralToken = await this.findActiveReferralToken(referrer.id, unitOfWork);
if (!referralToken) {
logger.warn('ReferralService:processReferral:no_active_token', { referrerId: referrer.id });
return false;
}
await this.createReferralUsage(referralToken.id, userId, unitOfWork);
await this.updateTokenUsage(referralToken.id, unitOfWork);
logger.info('ReferralService:processReferral:success', { userId, referralTokenId: referralToken.id });
return true;
} catch (error) {
logger.error('ReferralService:processReferral:error', { userId, referralEmail, error });
return false;
}
}
static async findReferrerByEmail(email, unitOfWork) {
logger.debug('ReferralService:findReferrerByEmail', { email });
const conn = unitOfWork.connection;
const query = `SELECT id FROM users WHERE email = ?`;
const [rows] = await conn.query(query, [email]);
return rows.length > 0 ? rows[0] : null;
}
static async findActiveReferralToken(userId, unitOfWork) {
logger.debug('ReferralService:findActiveReferralToken', { userId });
const conn = unitOfWork.connection;
const query = `
SELECT * FROM referral_tokens
WHERE created_by_user_id = ? AND status = 'active' AND expires_at > NOW()
LIMIT 1
`;
const [rows] = await conn.query(query, [userId]);
return rows.length > 0 ? rows[0] : null;
}
static async createReferralUsage(tokenId, userId, unitOfWork) {
logger.debug('ReferralService:createReferralUsage', { tokenId, userId });
const conn = unitOfWork.connection;
const query = `
INSERT INTO referral_token_usage (referral_token_id, used_by_user_id)
VALUES (?, ?)
`;
await conn.query(query, [tokenId, userId]);
}
static async updateTokenUsage(tokenId, unitOfWork) {
logger.debug('ReferralService:updateTokenUsage', { tokenId });
const conn = unitOfWork.connection;
const query = `
UPDATE referral_tokens
SET uses_remaining = uses_remaining - 1
WHERE id = ? AND uses_remaining > 0
`;
await conn.query(query, [tokenId]);
}
}
module.exports = ReferralService;