MicProtocol / apps /api /dist /network /network.processor.js
Benaah's picture
Upload folder using huggingface_hub (part 2)
9f29339 verified
Raw History Blame Contribute Delete
25 kB
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var NetworkProcessor_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.NetworkQueueEvents = exports.NetworkProcessor = void 0;
const bullmq_1 = require("@nestjs/bullmq");
const common_1 = require("@nestjs/common");
const bullmq_2 = require("bullmq");
const bullmq_3 = require("@nestjs/bullmq");
const bullmq_4 = require("bullmq");
const shared_types_1 = require("@micprotocol/shared-types");
const prisma_service_1 = require("../prisma/prisma.service");
const reputation_service_1 = require("./reputation.service");
const sms_service_1 = require("./sms.service");
const graph_service_1 = require("./graph.service");
const meilisearch_service_1 = require("./meilisearch.service");
const escape_html_1 = require("../common/escape-html");
let NetworkProcessor = NetworkProcessor_1 = class NetworkProcessor extends bullmq_1.WorkerHost {
prisma;
reputationEngine;
smsService;
graphService;
meilisearch;
emailQueue;
logger = new common_1.Logger(NetworkProcessor_1.name);
constructor(prisma, reputationEngine, smsService, graphService, meilisearch, emailQueue) {
super();
this.prisma = prisma;
this.reputationEngine = reputationEngine;
this.smsService = smsService;
this.graphService = graphService;
this.meilisearch = meilisearch;
this.emailQueue = emailQueue;
}
async process(job) {
this.logger.log(`Processing network job ${job.id}: ${job.name}`);
switch (job.name) {
case "booking-request.notify":
return this.handleBookingRequestNotify(job.data);
case "booking-request.response-notify":
return this.handleBookingResponseNotify(job.data);
case "booking-request.accepted":
return this.handleBookingAccepted(job.data);
case "reputation.recalculate":
return this.handleReputationRecalculate(job.data);
case "graph.ingest-profile":
return this.handleGraphIngest(job.data);
case "digest.weekly":
return this.handleWeeklyDigest(job.data);
case "search.reindex":
return this.handleSearchReindex();
case "search.index-profile":
return this.handleSearchIndexProfile(job.data);
case "search.remove-profile":
return this.handleSearchRemoveProfile(job.data);
default:
this.logger.warn(`Unknown job type: ${job.name}`);
return { success: false, error: "Unknown job type" };
}
}
extractFirstProposedDate(proposedDates) {
if (!Array.isArray(proposedDates)) {
return null;
}
for (const candidate of proposedDates) {
if (typeof candidate !== "string") {
continue;
}
const parsedDate = new Date(candidate);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate;
}
}
return null;
}
async handleBookingRequestNotify(data) {
this.logger.log(`Sending booking request notification to ${data.guestEmail}`);
const channels = [];
try {
const profile = await this.prisma.guestPublicProfile.findFirst({
where: { user: { email: data.guestEmail } },
select: { vanityUrl: true },
});
const actionUrl = profile
? `${process.env.APP_URL || "https://micprotocol.io"}/network/booking/${data.requestId}`
: process.env.APP_URL || "https://micprotocol.io";
if (data.guestWhatsapp || data.guestPhone) {
const phone = data.guestWhatsapp || data.guestPhone;
const lang = this.smsService.detectLanguage(phone);
const result = await this.smsService.sendBookingRequest(phone, {
podcastName: data.podcastTitle,
message: data.message,
actionUrl,
}, lang);
if (result.success) {
channels.push(result.channel);
}
}
try {
const safePodcastTitle = (0, escape_html_1.escapeHtml)(data.podcastTitle);
const safeMessage = (0, escape_html_1.escapeHtml)(data.message);
const safeActionUrl = encodeURI(actionUrl);
await this.emailQueue.add("send-email", {
to: data.guestEmail,
subject: `Booking request from ${data.podcastTitle}`,
bodyHtml: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #1a1a2e;">You have a booking request!</h2>
<p style="color: #4a4a6a; line-height: 1.6;">
<strong>${safePodcastTitle}</strong> would like to invite you as a guest.
</p>
<blockquote style="border-left: 3px solid #FF6B35; padding-left: 16px; margin: 20px 0; color: #4a4a6a; font-style: italic;">
${safeMessage}
</blockquote>
<div style="text-align: center; margin: 30px 0;">
<a href="${safeActionUrl}"
style="background: #FF6B35; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">
View Request
</a>
</div>
<p style="color: #888; font-size: 12px;">
This request was sent via the MicProtocol Verified Guest Network.
</p>
</div>
`,
bodyText: `Booking request from ${data.podcastTitle}: ${data.message}\n\nView: ${actionUrl}`,
});
channels.push("email");
}
catch (emailError) {
this.logger.warn(`Failed to queue booking email: ${emailError instanceof Error ? emailError.message : String(emailError)}`);
}
this.logger.log(`Notification sent via: ${channels.join(", ")}`);
return { success: true, channels };
}
catch (error) {
this.logger.error(`Failed to send booking notification: ${error}`);
throw error;
}
}
async handleBookingResponseNotify(data) {
this.logger.log(`Sending booking response notification for ${data.requestId}`);
try {
const request = await this.prisma.networkBookingRequest.findUnique({
where: { id: data.requestId },
include: {
toProfile: {
select: {
headline: true,
phoneNumber: true,
whatsappNumber: true,
},
},
fromPodcast: {
select: {
title: true,
workspace: {
select: {
members: {
where: { role: "owner" },
select: {
user: {
select: { email: true },
},
},
},
},
},
},
},
},
});
if (!request) {
throw new Error("Booking request not found");
}
const req = request;
const hostEmails = req.fromPodcast?.workspace?.members
?.map((m) => m.user.email)
.filter(Boolean) || [];
const guestName = req.toProfile?.headline?.split("|")[0]?.trim() || "Guest";
const podcastTitle = req.fromPodcast?.title || "the podcast";
const statusText = data.status === "accepted" ? "accepted" : "declined";
const actionUrl = `${process.env.APP_URL || "https://micprotocol.io"}/network/requests/${data.requestId}`;
if (hostEmails.length > 0) {
const safeGuestName = (0, escape_html_1.escapeHtml)(guestName);
const safePodcastTitle = (0, escape_html_1.escapeHtml)(podcastTitle);
const safeActionUrl = encodeURI(actionUrl);
const headerColor = data.status === "accepted" ? "#00B894" : "#E17055";
const headerText = data.status === "accepted" ? "Booking Accepted" : "Booking Declined";
const bodyPara = data.status === "accepted"
? `<p style="color: #4a4a6a;">The episode and booking have been automatically created in your pipeline. Check your dashboard for details.</p>`
: `<p style="color: #4a4a6a;">You can look for other guests in the Verified Guest Network.</p>`;
try {
await this.emailQueue.add("send-email", {
to: hostEmails[0],
subject: `Guest ${statusText}: ${guestName} — ${podcastTitle}`,
bodyHtml: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: ${headerColor};">
${headerText}
</h2>
<p style="color: #4a4a6a; line-height: 1.6;">
<strong>${safeGuestName}</strong> has ${statusText} your booking request for
<strong>${safePodcastTitle}</strong>.
</p>
${bodyPara}
<div style="text-align: center; margin: 30px 0;">
<a href="${safeActionUrl}"
style="background: #FF6B35; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">
View Details
</a>
</div>
</div>
`,
bodyText: `Guest ${statusText}: ${guestName} for ${podcastTitle}.\n\nView: ${actionUrl}`,
});
for (const email of hostEmails.slice(1)) {
await this.emailQueue.add("send-email", {
to: email,
subject: `Guest ${statusText}: ${guestName} — ${podcastTitle}`,
bodyHtml: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<p style="color: #4a4a6a;">
<strong>${safeGuestName}</strong> has ${statusText} the booking request for
<strong>${safePodcastTitle}</strong>.
</p>
<p><a href="${safeActionUrl}">View Details</a></p>
</div>
`,
bodyText: `Guest ${statusText}: ${guestName}. View: ${actionUrl}`,
});
}
}
catch (emailError) {
this.logger.warn(`Failed to send response email: ${emailError instanceof Error ? emailError.message : String(emailError)}`);
}
}
const hostPhone = req.toProfile?.whatsappNumber || req.toProfile?.phoneNumber;
if (hostPhone) {
try {
const lang = this.smsService.detectLanguage(hostPhone);
await this.smsService.sendBookingRequest(hostPhone, {
podcastName: podcastTitle,
message: `Your booking request was ${statusText} by ${guestName}.`,
actionUrl,
}, lang);
}
catch (smsError) {
this.logger.warn(`Failed to send response SMS: ${smsError instanceof Error ? smsError.message : String(smsError)}`);
}
}
return { success: true };
}
catch (error) {
this.logger.error(`Failed to send response notification: ${error}`);
throw error;
}
}
async handleBookingAccepted(data) {
this.logger.log(`Creating pipeline entry for accepted booking ${data.requestId}`);
try {
const [request, profile] = await Promise.all([
this.prisma.networkBookingRequest.findUnique({
where: { id: data.requestId },
include: {
fromPodcast: {
select: { title: true },
},
},
}),
this.prisma.guestPublicProfile.findUnique({
where: { id: data.profileId },
include: { user: { select: { email: true } } },
}),
]);
if (!request || !profile) {
throw new Error("Request or profile not found");
}
const fullName = profile.headline?.split("|")[0]?.trim() || profile.user.email.split("@")[0] || "Network Guest";
const company = profile.headline?.split("|")[1]?.trim() || null;
const existingGuest = await this.prisma.guest.findUnique({
where: {
podcastId_email: {
podcastId: data.podcastId,
email: profile.user.email,
},
},
});
const guest = existingGuest
? await this.prisma.guest.update({
where: { id: existingGuest.id },
data: {
fullName,
company,
bio: profile.fullBio,
socialLinks: profile.socialProof || {},
status: "scheduled",
lastContactAt: new Date(),
},
})
: await this.prisma.guest.create({
data: {
podcastId: data.podcastId,
email: profile.user.email,
fullName,
company,
title: "",
bio: profile.fullBio,
socialLinks: profile.socialProof || {},
status: "scheduled",
lastContactAt: new Date(),
},
});
if (!existingGuest || existingGuest.status !== "scheduled") {
await this.prisma.guestJourneyEvent.create({
data: {
guestId: guest.id,
eventType: shared_types_1.GuestJourneyEventType.SCHEDULED,
title: "Guest marked as scheduled",
details: "Guest lifecycle moved to scheduled via accepted network booking.",
metadata: {
source: "network.processor.handleBookingAccepted",
requestId: data.requestId,
},
},
});
}
const requestMarker = `[network-request:${request.id}]`;
const proposedStart = this.extractFirstProposedDate(request.proposedDates);
let episode = await this.prisma.episode.findFirst({
where: {
podcastId: data.podcastId,
guestId: guest.id,
hostNotes: {
contains: requestMarker,
},
},
orderBy: { createdAt: "desc" },
});
if (!episode) {
episode = await this.prisma.episode.create({
data: {
podcastId: data.podcastId,
guestId: guest.id,
title: `Network booking: ${fullName}`,
status: proposedStart ? "scheduled" : "draft",
scheduledAt: proposedStart,
hostNotes: `${requestMarker}\n\nNetwork booking request accepted`,
},
});
}
else {
await this.prisma.episode.update({
where: { id: episode.id },
data: {
status: "scheduled",
scheduledAt: episode.scheduledAt || proposedStart,
},
});
}
if (proposedStart) {
const existingBooking = await this.prisma.booking.findFirst({
where: { episodeId: episode.id },
});
if (!existingBooking) {
const proposedEnd = new Date(proposedStart.getTime() + 60 * 60 * 1000);
await this.prisma.booking.create({
data: {
episodeId: episode.id,
startTime: proposedStart,
endTime: proposedEnd,
timezone: profile.timezone || "UTC",
meetLink: null,
isConfirmed: false,
},
});
}
}
const existingBookingHistory = await this.prisma.bookingHistory.findFirst({
where: {
profileId: data.profileId,
episodeId: episode.id,
},
});
if (!existingBookingHistory) {
await this.prisma.bookingHistory.create({
data: {
profileId: data.profileId,
episodeId: episode.id,
podcastName: request.fromPodcast.title,
appearedAt: new Date(),
isPublic: true,
topicsDiscussed: request.proposedTopics,
},
});
}
this.logger.log(`Pipeline entry created: guest=${guest.id}, episode=${episode.id}`);
return {
success: true,
guestId: guest.id,
episodeId: episode.id,
};
}
catch (error) {
this.logger.error(`Failed to create pipeline entry: ${error}`);
throw error;
}
}
async handleReputationRecalculate(data) {
this.logger.log(`Recalculating reputation for ${data.profileId} (trigger: ${data.trigger})`);
try {
const score = await this.reputationEngine.calculate(data.profileId);
this.logger.log(`Reputation updated: ${data.profileId} = ${score.composite} (${score.tier})`);
const profile = await this.prisma.guestPublicProfile.findUnique({
where: { id: data.profileId },
});
if (profile) {
await this.meilisearch.updateProfile(profile);
}
return {
success: true,
score: score.composite,
tier: score.tier,
};
}
catch (error) {
this.logger.error(`Failed to recalculate reputation: ${error}`);
throw error;
}
}
async handleGraphIngest(data) {
this.logger.log(`Ingesting profile ${data.profileId} into graph`);
try {
await this.graphService.ingestProfile(data.profileId);
return { success: true };
}
catch (error) {
this.logger.error(`Failed to ingest profile: ${error}`);
throw error;
}
}
async handleSearchIndexProfile(data) {
this.logger.log(`Indexing profile ${data.profileId} in search`);
try {
const profile = await this.prisma.guestPublicProfile.findUnique({
where: { id: data.profileId },
include: { expertiseTags: true },
});
if (profile) {
await this.meilisearch.indexProfile(profile);
}
return { success: true };
}
catch (error) {
this.logger.error(`Failed to index profile: ${error}`);
throw error;
}
}
async handleSearchRemoveProfile(data) {
this.logger.log(`Removing profile ${data.profileId} from search`);
try {
await this.meilisearch.removeProfile(data.profileId);
return { success: true };
}
catch (error) {
this.logger.error(`Failed to remove profile from search: ${error}`);
throw error;
}
}
async handleSearchReindex() {
this.logger.log("Starting search reindex via background job");
try {
return await this.meilisearch.bulkIndex();
}
catch (error) {
this.logger.error("Search reindex failed", error);
throw error;
}
}
async handleWeeklyDigest(data) {
this.logger.log(`Sending weekly digest for ${data.profileId}`);
try {
const profile = await this.prisma.guestPublicProfile.findUnique({
where: { id: data.profileId },
include: {
user: { select: { email: true } },
},
});
if (!profile || !profile.isPublic) {
return { success: false };
}
const viewsLastWeek = await this.prisma.profileView.count({
where: {
profileId: data.profileId,
viewedAt: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
},
});
const pendingRequests = await this.prisma.networkBookingRequest.count({
where: {
toProfileId: data.profileId,
status: "pending",
},
});
if (viewsLastWeek > 10 || pendingRequests > 0) {
if (profile.whatsappNumber || profile.phoneNumber) {
await this.smsService.sendProfileViewNotification(profile.whatsappNumber || profile.phoneNumber, viewsLastWeek);
}
}
return { success: true };
}
catch (error) {
this.logger.error(`Failed to send weekly digest: ${error}`);
throw error;
}
}
};
exports.NetworkProcessor = NetworkProcessor;
exports.NetworkProcessor = NetworkProcessor = NetworkProcessor_1 = __decorate([
(0, bullmq_1.Processor)("network"),
__param(5, (0, bullmq_3.InjectQueue)("email")),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
reputation_service_1.ReputationEngine,
sms_service_1.SmsService,
graph_service_1.GraphService,
meilisearch_service_1.MeilisearchService,
bullmq_4.Queue])
], NetworkProcessor);
class NetworkQueueEvents extends bullmq_1.QueueEventsHost {
logger = new common_1.Logger(NetworkQueueEvents.name);
onCompleted(job) {
this.logger.debug(`Job ${job.id} completed successfully`);
}
onFailed(job, error) {
this.logger.error(`Job ${job.id} failed: ${error.message}`);
}
onStalled(jobId) {
this.logger.warn(`Job ${jobId} has stalled`);
}
}
exports.NetworkQueueEvents = NetworkQueueEvents;
__decorate([
(0, bullmq_1.OnQueueEvent)("completed"),
__metadata("design:type", Function),
__metadata("design:paramtypes", [bullmq_2.Job]),
__metadata("design:returntype", void 0)
], NetworkQueueEvents.prototype, "onCompleted", null);
__decorate([
(0, bullmq_1.OnQueueEvent)("failed"),
__metadata("design:type", Function),
__metadata("design:paramtypes", [bullmq_2.Job, Error]),
__metadata("design:returntype", void 0)
], NetworkQueueEvents.prototype, "onFailed", null);
__decorate([
(0, bullmq_1.OnQueueEvent)("stalled"),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", void 0)
], NetworkQueueEvents.prototype, "onStalled", null);
//# sourceMappingURL=network.processor.js.map