// ENVIRONMENT require('dotenv').config(); // DATABASE const { PrismaClient: CMSClient } = require("../../prisma/clients/cms"); const prisma = new CMSClient(); // SERVICES const { sendNotification } = require("../services/firebase.services.js"); const logger = require("../services/logger.services"); const { localTime } = require("../services/time.services.js"); const fileServices = require("../services/file.services.js"); const { default: prefixes } = require("../static/prefix.js"); // CONSTANTS const { badRequestResponse, successResponse, notFoundResponse } = require("../res/responses.js"); // HELPER FUNCTIONS const deleteCampaignImage = async (imageUrl) => { try { if (!imageUrl) return; const urlParts = imageUrl.split('/'); const fileName = urlParts[urlParts.length - 1]; await fileServices.delete(prefixes.bucketName, "notifications", fileName); logger.info(`Deleted campaign image: ${fileName}`); } catch (err) { logger.error(`Error deleting campaign image: ${err}`); } }; // CONTROLLER exports.sendNotification = async (req, res) => { try { const { userID, title, body, data = {} } = req.body; const imageFile = req.files?.find(file => file.fieldname === 'image'); let imageUrl = null; if (imageFile) { const [uploadedUrl] = await fileServices.upload( prefixes.bucketName, "notifications", imageFile.mimetype, [imageFile] ); imageUrl = uploadedUrl; } const userToken = await prisma.usersToken.findFirst({ where: { UserID_UT: userID } }); let parsedData = data; if (typeof data === 'string') { try { parsedData = JSON.parse(data); } catch (e) { parsedData = {}; } } await sendNotification(userToken.Token_UT, title, body, parsedData, imageUrl); return successResponse(res, "Notification sent successfully!", null); } catch (err) { return badRequestResponse(res, "Error sending notification", err); } } exports.setupCampaign = async (req, res) => { try { const { title, content, date, data = null } = req.body; const imageFile = req.files?.find(file => file.fieldname === 'image'); let imageUrl = null; let fileName = null; if (imageFile) { const [uploadedUrl, uploadedFileName] = await fileServices.upload( prefixes.bucketName, "notifications", imageFile.mimetype, [imageFile] ); imageUrl = uploadedUrl; fileName = uploadedFileName; } let parsedData = data; if (typeof data === 'string') { try { parsedData = JSON.parse(data); } catch (e) { parsedData = null; } } await prisma.appCampaign.create({ data: { Title_ACP: title, Content_ACP: content, Date_ACP: date, ImageUrl_ACP: imageUrl, Data_ACP: parsedData ? JSON.stringify(parsedData) : null, CreatedAt_ACP: localTime(new Date()), UpdatedAt_ACP: localTime(new Date()), } }); return successResponse(res, "Campaign setup successfully!", null); } catch (err) { console.log(err); return badRequestResponse(res, "Error setting up campaign", err); } } exports.getAllCampaigns = async (req, res) => { try { const { status } = req.query; const where = {}; if (status) { const statuses = Array.isArray(status) ? status : String(status) .split(",") .map((s) => s.trim()) .filter(Boolean); where.Status_ACP = statuses.length > 1 ? { in: statuses } : statuses[0]; } const campaigns = await prisma.appCampaign.findMany({ where, orderBy: { Date_ACP: "desc", }, }); return successResponse(res, "Campaigns retrieved successfully!", campaigns); } catch (err) { return badRequestResponse(res, "Error retrieving campaigns", err); } } exports.updateCampaign = async (req, res) => { try { const { id } = req.params; const { title, content, date, status, data } = req.body; const imageFile = req.files?.find(file => file.fieldname === 'image'); const exists = await prisma.appCampaign.findFirst({ where: { UUID_ACP: id } }); if (!exists) { return notFoundResponse(res, "Campaign tidak ditemukan", null); } const dataToUpdate = {}; if (title !== undefined) dataToUpdate.Title_ACP = title; if (content !== undefined) dataToUpdate.Content_ACP = content; if (date !== undefined) dataToUpdate.Date_ACP = date; if (status !== undefined) dataToUpdate.Status_ACP = status; if (imageFile) { if (exists.ImageUrl_ACP) { await deleteCampaignImage(exists.ImageUrl_ACP); } const [uploadedUrl] = await fileServices.upload( prefixes.bucketName, "notifications", imageFile.mimetype, [imageFile] ); dataToUpdate.ImageUrl_ACP = uploadedUrl; } if (data !== undefined) { let parsedData = data; if (typeof data === 'string') { try { parsedData = JSON.parse(data); } catch (e) { parsedData = null; } } dataToUpdate.Data_ACP = parsedData ? JSON.stringify(parsedData) : null; } dataToUpdate.UpdatedAt_ACP = localTime(new Date()); if (Object.keys(dataToUpdate).length === 1) { return badRequestResponse(res, "Tidak ada field yang diupdate", null); } const updated = await prisma.appCampaign.update({ where: { UUID_ACP: id }, data: dataToUpdate }); logger.info(`Campaign ${id} diupdate.`); return successResponse(res, "Campaign berhasil diupdate!", updated); } catch (err) { return badRequestResponse(res, "Terjadi kesalahan saat update campaign", err); } } exports.deleteCampaign = async (req, res) => { try { const { id } = req.params; const campaign = await prisma.appCampaign.findFirst({ where: { UUID_ACP: id } }); if (!campaign) { return notFoundResponse(res, "Campaign not found", null); } if (campaign.ImageUrl_ACP) { await deleteCampaignImage(campaign.ImageUrl_ACP); } await prisma.appCampaign.update({ where: { UUID_ACP: id }, data: { Status_ACP: "cancelled", UpdatedAt_ACP: localTime(new Date()) } }); return successResponse(res, "Campaign cancelled successfully!", null); } catch (err) { return badRequestResponse(res, "Error cancelling campaign", err); } } exports.sendCampaign = async (campaignID) => { try { const campaign = await prisma.appCampaign.findFirst({ where: { UUID_ACP: campaignID, Status_ACP: "pending" } }); if (!campaign) { logger.info(`No pending campaign found for id ${campaignID}`); return null; } const { Title_ACP, Content_ACP, ImageUrl_ACP, Data_ACP } = campaign; let customData = { campaignId: campaignID }; if (Data_ACP) { try { const parsedData = JSON.parse(Data_ACP); customData = { ...customData, ...parsedData }; } catch (e) { logger.error(`Failed to parse Data_ACP for campaign ${campaignID}: ${e}`); } } const result = await require("../services/firebase.services").sendCampaignWithTracking( campaignID, Title_ACP, Content_ACP, customData, ImageUrl_ACP ); if (result.success) { await prisma.appCampaign.update({ where: { UUID_ACP: campaignID }, data: { Status_ACP: "completed", TargetUsers_ACP: result.targetUsers, SentCount_ACP: result.targetUsers, SuccessCount_ACP: result.successCount, FailureCount_ACP: result.failureCount, DeliveryRate_ACP: parseFloat(result.deliveryRate), SentAt_ACP: result.sentAt, CompletedAt_ACP: localTime(new Date()), UpdatedAt_ACP: localTime(new Date()) } }); logger.info(`Campaign ${campaignID} sent successfully! Delivered: ${result.successCount}/${result.targetUsers}`); } else { throw new Error(result.message || "Failed to send campaign"); } } catch (err) { const failedCampaign = await prisma.appCampaign.findFirst({ where: { UUID_ACP: campaignID } }); await prisma.appCampaign.update({ where: { UUID_ACP: campaignID }, data: { Status_ACP: "failed", ErrorMessage_ACP: err.message || err.toString() } }); if (failedCampaign?.ImageUrl_ACP) { await deleteCampaignImage(failedCampaign.ImageUrl_ACP); } logger.error(`Error sending campaign ${campaignID}: ${err}`); } } exports.checkCampaign = async (datetime) => { try { const target = new Date(datetime); const startOfMinute = new Date(target); startOfMinute.setSeconds(0, 0); const endOfMinute = new Date(startOfMinute); endOfMinute.setMinutes(endOfMinute.getMinutes() + 1); const campaign = await prisma.appCampaign.findFirst({ where: { Date_ACP: { gte: startOfMinute, lt: endOfMinute } } }); return campaign ? campaign.UUID_ACP : null; } catch (err) { return null; } } exports.getCampaignAnalytics = async (req, res) => { try { const now = new Date(); const last30Days = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const last7Days = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const [ totalCampaigns, statusDistribution, allCampaigns, recentCampaigns, upcomingCampaigns, totalDeliveries, deliveryStatusDistribution ] = await Promise.all([ prisma.appCampaign.count(), prisma.appCampaign.groupBy({ by: ['Status_ACP'], _count: { Status_ACP: true } }), prisma.appCampaign.findMany({ select: { UUID_ACP: true, Title_ACP: true, Status_ACP: true, Date_ACP: true, CreatedAt_ACP: true, UpdatedAt_ACP: true, TargetUsers_ACP: true, SentCount_ACP: true, SuccessCount_ACP: true, FailureCount_ACP: true, DeliveryRate_ACP: true }, orderBy: { Date_ACP: 'desc' } }), prisma.appCampaign.findMany({ where: { Date_ACP: { gte: last30Days, lte: now } }, select: { Date_ACP: true, Status_ACP: true, Title_ACP: true, SuccessCount_ACP: true, FailureCount_ACP: true, TargetUsers_ACP: true }, orderBy: { Date_ACP: 'asc' } }), prisma.appCampaign.findMany({ where: { Date_ACP: { gte: now }, Status_ACP: 'pending' }, select: { UUID_ACP: true, Title_ACP: true, Date_ACP: true }, orderBy: { Date_ACP: 'asc' }, take: 10 }), prisma.campaignDelivery.count(), prisma.campaignDelivery.groupBy({ by: ['Status_CD'], _count: { Status_CD: true } }) ]); const statusStats = statusDistribution.reduce((acc, item) => { acc[item.Status_ACP] = item._count.Status_ACP; return acc; }, {}); const completedCount = statusStats.completed || 0; const failedCount = statusStats.failed || 0; const totalExecuted = completedCount + failedCount; const successRate = totalExecuted > 0 ? ((completedCount / totalExecuted) * 100).toFixed(2) : 0; const campaignTimeline = {}; recentCampaigns.forEach(campaign => { const date = new Date(campaign.Date_ACP).toISOString().split('T')[0]; if (!campaignTimeline[date]) { campaignTimeline[date] = { total: 0, completed: 0, failed: 0, pending: 0, cancelled: 0 }; } campaignTimeline[date].total++; campaignTimeline[date][campaign.Status_ACP]++; }); const creationTrend = {}; allCampaigns.forEach(campaign => { const date = new Date(campaign.CreatedAt_ACP).toISOString().split('T')[0]; if (!creationTrend[date]) { creationTrend[date] = 0; } creationTrend[date]++; }); const executedCampaigns = allCampaigns.filter(c => c.Status_ACP === 'completed' || c.Status_ACP === 'failed' ); let avgResponseTime = 0; if (executedCampaigns.length > 0) { const totalTime = executedCampaigns.reduce((sum, campaign) => { const responseTime = new Date(campaign.UpdatedAt_ACP) - new Date(campaign.Date_ACP); return sum + responseTime; }, 0); avgResponseTime = Math.round(totalTime / executedCampaigns.length / 1000 / 60); // in minutes } const statusOverTime = {}; const last7DaysCampaigns = allCampaigns.filter(c => new Date(c.Date_ACP) >= last7Days ); last7DaysCampaigns.forEach(campaign => { const date = new Date(campaign.Date_ACP).toISOString().split('T')[0]; if (!statusOverTime[date]) { statusOverTime[date] = { completed: 0, failed: 0, pending: 0, cancelled: 0 }; } statusOverTime[date][campaign.Status_ACP]++; }); const totalTargetUsers = allCampaigns.reduce((sum, c) => sum + (c.TargetUsers_ACP || 0), 0); const totalSent = allCampaigns.reduce((sum, c) => sum + (c.SentCount_ACP || 0), 0); const totalSuccess = allCampaigns.reduce((sum, c) => sum + (c.SuccessCount_ACP || 0), 0); const totalFailure = allCampaigns.reduce((sum, c) => sum + (c.FailureCount_ACP || 0), 0); const overallDeliveryRate = totalSent > 0 ? ((totalSuccess / totalSent) * 100).toFixed(2) : 0; const deliveryStats = deliveryStatusDistribution.reduce((acc, item) => { acc[item.Status_CD] = item._count.Status_CD; return acc; }, {}); const deliveryRateTrend = {}; recentCampaigns.forEach(campaign => { if (campaign.TargetUsers_ACP > 0) { const date = new Date(campaign.Date_ACP).toISOString().split('T')[0]; const rate = campaign.SuccessCount_ACP && campaign.TargetUsers_ACP ? ((campaign.SuccessCount_ACP / campaign.TargetUsers_ACP) * 100).toFixed(2) : 0; deliveryRateTrend[date] = rate; } }); const campaignPerformance = allCampaigns .filter(c => c.Status_ACP === 'completed' && c.TargetUsers_ACP > 0) .map(c => ({ id: c.UUID_ACP, title: c.Title_ACP, targetUsers: c.TargetUsers_ACP, successCount: c.SuccessCount_ACP, failureCount: c.FailureCount_ACP, deliveryRate: c.DeliveryRate_ACP, date: c.Date_ACP })) .sort((a, b) => b.deliveryRate - a.deliveryRate) .slice(0, 10); return successResponse(res, "Campaign analytics retrieved successfully!", { summary: { campaigns: { total: totalCampaigns, completed: statusStats.completed || 0, failed: statusStats.failed || 0, pending: statusStats.pending || 0, cancelled: statusStats.cancelled || 0, successRate: `${successRate}%`, avgResponseTime: `${avgResponseTime} minutes`, upcomingCount: upcomingCampaigns.length }, delivery: { totalTargetUsers: totalTargetUsers, totalSent: totalSent, totalDelivered: totalSuccess, totalFailed: totalFailure, overallDeliveryRate: `${overallDeliveryRate}%`, totalDeliveryRecords: totalDeliveries } }, charts: { statusDistribution: { labels: Object.keys(statusStats), data: Object.values(statusStats) }, deliveryStatusDistribution: { labels: Object.keys(deliveryStats), data: Object.values(deliveryStats) }, campaignTimeline: campaignTimeline, creationTrend: { labels: Object.keys(creationTrend).sort(), data: Object.keys(creationTrend).sort().map(date => creationTrend[date]) }, statusOverTime: statusOverTime, deliveryRateTrend: { labels: Object.keys(deliveryRateTrend).sort(), data: Object.keys(deliveryRateTrend).sort().map(date => parseFloat(deliveryRateTrend[date])) } }, topPerforming: campaignPerformance, upcoming: upcomingCampaigns, recentActivity: recentCampaigns.slice(0, 10) }); } catch (err) { return badRequestResponse(res, "Error retrieving campaign analytics", err); } } exports.getCampaignReport = async (req, res) => { try { const { id } = req.params; const [campaign, deliveryRecords, deliveryStatusBreakdown] = await Promise.all([ prisma.appCampaign.findFirst({ where: { UUID_ACP: id } }), prisma.campaignDelivery.findMany({ where: { Campaign_CD: id }, select: { UUID_CD: true, UserID_CD: true, Status_CD: true, SentAt_CD: true, DeliveredAt_CD: true, FailedAt_CD: true, ErrorMessage_CD: true, CreatedAt_CD: true }, orderBy: { CreatedAt_CD: 'desc' } }), prisma.campaignDelivery.groupBy({ by: ['Status_CD'], where: { Campaign_CD: id }, _count: { Status_CD: true } }) ]); if (!campaign) { return notFoundResponse(res, "Campaign not found", null); } const createdAt = new Date(campaign.CreatedAt_ACP); const scheduledAt = new Date(campaign.Date_ACP); const updatedAt = new Date(campaign.UpdatedAt_ACP); const now = new Date(); const leadTime = Math.round((scheduledAt - createdAt) / 1000 / 60 / 60); // hours const executionTime = campaign.Status_ACP !== 'pending' ? Math.round((updatedAt - scheduledAt) / 1000 / 60) // minutes : null; const isScheduled = scheduledAt > now; const isOverdue = scheduledAt < now && campaign.Status_ACP === 'pending'; const timeUntilExecution = isScheduled ? Math.round((scheduledAt - now) / 1000 / 60 / 60) // hours : null; const deliveryStats = deliveryStatusBreakdown.reduce((acc, item) => { acc[item.Status_CD] = item._count.Status_CD; return acc; }, {}); const failedDeliveries = deliveryRecords.filter(d => d.Status_CD === 'failed'); const errorBreakdown = {}; failedDeliveries.forEach(delivery => { const errorKey = delivery.ErrorMessage_CD || 'Unknown error'; if (!errorBreakdown[errorKey]) { errorBreakdown[errorKey] = 0; } errorBreakdown[errorKey]++; }); const deliveredRecords = deliveryRecords.filter(d => d.DeliveredAt_CD); let avgDeliveryTime = 0; if (deliveredRecords.length > 0 && campaign.SentAt_ACP) { const sentTime = new Date(campaign.SentAt_ACP); const totalDeliveryTime = deliveredRecords.reduce((sum, record) => { const deliveryTime = new Date(record.DeliveredAt_CD) - sentTime; return sum + deliveryTime; }, 0); avgDeliveryTime = Math.round(totalDeliveryTime / deliveredRecords.length / 1000); // in seconds } return successResponse(res, "Campaign report retrieved successfully!", { campaign: { id: campaign.UUID_ACP, title: campaign.Title_ACP, content: campaign.Content_ACP, status: campaign.Status_ACP, scheduledDate: campaign.Date_ACP, createdAt: campaign.CreatedAt_ACP, updatedAt: campaign.UpdatedAt_ACP, errorMessage: campaign.ErrorMessage_ACP }, metrics: { leadTime: `${leadTime} hours`, executionTime: executionTime ? `${executionTime} minutes` : 'Not executed yet', isScheduled: isScheduled, isOverdue: isOverdue, timeUntilExecution: timeUntilExecution ? `${timeUntilExecution} hours` : null, status: campaign.Status_ACP, avgDeliveryTime: avgDeliveryTime ? `${avgDeliveryTime} seconds` : 'N/A' }, delivery: { targetUsers: campaign.TargetUsers_ACP || 0, sentCount: campaign.SentCount_ACP || 0, successCount: campaign.SuccessCount_ACP || 0, failureCount: campaign.FailureCount_ACP || 0, deliveryRate: campaign.DeliveryRate_ACP ? `${campaign.DeliveryRate_ACP}%` : '0%', sentAt: campaign.SentAt_ACP, completedAt: campaign.CompletedAt_ACP, statusBreakdown: { pending: deliveryStats.pending || 0, sent: deliveryStats.sent || 0, delivered: deliveryStats.delivered || 0, failed: deliveryStats.failed || 0 }, errorBreakdown: errorBreakdown }, timeline: { created: createdAt.toISOString(), scheduled: scheduledAt.toISOString(), sent: campaign.SentAt_ACP ? new Date(campaign.SentAt_ACP).toISOString() : null, completed: campaign.CompletedAt_ACP ? new Date(campaign.CompletedAt_ACP).toISOString() : null, executed: campaign.Status_ACP !== 'pending' ? updatedAt.toISOString() : null }, deliveryRecords: { total: deliveryRecords.length, records: deliveryRecords.slice(0, 100) } }); } catch (err) { return badRequestResponse(res, "Error retrieving campaign report", err); } }