MicProtocol / apps /api /src /network /network.service.ts
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
66.9 kB
import { Injectable, Logger, NotFoundException, ConflictException, ForbiddenException, BadRequestException, } from "@nestjs/common"; import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { createHash } from "crypto"; import { PrismaService } from "../prisma/prisma.service"; import { AuthorizationService } from "../common/authorization.service"; import { assertPlatformAdmin } from "../common/platform-admin"; import { RedisRateLimiterService } from "../common/redis-rate-limiter.service"; import type { Prisma } from "@prisma/client"; import { GuestStatus, GuestJourneyEventType } from "@micprotocol/shared-types"; import type { CreateGuestPublicProfile, UpdateGuestPublicProfile, GuestNetworkSearch, CreateNetworkBookingRequest, CreateNetworkBookmark, UpdateNetworkBookmark, CreateHostReview, TrackProfileView, } from "@micprotocol/shared-types"; import { MeilisearchService } from "./meilisearch.service"; import { ReputationEngine } from "./reputation.service"; import { GraphService } from "./graph.service"; @Injectable() export class NetworkService { private readonly logger = new Logger(NetworkService.name); private readonly allowedVerificationStatuses = ["verified", "featured"] as const; constructor( private readonly prisma: PrismaService, private readonly authorizationService: AuthorizationService, private readonly rateLimiter: RedisRateLimiterService, private readonly meilisearch: MeilisearchService, private readonly reputationEngine: ReputationEngine, private readonly graphService: GraphService, @InjectQueue("network") private readonly networkQueue: Queue, ) {} // ─── Vanity URL Generation ─────────────────────────── private slugify(text: string): string { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, "") .replace(/[\s_]+/g, "-") .replace(/-+/g, "-") .substring(0, 60); } private async generateVanityUrl(name: string): Promise<string> { const base = this.slugify(name); const existing = await this.prisma.guestPublicProfile.findUnique({ where: { vanityUrl: base } }); if (!existing) { return base; } const suffix = createHash("sha256").update(`${base}-${Date.now()}`).digest("hex").substring(0, 8); return `${base}-${suffix}`; } private isVerificationEligible(status: string | null | undefined): boolean { return status === "verified" || status === "featured"; } private isProfileDiscoverable(profile: { isPublic: boolean; verificationStatus: string; }): boolean { return profile.isPublic && this.isVerificationEligible(profile.verificationStatus); } // ─── Profile CRUD ──────────────────────────────────── async createProfile(userId: string, data: CreateGuestPublicProfile) { this.logger.log(`Creating guest public profile for user ${userId}`); // Check if profile already exists const existing = await this.prisma.guestPublicProfile.findUnique({ where: { userId }, }); if (existing) { throw new ConflictException("User already has a public profile"); } // Get user for vanity URL generation const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new NotFoundException("User not found"); } const displayName = (user.profile as any)?.fullName || user.email.split("@")[0]; let profile: Awaited<ReturnType<typeof this.prisma.guestPublicProfile.create>> | null = null; let attempt = 0; while (!profile && attempt < 5) { attempt++; const baseVanity = await this.generateVanityUrl(displayName); const vanityUrl = baseVanity + (attempt > 1 ? `-${attempt}` : ""); try { profile = await this.prisma.guestPublicProfile.create({ data: { userId, vanityUrl, isPublic: false, headline: data.headline, fullBio: data.fullBio, country: data.country, city: data.city, languages: data.languages || [], timezone: data.timezone, phoneNumber: data.phoneNumber, whatsappNumber: data.whatsappNumber, avatarUrl: data.avatarUrl, visibilitySettings: (data.visibilitySettings as any) || {}, expertiseTags: data.expertiseTags ? { createMany: { data: data.expertiseTags.map((t) => ({ name: t.name, category: t.category || null, })), }, } : undefined, availabilityWindows: data.availabilityWindows ? { createMany: { data: data.availabilityWindows.map((w) => ({ dayOfWeek: w.dayOfWeek, startHour: w.startHour, endHour: w.endHour, timezone: w.timezone || "UTC", })), }, } : undefined, }, include: { expertiseTags: true, availabilityWindows: true, }, }); } catch (error) { const maybeKnownError = error as { code?: string; meta?: { target?: string[] } }; const target = maybeKnownError.meta?.target || []; const isUniqueConstraint = maybeKnownError.code === "P2002"; if (isUniqueConstraint && (target.includes("vanity_url") || target.includes("vanityUrl"))) { this.logger.warn( `Vanity URL collision for user ${userId}; retrying createProfile attempt ${attempt}/5`, ); continue; } if (isUniqueConstraint && (target.includes("user_id") || target.includes("userId"))) { throw new ConflictException("User already has a public profile"); } throw error; } } if (!profile) { throw new ConflictException( "Could not create profile due to repeated vanity URL conflicts. Please retry.", ); } // Enqueue search index update β€” avoids blocking profile creation on Meilisearch if (this.isProfileDiscoverable(profile)) { await this.networkQueue.add("search.index-profile", { profileId: profile.id, }); } // Enqueue graph ingestion await this.networkQueue.add("graph.ingest-profile", { profileId: profile.id, userId, }); return profile; } async updateProfile(userId: string, data: UpdateGuestPublicProfile) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { userId }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (data.isPublic === true && !this.isVerificationEligible(profile.verificationStatus)) { throw new ForbiddenException( "Only verified or featured profiles can be listed publicly. Complete verification first.", ); } const updated = await this.prisma.$transaction(async (tx) => { // Update expertise tags if provided if (data.expertiseTags !== undefined) { await tx.expertiseTag.deleteMany({ where: { profileId: profile.id }, }); if (data.expertiseTags.length > 0) { await tx.expertiseTag.createMany({ data: data.expertiseTags.map((t) => ({ profileId: profile.id, name: t.name, category: t.category || null, })), }); } } // Update availability windows if provided if (data.availabilityWindows !== undefined) { await tx.availabilityWindow.deleteMany({ where: { profileId: profile.id }, }); if (data.availabilityWindows.length > 0) { await tx.availabilityWindow.createMany({ data: data.availabilityWindows.map((w) => ({ profileId: profile.id, dayOfWeek: w.dayOfWeek, startHour: w.startHour, endHour: w.endHour, timezone: w.timezone || "UTC", })), }); } } return tx.guestPublicProfile.update({ where: { id: profile.id }, data: { headline: data.headline, fullBio: data.fullBio, country: data.country, city: data.city, languages: data.languages, timezone: data.timezone, phoneNumber: data.phoneNumber, whatsappNumber: data.whatsappNumber, avatarUrl: data.avatarUrl, isPublic: data.isPublic, visibilitySettings: data.visibilitySettings ? (data.visibilitySettings as any) : undefined, }, include: { expertiseTags: true, availabilityWindows: true, }, }); }); // Enqueue search index update β€” non-blocking if (this.isProfileDiscoverable(updated)) { await this.networkQueue.add("search.index-profile", { profileId: updated.id, }); } else { await this.networkQueue.add("search.remove-profile", { profileId: updated.id, }); } return updated; } async getMyProfile(userId: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { userId }, include: { expertiseTags: true, availabilityWindows: true, bookingHistory: { where: { isPublic: true }, orderBy: { appearedAt: "desc" }, take: 20, }, reliabilityMetrics: { orderBy: { calculatedAt: "desc" }, take: 1, }, hostReviews: { orderBy: { createdAt: "desc" }, take: 10, }, }, }); if (!profile) { return null; } // Get analytics const [viewsThisWeek, pendingRequests] = await Promise.all([ this.prisma.profileView.count({ where: { profileId: profile.id, viewedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), }, }, }), this.prisma.networkBookingRequest.count({ where: { toProfileId: profile.id, status: "pending", }, }), ]); return { ...profile, analytics: { viewsThisWeek, pendingRequests, }, }; } async getProfileByVanityUrl(vanityUrl: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { vanityUrl }, select: { id: true, vanityUrl: true, headline: true, fullBio: true, country: true, city: true, languages: true, timezone: true, avatarUrl: true, verificationStatus: true, reliabilityScore: true, totalAppearances: true, averageRating: true, tier: true, verificationBadges: true, isPublic: true, createdAt: true, expertiseTags: { select: { name: true, category: true } }, availabilityWindows: true, bookingHistory: { where: { isPublic: true }, orderBy: { appearedAt: "desc" }, select: { id: true, podcastName: true, hostName: true, appearedAt: true, topicsDiscussed: true, isPublic: true, }, }, hostReviews: { orderBy: { createdAt: "desc" }, take: 5, select: { contentRating: true, prepQuality: true, overallRating: true, comment: true, createdAt: true, }, }, }, }); if (!profile || !this.isProfileDiscoverable(profile)) { throw new NotFoundException("Profile not found"); } return profile; } async getProfileById(profileId: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { id: true, vanityUrl: true, headline: true, fullBio: true, country: true, city: true, languages: true, timezone: true, avatarUrl: true, verificationStatus: true, reliabilityScore: true, totalAppearances: true, averageRating: true, tier: true, verificationBadges: true, isPublic: true, createdAt: true, expertiseTags: { select: { name: true, category: true } }, availabilityWindows: true, bookingHistory: { where: { isPublic: true }, orderBy: { appearedAt: "desc" }, select: { id: true, podcastName: true, hostName: true, appearedAt: true, topicsDiscussed: true, isPublic: true, }, }, }, }); if (!profile || !this.isProfileDiscoverable(profile)) { throw new NotFoundException("Profile not found"); } return profile; } // ─── Discovery Search ──────────────────────────────── async searchProfiles(params: GuestNetworkSearch) { const meiliResults = await this.meilisearch.searchProfiles(params); if (meiliResults.total > 0) { this.logger.debug(`Network search (meili) query="${params.query ?? ""}" total=${meiliResults.total}`); return meiliResults; } const fallbackResults = await this.searchProfilesFromDatabase(params); this.logger.debug( `Network search (fallback-db) query="${params.query ?? ""}" total=${fallbackResults.total}`, ); return fallbackResults; } private async searchProfilesFromDatabase(params: GuestNetworkSearch): Promise<{ hits: any[]; total: number; page: number; totalPages: number; }> { const { query, country, countries, expertise, languages, minReliabilityScore, minAppearances, verificationStatus, tier, maxResponseTimeHours, sortBy, sortOrder, page = 1, limit = 20, } = params; const andFilters: Prisma.GuestPublicProfileWhereInput[] = [ { isPublic: true }, { verificationStatus: { in: [...this.allowedVerificationStatuses] } }, ]; if (query && query.trim()) { const q = query.trim(); andFilters.push({ OR: [ { headline: { contains: q, mode: "insensitive" } }, { fullBio: { contains: q, mode: "insensitive" } }, { country: { contains: q, mode: "insensitive" } }, { city: { contains: q, mode: "insensitive" } }, { expertiseTags: { some: { name: { contains: q, mode: "insensitive" }, }, }, }, ], }); } if (country) { andFilters.push({ country }); } if (countries && countries.length > 0) { andFilters.push({ country: { in: countries } }); } if (expertise && expertise.length > 0) { andFilters.push({ expertiseTags: { some: { name: { in: expertise }, }, }, }); } if (languages && languages.length > 0) { andFilters.push({ languages: { hasSome: languages } }); } if (minReliabilityScore !== undefined) { andFilters.push({ reliabilityScore: { gte: minReliabilityScore as any } }); } if (minAppearances !== undefined) { andFilters.push({ totalAppearances: { gte: minAppearances } }); } if (verificationStatus) { andFilters.push({ verificationStatus }); } if (tier) { andFilters.push({ tier }); } if (maxResponseTimeHours !== undefined) { andFilters.push({ responseTimeHours: { lte: maxResponseTimeHours } }); } const where: Prisma.GuestPublicProfileWhereInput = { AND: andFilters, }; const orderBy: Prisma.GuestPublicProfileOrderByWithRelationInput[] = []; const direction = sortOrder === "asc" ? "asc" : "desc"; if (sortBy && sortBy !== "relevance") { if (sortBy === "reliabilityScore") orderBy.push({ reliabilityScore: direction as any }); if (sortBy === "responseTimeHours") orderBy.push({ responseTimeHours: direction }); if (sortBy === "totalAppearances") orderBy.push({ totalAppearances: direction }); if (sortBy === "createdAt") orderBy.push({ createdAt: direction }); } if (orderBy.length === 0) { orderBy.push({ reliabilityScore: "desc" as any }, { totalAppearances: "desc" }); } const [profiles, total] = await Promise.all([ this.prisma.guestPublicProfile.findMany({ where, include: { expertiseTags: true, }, orderBy, skip: (page - 1) * limit, take: limit, }), this.prisma.guestPublicProfile.count({ where }), ]); return { hits: profiles.map((profile) => ({ id: profile.id, vanityUrl: profile.vanityUrl, avatarUrl: profile.avatarUrl, headline: profile.headline, fullBio: profile.fullBio, country: profile.country, city: profile.city, languages: profile.languages, timezone: profile.timezone, expertiseTags: profile.expertiseTags.map((tag) => tag.name), reliabilityScore: Number(profile.reliabilityScore), totalAppearances: profile.totalAppearances, responseTimeHours: profile.responseTimeHours, verificationStatus: profile.verificationStatus, tier: profile.tier, })), total, page, totalPages: Math.ceil(total / limit), }; } async getTrending(region?: string, limit = 10) { const where: Prisma.GuestPublicProfileWhereInput = { isPublic: true, verificationStatus: { in: [...this.allowedVerificationStatuses] }, }; if (region) { // Map regions to countries (must match MeilisearchService region mappings) const regionCountries: Record<string, string[]> = { "east-africa": ["Kenya", "Tanzania", "Uganda", "Rwanda", "Ethiopia", "Burundi"], "west-africa": ["Nigeria", "Ghana", "Senegal", "Ivory Coast", "Mali", "Burkina Faso"], "southern-africa": ["South Africa", "Botswana", "Zimbabwe", "Mozambique", "Namibia"], "north-africa": ["Egypt", "Morocco", "Tunisia", "Algeria", "Libya"], "central-africa": ["Cameroon", "Gabon", "DRC", "Chad", "CAR"], }; const countries = regionCountries[region]; if (countries) { where.country = { in: countries }; } } return this.prisma.guestPublicProfile.findMany({ where, orderBy: [ { totalAppearances: "desc" }, { reliabilityScore: "desc" }, ], take: limit, include: { expertiseTags: true, }, }); } // ─── Profile Views ─────────────────────────────────── async trackView(data: TrackProfileView) { // Rate limit: max 10 views per profile per minute from any source if (!await this.checkViewRateLimit(data.profileId, 10)) { this.logger.warn(`View rate limit exceeded for profile ${data.profileId}`); return { rateLimited: true }; } // Deduplicate: prevent same podcast viewing same profile multiple times in 24h if (data.viewerPodcastId) { const existingRecentView = await this.prisma.profileView.findFirst({ where: { profileId: data.profileId, viewerPodcastId: data.viewerPodcastId, viewedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, }, }); if (existingRecentView) { // Update timestamp instead of creating duplicate await this.prisma.profileView.update({ where: { id: existingRecentView.id }, data: { viewedAt: new Date() }, }); return { deduplicated: true, id: existingRecentView.id }; } } return this.prisma.profileView.create({ data: { profileId: data.profileId, viewerPodcastId: data.viewerPodcastId || null, source: data.source || "direct", }, }); } async getProfileViewStats(userId: string, profileId: string, days = 30) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { userId: true }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (profile.userId !== userId) { throw new ForbiddenException("Not authorized to view stats for this profile"); } const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); const [total, bySource, byDay] = await Promise.all([ this.prisma.profileView.count({ where: { profileId, viewedAt: { gte: since } }, }), this.prisma.profileView.groupBy({ by: ["source"], where: { profileId, viewedAt: { gte: since } }, _count: true, }), this.prisma.$queryRaw` SELECT DATE(viewed_at) as date, COUNT(*)::int as views FROM profile_views WHERE profile_id = ${profileId}::uuid AND viewed_at >= ${since} GROUP BY DATE(viewed_at) ORDER BY date ` as Promise<Array<{ date: Date; views: number }>>, ]); return { total, bySource, byDay }; } private readonly guestStatusRank: Record<string, number> = { [GuestStatus.PROSPECT]: 1, [GuestStatus.CONTACTED]: 2, [GuestStatus.SCHEDULED]: 3, [GuestStatus.RECORDED]: 4, [GuestStatus.PUBLISHED]: 5, }; private elevateGuestStatus(current: string, desired: GuestStatus): GuestStatus { const currentRank = this.guestStatusRank[current] ?? 0; const desiredRank = this.guestStatusRank[desired] ?? 0; return desiredRank > currentRank ? desired : (current as GuestStatus); } private journeyEventForStatus(status: GuestStatus): GuestJourneyEventType | null { if (status === GuestStatus.CONTACTED) return GuestJourneyEventType.CONTACTED; if (status === GuestStatus.SCHEDULED) return GuestJourneyEventType.SCHEDULED; if (status === GuestStatus.RECORDED) return GuestJourneyEventType.RECORDED; if (status === GuestStatus.PUBLISHED) return GuestJourneyEventType.PUBLISHED; return null; } private extractFirstProposedDate(proposedDates: unknown): Date | null { if (!Array.isArray(proposedDates)) { return null; } for (const value of proposedDates) { if (typeof value !== "string") { continue; } const parsedDate = new Date(value); if (!Number.isNaN(parsedDate.getTime())) { return parsedDate; } } return null; } private parseProfileIdentity(profile: { headline: string | null; user: { email: string }; }): { fullName: string; company: string | null } { const headline = profile.headline?.trim() || ""; const [namePart, companyPart] = headline.split("|").map((part) => part.trim()); const fullName = namePart || profile.user.email.split("@")[0] || "Network Guest"; const company = companyPart || null; return { fullName, company }; } private async ensureGuestPipelineEntry(params: { podcastId: string; profile: { id: string; headline: string | null; fullBio: string | null; socialProof: Prisma.JsonValue; user: { email: string }; }; desiredStatus: GuestStatus; }) { const { podcastId, profile, desiredStatus } = params; const { fullName, company } = this.parseProfileIdentity(profile); const existingGuest = await this.prisma.guest.findUnique({ where: { podcastId_email: { podcastId, email: profile.user.email, }, }, }); if (existingGuest) { const nextStatus = this.elevateGuestStatus(existingGuest.status, desiredStatus); const updatedGuest = await this.prisma.guest.update({ where: { id: existingGuest.id }, data: { fullName: fullName || existingGuest.fullName, company: company ?? existingGuest.company, bio: profile.fullBio ?? existingGuest.bio, socialLinks: (profile.socialProof || {}) as Prisma.InputJsonValue, status: nextStatus, ...(nextStatus === GuestStatus.CONTACTED || nextStatus === GuestStatus.SCHEDULED ? { lastContactAt: new Date() } : {}), }, }); if (nextStatus !== (existingGuest.status as GuestStatus)) { const eventType = this.journeyEventForStatus(nextStatus); if (eventType) { await this.prisma.guestJourneyEvent.create({ data: { guestId: existingGuest.id, eventType, title: `Guest marked as ${nextStatus}`, details: `Guest lifecycle moved to ${nextStatus} via network sync.`, metadata: { source: "network.ensureGuestPipelineEntry", profileId: profile.id, } as Prisma.InputJsonValue, }, }); } } return updatedGuest; } const createdGuest = await this.prisma.guest.create({ data: { podcastId, email: profile.user.email, fullName, company, title: null, bio: profile.fullBio, socialLinks: (profile.socialProof || {}) as Prisma.InputJsonValue, status: desiredStatus, ...(desiredStatus === GuestStatus.CONTACTED || desiredStatus === GuestStatus.SCHEDULED ? { lastContactAt: new Date() } : {}), }, }); const eventType = this.journeyEventForStatus(desiredStatus); if (eventType) { await this.prisma.guestJourneyEvent.create({ data: { guestId: createdGuest.id, eventType, title: `Guest marked as ${desiredStatus}`, details: `Guest lifecycle moved to ${desiredStatus} via network sync.`, metadata: { source: "network.ensureGuestPipelineEntry", profileId: profile.id, } as Prisma.InputJsonValue, }, }); } return createdGuest; } private async ensureSchedulingPipelineEntry(params: { podcastId: string; guestId: string; fullName: string; timezone: string | null; marker: string; action: "bookmark" | "request"; proposedDate: Date | null; message?: string; topics?: string[]; }) { const { podcastId, guestId, fullName, timezone, marker, action, proposedDate, message, topics, } = params; const noteSections = [marker]; if (message) { noteSections.push(`Network message: ${message}`); } if (topics && topics.length > 0) { noteSections.push(`Proposed topics: ${topics.join(", ")}`); } const hostNotes = noteSections.join("\n\n"); let episode = await this.prisma.episode.findFirst({ where: { podcastId, guestId, hostNotes: { contains: marker }, }, orderBy: { createdAt: "desc" }, }); if (!episode) { episode = await this.prisma.episode.create({ data: { podcastId, guestId, title: action === "request" ? `Network booking: ${fullName}` : `Network lead: ${fullName}`, status: proposedDate ? "scheduled" : "draft", scheduledAt: proposedDate, durationMinutes: 60, hostNotes, }, }); } else if (proposedDate) { await this.prisma.episode.update({ where: { id: episode.id }, data: { status: "scheduled", scheduledAt: episode.scheduledAt || proposedDate, hostNotes: episode.hostNotes && episode.hostNotes.includes(marker) ? episode.hostNotes : `${episode.hostNotes || ""}\n\n${marker}`.trim(), }, }); } if (!proposedDate) { return; } const existingBooking = await this.prisma.booking.findFirst({ where: { episodeId: episode.id, }, }); if (existingBooking) { return; } const endDate = new Date(proposedDate.getTime() + 60 * 60 * 1000); await this.prisma.booking.create({ data: { episodeId: episode.id, startTime: proposedDate, endTime: endDate, timezone: timezone || "UTC", meetLink: null, isConfirmed: false, }, }); } private async syncProfileToPipelines(params: { podcastId: string; profile: { id: string; headline: string | null; fullBio: string | null; socialProof: Prisma.JsonValue; timezone: string | null; user: { email: string }; }; action: "bookmark" | "request"; requestId?: string; message?: string; proposedTopics?: string[]; proposedDates?: unknown; }) { const { podcastId, profile, action, requestId, message, proposedTopics, proposedDates } = params; const proposedDate = this.extractFirstProposedDate(proposedDates); const desiredStatus: GuestStatus = action === "bookmark" ? GuestStatus.PROSPECT : proposedDate ? GuestStatus.SCHEDULED : GuestStatus.CONTACTED; const guest = await this.ensureGuestPipelineEntry({ podcastId, profile, desiredStatus, }); const { fullName } = this.parseProfileIdentity(profile); const marker = action === "bookmark" ? `[network-bookmark:${profile.id}]` : `[network-request:${requestId || profile.id}]`; await this.ensureSchedulingPipelineEntry({ podcastId, guestId: guest.id, fullName, timezone: profile.timezone, marker, action, proposedDate, message, topics: proposedTopics, }); this.logger.debug( `Synced network ${action} into pipelines: podcast=${podcastId} profile=${profile.id} guest=${guest.id}`, ); } // ─── Bookmarks ─────────────────────────────────────── async createBookmark(data: CreateNetworkBookmark, userId: string) { await this.authorizationService.checkPodcastAccess(data.podcastId, userId); const bookmark = await this.prisma.networkBookmark.upsert({ where: { podcastId_guestProfileId: { podcastId: data.podcastId, guestProfileId: data.guestProfileId, }, }, create: { podcastId: data.podcastId, guestProfileId: data.guestProfileId, notes: data.notes, }, update: { ...(data.notes !== undefined ? { notes: data.notes } : {}), }, include: { guestProfile: { include: { expertiseTags: true, user: { select: { email: true } }, }, }, }, }); if (!bookmark.guestProfile || !this.isProfileDiscoverable(bookmark.guestProfile)) { throw new NotFoundException("Guest profile not found"); } await this.syncProfileToPipelines({ podcastId: data.podcastId, profile: bookmark.guestProfile, action: "bookmark", }); return bookmark; } async updateBookmark(userId: string, id: string, data: UpdateNetworkBookmark) { const bookmark = await this.prisma.networkBookmark.findUnique({ where: { id }, include: { podcast: { select: { workspaceId: true } }, }, }); if (!bookmark) { throw new NotFoundException("Bookmark not found"); } const membership = await this.prisma.workspaceMember.findFirst({ where: { userId, workspaceId: bookmark.podcast.workspaceId }, }); if (!membership) { throw new ForbiddenException("Not authorized to update this bookmark"); } return this.prisma.networkBookmark.update({ where: { id }, data: { status: data.status, notes: data.notes, }, }); } async removeBookmark(userId: string, id: string) { const bookmark = await this.prisma.networkBookmark.findUnique({ where: { id }, include: { podcast: { select: { workspaceId: true } }, }, }); if (!bookmark) { throw new NotFoundException("Bookmark not found"); } const membership = await this.prisma.workspaceMember.findFirst({ where: { userId, workspaceId: bookmark.podcast.workspaceId }, }); if (!membership) { throw new ForbiddenException("Not authorized to remove this bookmark"); } return this.prisma.networkBookmark.delete({ where: { id } }); } async listBookmarks(podcastId: string, userId: string, status?: string) { await this.authorizationService.checkPodcastAccess(podcastId, userId); const where: Prisma.NetworkBookmarkWhereInput = { podcastId }; if (status) where.status = status as Prisma.EnumBookmarkStatusFilter; return this.prisma.networkBookmark.findMany({ where, include: { guestProfile: { include: { expertiseTags: true }, }, }, orderBy: { createdAt: "desc" }, }); } // ─── Booking Requests ──────────────────────────────── /** * Rate limiter for profile views. * Tracks views per profile per minute using Redis sliding window to prevent view inflation. */ private async checkViewRateLimit(profileId: string, maxViewsPerMinute = 10): Promise<boolean> { const result = await this.rateLimiter.checkLimit(profileId, { max: maxViewsPerMinute, windowMs: 60_000, keyPrefix: "network:view", }); return result.allowed; } /** * Validate phone number format (basic international format). * Allows +, digits, spaces, dashes, parentheses. */ private isValidPhoneNumber(phone: string | null | undefined): boolean { if (!phone) return true; // Optional fields are OK if empty return /^[\d\s\+\-\(\)]{7,20}$/.test(phone); } /** * Check for duplicate pending booking request to prevent spam. * A profile can only have X pending requests from the same podcast. */ private async checkDuplicatePendingRequest( fromPodcastId: string, toProfileId: string, maxPending = 3, ): Promise<void> { const existingPendingCount = await this.prisma.networkBookingRequest.count({ where: { fromPodcastId, toProfileId, status: "pending", }, }); if (existingPendingCount >= maxPending) { throw new ConflictException( `You already have ${existingPendingCount} pending request(s) to this profile. Complete or cancel existing requests first.`, ); } } async createBookingRequest(data: CreateNetworkBookingRequest, userId: string) { await this.authorizationService.checkPodcastAccess(data.fromPodcastId, userId); this.logger.log( `Booking request from podcast ${data.fromPodcastId} to profile ${data.toProfileId}`, ); // Rate limit outbound requests per host (spam/blast protection on // top of the per-profile duplicate cap below). const outboundLimit = await this.rateLimiter.checkLimit(`user:${userId}:booking-request`, { max: 20, windowMs: 3_600_000, keyPrefix: "network:booking", }); if (!outboundLimit.allowed) { throw new BadRequestException( "Too many booking requests sent recently. Please wait a while before sending more.", ); } // Verify the profile exists and is public const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: data.toProfileId }, include: { user: { select: { email: true } } }, }); if (!profile || !this.isProfileDiscoverable(profile)) { throw new NotFoundException("Guest profile not found"); } // SECURITY: Prevent self-booking (sending a request to your own profile) const podcast = await this.prisma.podcast.findUnique({ where: { id: data.fromPodcastId }, select: { workspace: { select: { members: { select: { userId: true } } } } }, }); if (podcast?.workspace?.members?.some((m) => m.userId === profile.userId)) { throw new ForbiddenException( "You cannot send a booking request to your own guest profile", ); } // Rate limit duplicate requests to prevent spam await this.checkDuplicatePendingRequest(data.fromPodcastId, data.toProfileId); // Validate proposed dates are in the future if (data.proposedDates && data.proposedDates.length > 0) { const now = new Date(); for (const dateStr of data.proposedDates) { const date = new Date(dateStr); if (date < now) { throw new BadRequestException( `Proposed date "${dateStr}" is in the past. All proposed dates must be in the future.`, ); } } } const request = await this.prisma.networkBookingRequest.create({ data: { fromPodcastId: data.fromPodcastId, toProfileId: data.toProfileId, message: data.message, proposedTopics: data.proposedTopics || [], proposedDates: data.proposedDates || [], }, include: { fromPodcast: { select: { title: true } }, toProfile: { select: { headline: true, visibilitySettings: true, phoneNumber: true, whatsappNumber: true, user: { select: { email: true } }, }, }, }, }); // Respect guest privacy: only expose PII if visibility settings allow it // Supports both string-based ("hidden") and boolean-based (false) visibility checks const visibilitySettings = (request.toProfile.visibilitySettings as Record<string, unknown>) || {}; const pii = { email: "", phone: "", whatsapp: "" }; const isEmailHidden = visibilitySettings.email === "hidden" || visibilitySettings.showEmail === false; if (!isEmailHidden) { pii.email = request.toProfile.user.email; } const isPhoneHidden = visibilitySettings.phone === "hidden" || visibilitySettings.showPhone === false; if (!isPhoneHidden) { pii.phone = request.toProfile.phoneNumber || ""; } const isWhatsappHidden = visibilitySettings.whatsapp === "hidden" || visibilitySettings.showPhone === false; if (!isWhatsappHidden) { pii.whatsapp = request.toProfile.whatsappNumber || ""; } // Ensure bookmark exists and is marked as contacted await this.prisma.networkBookmark.upsert({ where: { podcastId_guestProfileId: { podcastId: data.fromPodcastId, guestProfileId: data.toProfileId, }, }, create: { podcastId: data.fromPodcastId, guestProfileId: data.toProfileId, status: "contacted", notes: "Auto-created from booking request", }, update: { status: "contacted", }, }); // Wrap pipeline sync in try/catch so a sync failure doesn't lose the booking request try { await this.syncProfileToPipelines({ podcastId: data.fromPodcastId, profile, action: "request", requestId: request.id, message: data.message, proposedTopics: data.proposedTopics || [], proposedDates: data.proposedDates, }); } catch (syncError) { this.logger.error( `Pipeline sync failed for booking request ${request.id}: ${syncError}`, ); } // Enqueue notification (email + SMS) await this.networkQueue.add("booking-request.notify", { requestId: request.id, guestEmail: pii.email, guestPhone: pii.phone, guestWhatsapp: pii.whatsapp, podcastTitle: request.fromPodcast.title, message: data.message, }); // Strip PII from response, return through privacy-aware fields const { toProfile: _, ...requestData } = request; return { ...requestData, toProfile: { headline: request.toProfile.headline, ...(pii.email ? { email: pii.email } : {}), ...(pii.phone ? { phoneNumber: pii.phone } : {}), ...(pii.whatsapp ? { whatsappNumber: pii.whatsapp } : {}), }, }; } async respondToBookingRequest( requestId: string, userId: string, status: "accepted" | "declined", ) { const request = await this.prisma.networkBookingRequest.findUnique({ where: { id: requestId }, include: { toProfile: { select: { userId: true } }, fromPodcast: { select: { id: true, title: true, workspaceId: true }, }, }, }); if (!request) { throw new NotFoundException("Booking request not found"); } // Verify the respondent owns the target profile if (request.toProfile.userId !== userId) { throw new ForbiddenException("Not authorized to respond to this request"); } // State machine: only PENDING requests can be responded to. // NOTE: a check-then-update here can race under concurrent responses // (e.g. two taps on Accept/Decline), so we still perform an atomic // update-many with a status precondition below and treat 0 affected rows // as the real authority. if (request.status !== "pending") { if (request.status === "accepted") { throw new ConflictException("You have already accepted this booking request"); } if (request.status === "declined") { throw new ConflictException("You have already declined this booking request"); } if (request.status === "expired") { throw new ConflictException("This booking request has expired and can no longer be responded to"); } throw new ConflictException(`Request has status "${request.status}" and cannot be responded to`); } // Auto-expire: if request is older than 30 days, reject. // NOTE: like the status check above, expiration here races with // concurrent transitions; the atomic update-many below also bumps // affected rows to 0 once the row is no longer pending. const requestAge = Date.now() - request.createdAt.getTime(); const maxAgeMs = 30 * 24 * 60 * 60 * 1000; // 30 days if (requestAge > maxAgeMs) { await this.prisma.networkBookingRequest.update({ where: { id: requestId }, data: { status: "expired" as any }, }); throw new ConflictException("This booking request has expired (older than 30 days)"); } // Idempotency label for telemetry/debugging when the same caller // responds twice in quick succession β€” not a security gate, since the // row-level status check below is authoritative. this.logger.log(`Responding to booking request ${requestId} from userId ${userId}`, { requestId, respondentId: userId, targetStatus: status, }); // Atomic state transition: update only if still pending, so two // concurrent responses (e.g. accept + decline) can't both win. const transition = await this.prisma.networkBookingRequest.updateMany({ where: { id: requestId, status: "pending" }, data: { status, respondedAt: new Date(), }, }); if (transition.count === 0) { // Second racer arrives after the first mutated the row (or it was // expired between our check and write) β€” reload and report truth. const current = await this.prisma.networkBookingRequest.findUnique({ where: { id: requestId }, select: { status: true }, }); throw new ConflictException( current?.status === "accepted" ? "You have already accepted this booking request" : current?.status === "declined" ? "You have already declined this booking request" : "This booking request is no longer pending and cannot be responded to", ); } const updated = await this.prisma.networkBookingRequest.findUnique({ where: { id: requestId }, }); if (status === "accepted") { // Update bookmark to "booked" if it exists await this.prisma.networkBookmark.updateMany({ where: { podcastId: request.fromPodcast.id, guestProfileId: request.toProfileId, }, data: { status: "booked" }, }); // Enqueue pipeline integration β€” creates Guest + Episode draft in the host's podcast await this.networkQueue.add("booking-request.accepted", { requestId: request.id, podcastId: request.fromPodcast.id, profileId: request.toProfileId, }); } // Notify the host await this.networkQueue.add("booking-request.response-notify", { requestId: request.id, status, podcastId: request.fromPodcast.id, }); return updated; } async listBookingRequests(userId: string, profileId: string, status?: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { userId: true }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (profile.userId !== userId) { throw new ForbiddenException("Not authorized to view these booking requests"); } const where: Prisma.NetworkBookingRequestWhereInput = { toProfileId: profileId }; if (status) where.status = status as Prisma.EnumNetworkBookingRequestStatusFilter; return this.prisma.networkBookingRequest.findMany({ where, include: { fromPodcast: { select: { id: true, title: true } }, }, orderBy: { createdAt: "desc" }, }); } async listSentBookingRequests(userId: string, podcastId: string, status?: string) { const membership = await this.prisma.workspaceMember.findFirst({ where: { userId, workspace: { podcasts: { some: { id: podcastId } } }, }, }); if (!membership) { throw new ForbiddenException("Not authorized to view booking requests for this podcast"); } const where: Prisma.NetworkBookingRequestWhereInput = { fromPodcastId: podcastId }; if (status) where.status = status as Prisma.EnumNetworkBookingRequestStatusFilter; return this.prisma.networkBookingRequest.findMany({ where, include: { toProfile: { select: { id: true, vanityUrl: true, avatarUrl: true, headline: true, tier: true, reliabilityScore: true, }, }, }, orderBy: { createdAt: "desc" }, }); } // ─── Host Reviews ──────────────────────────────────── async submitReview(userId: string, data: CreateHostReview) { // Verify the booking request exists and is accepted. const request = await this.prisma.networkBookingRequest.findUnique({ where: { id: data.bookingRequestId }, include: { fromPodcast: { select: { id: true, workspaceId: true } }, }, }); if (!request || request.status !== "accepted") { throw new NotFoundException("Accepted booking request not found"); } // SECURITY: only the host side of the booking may leave a review β€” // verify the caller is a member of the podcast that sent the request. // NOTE: `request.fromPodcast` is shaped by the query include; if it is // partial (e.g. a test double omits it), keep the check safe. const hostPodcastId = request.fromPodcast?.id; const reviewerIsHostMember = await this.prisma.workspaceMember.findFirst({ where: { userId: userId, workspace: { podcasts: { some: { id: hostPodcastId } }, }, }, }); if (!reviewerIsHostMember) { throw new ForbiddenException("Not authorized to review this booking"); } // SECURITY: Prevent duplicate reviews for the same booking const existingReview = await this.prisma.hostReview.findUnique({ where: { bookingRequestId: data.bookingRequestId }, }); if (existingReview) { throw new ConflictException( "A review has already been submitted for this booking request", ); } // Validate that the profile in the review matches the booking request's target if (request.toProfileId !== data.profileId) { throw new BadRequestException( "Profile ID does not match the booking request's target profile", ); } const review = await this.prisma.hostReview.create({ data: { bookingRequestId: data.bookingRequestId, profileId: data.profileId, contentRating: data.contentRating, prepQuality: data.prepQuality, overallRating: data.overallRating, comment: data.comment, }, }); await this.networkQueue.add("reputation.recalculate", { profileId: data.profileId, trigger: "review", }); return review; } // ─── Podcast Network Stats (for analytics) ─────────── async getPodcastNetworkStats(podcastId: string, userId: string) { await this.authorizationService.checkPodcastAccess(podcastId, userId); const [ totalBookmarks, sentRequests, acceptedRequests, avgResponseHours, ] = await Promise.all([ this.prisma.networkBookmark.count({ where: { podcastId } }), this.prisma.networkBookingRequest.count({ where: { fromPodcastId: podcastId } }), this.prisma.networkBookingRequest.count({ where: { fromPodcastId: podcastId, status: "accepted" }, }), this.prisma.$queryRaw` SELECT AVG(EXTRACT(EPOCH FROM (responded_at - created_at)) / 3600)::numeric(10,2) as avg_hours FROM network_booking_requests WHERE from_podcast_id = ${podcastId}::uuid AND responded_at IS NOT NULL ` as Promise<Array<{ avg_hours: number | null }>>, ]); return { totalBookmarks, sentRequests, acceptedRequests, conversionRate: sentRequests > 0 ? (acceptedRequests / sentRequests) * 100 : 0, avgResponseHours: avgResponseHours[0]?.avg_hours || null, }; } // ─── Reputation ────────────────────────────────────── async getReputationInsights(userId: string, profileId: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { userId: true }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (profile.userId !== userId) { throw new ForbiddenException("Not authorized to view reputation insights for this profile"); } return this.reputationEngine.getInsights(profileId); } async recalculateReputation(userId: string, profileId: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { userId: true }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (profile.userId !== userId) { throw new ForbiddenException("Not authorized to recalculate reputation for this profile"); } return this.reputationEngine.calculate(profileId); } // ─── Graph / Recommendations ─────────────────────────── async getRecommendations(userId: string, profileId: string, limit = 5) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { userId: true }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (profile.userId !== userId) { throw new ForbiddenException("Not authorized to view recommendations for this profile"); } return this.graphService.getRecommendations(profileId, limit); } async getProfileNetworkStats(userId: string, profileId: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, select: { userId: true }, }); if (!profile) { throw new NotFoundException("Profile not found"); } if (profile.userId !== userId) { throw new ForbiddenException("Not authorized to view network stats for this profile"); } return this.graphService.getNetworkStats(profileId); } // ─── Admin / Verification ──────────────────────────── async verifyProfile(adminUserId: string, profileId: string, status: "basic" | "verified" | "featured") { // SECURITY: role-aware admin check (RBAC). The tRPC layer additionally // requires MODERATION_RESOLVE before this service method is reached. await assertPlatformAdmin(this.prisma, adminUserId); const updated = await this.prisma.guestPublicProfile.update({ where: { id: profileId }, data: { verificationStatus: status, isPublic: status === "basic" ? false : true, }, }); if (this.isProfileDiscoverable(updated)) { await this.networkQueue.add("search.index-profile", { profileId: updated.id }); } else { await this.networkQueue.add("search.remove-profile", { profileId: updated.id }); } return updated; } async checkAutoVerification(profileId: string) { const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, include: { bookingHistory: true, hostReviews: true, }, }); if (!profile) return; // Auto-grant "verified" status: 3+ appearances and 4.5+ rating if ( profile.verificationStatus === "basic" && profile.bookingHistory.length >= 3 && profile.averageRating.toNumber() >= 4.5 ) { const updated = await this.prisma.guestPublicProfile.update({ where: { id: profileId }, data: { verificationStatus: "verified", isPublic: true, }, }); await this.meilisearch.indexProfile(updated); this.logger.log(`Profile ${profileId} auto-verified to VERIFIED tier`); } } // ─── Network Health ──────────────────────────────── /** * Aggregate health metrics for the entire guest network. * Returns counts, averages, and distribution info useful for * dashboards and operational monitoring. */ async getNetworkHealth() { const [ totalProfiles, publicProfiles, verifiedProfiles, featuredProfiles, totalBookings, acceptedBookings, totalReviews, avgRatingResult, badgeDistribution, trustScoreStats, ] = await Promise.all([ this.prisma.guestPublicProfile.count(), this.prisma.guestPublicProfile.count({ where: { isPublic: true } }), this.prisma.guestPublicProfile.count({ where: { verificationStatus: "verified" } }), this.prisma.guestPublicProfile.count({ where: { verificationStatus: "featured" } }), this.prisma.networkBookingRequest.count(), this.prisma.networkBookingRequest.count({ where: { status: "accepted" } }), this.prisma.hostReview.count(), this.prisma.guestPublicProfile.aggregate({ _avg: { averageRating: true }, }), this.prisma.guestPublicProfile.findMany({ select: { verificationBadges: true }, }), this.prisma.trustScore.aggregate({ _avg: { score: true }, _min: { score: true }, _max: { score: true }, _count: true, }), ]); // Compute badge distribution across all profiles const badgeCounts: Record<string, number> = { linkedin: 0, email: 0, podcast_link: 0, identity: 0, website: 0, }; for (const profile of badgeDistribution) { const badges = (profile.verificationBadges ?? []) as string[]; for (const badge of badges) { if (badgeCounts[badge] !== undefined) { badgeCounts[badge]++; } } } return { profiles: { total: totalProfiles, public: publicProfiles, verified: verifiedProfiles, featured: featuredProfiles, basic: totalProfiles - verifiedProfiles - featuredProfiles, }, bookings: { total: totalBookings, accepted: acceptedBookings, acceptanceRate: totalBookings > 0 ? acceptedBookings / totalBookings : 0, }, reviews: { total: totalReviews, averageRating: (avgRatingResult._avg.averageRating as any)?.toNumber?.() ?? Number(avgRatingResult._avg.averageRating || 0), }, badgeDistribution: badgeCounts, trustScore: { average: (trustScoreStats._avg.score as any)?.toNumber?.() ?? Number(trustScoreStats._avg.score || 0), min: (trustScoreStats._min.score as any)?.toNumber?.() ?? Number(trustScoreStats._min.score || 0), max: (trustScoreStats._max.score as any)?.toNumber?.() ?? Number(trustScoreStats._max.score || 0), computedCount: trustScoreStats._count, }, }; } }
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
ForbiddenException,
BadRequestException,
} from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { createHash } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
import { AuthorizationService } from "../common/authorization.service";
import { assertPlatformAdmin } from "../common/platform-admin";
import { RedisRateLimiterService } from "../common/redis-rate-limiter.service";
import type { Prisma } from "@prisma/client";
import { GuestStatus, GuestJourneyEventType } from "@micprotocol/shared-types";
import type {
CreateGuestPublicProfile,
UpdateGuestPublicProfile,
GuestNetworkSearch,
CreateNetworkBookingRequest,
CreateNetworkBookmark,
UpdateNetworkBookmark,
CreateHostReview,
TrackProfileView,
} from "@micprotocol/shared-types";
import { MeilisearchService } from "./meilisearch.service";
import { ReputationEngine } from "./reputation.service";
import { GraphService } from "./graph.service";
@Injectable()
export class NetworkService {
private readonly logger = new Logger(NetworkService.name);
private readonly allowedVerificationStatuses = ["verified", "featured"] as const;
constructor(
private readonly prisma: PrismaService,
private readonly authorizationService: AuthorizationService,
private readonly rateLimiter: RedisRateLimiterService,
private readonly meilisearch: MeilisearchService,
private readonly reputationEngine: ReputationEngine,
private readonly graphService: GraphService,
@InjectQueue("network") private readonly networkQueue: Queue,
) {}
// ─── Vanity URL Generation ───────────────────────────
private slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.substring(0, 60);
}
private async generateVanityUrl(name: string): Promise<string> {
const base = this.slugify(name);
const existing = await this.prisma.guestPublicProfile.findUnique({ where: { vanityUrl: base } });
if (!existing) {
return base;
}
const suffix = createHash("sha256").update(`${base}-${Date.now()}`).digest("hex").substring(0, 8);
return `${base}-${suffix}`;
}
private isVerificationEligible(status: string | null | undefined): boolean {
return status === "verified" || status === "featured";
}
private isProfileDiscoverable(profile: {
isPublic: boolean;
verificationStatus: string;
}): boolean {
return profile.isPublic && this.isVerificationEligible(profile.verificationStatus);
}
// ─── Profile CRUD ────────────────────────────────────
async createProfile(userId: string, data: CreateGuestPublicProfile) {
this.logger.log(`Creating guest public profile for user ${userId}`);
// Check if profile already exists
const existing = await this.prisma.guestPublicProfile.findUnique({
where: { userId },
});
if (existing) {
throw new ConflictException("User already has a public profile");
}
// Get user for vanity URL generation
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException("User not found");
}