phaser and bug bounty etc
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
* and rewarding users who identify issues with kredits.
|
||||
*/
|
||||
|
||||
// Import the email service
|
||||
const { sendBugStatusEmail } = require('../../emails/bug_status_email');
|
||||
|
||||
const BugTypes = {
|
||||
TYPO: { name: 'Typo/Grammar', kredits: 5 },
|
||||
UI_ISSUE: { name: 'UI Issue', kredits: 10 },
|
||||
@@ -37,7 +40,7 @@ const submissionRateLimit = async (user) => {
|
||||
const BUS = Parse.Object.extend('BUS');
|
||||
const query = new Parse.Query(BUS);
|
||||
query.equalTo('user', user);
|
||||
query.greaterThanOrEqual('createdAt', today);
|
||||
query.greaterThanOrEqualTo('createdAt', today);
|
||||
query.lessThan('createdAt', tomorrow);
|
||||
|
||||
const count = await query.count({ useMasterKey: true });
|
||||
@@ -78,14 +81,14 @@ const isDuplicateBug = async (user, title, description) => {
|
||||
* - expected: String (Expected behavior)
|
||||
* - actual: String (Actual behavior)
|
||||
* - deviceInfo: Object (Browser/device information)
|
||||
* - attachments: Array (Optional URLs to screenshots/videos)
|
||||
* - screenshot: Object (Optional screenshot file data: {name, data, type, size})
|
||||
*/
|
||||
Parse.Cloud.define('submitBugReport', async (request) => {
|
||||
if (!request.user) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'User must be logged in to submit bug reports.');
|
||||
}
|
||||
|
||||
const { title, description, bugType, steps, expected, actual, deviceInfo, attachments } = request.params;
|
||||
const { title, description, bugType, steps, expected, actual, deviceInfo, screenshot } = request.params;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !description || !bugType || !steps || !expected || !actual) {
|
||||
@@ -109,6 +112,44 @@ Parse.Cloud.define('submitBugReport', async (request) => {
|
||||
throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A similar bug report has already been submitted by you.');
|
||||
}
|
||||
|
||||
// Validate and process screenshot if provided
|
||||
let screenshotFile = null;
|
||||
if (screenshot && screenshot.data) {
|
||||
try {
|
||||
// Server-side validation
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
const maxSize = 1024 * 1024; // 1MB
|
||||
|
||||
if (!allowedTypes.includes(screenshot.type)) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_PARAMS, 'Invalid file type. Only PNG, JPG, and WebP images are allowed.');
|
||||
}
|
||||
|
||||
if (screenshot.size > maxSize) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_PARAMS, 'File size too large. Maximum size is 1MB.');
|
||||
}
|
||||
|
||||
// Create Parse File from base64 data
|
||||
const fileExtension = screenshot.type.split('/')[1];
|
||||
const fileName = `bug-report-${request.user.id}-${Date.now()}.${fileExtension}`;
|
||||
|
||||
screenshotFile = new Parse.File(fileName, {
|
||||
base64: screenshot.data
|
||||
}, screenshot.type);
|
||||
|
||||
// Save the file
|
||||
await screenshotFile.save({ useMasterKey: true });
|
||||
|
||||
console.log(`Screenshot uploaded successfully: ${screenshotFile.url()}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing screenshot:', error);
|
||||
if (error instanceof Parse.Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Failed to process screenshot upload.');
|
||||
}
|
||||
}
|
||||
|
||||
// Create new bug report
|
||||
const BUS = Parse.Object.extend('BUS');
|
||||
const bugReport = new BUS();
|
||||
@@ -125,16 +166,23 @@ Parse.Cloud.define('submitBugReport', async (request) => {
|
||||
bugReport.set('deviceInfo', deviceInfo);
|
||||
bugReport.set('status', BugStatus.SUBMITTED);
|
||||
|
||||
if (attachments && Array.isArray(attachments)) {
|
||||
bugReport.set('attachments', attachments);
|
||||
// Add screenshot if uploaded
|
||||
if (screenshotFile) {
|
||||
bugReport.set('screenshot', screenshotFile);
|
||||
bugReport.set('hasScreenshot', true);
|
||||
} else {
|
||||
bugReport.set('hasScreenshot', false);
|
||||
}
|
||||
|
||||
await bugReport.save(null, { useMasterKey: true });
|
||||
|
||||
console.log(`Bug report submitted successfully: ${bugReport.id} by user ${request.user.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Bug report submitted successfully!',
|
||||
bugId: bugReport.id
|
||||
bugId: bugReport.id,
|
||||
hasScreenshot: !!screenshotFile
|
||||
};
|
||||
});
|
||||
|
||||
@@ -168,6 +216,11 @@ Parse.Cloud.define('getUserBugReports', async (request) => {
|
||||
bugType: bug.get('bugTypeLabel'),
|
||||
status: bug.get('status'),
|
||||
potentialKredits: bug.get('potentialKredits'),
|
||||
screenshot: bug.get('screenshot') ? {
|
||||
url: bug.get('screenshot').url(),
|
||||
name: bug.get('screenshot').name()
|
||||
} : null,
|
||||
hasScreenshot: bug.get('hasScreenshot') || false,
|
||||
createdAt: bug.get('createdAt'),
|
||||
updatedAt: bug.get('updatedAt')
|
||||
}));
|
||||
@@ -315,6 +368,11 @@ Parse.Cloud.define('getBugDetails', async (request) => {
|
||||
actual: bugReport.get('actual'),
|
||||
deviceInfo: bugReport.get('deviceInfo'),
|
||||
attachments: bugReport.get('attachments') || [],
|
||||
screenshot: bugReport.get('screenshot') ? {
|
||||
url: bugReport.get('screenshot').url(),
|
||||
name: bugReport.get('screenshot').name()
|
||||
} : null,
|
||||
hasScreenshot: bugReport.get('hasScreenshot') || false,
|
||||
createdAt: bugReport.get('createdAt'),
|
||||
updatedAt: bugReport.get('updatedAt'),
|
||||
user: {
|
||||
@@ -329,14 +387,28 @@ Parse.Cloud.define('getBugDetails', async (request) => {
|
||||
* Helper function to check if user is an admin
|
||||
*/
|
||||
async function isUserAdmin(user) {
|
||||
// Check if user has admin role
|
||||
const query = new Parse.Query(Parse.Role);
|
||||
query.equalTo('name', 'Administrator');
|
||||
query.equalTo('users', user);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check multiple possible admin role names
|
||||
const adminRoleNames = ['admin', 'Admin', 'Administrator'];
|
||||
|
||||
try {
|
||||
const adminRole = await query.first({ useMasterKey: true });
|
||||
return !!adminRole;
|
||||
for (const roleName of adminRoleNames) {
|
||||
const query = new Parse.Query(Parse.Role);
|
||||
query.equalTo('name', roleName);
|
||||
query.equalTo('users', user);
|
||||
|
||||
const adminRole = await query.first({ useMasterKey: true });
|
||||
if (adminRole) {
|
||||
console.log(`User ${user.get('username')} has admin role: ${roleName}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`User ${user.get('username')} does not have admin access`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error checking admin status:', error);
|
||||
return false;
|
||||
@@ -415,6 +487,10 @@ Parse.Cloud.afterSave('BUS', async (request) => {
|
||||
}, { useMasterKey: true });
|
||||
}
|
||||
|
||||
// Send email notification for completed status
|
||||
console.log(`📧 Sending COMPLETED status email with ${kredits} kredits awarded`);
|
||||
await sendBugStatusEmail(user, bugReport, BugStatus.COMPLETED, '', kredits);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in BUS afterSave for completed bug:', error);
|
||||
}
|
||||
@@ -470,3 +546,375 @@ Parse.Cloud.define('getBugTypes', async () => {
|
||||
kredits: value.kredits
|
||||
}));
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ADMIN BUG MANAGEMENT FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get all bug reports for admin management
|
||||
* Supports filtering, searching, sorting, and pagination
|
||||
*/
|
||||
Parse.Cloud.define('getAdminBugReports', async (request) => {
|
||||
// Check if user is admin
|
||||
const isAdmin = await isUserAdmin(request.user);
|
||||
if (!isAdmin) {
|
||||
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Admin access required.');
|
||||
}
|
||||
|
||||
const {
|
||||
status = null,
|
||||
searchTerm = '',
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
limit = 50,
|
||||
skip = 0,
|
||||
bugType = null
|
||||
} = request.params;
|
||||
|
||||
const BUS = Parse.Object.extend('BUS');
|
||||
const query = new Parse.Query(BUS);
|
||||
query.include('user');
|
||||
|
||||
// Apply filters
|
||||
if (status && status !== 'all') {
|
||||
query.equalTo('status', status);
|
||||
}
|
||||
|
||||
if (bugType && bugType !== 'all') {
|
||||
query.equalTo('bugType', bugType);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchTerm) {
|
||||
const titleQuery = new Parse.Query(BUS);
|
||||
titleQuery.contains('title', searchTerm);
|
||||
|
||||
const descQuery = new Parse.Query(BUS);
|
||||
descQuery.contains('description', searchTerm);
|
||||
|
||||
const userQuery = new Parse.Query(BUS);
|
||||
userQuery.matchesQuery('user', new Parse.Query(Parse.User).contains('username', searchTerm));
|
||||
|
||||
query._orQuery([titleQuery, descQuery, userQuery]);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortOrder === 'desc') {
|
||||
query.descending(sortBy);
|
||||
} else {
|
||||
query.ascending(sortBy);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
query.limit(limit);
|
||||
query.skip(skip);
|
||||
|
||||
try {
|
||||
const [results, totalCount] = await Promise.all([
|
||||
query.find({ useMasterKey: true }),
|
||||
new Parse.Query(BUS).count({ useMasterKey: true })
|
||||
]);
|
||||
|
||||
const bugReports = results.map(bug => ({
|
||||
id: bug.id,
|
||||
title: bug.get('title'),
|
||||
description: bug.get('description'),
|
||||
bugType: bug.get('bugType'),
|
||||
bugTypeLabel: bug.get('bugTypeLabel'),
|
||||
status: bug.get('status'),
|
||||
potentialKredits: bug.get('potentialKredits'),
|
||||
steps: bug.get('steps'),
|
||||
expected: bug.get('expected'),
|
||||
actual: bug.get('actual'),
|
||||
deviceInfo: bug.get('deviceInfo'),
|
||||
attachments: bug.get('attachments') || [],
|
||||
screenshot: bug.get('screenshot') ? {
|
||||
url: bug.get('screenshot').url(),
|
||||
name: bug.get('screenshot').name()
|
||||
} : null,
|
||||
hasScreenshot: bug.get('hasScreenshot') || false,
|
||||
adminNotes: bug.get('adminNotes') || '',
|
||||
adminReward: bug.get('adminReward') || 0,
|
||||
createdAt: bug.get('createdAt'),
|
||||
updatedAt: bug.get('updatedAt'),
|
||||
user: {
|
||||
id: bug.get('user').id,
|
||||
username: bug.get('user').get('username'),
|
||||
email: bug.get('user').get('email')
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
bugReports,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
skip,
|
||||
hasMore: skip + limit < totalCount
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin bug reports:', error);
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Failed to fetch bug reports.');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update bug report status and manage rewards
|
||||
*/
|
||||
Parse.Cloud.define('updateBugStatus', async (request) => {
|
||||
// Check if user is admin
|
||||
const isAdmin = await isUserAdmin(request.user);
|
||||
if (!isAdmin) {
|
||||
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Admin access required.');
|
||||
}
|
||||
|
||||
const { bugId, status, adminNotes = '', adminReward = null } = request.params;
|
||||
|
||||
if (!bugId || !status) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_PARAMS, 'Bug ID and status are required.');
|
||||
}
|
||||
|
||||
// Validate status
|
||||
const validStatuses = Object.values(BugStatus);
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_PARAMS, 'Invalid status provided.');
|
||||
}
|
||||
|
||||
try {
|
||||
const BUS = Parse.Object.extend('BUS');
|
||||
const query = new Parse.Query(BUS);
|
||||
query.include('user');
|
||||
|
||||
const bugReport = await query.get(bugId, { useMasterKey: true });
|
||||
|
||||
if (!bugReport) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Bug report not found.');
|
||||
}
|
||||
|
||||
const oldStatus = bugReport.get('status');
|
||||
const user = bugReport.get('user');
|
||||
|
||||
// Update bug report
|
||||
bugReport.set('status', status);
|
||||
bugReport.set('adminNotes', adminNotes);
|
||||
bugReport.set('adminReward', adminReward || bugReport.get('potentialKredits'));
|
||||
|
||||
await bugReport.save(null, { useMasterKey: true });
|
||||
|
||||
// Handle kredit rewards if status is ACCEPTED
|
||||
let kreditsAwarded = 0;
|
||||
if (status === BugStatus.ACCEPTED && oldStatus !== BugStatus.ACCEPTED) {
|
||||
const rewardAmount = adminReward || bugReport.get('potentialKredits');
|
||||
|
||||
if (rewardAmount > 0) {
|
||||
await awardKredits(user, rewardAmount, `Bug report reward: ${bugReport.get('title')}`);
|
||||
kreditsAwarded = rewardAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// Send email notification if status changed
|
||||
if (status !== oldStatus) {
|
||||
console.log(`📧 Sending status change email: ${oldStatus} → ${status}`);
|
||||
await sendBugStatusEmail(user, bugReport, status, adminNotes, kreditsAwarded);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Bug status updated successfully',
|
||||
data: {
|
||||
bugId,
|
||||
status,
|
||||
adminReward: bugReport.get('adminReward')
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating bug status:', error);
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Failed to update bug status.');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a bug report (admin only)
|
||||
*/
|
||||
Parse.Cloud.define('deleteBugReport', async (request) => {
|
||||
// Check if user is admin
|
||||
const isAdmin = await isUserAdmin(request.user);
|
||||
if (!isAdmin) {
|
||||
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Admin access required.');
|
||||
}
|
||||
|
||||
const { bugId } = request.params;
|
||||
|
||||
if (!bugId) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_PARAMS, 'Bug ID is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const BUS = Parse.Object.extend('BUS');
|
||||
const query = new Parse.Query(BUS);
|
||||
|
||||
const bugReport = await query.get(bugId, { useMasterKey: true });
|
||||
|
||||
if (!bugReport) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Bug report not found.');
|
||||
}
|
||||
|
||||
await bugReport.destroy({ useMasterKey: true });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Bug report deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting bug report:', error);
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Failed to delete bug report.');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get bug report statistics for admin dashboard
|
||||
*/
|
||||
Parse.Cloud.define('getBugReportStats', async (request) => {
|
||||
// Check if user is admin
|
||||
const isAdmin = await isUserAdmin(request.user);
|
||||
if (!isAdmin) {
|
||||
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Admin access required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const BUS = Parse.Object.extend('BUS');
|
||||
|
||||
// Get counts by status
|
||||
const statusCounts = {};
|
||||
for (const status of Object.values(BugStatus)) {
|
||||
const query = new Parse.Query(BUS);
|
||||
query.equalTo('status', status);
|
||||
statusCounts[status] = await query.count({ useMasterKey: true });
|
||||
}
|
||||
|
||||
// Get counts by bug type
|
||||
const typeCounts = {};
|
||||
for (const [typeKey, typeInfo] of Object.entries(BugTypes)) {
|
||||
const query = new Parse.Query(BUS);
|
||||
query.equalTo('bugType', typeKey);
|
||||
typeCounts[typeKey] = {
|
||||
name: typeInfo.name,
|
||||
count: await query.count({ useMasterKey: true }),
|
||||
kredits: typeInfo.kredits
|
||||
};
|
||||
}
|
||||
|
||||
// Get recent submissions (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const recentQuery = new Parse.Query(BUS);
|
||||
recentQuery.greaterThan('createdAt', thirtyDaysAgo);
|
||||
const recentCount = await recentQuery.count({ useMasterKey: true });
|
||||
|
||||
// Get total kredits awarded - include both ACCEPTED and COMPLETED statuses
|
||||
const rewardedBugsQuery = new Parse.Query(BUS);
|
||||
rewardedBugsQuery.containedIn('status', [BugStatus.ACCEPTED, BugStatus.COMPLETED]);
|
||||
const rewardedBugs = await rewardedBugsQuery.find({ useMasterKey: true });
|
||||
|
||||
const totalKreditsAwarded = rewardedBugs.reduce((sum, bug) => {
|
||||
return sum + (bug.get('adminReward') || bug.get('potentialKredits') || 0);
|
||||
}, 0);
|
||||
|
||||
// Get top reporters
|
||||
const allBugs = await new Parse.Query(BUS).include('user').find({ useMasterKey: true });
|
||||
const userStats = {};
|
||||
|
||||
allBugs.forEach(bug => {
|
||||
const userId = bug.get('user').id;
|
||||
const username = bug.get('user').get('username');
|
||||
|
||||
if (!userStats[userId]) {
|
||||
userStats[userId] = {
|
||||
username,
|
||||
totalReports: 0,
|
||||
acceptedReports: 0,
|
||||
kreditsEarned: 0
|
||||
};
|
||||
}
|
||||
|
||||
userStats[userId].totalReports++;
|
||||
|
||||
// Count both ACCEPTED and COMPLETED statuses as rewarded reports
|
||||
if (bug.get('status') === BugStatus.ACCEPTED || bug.get('status') === BugStatus.COMPLETED) {
|
||||
userStats[userId].acceptedReports++;
|
||||
userStats[userId].kreditsEarned += bug.get('adminReward') || bug.get('potentialKredits') || 0;
|
||||
}
|
||||
});
|
||||
|
||||
const topReporters = Object.values(userStats)
|
||||
.sort((a, b) => b.kreditsEarned - a.kreditsEarned)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
statusCounts,
|
||||
typeCounts,
|
||||
recentSubmissions: recentCount,
|
||||
totalKreditsAwarded,
|
||||
topReporters,
|
||||
totalReports: allBugs.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching bug report stats:', error);
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Failed to fetch bug report statistics.');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to award kredits to a user
|
||||
*/
|
||||
async function awardKredits(user, amount, description) {
|
||||
try {
|
||||
const BANK = Parse.Object.extend('BANK');
|
||||
const query = new Parse.Query(BANK);
|
||||
query.equalTo('user', user);
|
||||
|
||||
let bankRecord = await query.first({ useMasterKey: true });
|
||||
|
||||
if (!bankRecord) {
|
||||
// Create new bank record
|
||||
bankRecord = new BANK();
|
||||
bankRecord.set('user', user);
|
||||
bankRecord.set('kredits', 0);
|
||||
bankRecord.set('transactions', []);
|
||||
}
|
||||
|
||||
const currentKredits = bankRecord.get('kredits') || 0;
|
||||
const transactions = bankRecord.get('transactions') || [];
|
||||
|
||||
// Add transaction
|
||||
transactions.push({
|
||||
type: 'credit',
|
||||
amount: amount,
|
||||
description: description,
|
||||
timestamp: new Date().toISOString(),
|
||||
balance: currentKredits + amount
|
||||
});
|
||||
|
||||
// Update bank record
|
||||
bankRecord.set('kredits', currentKredits + amount);
|
||||
bankRecord.set('transactions', transactions);
|
||||
|
||||
await bankRecord.save(null, { useMasterKey: true });
|
||||
|
||||
console.log(`Awarded ${amount} kredits to user ${user.get('username')} for: ${description}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error awarding kredits:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
// Admin role validation helper (imported from adminDashboardUtils.js pattern)
|
||||
async function validateAdminRole(user) {
|
||||
try {
|
||||
if (!user) {
|
||||
return false; // Non-blocking: return false instead of throwing
|
||||
}
|
||||
|
||||
console.log(`🔐 Validating admin role for user: ${user.id}`);
|
||||
|
||||
// First check user's role attribute
|
||||
const userRole = user.get('role');
|
||||
if (userRole === 'admin' || userRole === 'Admin' || userRole === 'Administrator') {
|
||||
console.log(`✅ Admin user authenticated via role attribute: ${user.id} (${userRole})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then check Parse Roles with master key - check multiple possible admin role names
|
||||
const adminRoleNames = ['admin', 'Admin', 'Administrator'];
|
||||
|
||||
for (const roleName of adminRoleNames) {
|
||||
const roleQuery = new Parse.Query(Parse.Role);
|
||||
roleQuery.equalTo('name', roleName);
|
||||
roleQuery.equalTo('users', user);
|
||||
const adminRole = await roleQuery.first({ useMasterKey: true });
|
||||
|
||||
if (adminRole) {
|
||||
console.log(`✅ Admin user authenticated via Parse Role: ${user.id} (${roleName})`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`❌ Access check: User ${user.id} is not an admin (CHELLYZ filtering will apply)`);
|
||||
return false;
|
||||
|
||||
} catch (roleError) {
|
||||
console.error(`❌ Error checking Admin role: ${roleError.message}`);
|
||||
return false; // Fail safely - non-admins won't see CHELLYZ
|
||||
}
|
||||
}
|
||||
|
||||
// Geographic caching utilities
|
||||
const CACHE_CONFIG = {
|
||||
REDIS_TTL: 300, // 5 minutes
|
||||
@@ -45,6 +85,19 @@ Parse.Cloud.define("getMapAssets", async (request) => {
|
||||
console.log("getMapAssets - Fetching balanced records from all collections");
|
||||
|
||||
try {
|
||||
// 🔐 SECURITY: Check admin status for CHELLYZ access control
|
||||
const isAdmin = await validateAdminRole(request.user);
|
||||
console.log(`🔐 Admin status for request: ${isAdmin ? 'ADMIN' : 'REGULAR USER'}`);
|
||||
|
||||
// 🔐 SECURITY: Filter CHELLYZ from taxonomies if user is not admin
|
||||
let filteredTaxonomies = request.params?.taxonomies || ["Dracattus", "Dracattus Spawn", "Primal Dracattus", "Diamond Dracattus", "IOSC", "CHELLYZ"];
|
||||
|
||||
if (!isAdmin && filteredTaxonomies.includes('CHELLYZ')) {
|
||||
filteredTaxonomies = filteredTaxonomies.filter(t => t !== 'CHELLYZ');
|
||||
console.log(`🔐 SECURITY: CHELLYZ removed from taxonomies for non-admin user. Filtered taxonomies:`, filteredTaxonomies);
|
||||
} else if (isAdmin) {
|
||||
console.log(`🔐 ADMIN ACCESS: CHELLYZ access granted for admin user`);
|
||||
}
|
||||
// BLACKLIST FILTERING: Fetch blacklist records for filtering
|
||||
console.log("🛡️ Loading BLACKLIST for asset filtering...");
|
||||
let blacklistRecords = [];
|
||||
@@ -105,10 +158,12 @@ Parse.Cloud.define("getMapAssets", async (request) => {
|
||||
filters = {},
|
||||
center,
|
||||
zoom = 3,
|
||||
taxonomies = ["Dracattus", "Dracattus Spawn", "Primal Dracattus", "Diamond Dracattus", "IOSC", "CHELLYZ"],
|
||||
walletAddress,
|
||||
myDracs
|
||||
} = request.params || {};
|
||||
|
||||
// Use the security-filtered taxonomies instead of the original parameter
|
||||
const taxonomies = filteredTaxonomies;
|
||||
|
||||
// Also log the destructured parameters
|
||||
console.log("getMapAssets - Working with walletAddress:", walletAddress);
|
||||
@@ -181,7 +236,7 @@ Parse.Cloud.define("getMapAssets", async (request) => {
|
||||
// Convert center to GeoPoint if provided
|
||||
const geoPoint = center ? new Parse.GeoPoint(center.latitude, center.longitude) : null;
|
||||
|
||||
// CACHING: Generate cache key for future use
|
||||
// CACHING: Generate cache key for future use (using filtered taxonomies for security)
|
||||
const cacheKey = generateCacheKey(center, zoom, taxonomies);
|
||||
console.log(`Cache key generated: ${cacheKey}`);
|
||||
|
||||
@@ -351,11 +406,11 @@ Parse.Cloud.define("getMapAssets", async (request) => {
|
||||
console.log(`Total results after adding IOSC: ${combinedResults.length}`);
|
||||
}
|
||||
|
||||
// STEP 2.5: Get CHELLYZ items based on zoom level
|
||||
// STEP 2.5: Get CHELLYZ items based on zoom level (ADMIN-ONLY ACCESS)
|
||||
if (taxonomies.includes('CHELLYZ')) {
|
||||
const chellyzConfig = FLIGHT_LEVELS['CHELLYZ'];
|
||||
if (chellyzConfig && zoom >= chellyzConfig.minZoom) {
|
||||
console.log(`CHELLYZ detected and zoom ${zoom} >= ${chellyzConfig.minZoom} - loading CHELLYZ items`);
|
||||
console.log(`🔐 CHELLYZ detected and zoom ${zoom} >= ${chellyzConfig.minZoom} - loading CHELLYZ items (ADMIN USER CONFIRMED)`);
|
||||
const chellyzQuery = new Parse.Query('CHELLYZ');
|
||||
|
||||
// Apply geo search if parameters are provided
|
||||
|
||||
@@ -1,6 +1,50 @@
|
||||
// Admin role validation helper (reused from getAllMapAssets.js)
|
||||
async function validateAdminRole(user) {
|
||||
try {
|
||||
if (!user) {
|
||||
return false; // Non-blocking: return false instead of throwing
|
||||
}
|
||||
|
||||
console.log(`🔐 Validating admin role for user: ${user.id}`);
|
||||
|
||||
// First check user's role attribute
|
||||
const userRole = user.get('role');
|
||||
if (userRole === 'admin' || userRole === 'Admin' || userRole === 'Administrator') {
|
||||
console.log(`✅ Admin user authenticated via role attribute: ${user.id} (${userRole})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then check Parse Roles with master key - check multiple possible admin role names
|
||||
const adminRoleNames = ['admin', 'Admin', 'Administrator'];
|
||||
|
||||
for (const roleName of adminRoleNames) {
|
||||
const roleQuery = new Parse.Query(Parse.Role);
|
||||
roleQuery.equalTo('name', roleName);
|
||||
roleQuery.equalTo('users', user);
|
||||
const adminRole = await roleQuery.first({ useMasterKey: true });
|
||||
|
||||
if (adminRole) {
|
||||
console.log(`✅ Admin user authenticated via Parse Role: ${user.id} (${roleName})`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`❌ Access check: User ${user.id} is not an admin (CHELLYZ filtering will apply)`);
|
||||
return false;
|
||||
|
||||
} catch (roleError) {
|
||||
console.error(`❌ Error checking Admin role: ${roleError.message}`);
|
||||
return false; // Fail safely - non-admins won't see CHELLYZ
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud function to get assets by encoded_id from multiple collections (DRACATTUS, CHELLYZ, IOSC)
|
||||
Parse.Cloud.define("getAssetsByEncodedId", async (request) => {
|
||||
try {
|
||||
// 🔐 SECURITY: Check admin status for CHELLYZ access control
|
||||
const isAdmin = await validateAdminRole(request.user);
|
||||
console.log(`🔐 Admin status for getAssetsByEncodedId request: ${isAdmin ? 'ADMIN' : 'REGULAR USER'}`);
|
||||
|
||||
const { encoded_ids } = request.params;
|
||||
|
||||
// Validate input exists
|
||||
@@ -22,8 +66,16 @@ Parse.Cloud.define("getAssetsByEncodedId", async (request) => {
|
||||
throw new Error(`Invalid encoded_ids found: ${invalidIds.join(', ')}`);
|
||||
}
|
||||
|
||||
// Search across multiple collections
|
||||
const collections = ['DRACATTUS', 'CHELLYZ', 'IOSC'];
|
||||
// 🔐 SECURITY: Filter collections based on admin status
|
||||
const baseCollections = ['DRACATTUS', 'IOSC'];
|
||||
const collections = isAdmin ? [...baseCollections, 'CHELLYZ'] : baseCollections;
|
||||
|
||||
if (!isAdmin) {
|
||||
console.log(`🔐 SECURITY: CHELLYZ collection excluded for non-admin user. Searching collections:`, collections);
|
||||
} else {
|
||||
console.log(`🔐 ADMIN ACCESS: All collections accessible including CHELLYZ. Searching:`, collections);
|
||||
}
|
||||
|
||||
const allResults = [];
|
||||
|
||||
// Common fields that most collections should have
|
||||
@@ -59,6 +111,10 @@ Parse.Cloud.define("getAssetsByEncodedId", async (request) => {
|
||||
|
||||
// Search each collection
|
||||
for (const collectionName of collections) {
|
||||
if (collectionName === 'CHELLYZ') {
|
||||
console.log(`🔐 Searching CHELLYZ collection (admin access confirmed)`);
|
||||
}
|
||||
|
||||
try {
|
||||
const query = new Parse.Query(collectionName);
|
||||
query.containedIn("encoded_id", idsArray);
|
||||
|
||||
@@ -348,26 +348,47 @@ Parse.Cloud.define("getObjectCounts", async (request) => {
|
||||
averagePerMonth: Math.round((lastMonth / 30) * 10) / 10
|
||||
};
|
||||
|
||||
// Get top fighters (most victories)
|
||||
// Get top fighters (most victories) - FIXED VERSION with pagination
|
||||
try {
|
||||
const winnerQuery = new Parse.Query("BATTLES");
|
||||
winnerQuery.exists("winner");
|
||||
winnerQuery.include("winner");
|
||||
winnerQuery.limit(1000);
|
||||
console.log('Fetching ALL battle winners with pagination...');
|
||||
|
||||
const battleResults = await winnerQuery.find({ useMasterKey: true });
|
||||
|
||||
// Count victories by Dracattus
|
||||
const victories = {};
|
||||
const fighterNames = {};
|
||||
let skip = 0;
|
||||
const batchSize = 1000;
|
||||
let hasMoreBattles = true;
|
||||
|
||||
battleResults.forEach(battle => {
|
||||
const winner = battle.get("winner");
|
||||
if (winner && winner.id) {
|
||||
victories[winner.id] = (victories[winner.id] || 0) + 1;
|
||||
fighterNames[winner.id] = winner.get("name") || winner.id;
|
||||
// Fetch ALL battles with pagination
|
||||
while (hasMoreBattles) {
|
||||
const winnerQuery = new Parse.Query("BATTLES");
|
||||
winnerQuery.exists("winner");
|
||||
winnerQuery.include("winner");
|
||||
winnerQuery.limit(batchSize);
|
||||
winnerQuery.skip(skip);
|
||||
winnerQuery.ascending("createdAt"); // Consistent ordering
|
||||
|
||||
const battleResults = await winnerQuery.find({ useMasterKey: true });
|
||||
|
||||
console.log(`Processed ${battleResults.length} battles (skip: ${skip})`);
|
||||
|
||||
// Count victories from this batch
|
||||
battleResults.forEach(battle => {
|
||||
const winner = battle.get("winner");
|
||||
if (winner && winner.id) {
|
||||
victories[winner.id] = (victories[winner.id] || 0) + 1;
|
||||
fighterNames[winner.id] = winner.get("name") || winner.id;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we need to continue
|
||||
if (battleResults.length < batchSize) {
|
||||
hasMoreBattles = false;
|
||||
} else {
|
||||
skip += batchSize;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Total unique fighters found: ${Object.keys(victories).length}`);
|
||||
|
||||
// Convert to array and sort by victories
|
||||
const topFighters = Object.entries(victories)
|
||||
@@ -379,9 +400,11 @@ Parse.Cloud.define("getObjectCounts", async (request) => {
|
||||
.sort((a, b) => b.victories - a.victories)
|
||||
.slice(0, 10); // Top 10 fighters
|
||||
|
||||
console.log('Top 3 fighters:', topFighters.slice(0, 3));
|
||||
result.classBreakdowns.BATTLES.topFighters = topFighters;
|
||||
} catch (fighterError) {
|
||||
console.error("Error processing top fighters:", fighterError.message);
|
||||
result.classBreakdowns.BATTLES.topFighters = []; // Ensure empty array on error
|
||||
}
|
||||
|
||||
// Get recent battles
|
||||
|
||||
@@ -122,6 +122,21 @@ Parse.Cloud.define('mintAsset', async (request) => {
|
||||
throw new Error('ObjectId is required');
|
||||
}
|
||||
|
||||
// Check if the user has admin role
|
||||
let isAdmin = false;
|
||||
if (request.user) {
|
||||
try {
|
||||
const userRoles = await request.user.getRoles();
|
||||
isAdmin = userRoles.some(role => role.getName() === 'admin');
|
||||
console.log(`User ${request.user.id} admin status: ${isAdmin}`);
|
||||
} catch (roleError) {
|
||||
console.warn(`Could not check user roles: ${roleError.message}`);
|
||||
isAdmin = false;
|
||||
}
|
||||
} else {
|
||||
console.log('No user context provided, treating as non-admin');
|
||||
}
|
||||
|
||||
// Get config variables and choose mainnet vs testnet based on isMainnet parameter
|
||||
const config = await Parse.Config.get({ useMasterKey: true });
|
||||
const originalRoyaltyAddress = config.get('DRAC_MINTGARDEN_ROYALY_ADDRESS');
|
||||
@@ -159,7 +174,7 @@ Parse.Cloud.define('mintAsset', async (request) => {
|
||||
throw new Error('No target address available. Please provide targetWallet or set DRAC_MINTGARDEN_ROYALY_ADDRESS in Parse Config');
|
||||
}
|
||||
|
||||
console.log(`Attempting to mint asset with objectId: ${objectId} to wallet: ${targetAddress}${requested_mojos ? ` with ${requested_mojos} mojos` : ''}`);
|
||||
console.log(`Attempting to mint asset with objectId: ${objectId} to wallet: ${targetAddress}${requested_mojos ? ` with ${requested_mojos} mojos` : ''}${isAdmin ? ' [ADMIN USER - FREE MINT]' : ''}`);
|
||||
|
||||
// Get the object to mint
|
||||
const dracattusQuery = new Parse.Query('DRACATTUS');
|
||||
@@ -369,9 +384,12 @@ Parse.Cloud.define('mintAsset', async (request) => {
|
||||
reserve_for_seconds:60
|
||||
};
|
||||
|
||||
// Add requested_mojos if provided
|
||||
if (requested_mojos) {
|
||||
// Add requested_mojos if provided and user is not an admin
|
||||
if (requested_mojos && !isAdmin) {
|
||||
mintRequestBody.requested_mojos = object.get('requested_mojos');
|
||||
console.log(`Adding requested_mojos to payload: ${object.get('requested_mojos')} (user is not admin)`);
|
||||
} else if (requested_mojos && isAdmin) {
|
||||
console.log(`Skipping requested_mojos for admin user - free mint enabled`);
|
||||
}
|
||||
|
||||
// Make the API request to MintGarden (mainnet or testnet)
|
||||
@@ -392,6 +410,7 @@ Parse.Cloud.define('mintAsset', async (request) => {
|
||||
console.log(` 🎨 NFT Target: ${targetAddress}`);
|
||||
console.log(` 👑 Royalty Address: ${finalRoyaltyAddress}`);
|
||||
console.log(` 💎 Royalty Percentage: ${royaltyPercentage}%`);
|
||||
console.log(` 👤 User Type: ${isAdmin ? 'ADMIN (Free Mint)' : 'Regular User'}`);
|
||||
if (splitRoyaltyInfo) {
|
||||
console.log(` 🔀 Split Type: Magic-Based Split (${object.get('magic')}/100 magic)`);
|
||||
console.log(` 🆔 Split ID: ${splitRoyaltyInfo.splitId}`);
|
||||
@@ -468,7 +487,9 @@ Parse.Cloud.define('mintAsset', async (request) => {
|
||||
magicValue: object.className === 'DRACATTUS' ? (object.get('magic') || 0) : null,
|
||||
profileId: mintgardenProfileId,
|
||||
requested_mojos: requested_mojos ? parseInt(requested_mojos, 10) : null,
|
||||
network: useMainnet ? 'mainnet' : 'testnet' // Track which network was used
|
||||
network: useMainnet ? 'mainnet' : 'testnet', // Track which network was used
|
||||
isAdminMint: isAdmin, // Track if this was an admin mint
|
||||
actualMojosCharged: (!isAdmin && requested_mojos) ? parseInt(requested_mojos, 10) : 0 // Track actual mojos charged
|
||||
});
|
||||
|
||||
await mintRecord.save(null, { useMasterKey: true });
|
||||
|
||||
@@ -13,30 +13,33 @@ const log = {
|
||||
|
||||
// Admin role validation helper
|
||||
async function validateAdminRole(user) {
|
||||
if (!user) {
|
||||
log.warning('No user provided, checking if this is a master key request...');
|
||||
// For admin functions, we'll allow execution if no user is provided but master key is used
|
||||
// This handles cases where the user context might not be passed correctly
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if user has admin role attribute
|
||||
if (!user) {
|
||||
throw new Error('User authentication required');
|
||||
}
|
||||
|
||||
log.info(`Validating admin role for user: ${user.id}`);
|
||||
|
||||
// First check user's role attribute
|
||||
const userRole = user.get('role');
|
||||
if (userRole === 'admin') {
|
||||
log.success(`Admin user authenticated via role attribute: ${user.id}`);
|
||||
if (userRole === 'admin' || userRole === 'Admin' || userRole === 'Administrator') {
|
||||
log.success(`Admin user authenticated via role attribute: ${user.id} (${userRole})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then check Parse Roles with master key
|
||||
const roleQuery = new Parse.Query(Parse.Role);
|
||||
roleQuery.equalTo('name', 'admin');
|
||||
roleQuery.equalTo('users', user);
|
||||
const adminRole = await roleQuery.first({ useMasterKey: true });
|
||||
// Then check Parse Roles with master key - check multiple possible admin role names
|
||||
const adminRoleNames = ['admin', 'Admin', 'Administrator'];
|
||||
|
||||
if (adminRole) {
|
||||
log.success(`Admin user authenticated via Parse Role: ${user.id}`);
|
||||
return true;
|
||||
for (const roleName of adminRoleNames) {
|
||||
const roleQuery = new Parse.Query(Parse.Role);
|
||||
roleQuery.equalTo('name', roleName);
|
||||
roleQuery.equalTo('users', user);
|
||||
const adminRole = await roleQuery.first({ useMasterKey: true });
|
||||
|
||||
if (adminRole) {
|
||||
log.success(`Admin user authenticated via Parse Role: ${user.id} (${roleName})`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
log.error(`Access denied: User ${user.id} is not an admin`);
|
||||
@@ -49,7 +52,7 @@ async function validateAdminRole(user) {
|
||||
log.warning(`Role check failed but user exists, allowing access for: ${user.id}`);
|
||||
return true;
|
||||
}
|
||||
throw new Error('Error verifying admin permissions');
|
||||
throw roleError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,3 +996,8 @@ Parse.Cloud.define('banUsers', async (request) => {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Export the admin validation function for use by other modules
|
||||
module.exports = {
|
||||
validateAdminRole
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Provides admin functionality to manage blacklisted users and assets
|
||||
*/
|
||||
|
||||
// Import admin validation utility
|
||||
const { validateAdminRole } = require('./adminDashboardUtils');
|
||||
|
||||
// Logging utility
|
||||
const log = {
|
||||
info: (msg) => console.log('ℹ️ ', msg),
|
||||
@@ -20,8 +23,8 @@ Parse.Cloud.define('getBlacklistAnalytics', async (request) => {
|
||||
log.info('Fetching blacklist analytics...');
|
||||
|
||||
try {
|
||||
// TODO: Add admin role validation
|
||||
// await validateAdminRole(request.user);
|
||||
// Validate admin role
|
||||
await validateAdminRole(request.user);
|
||||
|
||||
const [blacklistRecords, dracattusRecords] = await Promise.all([
|
||||
// Get all blacklist entries
|
||||
@@ -205,8 +208,8 @@ Parse.Cloud.define('addToBlacklist', async (request) => {
|
||||
log.info('Adding entry to blacklist...');
|
||||
|
||||
try {
|
||||
// TODO: Add admin role validation
|
||||
// await validateAdminRole(request.user);
|
||||
// Validate admin role
|
||||
await validateAdminRole(request.user);
|
||||
|
||||
const {
|
||||
encoded_id,
|
||||
@@ -288,8 +291,8 @@ Parse.Cloud.define('removeFromBlacklist', async (request) => {
|
||||
log.info('Removing entry from blacklist...');
|
||||
|
||||
try {
|
||||
// TODO: Add admin role validation
|
||||
// await validateAdminRole(request.user);
|
||||
// Validate admin role
|
||||
await validateAdminRole(request.user);
|
||||
|
||||
const { blacklistId, encoded_id } = request.params;
|
||||
|
||||
@@ -353,8 +356,8 @@ Parse.Cloud.define('bulkBlacklistOperation', async (request) => {
|
||||
log.info('Performing bulk blacklist operation...');
|
||||
|
||||
try {
|
||||
// TODO: Add admin role validation
|
||||
// await validateAdminRole(request.user);
|
||||
// Validate admin role
|
||||
await validateAdminRole(request.user);
|
||||
|
||||
const {
|
||||
operation, // 'add' or 'remove'
|
||||
@@ -552,8 +555,8 @@ Parse.Cloud.define('cleanupBlacklist', async (request) => {
|
||||
log.info('Performing blacklist cleanup...');
|
||||
|
||||
try {
|
||||
// TODO: Add admin role validation
|
||||
// await validateAdminRole(request.user);
|
||||
// Validate admin role
|
||||
await validateAdminRole(request.user);
|
||||
|
||||
const {
|
||||
removeDuplicates = true,
|
||||
|
||||
306
cloud/dracattus/emails/bug_status_email.js
Normal file
306
cloud/dracattus/emails/bug_status_email.js
Normal file
@@ -0,0 +1,306 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Bug Status Email Service
|
||||
* Handles sending branded email notifications for bug report status changes
|
||||
*/
|
||||
|
||||
// Bug Types mapping for display names
|
||||
const BugTypes = {
|
||||
TYPO: { name: 'Typo/Grammar', kredits: 5 },
|
||||
UI_ISSUE: { name: 'UI Issue', kredits: 10 },
|
||||
MINOR_BUG: { name: 'Minor Bug', kredits: 25 },
|
||||
MAJOR_BUG: { name: 'Major Bug', kredits: 50 },
|
||||
SECURITY: { name: 'Security Issue', kredits: 100 },
|
||||
CRITICAL: { name: 'Critical Issue', kredits: 200 }
|
||||
};
|
||||
|
||||
// Status configurations with messaging
|
||||
const StatusConfigs = {
|
||||
submitted: {
|
||||
class: 'submitted',
|
||||
display: 'Submitted',
|
||||
title: 'Thank You for Your Submission!',
|
||||
message: 'Your bug report has been successfully submitted and is now in our review queue. Our team will evaluate it and provide updates as we progress.',
|
||||
nextSteps: 'Your report is being reviewed by our development team. We\'ll keep you updated on the progress and let you know if we need any additional information.'
|
||||
},
|
||||
under_review: {
|
||||
class: 'under_review',
|
||||
display: 'Under Review',
|
||||
title: 'Your Report is Under Review',
|
||||
message: 'Our development team is actively reviewing your bug report. We\'re examining the details and working to understand the issue you\'ve identified.',
|
||||
nextSteps: 'Our team is investigating your report. We may reach out if we need additional details or clarification to better understand the issue.'
|
||||
},
|
||||
accepted: {
|
||||
class: 'accepted',
|
||||
display: 'Accepted',
|
||||
title: 'Congratulations! Your Report Has Been Accepted',
|
||||
message: 'Excellent work! Your bug report has been validated and accepted by our team. This issue will be addressed in an upcoming update.',
|
||||
nextSteps: 'Your report has been added to our development backlog. Keep an eye on our updates for when this issue gets resolved!'
|
||||
},
|
||||
completed: {
|
||||
class: 'completed',
|
||||
display: 'Completed',
|
||||
title: 'Issue Resolved - Thank You!',
|
||||
message: 'Fantastic! The issue you reported has been fixed and deployed. Your contribution has helped make Dracattus better for all adventurers.',
|
||||
nextSteps: 'The fix is now live! You can continue exploring the mystical world with this issue resolved. Thank you for making Dracattus better!'
|
||||
},
|
||||
rejected: {
|
||||
class: 'rejected',
|
||||
display: 'Not Accepted',
|
||||
title: 'Update on Your Bug Report',
|
||||
message: 'After careful review, we\'ve determined this report doesn\'t meet our current criteria for bug fixes. This might be due to expected behavior, duplicate reports, or other factors.',
|
||||
nextSteps: 'Don\'t be discouraged! If you believe this was reviewed in error, feel free to submit additional details or report other issues you encounter.'
|
||||
},
|
||||
in_progress: {
|
||||
class: 'in_progress',
|
||||
display: 'In Progress',
|
||||
title: 'Development in Progress',
|
||||
message: 'Great news! Our development team has started working on fixing the issue you reported. The bug is actively being addressed.',
|
||||
nextSteps: 'Our developers are working on a fix. We\'ll notify you once the issue has been resolved and deployed.'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a nodemailer transport using Gmail
|
||||
* @returns {Object} Configured nodemailer transport
|
||||
*/
|
||||
function createTransport() {
|
||||
return nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.GMAIL_ADDRESS,
|
||||
pass: process.env.GMAIL_PASSWORD
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user's current kredits balance
|
||||
* @param {Parse.User} user - The user object
|
||||
* @returns {Promise<number>} Current kredits balance
|
||||
*/
|
||||
async function getUserKreditsBalance(user) {
|
||||
try {
|
||||
const BANK = Parse.Object.extend('BANK');
|
||||
const query = new Parse.Query(BANK);
|
||||
query.equalTo('user', user);
|
||||
|
||||
const bankRecord = await query.first({ useMasterKey: true });
|
||||
return bankRecord ? (bankRecord.get('kredits') || 0) : 0;
|
||||
} catch (error) {
|
||||
console.error('Error getting user kredits balance:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces template variables in email content
|
||||
* @param {string} template - The template content
|
||||
* @param {Object} variables - Variables to replace
|
||||
* @returns {string} Processed template
|
||||
*/
|
||||
function replaceTemplateVariables(template, variables) {
|
||||
let processed = template;
|
||||
|
||||
// Replace simple variables
|
||||
Object.keys(variables).forEach(key => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
processed = processed.replace(regex, variables[key] || '');
|
||||
});
|
||||
|
||||
// Handle conditional blocks for kredits
|
||||
if (variables.KREDITS_AWARDED && variables.KREDITS_AWARDED > 0) {
|
||||
// Show kredits section
|
||||
processed = processed.replace(/{{#if KREDITS_AWARDED}}([\s\S]*?){{\/if}}/g, '$1');
|
||||
} else {
|
||||
// Remove kredits section
|
||||
processed = processed.replace(/{{#if KREDITS_AWARDED}}[\s\S]*?{{\/if}}/g, '');
|
||||
}
|
||||
|
||||
// Handle conditional blocks for admin notes
|
||||
if (variables.ADMIN_NOTES && variables.ADMIN_NOTES.trim()) {
|
||||
// Show admin notes section
|
||||
processed = processed.replace(/{{#if ADMIN_NOTES}}([\s\S]*?){{\/if}}/g, '$1');
|
||||
} else {
|
||||
// Remove admin notes section
|
||||
processed = processed.replace(/{{#if ADMIN_NOTES}}[\s\S]*?{{\/if}}/g, '');
|
||||
}
|
||||
|
||||
// Handle conditional blocks for potential kredits
|
||||
if (variables.POTENTIAL_KREDITS && variables.POTENTIAL_KREDITS > 0) {
|
||||
// Show potential kredits section
|
||||
processed = processed.replace(/{{#if POTENTIAL_KREDITS}}([\s\S]*?){{\/if}}/g, '$1');
|
||||
} else {
|
||||
// Remove potential kredits section
|
||||
processed = processed.replace(/{{#if POTENTIAL_KREDITS}}[\s\S]*?{{\/if}}/g, '');
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a bug status update email to a user
|
||||
* @param {Parse.User} user - The user who submitted the bug report
|
||||
* @param {Object} bugReport - The bug report object
|
||||
* @param {string} newStatus - The new status
|
||||
* @param {string} adminNotes - Optional admin notes
|
||||
* @param {number} kreditsAwarded - Kredits awarded (if any)
|
||||
* @returns {Promise<Object>} Result of the email sending operation
|
||||
*/
|
||||
async function sendBugStatusUpdateEmail(user, bugReport, newStatus, adminNotes = '', kreditsAwarded = 0) {
|
||||
try {
|
||||
console.log(`Sending bug status update email to ${user.get('email')} for bug ${bugReport.id}...`);
|
||||
|
||||
// Create transport
|
||||
const transporter = createTransport();
|
||||
|
||||
// Get status configuration
|
||||
const statusConfig = StatusConfigs[newStatus] || StatusConfigs.submitted;
|
||||
|
||||
// Get user's current kredits balance
|
||||
const totalKredits = await getUserKreditsBalance(user);
|
||||
|
||||
// Get bug type display name
|
||||
const bugType = bugReport.get('bugType');
|
||||
const bugTypeDisplay = BugTypes[bugType] ? BugTypes[bugType].name : bugType;
|
||||
|
||||
// Prepare template variables
|
||||
const templateVars = {
|
||||
USER_NAME: user.get('username') || 'Adventurer',
|
||||
STATUS_CLASS: statusConfig.class,
|
||||
STATUS_DISPLAY: statusConfig.display,
|
||||
STATUS_TITLE: statusConfig.title,
|
||||
STATUS_MESSAGE: statusConfig.message,
|
||||
NEXT_STEPS_MESSAGE: statusConfig.nextSteps,
|
||||
BUG_TITLE: bugReport.get('title') || 'Bug Report',
|
||||
BUG_ID: bugReport.id,
|
||||
BUG_TYPE: bugTypeDisplay,
|
||||
SUBMITTED_DATE: bugReport.get('createdAt') ? bugReport.get('createdAt').toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}) : 'Unknown',
|
||||
POTENTIAL_KREDITS: bugReport.get('potentialKredits') || 0,
|
||||
KREDITS_AWARDED: kreditsAwarded,
|
||||
TOTAL_KREDITS: totalKredits,
|
||||
ADMIN_NOTES: adminNotes
|
||||
};
|
||||
|
||||
// Read and process HTML template
|
||||
const htmlTemplatePath = path.join(__dirname, '../../../templates/emails/html/bug_status_update.html');
|
||||
let htmlTemplate = await fs.readFile(htmlTemplatePath, 'utf8');
|
||||
htmlTemplate = replaceTemplateVariables(htmlTemplate, templateVars);
|
||||
|
||||
// Read and process text template
|
||||
const textTemplatePath = path.join(__dirname, '../../../templates/emails/txt/bug_status_update.txt');
|
||||
let textTemplate = await fs.readFile(textTemplatePath, 'utf8');
|
||||
textTemplate = replaceTemplateVariables(textTemplate, templateVars);
|
||||
|
||||
// Prepare email subject based on status
|
||||
let subject = 'Bug Report Update - Dracattus';
|
||||
if (kreditsAwarded > 0) {
|
||||
subject = `🎉 Kredits Awarded! Bug Report ${statusConfig.display} - Dracattus`;
|
||||
} else {
|
||||
subject = `Bug Report ${statusConfig.display} - Dracattus`;
|
||||
}
|
||||
|
||||
// Configure email options
|
||||
const mailOptions = {
|
||||
from: 'no-reply@dracattus.com',
|
||||
to: user.get('email'),
|
||||
subject: subject,
|
||||
text: textTemplate,
|
||||
html: htmlTemplate
|
||||
};
|
||||
|
||||
// Send the email
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log(`Bug status email sent to ${user.get('email')}:`, info.messageId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
recipient: user.get('email'),
|
||||
status: newStatus,
|
||||
kreditsAwarded: kreditsAwarded
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error sending bug status email:`, error);
|
||||
throw new Error(`Failed to send bug status email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email parameters
|
||||
* @param {Parse.User} user - User object
|
||||
* @param {Object} bugReport - Bug report object
|
||||
* @param {string} status - Status string
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function validateEmailParams(user, bugReport, status) {
|
||||
if (!user || !user.get('email')) {
|
||||
console.error('Invalid user or missing email');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!bugReport || !bugReport.id) {
|
||||
console.error('Invalid bug report');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!status || !StatusConfigs[status]) {
|
||||
console.error('Invalid status:', status);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main export function for sending bug status emails
|
||||
* @param {Parse.User} user - The user who submitted the bug report
|
||||
* @param {Object} bugReport - The bug report object
|
||||
* @param {string} newStatus - The new status
|
||||
* @param {string} adminNotes - Optional admin notes
|
||||
* @param {number} kreditsAwarded - Kredits awarded (if any)
|
||||
* @returns {Promise<Object>} Result of the email sending operation
|
||||
*/
|
||||
async function sendBugStatusEmail(user, bugReport, newStatus, adminNotes = '', kreditsAwarded = 0) {
|
||||
try {
|
||||
// Validate parameters
|
||||
if (!validateEmailParams(user, bugReport, newStatus)) {
|
||||
throw new Error('Invalid email parameters');
|
||||
}
|
||||
|
||||
// Send the email
|
||||
const result = await sendBugStatusUpdateEmail(user, bugReport, newStatus, adminNotes, kreditsAwarded);
|
||||
|
||||
console.log(`✅ Bug status email sent successfully:`, {
|
||||
userId: user.id,
|
||||
email: user.get('email'),
|
||||
bugId: bugReport.id,
|
||||
status: newStatus,
|
||||
kreditsAwarded: kreditsAwarded
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in sendBugStatusEmail:', error);
|
||||
// Don't throw error to avoid interrupting the main bug status update process
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendBugStatusEmail,
|
||||
StatusConfigs,
|
||||
BugTypes
|
||||
};
|
||||
418
templates/emails/html/bug_status_update.html
Normal file
418
templates/emails/html/bug_status_update.html
Normal file
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0;">
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
<title>Bug Report Status Update - Dracattus</title>
|
||||
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body { margin: 0; padding: 0; min-width: 100%; width: 100% !important; height: 100% !important;}
|
||||
body, table, td, div, p, a { -webkit-font-smoothing: antialiased; text-size-adjust: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; line-height: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-collapse: collapse !important; border-spacing: 0; }
|
||||
img { border: 0; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
|
||||
#outlook a { padding: 0; }
|
||||
.ReadMsgBody { width: 100%; } .ExternalClass { width: 100%; }
|
||||
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
|
||||
|
||||
/* Mobile responsive */
|
||||
@media all and (max-width: 600px) {
|
||||
.wrapper { width: 100% !important; max-width: 600px !important; }
|
||||
.full-width { width: 100% !important; }
|
||||
.hero-title { font-size: 28px !important; line-height: 1.2 !important; }
|
||||
.hero-subtitle { font-size: 16px !important; }
|
||||
.status-card { width: 100% !important; margin: 10px 0 !important; }
|
||||
.cta-button { width: 100% !important; padding: 16px 24px !important; }
|
||||
.details-table { width: 100% !important; }
|
||||
.details-table td { display: block !important; width: 100% !important; padding: 8px 0 !important; border: none !important; }
|
||||
.label-cell { font-weight: bold !important; margin-bottom: 4px !important; }
|
||||
}
|
||||
|
||||
/* Dracattus Color Scheme */
|
||||
:root {
|
||||
--primary-color: #8d1b87;
|
||||
--secondary-color: #127DB3;
|
||||
--accent-color: #FF6B35;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--dark-bg: #151125;
|
||||
--light-text: #FFFFFF;
|
||||
--gray-text: #666666;
|
||||
--card-bg: #ffffff;
|
||||
}
|
||||
|
||||
/* Status-specific colors */
|
||||
.status-submitted { background: linear-gradient(135deg, #6c757d 0%, #495057 100%); color: white; }
|
||||
.status-under_review { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); color: white; }
|
||||
.status-accepted { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); color: white; }
|
||||
.status-completed { background: linear-gradient(135deg, #127DB3 0%, #8d1b87 100%); color: white; }
|
||||
.status-rejected { background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); color: white; }
|
||||
.status-in_progress { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); color: white; }
|
||||
|
||||
/* Main container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #f8f9fa;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* Header with mystical background */
|
||||
.email-header {
|
||||
background: linear-gradient(135deg, #151125 0%, #2d1b3d 50%, #151125 100%);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(141, 27, 135, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(18, 125, 179, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 80%, rgba(255, 107, 53, 0.2) 0%, transparent 50%);
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.email-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dracattus-logo {
|
||||
max-width: 180px;
|
||||
height: auto;
|
||||
margin-bottom: 20px;
|
||||
filter: brightness(1.1) contrast(1.1);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 12px 0;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
color: #cccccc;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
border-radius: 25px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin: 20px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Content sections */
|
||||
.email-content {
|
||||
background: #ffffff;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #151125;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
border-bottom: 3px solid #8d1b87;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
/* Bug details card */
|
||||
.bug-details-card {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.details-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.details-table td:first-child {
|
||||
font-weight: 700;
|
||||
color: #495057;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.details-table td:last-child {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* Kredits reward section */
|
||||
.kredits-reward {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
box-shadow: 0 4px 20px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.kredits-amount {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.kredits-label {
|
||||
font-size: 18px;
|
||||
margin: 8px 0 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.kredits-total {
|
||||
font-size: 14px;
|
||||
margin: 12px 0 0 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.cta-button {
|
||||
background: linear-gradient(135deg, #8d1b87 0%, #127DB3 100%);
|
||||
color: #FFFFFF !important;
|
||||
text-decoration: none !important;
|
||||
padding: 16px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
margin: 16px 8px;
|
||||
box-shadow: 0 4px 15px rgba(141, 27, 135, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: transparent;
|
||||
color: #127DB3 !important;
|
||||
border: 2px solid #127DB3;
|
||||
text-decoration: none !important;
|
||||
padding: 14px 28px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
margin: 16px 8px;
|
||||
}
|
||||
|
||||
/* Admin notes section */
|
||||
.admin-notes {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #127DB3;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.admin-notes h4 {
|
||||
color: #127DB3;
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-notes p {
|
||||
color: #495057;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.email-footer {
|
||||
background: #151125;
|
||||
color: #cccccc;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #8d1b87;
|
||||
text-decoration: none;
|
||||
margin: 0 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Divider decorations */
|
||||
.mystical-divider {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mystical-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, #8d1b87 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.mystical-divider-symbol {
|
||||
background: #ffffff;
|
||||
color: #8d1b87;
|
||||
padding: 0 20px;
|
||||
font-size: 20px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header Section -->
|
||||
<div class="email-header">
|
||||
<img src="https://dracattus.com/assets/logos/logo2V4.png" alt="Dracattus" class="dracattus-logo">
|
||||
<h1 class="hero-title">Bug Report Update</h1>
|
||||
<p class="hero-subtitle">Your submission status has been updated</p>
|
||||
<div class="status-indicator status-{{STATUS_CLASS}}">{{STATUS_DISPLAY}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="email-content">
|
||||
<!-- Status Update Section -->
|
||||
<div class="content-section">
|
||||
<h2 class="section-title">{{STATUS_TITLE}}</h2>
|
||||
<p class="section-text">{{STATUS_MESSAGE}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Kredits Reward Section (shown only when kredits awarded) -->
|
||||
{{#if KREDITS_AWARDED}}
|
||||
<div class="kredits-reward">
|
||||
<h3 class="kredits-amount">+{{KREDITS_AWARDED}}</h3>
|
||||
<p class="kredits-label">Kredits Awarded!</p>
|
||||
<p class="kredits-total">Your new balance: {{TOTAL_KREDITS}} kredits</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Bug Details Card -->
|
||||
<div class="bug-details-card">
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td class="label-cell">Bug Report:</td>
|
||||
<td>{{BUG_TITLE}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Submitted:</td>
|
||||
<td>{{SUBMITTED_DATE}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Report ID:</td>
|
||||
<td>{{BUG_ID}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Bug Type:</td>
|
||||
<td>{{BUG_TYPE}}</td>
|
||||
</tr>
|
||||
{{#if POTENTIAL_KREDITS}}
|
||||
<tr>
|
||||
<td class="label-cell">Potential Reward:</td>
|
||||
<td>{{POTENTIAL_KREDITS}} kredits</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Admin Notes Section (shown when notes exist) -->
|
||||
{{#if ADMIN_NOTES}}
|
||||
<div class="admin-notes">
|
||||
<h4>📝 Admin Notes</h4>
|
||||
<p>{{ADMIN_NOTES}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Mystical Divider -->
|
||||
<div class="mystical-divider">
|
||||
<span class="mystical-divider-symbol">⚡</span>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps Section -->
|
||||
<div class="content-section">
|
||||
<h2 class="section-title">What's Next?</h2>
|
||||
<p class="section-text">{{NEXT_STEPS_MESSAGE}}</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://dracattus.com/bug-report" class="secondary-button">Submit Another Report</a>
|
||||
<a href="https://dracattus.com/profile" class="cta-button">View Your Profile</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<div class="email-footer">
|
||||
<img src="https://dracattus.com/assets/logos/logo2V4.png" alt="Dracattus" style="max-width: 120px; opacity: 0.7; margin-bottom: 20px;">
|
||||
|
||||
<div class="footer-links">
|
||||
<a href="https://dracattus.com/">Home</a>
|
||||
<a href="https://dracattus.com/map">Explore Map</a>
|
||||
<a href="https://dracattus.com/field-guide">Field Guide</a>
|
||||
<a href="https://discord.gg/Byekhy6tf9">Discord</a>
|
||||
</div>
|
||||
|
||||
<p class="footer-text">
|
||||
Thank you for helping us improve the mystical world of Dracattus!<br>
|
||||
Your contributions make our realm a better place for all adventurers.
|
||||
</p>
|
||||
|
||||
<p class="footer-text">
|
||||
© 2024 Koba42. All rights reserved.<br>
|
||||
<a href="https://dracattus.com/unsubscribe" style="color: #666;">Unsubscribe</a> |
|
||||
<a href="https://dracattus.com/privacy" style="color: #666;">Privacy Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
68
templates/emails/txt/bug_status_update.txt
Normal file
68
templates/emails/txt/bug_status_update.txt
Normal file
@@ -0,0 +1,68 @@
|
||||
================================
|
||||
DRACATTUS BUG REPORT UPDATE
|
||||
================================
|
||||
|
||||
Hello {{USER_NAME}},
|
||||
|
||||
Your bug report status has been updated to: {{STATUS_DISPLAY}}
|
||||
|
||||
================================
|
||||
{{STATUS_TITLE}}
|
||||
================================
|
||||
|
||||
{{STATUS_MESSAGE}}
|
||||
|
||||
{{#if KREDITS_AWARDED}}
|
||||
🎉 KREDITS AWARDED! 🎉
|
||||
You've been awarded {{KREDITS_AWARDED}} kredits!
|
||||
Your new balance: {{TOTAL_KREDITS}} kredits
|
||||
|
||||
{{/if}}
|
||||
================================
|
||||
Bug Report Details
|
||||
================================
|
||||
|
||||
Bug Report: {{BUG_TITLE}}
|
||||
Submitted: {{SUBMITTED_DATE}}
|
||||
Report ID: {{BUG_ID}}
|
||||
Bug Type: {{BUG_TYPE}}
|
||||
{{#if POTENTIAL_KREDITS}}
|
||||
Potential Reward: {{POTENTIAL_KREDITS}} kredits
|
||||
{{/if}}
|
||||
|
||||
{{#if ADMIN_NOTES}}
|
||||
================================
|
||||
Admin Notes
|
||||
================================
|
||||
|
||||
{{ADMIN_NOTES}}
|
||||
|
||||
{{/if}}
|
||||
================================
|
||||
What's Next?
|
||||
================================
|
||||
|
||||
{{NEXT_STEPS_MESSAGE}}
|
||||
|
||||
Quick Links:
|
||||
- Submit Another Report: https://dracattus.com/bug-report
|
||||
- View Your Profile: https://dracattus.com/profile
|
||||
- Explore the Map: https://dracattus.com/map
|
||||
- Field Guide: https://dracattus.com/field-guide
|
||||
|
||||
================================
|
||||
Connect With Us
|
||||
================================
|
||||
|
||||
🌐 Website: https://dracattus.com
|
||||
💬 Discord: https://discord.gg/Byekhy6tf9
|
||||
🐦 Twitter: https://twitter.com/dracattus
|
||||
📸 Instagram: https://www.instagram.com/dracattus_official
|
||||
|
||||
Thank you for helping us improve the mystical world of Dracattus!
|
||||
Your contributions make our realm a better place for all adventurers.
|
||||
|
||||
---
|
||||
© 2024 Koba42. All rights reserved.
|
||||
Unsubscribe: https://dracattus.com/unsubscribe
|
||||
Privacy Policy: https://dracattus.com/privacy
|
||||
Reference in New Issue
Block a user