370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.responsesService = void 0;
|
|
const crypto_1 = require("crypto");
|
|
const client_1 = require("@prisma/client");
|
|
const database_1 = require("../../../config/database");
|
|
const error_handler_1 = require("../../../middleware/error-handler");
|
|
const email_service_1 = require("../../../services/email.service");
|
|
const env_1 = require("../../../config/env");
|
|
const metrics_1 = require("../../../utils/metrics");
|
|
const VERIFICATION_EXPIRY_DAYS = 30;
|
|
exports.responsesService = {
|
|
// --- Public ---
|
|
async submitResponse(slug, data, senderIp) {
|
|
const campaign = await database_1.prisma.campaign.findUnique({
|
|
where: { slug },
|
|
select: { id: true, slug: true, title: true, status: true, showResponseWall: true },
|
|
});
|
|
if (!campaign) {
|
|
throw new error_handler_1.AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
|
}
|
|
if (campaign.status !== client_1.CampaignStatus.ACTIVE) {
|
|
throw new error_handler_1.AppError(400, 'Campaign is not active', 'CAMPAIGN_NOT_ACTIVE');
|
|
}
|
|
if (!campaign.showResponseWall) {
|
|
throw new error_handler_1.AppError(400, 'Response wall is not enabled for this campaign', 'RESPONSE_WALL_DISABLED');
|
|
}
|
|
let verificationToken = null;
|
|
let verificationSentAt = null;
|
|
if (data.sendVerification && data.representativeEmail) {
|
|
verificationToken = (0, crypto_1.randomBytes)(32).toString('hex');
|
|
verificationSentAt = new Date();
|
|
}
|
|
const response = await database_1.prisma.representativeResponse.create({
|
|
data: {
|
|
campaignId: campaign.id,
|
|
campaignSlug: campaign.slug,
|
|
representativeName: data.representativeName,
|
|
representativeTitle: data.representativeTitle,
|
|
representativeLevel: data.representativeLevel,
|
|
representativeEmail: data.representativeEmail,
|
|
responseType: data.responseType,
|
|
responseText: data.responseText,
|
|
userComment: data.userComment,
|
|
submittedByName: data.submittedByName,
|
|
submittedByEmail: data.submittedByEmail,
|
|
isAnonymous: data.isAnonymous,
|
|
status: client_1.ResponseStatus.PENDING,
|
|
verificationToken,
|
|
verificationSentAt,
|
|
submittedIp: senderIp,
|
|
},
|
|
});
|
|
if (verificationToken && data.representativeEmail) {
|
|
const baseUrl = env_1.env.API_URL;
|
|
await email_service_1.emailService.sendResponseVerification({
|
|
recipientEmail: data.representativeEmail,
|
|
campaignTitle: campaign.title,
|
|
responseType: data.responseType,
|
|
responseText: data.responseText,
|
|
submitterName: data.isAnonymous ? 'Anonymous' : (data.submittedByName || 'Anonymous'),
|
|
verificationUrl: `${baseUrl}/api/responses/${response.id}/verify/${verificationToken}`,
|
|
reportUrl: `${baseUrl}/api/responses/${response.id}/report/${verificationToken}`,
|
|
});
|
|
}
|
|
(0, metrics_1.recordResponseSubmission)();
|
|
return {
|
|
id: response.id,
|
|
status: response.status,
|
|
verificationSent: !!verificationToken,
|
|
};
|
|
},
|
|
async listApproved(slug, filters) {
|
|
const { page, limit, sort, level } = filters;
|
|
const skip = (page - 1) * limit;
|
|
const where = {
|
|
campaignSlug: slug,
|
|
status: client_1.ResponseStatus.APPROVED,
|
|
};
|
|
if (level)
|
|
where.representativeLevel = level;
|
|
let orderBy;
|
|
switch (sort) {
|
|
case 'upvotes':
|
|
orderBy = { upvoteCount: 'desc' };
|
|
break;
|
|
case 'verified':
|
|
orderBy = { isVerified: 'desc' };
|
|
break;
|
|
default:
|
|
orderBy = { createdAt: 'desc' };
|
|
}
|
|
const [responses, total] = await Promise.all([
|
|
database_1.prisma.representativeResponse.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
orderBy,
|
|
select: {
|
|
id: true,
|
|
representativeName: true,
|
|
representativeTitle: true,
|
|
representativeLevel: true,
|
|
responseType: true,
|
|
responseText: true,
|
|
userComment: true,
|
|
submittedByName: true,
|
|
isAnonymous: true,
|
|
isVerified: true,
|
|
verifiedAt: true,
|
|
upvoteCount: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
database_1.prisma.representativeResponse.count({ where }),
|
|
]);
|
|
return {
|
|
responses,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
},
|
|
async getStats(slug) {
|
|
const where = { campaignSlug: slug, status: client_1.ResponseStatus.APPROVED };
|
|
const [total, verified, upvoteSum, byLevel] = await Promise.all([
|
|
database_1.prisma.representativeResponse.count({ where }),
|
|
database_1.prisma.representativeResponse.count({ where: { ...where, isVerified: true } }),
|
|
database_1.prisma.representativeResponse.aggregate({ where, _sum: { upvoteCount: true } }),
|
|
database_1.prisma.representativeResponse.groupBy({
|
|
by: ['representativeLevel'],
|
|
where,
|
|
_count: true,
|
|
}),
|
|
]);
|
|
const levelBreakdown = {};
|
|
for (const row of byLevel) {
|
|
levelBreakdown[row.representativeLevel] = row._count;
|
|
}
|
|
return {
|
|
total,
|
|
verified,
|
|
totalUpvotes: upvoteSum._sum.upvoteCount || 0,
|
|
byLevel: levelBreakdown,
|
|
};
|
|
},
|
|
async upvote(responseId, userIp, userId) {
|
|
// Verify response exists and is approved
|
|
const response = await database_1.prisma.representativeResponse.findUnique({
|
|
where: { id: responseId },
|
|
select: { id: true, status: true },
|
|
});
|
|
if (!response)
|
|
throw new error_handler_1.AppError(404, 'Response not found', 'RESPONSE_NOT_FOUND');
|
|
if (response.status !== client_1.ResponseStatus.APPROVED)
|
|
throw new error_handler_1.AppError(400, 'Response is not approved', 'RESPONSE_NOT_APPROVED');
|
|
try {
|
|
await database_1.prisma.responseUpvote.create({
|
|
data: {
|
|
responseId,
|
|
userId: userId || null,
|
|
upvotedIp: !userId ? (userIp || null) : null,
|
|
},
|
|
});
|
|
await database_1.prisma.representativeResponse.update({
|
|
where: { id: responseId },
|
|
data: { upvoteCount: { increment: 1 } },
|
|
});
|
|
return { success: true };
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'P2002') {
|
|
return { success: false, alreadyUpvoted: true };
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
async removeUpvote(responseId, userIp, userId) {
|
|
const where = { responseId };
|
|
if (userId) {
|
|
where.userId = userId;
|
|
}
|
|
else if (userIp) {
|
|
where.upvotedIp = userIp;
|
|
}
|
|
else {
|
|
return { success: false };
|
|
}
|
|
const deleted = await database_1.prisma.responseUpvote.deleteMany({ where });
|
|
if (deleted.count > 0) {
|
|
await database_1.prisma.representativeResponse.update({
|
|
where: { id: responseId },
|
|
data: {
|
|
upvoteCount: { decrement: 1 },
|
|
},
|
|
});
|
|
}
|
|
return { success: deleted.count > 0 };
|
|
},
|
|
async verify(responseId, token) {
|
|
const response = await database_1.prisma.representativeResponse.findUnique({
|
|
where: { id: responseId },
|
|
select: {
|
|
id: true,
|
|
verificationToken: true,
|
|
verificationSentAt: true,
|
|
representativeEmail: true,
|
|
campaign: { select: { title: true } },
|
|
},
|
|
});
|
|
if (!response || response.verificationToken !== token) {
|
|
return { success: false, reason: 'Invalid verification link' };
|
|
}
|
|
// Check 30-day expiry
|
|
if (response.verificationSentAt) {
|
|
const daysSinceSent = (Date.now() - response.verificationSentAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
if (daysSinceSent > VERIFICATION_EXPIRY_DAYS) {
|
|
return { success: false, reason: 'Verification link has expired' };
|
|
}
|
|
}
|
|
await database_1.prisma.representativeResponse.update({
|
|
where: { id: responseId },
|
|
data: {
|
|
isVerified: true,
|
|
verifiedAt: new Date(),
|
|
verifiedBy: response.representativeEmail || 'Representative',
|
|
status: client_1.ResponseStatus.APPROVED,
|
|
},
|
|
});
|
|
return { success: true, campaignTitle: response.campaign.title };
|
|
},
|
|
async report(responseId, token) {
|
|
const response = await database_1.prisma.representativeResponse.findUnique({
|
|
where: { id: responseId },
|
|
select: {
|
|
id: true,
|
|
verificationToken: true,
|
|
representativeEmail: true,
|
|
campaign: { select: { title: true } },
|
|
},
|
|
});
|
|
if (!response || response.verificationToken !== token) {
|
|
return { success: false, reason: 'Invalid verification link' };
|
|
}
|
|
await database_1.prisma.representativeResponse.update({
|
|
where: { id: responseId },
|
|
data: {
|
|
status: client_1.ResponseStatus.REJECTED,
|
|
isVerified: false,
|
|
verifiedBy: `Disputed by ${response.representativeEmail || 'representative'}`,
|
|
},
|
|
});
|
|
return { success: true, campaignTitle: response.campaign.title };
|
|
},
|
|
// --- Admin ---
|
|
async findAll(filters) {
|
|
const { page, limit, status, campaignId, search } = filters;
|
|
const skip = (page - 1) * limit;
|
|
const where = {};
|
|
if (status)
|
|
where.status = status;
|
|
if (campaignId)
|
|
where.campaignId = campaignId;
|
|
if (search) {
|
|
where.OR = [
|
|
{ representativeName: { contains: search, mode: 'insensitive' } },
|
|
{ responseText: { contains: search, mode: 'insensitive' } },
|
|
{ submittedByName: { contains: search, mode: 'insensitive' } },
|
|
];
|
|
}
|
|
const [responses, total] = await Promise.all([
|
|
database_1.prisma.representativeResponse.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
orderBy: { createdAt: 'desc' },
|
|
select: {
|
|
id: true,
|
|
representativeName: true,
|
|
representativeTitle: true,
|
|
representativeLevel: true,
|
|
representativeEmail: true,
|
|
responseType: true,
|
|
responseText: true,
|
|
userComment: true,
|
|
submittedByName: true,
|
|
submittedByEmail: true,
|
|
isAnonymous: true,
|
|
status: true,
|
|
isVerified: true,
|
|
verifiedAt: true,
|
|
verifiedBy: true,
|
|
upvoteCount: true,
|
|
createdAt: true,
|
|
campaign: { select: { id: true, title: true, slug: true } },
|
|
},
|
|
}),
|
|
database_1.prisma.representativeResponse.count({ where }),
|
|
]);
|
|
return {
|
|
responses,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
},
|
|
async updateStatus(id, data) {
|
|
const existing = await database_1.prisma.representativeResponse.findUnique({ where: { id } });
|
|
if (!existing)
|
|
throw new error_handler_1.AppError(404, 'Response not found', 'RESPONSE_NOT_FOUND');
|
|
return database_1.prisma.representativeResponse.update({
|
|
where: { id },
|
|
data: { status: data.status },
|
|
});
|
|
},
|
|
async deleteResponse(id) {
|
|
const existing = await database_1.prisma.representativeResponse.findUnique({ where: { id } });
|
|
if (!existing)
|
|
throw new error_handler_1.AppError(404, 'Response not found', 'RESPONSE_NOT_FOUND');
|
|
await database_1.prisma.representativeResponse.delete({ where: { id } });
|
|
},
|
|
async resendVerification(id) {
|
|
const response = await database_1.prisma.representativeResponse.findUnique({
|
|
where: { id },
|
|
select: {
|
|
id: true,
|
|
representativeEmail: true,
|
|
representativeName: true,
|
|
responseType: true,
|
|
responseText: true,
|
|
submittedByName: true,
|
|
isAnonymous: true,
|
|
verificationToken: true,
|
|
campaign: { select: { title: true } },
|
|
},
|
|
});
|
|
if (!response)
|
|
throw new error_handler_1.AppError(404, 'Response not found', 'RESPONSE_NOT_FOUND');
|
|
if (!response.representativeEmail) {
|
|
throw new error_handler_1.AppError(400, 'No representative email on record', 'NO_REPRESENTATIVE_EMAIL');
|
|
}
|
|
// Regenerate token
|
|
const verificationToken = response.verificationToken || (0, crypto_1.randomBytes)(32).toString('hex');
|
|
await database_1.prisma.representativeResponse.update({
|
|
where: { id },
|
|
data: {
|
|
verificationToken,
|
|
verificationSentAt: new Date(),
|
|
},
|
|
});
|
|
const baseUrl = env_1.env.API_URL;
|
|
await email_service_1.emailService.sendResponseVerification({
|
|
recipientEmail: response.representativeEmail,
|
|
campaignTitle: response.campaign.title,
|
|
responseType: response.responseType,
|
|
responseText: response.responseText,
|
|
submitterName: response.isAnonymous ? 'Anonymous' : (response.submittedByName || 'Anonymous'),
|
|
verificationUrl: `${baseUrl}/api/responses/${id}/verify/${verificationToken}`,
|
|
reportUrl: `${baseUrl}/api/responses/${id}/report/${verificationToken}`,
|
|
});
|
|
return { success: true };
|
|
},
|
|
};
|
|
//# sourceMappingURL=responses.service.js.map
|