Spaces:
Running
Running
Download apps/api/src/network/network.service.ts from Benaah/MicProtocol: direct link, hf CLI and curl.
- Browser
- Download file 66.9 kB
-
https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/network/network.service.ts
- Command line
-
hf download hf://spaces/Benaah/MicProtocol/apps/api/src/network/network.service.ts
-
curl -L -o network.service.ts https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/network/network.service.ts
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";
()
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,
("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");
}