csa-backend-test/app/controllers/campaign.controller.js

733 lines
25 KiB
JavaScript

// 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);
}
}