chitfund/backend/src/controllers/monthlyDrawController.js

819 lines
22 KiB
JavaScript

const { MonthlyDraw, ChitGroup, GroupMember, User, Payment } = require('../models');
const { Op } = require('sequelize');
const crypto = require('crypto');
// Generate provably fair lottery result
const generateProvablyFairResult = (serverSeed, clientSeed, nonce, eligibleMembers) => {
// Create combined seed
const combinedSeed = `${serverSeed}-${clientSeed}-${nonce}`;
const hash = crypto.createHash('sha256').update(combinedSeed).digest('hex');
// Use first 8 characters of hash as random number
const randomHex = hash.substring(0, 8);
const randomNumber = parseInt(randomHex, 16);
// Select winner from eligible members
const winnerIndex = randomNumber % eligibleMembers.length;
const winner = eligibleMembers[winnerIndex];
return {
winner,
hash,
randomNumber,
serverSeed,
clientSeed,
nonce
};
};
// Create a new monthly draw
const createMonthlyDraw = async (req, res) => {
try {
const {
group_id,
month,
year,
client_seed,
winner_id,
prize_amount,
is_past_draw,
animation_winner_user_id,
server_seed: bodyServerSeed,
nonce: bodyNonce
} = req.body;
// Convert month and year to integers
const monthInt = parseInt(month, 10);
const yearInt = parseInt(year, 10);
const managerId = req.user.id;
// Validate input
if (!group_id || !month || !year) {
return res.status(400).json({
success: false,
message: 'Group ID, month, and year are required'
});
}
// Validate month and year are valid numbers
if (isNaN(monthInt) || isNaN(yearInt)) {
return res.status(400).json({
success: false,
message: 'Month and year must be valid numbers'
});
}
// Validate month range
if (monthInt < 1 || monthInt > 12) {
return res.status(400).json({
success: false,
message: 'Month must be between 1 and 12'
});
}
// Check if user is the manager of this group
const chitGroup = await ChitGroup.findOne({
where: { id: group_id, manager_id: managerId }
});
if (!chitGroup) {
return res.status(404).json({
success: false,
message: 'Chit group not found or you are not the manager'
});
}
// Check if group is active
if (chitGroup.status !== 'active') {
return res.status(400).json({
success: false,
message: 'Can only conduct draws for active chit groups'
});
}
// Check if draw already exists for this month/year
const existingDraw = await MonthlyDraw.findOne({
where: { group_id, month: monthInt, year: yearInt }
});
if (existingDraw) {
return res.status(400).json({
success: false,
message: 'Draw already exists for this month and year'
});
}
// Get eligible members (active members who haven't won yet)
const eligibleMembers = await GroupMember.findAll({
where: {
group_id,
status: 'active'
},
include: [
{
model: User,
attributes: ['id', 'full_name', 'mobile_number']
}
]
});
// Filter out members who have already won
const wonMembers = await MonthlyDraw.findAll({
where: { group_id },
attributes: ['winner_id']
});
const wonMemberIds = wonMembers.map((draw) => draw.winner_id);
const availableMembers = eligibleMembers.filter(
(member) =>
!wonMemberIds.some((id) => id != null && String(id) === String(member.user_id))
);
const animationWinner =
animation_winner_user_id != null && String(animation_winner_user_id).trim() !== ''
? String(animation_winner_user_id).trim()
: null;
if (availableMembers.length === 0 && !is_past_draw && !animationWinner) {
return res.status(400).json({
success: false,
message: 'No eligible members available for draw'
});
}
const alreadyWon = (uid) =>
wonMemberIds.some((id) => id != null && String(id) === String(uid));
// Determine winner
let selectedWinnerId;
let selectedWinner;
let serverSeed;
let resultHash;
let nonce;
let fromAppAnimation = false;
if (animationWinner) {
// App showed a winner in the animation; persist that user (manager-authenticated).
// Without this branch the server re-rolls and WhatsApp/public links show the wrong name.
fromAppAnimation = true;
selectedWinnerId = animationWinner;
selectedWinner = eligibleMembers.find((m) => String(m.user_id) === selectedWinnerId);
if (!selectedWinner) {
return res.status(400).json({
success: false,
message: 'Winner is not an active member of this group'
});
}
if (alreadyWon(selectedWinnerId)) {
return res.status(400).json({
success: false,
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
alreadyWon: true,
winnerName: selectedWinner.User.full_name
});
}
if (!availableMembers.some((m) => String(m.user_id) === selectedWinnerId)) {
return res.status(400).json({
success: false,
message: 'Winner is not eligible for this draw (already won or not in pool)'
});
}
serverSeed =
bodyServerSeed && String(bodyServerSeed).trim() !== ''
? String(bodyServerSeed).trim()
: crypto.randomBytes(32).toString('hex');
nonce = bodyNonce != null ? parseInt(bodyNonce, 10) : Date.now();
if (Number.isNaN(nonce)) {
nonce = Date.now();
}
const cs = client_seed != null && String(client_seed).trim() !== '' ? String(client_seed) : 'animation';
resultHash = crypto
.createHash('sha256')
.update(`${serverSeed}-${cs}-${nonce}-${selectedWinnerId}`)
.digest('hex');
} else if (is_past_draw && winner_id) {
// Past draw - manual winner selection
selectedWinnerId = winner_id;
serverSeed = `PAST_DRAW_${Date.now()}`;
nonce = Date.now();
resultHash = crypto.createHash('sha256').update(`${serverSeed}_${winner_id}_${nonce}`).digest('hex');
// Get winner details
selectedWinner = eligibleMembers.find((m) => String(m.user_id) === String(winner_id));
if (!selectedWinner) {
return res.status(400).json({
success: false,
message: 'Selected winner is not a member of this group'
});
}
// Check if this member has already won
if (alreadyWon(winner_id)) {
return res.status(400).json({
success: false,
message: `${selectedWinner.User.full_name} has already won in a previous draw. Each member can only win once.`,
alreadyWon: true,
winnerName: selectedWinner.User.full_name
});
}
} else {
// Regular draw - provably fair random selection (API-only / no animation winner)
serverSeed = crypto.randomBytes(32).toString('hex');
nonce = Date.now();
const result = generateProvablyFairResult(
serverSeed,
client_seed || 'default',
nonce,
availableMembers
);
selectedWinnerId = result.winner.user_id;
selectedWinner = result.winner;
resultHash = result.hash;
}
// Calculate prize amount
const totalMembers = eligibleMembers.length;
const calculatedPrizeAmount = prize_amount || chitGroup.total_value; // Use provided or default
// For past imports, use all active members; for animation + random draws, use not-yet-won pool
const membersForDraw = is_past_draw && !fromAppAnimation ? eligibleMembers : availableMembers;
// Create monthly draw
const monthlyDraw = await MonthlyDraw.create({
group_id,
month: monthInt,
year: yearInt,
draw_date: new Date(),
eligible_members: membersForDraw.map(member => ({
id: member.user_id,
name: member.User.full_name,
mobile: member.User.mobile_number
})),
winner_id: selectedWinnerId,
prize_amount: calculatedPrizeAmount,
server_seed: serverSeed,
server_seed_hash: crypto.createHash('sha256').update(serverSeed).digest('hex'),
client_seed:
client_seed || (fromAppAnimation ? 'animation' : is_past_draw ? 'PAST_DRAW' : 'default'),
nonce,
result_hash: resultHash,
status: 'completed',
notes: fromAppAnimation
? `App animation draw — ${selectedWinner.User.full_name}`
: is_past_draw
? `Past draw result (imported) - Winner: ${selectedWinner.User.full_name}`
: `Winner selected: ${selectedWinner.User.full_name}`
});
res.status(201).json({
success: true,
message: 'Monthly draw completed successfully',
data: {
...monthlyDraw.toJSON(),
winner: selectedWinner.User,
eligible_count: membersForDraw.length
}
});
} catch (error) {
console.error('Create monthly draw error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
// Get all monthly draws for a group
const getGroupMonthlyDraws = async (req, res) => {
try {
const { group_id } = req.params;
const { status, page = 1, limit = 20 } = req.query;
// Check if user has access to this group
const chitGroup = await ChitGroup.findOne({
where: {
id: group_id,
[Op.or]: [
{ manager_id: req.user.id },
{ '$members.user_id$': req.user.id }
]
},
include: [
{
model: GroupMember,
as: 'members',
include: [
{
model: User,
attributes: ['id', 'full_name', 'mobile_number']
}
]
}
]
});
if (!chitGroup) {
return res.status(404).json({
success: false,
message: 'Chit group not found or access denied'
});
}
const whereClause = { group_id };
if (status) {
whereClause.status = status;
}
const offset = (page - 1) * limit;
const monthlyDraws = await MonthlyDraw.findAndCountAll({
where: whereClause,
include: [
{
model: User,
as: 'winner',
attributes: ['id', 'full_name', 'mobile_number']
}
],
order: [['created_at', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset)
});
res.json({
success: true,
message: 'Monthly draws retrieved successfully',
data: {
monthlyDraws: monthlyDraws.rows,
total: monthlyDraws.count,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(monthlyDraws.count / limit)
}
});
} catch (error) {
console.error('Get group monthly draws error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
// Get monthly draw details
const getMonthlyDrawDetails = async (req, res) => {
try {
const { draw_id } = req.params;
const monthlyDraw = await MonthlyDraw.findOne({
where: { id: draw_id },
include: [
{
model: User,
as: 'winner',
attributes: ['id', 'full_name', 'mobile_number']
},
{
model: ChitGroup,
attributes: ['id', 'name', 'monthly_installment', 'foreman_commission_amount']
}
]
});
if (!monthlyDraw) {
return res.status(404).json({
success: false,
message: 'Monthly draw not found'
});
}
// Check if user has access to this draw
const chitGroup = await ChitGroup.findOne({
where: {
id: monthlyDraw.group_id,
[Op.or]: [
{ manager_id: req.user.id },
{ '$members.user_id$': req.user.id }
]
}
});
if (!chitGroup) {
return res.status(403).json({
success: false,
message: 'Access denied to this monthly draw'
});
}
res.json({
success: true,
message: 'Monthly draw details retrieved successfully',
data: monthlyDraw
});
} catch (error) {
console.error('Get monthly draw details error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
// Verify provably fair result
const verifyDrawResult = async (req, res) => {
try {
const { draw_id } = req.params;
const { client_seed } = req.body;
const monthlyDraw = await MonthlyDraw.findOne({
where: { id: draw_id },
include: [
{
model: User,
as: 'winner',
attributes: ['id', 'full_name', 'mobile_number']
}
]
});
if (!monthlyDraw) {
return res.status(404).json({
success: false,
message: 'Monthly draw not found'
});
}
// Check if user has access to this draw
const chitGroup = await ChitGroup.findOne({
where: {
id: monthlyDraw.group_id,
[Op.or]: [
{ manager_id: req.user.id },
{ '$members.user_id$': req.user.id }
]
}
});
if (!chitGroup) {
return res.status(403).json({
success: false,
message: 'Access denied to this monthly draw'
});
}
// Verify the result
const verificationResult = generateProvablyFairResult(
monthlyDraw.server_seed,
client_seed || monthlyDraw.client_seed,
monthlyDraw.nonce,
monthlyDraw.eligible_members
);
const isVerified = verificationResult.hash === monthlyDraw.result_hash;
res.json({
success: true,
message: 'Draw verification completed',
data: {
isVerified,
originalHash: monthlyDraw.result_hash,
verificationHash: verificationResult.hash,
serverSeed: monthlyDraw.server_seed,
clientSeed: client_seed || monthlyDraw.client_seed,
nonce: monthlyDraw.nonce,
winner: monthlyDraw.winner
}
});
} catch (error) {
console.error('Verify draw result error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
// Get draw statistics for a group
const getDrawStatistics = async (req, res) => {
try {
const { group_id } = req.params;
// Check if user has access to this group
const chitGroup = await ChitGroup.findOne({
where: {
id: group_id,
[Op.or]: [
{ manager_id: req.user.id },
{ '$members.user_id$': req.user.id }
]
}
});
if (!chitGroup) {
return res.status(404).json({
success: false,
message: 'Chit group not found or access denied'
});
}
// Get all draws for this group
const draws = await MonthlyDraw.findAll({
where: { group_id },
include: [
{
model: User,
as: 'winner',
attributes: ['id', 'full_name', 'mobile_number']
}
],
order: [['created_at', 'ASC']]
});
// Get total members
const totalMembers = await GroupMember.count({
where: { group_id, status: 'active' }
});
// Calculate statistics
const completedDraws = draws.filter(draw => draw.status === 'completed');
const totalPrizeAmount = completedDraws.reduce((sum, draw) => sum + parseFloat(draw.prize_amount), 0);
const averagePrizeAmount = completedDraws.length > 0 ? totalPrizeAmount / completedDraws.length : 0;
// Get winners by month
const winnersByMonth = completedDraws.map(draw => ({
month: draw.month,
year: draw.year,
winner: draw.winner,
prize_amount: draw.prize_amount,
draw_date: draw.draw_date
}));
res.json({
success: true,
message: 'Draw statistics retrieved successfully',
data: {
totalDraws: draws.length,
completedDraws: completedDraws.length,
pendingDraws: draws.filter(draw => draw.status === 'pending').length,
totalMembers,
totalPrizeAmount,
averagePrizeAmount,
winnersByMonth,
remainingDraws: chitGroup.duration_months - completedDraws.length
}
});
} catch (error) {
console.error('Get draw statistics error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
// Delete monthly draw (manager only, for mistakes)
const deleteMonthlyDraw = async (req, res) => {
try {
const { draw_id } = req.params;
const managerId = req.user.id;
// Find the draw
const monthlyDraw = await MonthlyDraw.findOne({
where: { id: draw_id },
include: [
{
model: ChitGroup,
attributes: ['id', 'manager_id', 'name']
}
]
});
if (!monthlyDraw) {
return res.status(404).json({
success: false,
message: 'Monthly draw not found'
});
}
// Check if user is the manager
if (monthlyDraw.ChitGroup.manager_id !== managerId) {
return res.status(403).json({
success: false,
message: 'Only the group manager can delete draws'
});
}
// Store draw info for response
const drawInfo = {
month: monthlyDraw.month,
year: monthlyDraw.year,
groupName: monthlyDraw.ChitGroup.name
};
// Delete the draw
await monthlyDraw.destroy();
res.json({
success: true,
message: `Draw for ${drawInfo.month}/${drawInfo.year} deleted successfully`,
data: drawInfo
});
} catch (error) {
console.error('Delete monthly draw error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
// Update monthly draw (manager only, for corrections)
const updateMonthlyDraw = async (req, res) => {
try {
const { draw_id } = req.params;
const { winner_id, prize_amount, notes, month, year, draw_date } = req.body;
const managerId = req.user.id;
// Find the draw
const monthlyDraw = await MonthlyDraw.findOne({
where: { id: draw_id },
include: [
{
model: ChitGroup,
attributes: ['id', 'manager_id', 'name']
}
]
});
if (!monthlyDraw) {
return res.status(404).json({
success: false,
message: 'Monthly draw not found'
});
}
// Check if user is the manager
if (monthlyDraw.ChitGroup.manager_id !== managerId) {
return res.status(403).json({
success: false,
message: 'Only the group manager can update draws'
});
}
const updates = {};
let nextMonth = monthlyDraw.month;
let nextYear = monthlyDraw.year;
const monthProvided = month !== undefined && month !== null && month !== '';
const yearProvided = year !== undefined && year !== null && year !== '';
if (monthProvided) {
nextMonth = parseInt(month, 10);
}
if (yearProvided) {
nextYear = parseInt(year, 10);
}
if (monthProvided || yearProvided) {
if (Number.isNaN(nextMonth) || nextMonth < 1 || nextMonth > 12) {
return res.status(400).json({
success: false,
message: 'Month must be between 1 and 12'
});
}
if (Number.isNaN(nextYear) || nextYear < 2020) {
return res.status(400).json({
success: false,
message: 'Year must be 2020 or later'
});
}
const conflict = await MonthlyDraw.findOne({
where: {
group_id: monthlyDraw.group_id,
month: nextMonth,
year: nextYear,
id: { [Op.ne]: draw_id }
}
});
if (conflict) {
return res.status(400).json({
success: false,
message: `Another draw already exists for ${nextMonth}/${nextYear}`
});
}
updates.month = nextMonth;
updates.year = nextYear;
}
if (draw_date !== undefined && draw_date !== null && draw_date !== '') {
const d = new Date(draw_date);
if (Number.isNaN(d.getTime())) {
return res.status(400).json({
success: false,
message: 'Invalid draw_date'
});
}
updates.draw_date = d;
}
// Update winner if provided and different from current
if (winner_id) {
const winnerStr = String(winner_id);
const currentWinnerStr = monthlyDraw.winner_id != null ? String(monthlyDraw.winner_id) : '';
if (winnerStr !== currentWinnerStr) {
const member = await GroupMember.findOne({
where: {
group_id: monthlyDraw.ChitGroup.id,
user_id: winner_id,
status: 'active'
},
include: [{ model: User, attributes: ['id', 'full_name'] }]
});
if (!member) {
return res.status(400).json({
success: false,
message: 'Winner must be an active member of this group'
});
}
const duplicateWinner = await MonthlyDraw.findOne({
where: {
group_id: monthlyDraw.group_id,
winner_id,
id: { [Op.ne]: draw_id }
}
});
if (duplicateWinner) {
return res.status(400).json({
success: false,
message: `${member.User.full_name} has already won in another draw. Each member can only win once.`,
alreadyWon: true,
winnerName: member.User.full_name
});
}
updates.winner_id = winner_id;
if (notes === undefined || notes === null) {
updates.notes = `Winner updated to: ${member.User.full_name}`;
}
}
}
// Update prize amount if provided (including 0)
if (prize_amount != null && prize_amount !== '') {
updates.prize_amount = prize_amount;
}
// Update notes if provided
if (notes !== undefined && notes !== null) {
updates.notes = notes;
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
success: false,
message: 'No valid fields to update'
});
}
// Perform update
await monthlyDraw.update(updates);
// Fetch updated draw with winner details
const updatedDraw = await MonthlyDraw.findOne({
where: { id: draw_id },
include: [
{
model: User,
as: 'winner',
attributes: ['id', 'full_name', 'mobile_number']
}
]
});
res.json({
success: true,
message: 'Monthly draw updated successfully',
data: updatedDraw
});
} catch (error) {
console.error('Update monthly draw error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
};
module.exports = {
createMonthlyDraw,
getGroupMonthlyDraws,
getMonthlyDrawDetails,
verifyDrawResult,
getDrawStatistics,
deleteMonthlyDraw,
updateMonthlyDraw
};