Files
backend/src/agents/agents.service.ts

1535 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateAgentProfileDto,
UpdateAgentProfileDto,
SearchAgentsDto,
SaveFieldValuesDto,
} from './dto';
import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client';
import { ConnectionRequestsService } from '../connection-requests/connection-requests.service';
function generateSlug(firstName: string, lastName: string): string {
const base = `${firstName}-${lastName}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const uniqueSuffix = Math.random().toString(36).substring(2, 8);
return `${base}-${uniqueSuffix}`;
}
@Injectable()
export class AgentsService {
constructor(
private prisma: PrismaService,
private connectionRequestsService: ConnectionRequestsService,
) {}
/**
* Get profile_visibility setting from user's privacy preferences
*/
private getProfileVisibility(privacyPreferences: any): string {
return privacyPreferences?.privacySettings?.profile_visibility || 'public';
}
/**
* Get activity_status setting from user's privacy preferences
*/
private getActivityStatus(privacyPreferences: any): string {
return privacyPreferences?.privacySettings?.activity_status || 'public';
}
async createProfile(userId: string, dto: CreateAgentProfileDto) {
// Check if user already has an agent profile
const existingProfile = await this.prisma.agentProfile.findUnique({
where: { userId },
});
if (existingProfile) {
throw new ConflictException('User already has an agent profile');
}
const slug = generateSlug(dto.firstName, dto.lastName);
const profile = await this.prisma.agentProfile.create({
data: {
...dto,
userId,
slug,
},
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
},
});
return profile;
}
async getProfile(userId: string) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId },
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
},
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
// Fetch serviceAreas from field values (not a direct column)
const serviceAreasField = await this.prisma.profileField.findFirst({
where: { slug: { in: ['service_areas', 'serviceAreas', 'city'] } },
});
let serviceAreas: string[] = [];
if (serviceAreasField) {
const fv = await this.prisma.agentProfileFieldValue.findUnique({
where: {
agentProfileId_fieldId: {
agentProfileId: profile.id,
fieldId: serviceAreasField.id,
},
},
});
if (fv?.jsonValue && Array.isArray(fv.jsonValue)) {
serviceAreas = (fv.jsonValue as string[]);
} else if (fv?.textValue) {
serviceAreas = [fv.textValue];
}
}
return { ...profile, serviceAreas };
}
async getProfileById(profileId: string, requestingUserId?: string) {
const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId },
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
status: true,
privacyPreferences: true,
},
},
agentType: true,
},
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
// Block access to inactive user profiles (unless viewing own profile)
if (profile.user.status !== 'ACTIVE' && profile.userId !== requestingUserId) {
throw new NotFoundException('Agent profile not found');
}
const visibility = this.getProfileVisibility(profile.user.privacyPreferences);
// Own profile — always visible
if (requestingUserId && requestingUserId === profile.user.id) {
const { privacyPreferences, ...userWithoutPrefs } = profile.user;
return { ...profile, user: userWithoutPrefs };
}
// Block access to non-paid agents (PAST_DUE included as grace period during Stripe retries)
const visibleStatuses = ['ACTIVE', 'TRIALING', 'PAST_DUE'];
if (!profile.subscriptionStatus || !visibleStatuses.includes(profile.subscriptionStatus)) {
throw new NotFoundException('Agent profile not found');
}
if (visibility === 'private') {
throw new ForbiddenException('This profile is not available');
}
if (visibility === 'connections') {
let isConnected = false;
if (requestingUserId) {
isConnected = await this.connectionRequestsService.isConnected(requestingUserId, profile.user.id);
}
if (!isConnected) {
throw new ForbiddenException('This profile is only visible to connections');
}
}
// Strip privacyPreferences from response
const { privacyPreferences, ...userWithoutPrefs } = profile.user;
return { ...profile, user: userWithoutPrefs };
}
async updateProfile(userId: string, dto: UpdateAgentProfileDto) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
// Filter out fields that don't exist in the database schema
// serviceAreas is accepted by DTO but stored in field values, not direct column
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { serviceAreas, ...profileData } = dto as UpdateAgentProfileDto & {
serviceAreas?: string[];
};
const updatedProfile = await this.prisma.agentProfile.update({
where: { userId },
data: profileData,
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
},
});
// Sync phone to field values if phone was updated
// This keeps AgentProfile.phone and AgentProfileFieldValue in sync
if (dto.phone !== undefined) {
await this.syncPhoneToFieldValues(profile.id, dto.phone);
}
// Sync serviceAreas to field values if provided
if (serviceAreas && serviceAreas.length > 0) {
await this.syncServiceAreasToFieldValues(profile.id, serviceAreas);
}
return updatedProfile;
}
/**
* Sync phone from AgentProfile to AgentProfileFieldValue
* This ensures both storage locations have the same phone value
*/
private async syncPhoneToFieldValues(
agentProfileId: string,
phone: string | null,
) {
// Find any phone-related field definitions
const phoneField = await this.prisma.profileField.findFirst({
where: {
slug: { in: ['phone', 'phone_number', 'cell_number', 'office_number'] },
},
});
if (phoneField) {
await this.prisma.agentProfileFieldValue.upsert({
where: {
agentProfileId_fieldId: {
agentProfileId,
fieldId: phoneField.id,
},
},
create: {
agentProfileId,
fieldId: phoneField.id,
textValue: phone,
},
update: {
textValue: phone,
},
});
}
}
/**
* Sync serviceAreas to AgentProfileFieldValue
*/
private async syncServiceAreasToFieldValues(
agentProfileId: string,
serviceAreas: string[],
) {
const serviceAreasField = await this.prisma.profileField.findFirst({
where: {
slug: { in: ['service_areas', 'serviceAreas', 'city'] },
},
});
if (serviceAreasField) {
await this.prisma.agentProfileFieldValue.upsert({
where: {
agentProfileId_fieldId: {
agentProfileId,
fieldId: serviceAreasField.id,
},
},
create: {
agentProfileId,
fieldId: serviceAreasField.id,
jsonValue: serviceAreas,
},
update: {
jsonValue: serviceAreas,
},
});
}
}
async searchAgents(dto: SearchAgentsDto, requestingUserId?: string | null) {
const {
search,
location,
city,
state,
country,
agentTypeId,
isVerified,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
filters,
} = dto;
const skip = (page - 1) * limit;
// Build where clause — only show active, admin-approved, paid subscribers in search
// PAST_DUE included as grace period (Stripe is retrying payment)
const where: Prisma.AgentProfileWhereInput = {
verificationStatus: 'APPROVED',
user: { status: 'ACTIVE' },
subscriptionStatus: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE'] },
};
if (search) {
const searchParts = search.trim().split(/\s+/);
if (searchParts.length > 1) {
// Multi-word search (e.g. "John Smith") — match first+last name combination
// plus still try the full string against other fields
where.OR = [
// Match firstName + lastName across parts
{
AND: searchParts.map((part) => ({
OR: [
{ firstName: { contains: part, mode: 'insensitive' as const } },
{ lastName: { contains: part, mode: 'insensitive' as const } },
],
})),
},
// Also match the full search string against other fields
{ bio: { contains: search, mode: 'insensitive' } },
{ headline: { contains: search, mode: 'insensitive' } },
{ companyName: { contains: search, mode: 'insensitive' } },
];
} else {
// Single word search — match against any field
where.OR = [
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
{ bio: { contains: search, mode: 'insensitive' } },
{ headline: { contains: search, mode: 'insensitive' } },
{ companyName: { contains: search, mode: 'insensitive' } },
];
}
}
// General location search - searches across legacy columns AND dynamic field values
if (location) {
// Find agent IDs that have matching city/state in dynamic field values
// City and State are CHECKBOX_GROUP fields stored as JSON arrays in jsonValue
const locationFields = await this.prisma.profileField.findMany({
where: { slug: { in: ['city', 'state', 'service_areas'] } },
select: { id: true },
});
const fieldIds = locationFields.map((f) => f.id);
let matchingAgentIds: string[] = [];
if (fieldIds.length > 0) {
// Search jsonValue (JSON arrays) and textValue using raw SQL for case-insensitive JSON content matching
const locationLower = location.toLowerCase();
// Also match snake_case version (e.g., "New York" -> "new_york")
const locationSnakeCase = locationLower.replace(/\s+/g, '_');
const matchingFieldValues =
await this.prisma.agentProfileFieldValue.findMany({
where: {
fieldId: { in: fieldIds },
OR: [
{
textValue: { contains: location, mode: 'insensitive' },
},
],
},
select: { agentProfileId: true, jsonValue: true },
});
// Also fetch all field values with jsonValue for these fields to search within JSON arrays
const jsonFieldValues =
await this.prisma.agentProfileFieldValue.findMany({
where: {
fieldId: { in: fieldIds },
jsonValue: { not: Prisma.JsonNull },
},
select: { agentProfileId: true, jsonValue: true },
});
// Filter JSON array values that contain the location string
const jsonMatchIds = jsonFieldValues
.filter((fv) => {
const val = fv.jsonValue;
if (Array.isArray(val)) {
return val.some((v: unknown) => {
if (typeof v !== 'string') return false;
const vLower = v.toLowerCase();
return (
vLower.includes(locationLower) ||
vLower.includes(locationSnakeCase) ||
locationLower.includes(vLower) ||
locationSnakeCase.includes(vLower)
);
});
}
if (typeof val === 'string') {
return val.toLowerCase().includes(locationLower);
}
return false;
})
.map((fv) => fv.agentProfileId);
const textMatchIds = matchingFieldValues.map(
(fv) => fv.agentProfileId,
);
matchingAgentIds = [
...new Set([...textMatchIds, ...jsonMatchIds]),
];
}
// Search legacy columns OR dynamic field values
where.OR = [
...(where.OR || []),
{ city: { contains: location, mode: 'insensitive' } },
{ state: { contains: location, mode: 'insensitive' } },
{ country: { contains: location, mode: 'insensitive' } },
...(matchingAgentIds.length > 0
? [{ id: { in: matchingAgentIds } }]
: []),
];
}
if (city) {
where.city = { contains: city, mode: 'insensitive' };
}
if (state) {
where.state = { contains: state, mode: 'insensitive' };
}
if (country) {
where.country = { contains: country, mode: 'insensitive' };
}
if (isVerified !== undefined) {
where.isVerified = isVerified;
}
// Filter by agent type
if (agentTypeId) {
where.agentTypeId = agentTypeId;
}
// Pre-compute RADIO option expansion map for filter matching.
// For ordinal RADIO fields (e.g. years_in_business), selecting "2+" should
// also match "5+" and "10+" — all options at or above the selected index.
let radioExpansionMap: Map<string, string[]> | null = null;
if (filters) {
try {
const pf: Record<string, string[]> = JSON.parse(filters);
const slugs = Object.keys(pf).filter(k => pf[k]?.length > 0);
if (slugs.length > 0) {
const radioFields = await this.prisma.profileField.findMany({
where: { slug: { in: slugs }, fieldType: 'RADIO' },
select: { slug: true, options: true },
});
radioExpansionMap = new Map();
for (const rf of radioFields) {
if (!Array.isArray(rf.options)) continue;
const optValues = (rf.options as Array<{ value: string }>).map(o => String(o.value));
radioExpansionMap.set(rf.slug, optValues);
}
}
} catch { /* invalid JSON — ignore */ }
}
const expandRadioValuesFromMap = (slug: string, selected: string[]): string[] => {
if (!radioExpansionMap) return selected;
const optValues = radioExpansionMap.get(slug);
if (!optValues) return selected;
const expanded = new Set(selected);
for (const sv of selected) {
const idx = optValues.indexOf(sv);
if (idx >= 0) {
for (let i = idx; i < optValues.length; i++) expanded.add(optValues[i]);
}
}
return [...expanded];
};
// Process dynamic profile field filters
if (filters) {
try {
const parsedFilters: Record<string, string[]> = JSON.parse(filters);
const filterSlugs = Object.keys(parsedFilters);
if (filterSlugs.length > 0) {
// Get field IDs + type + options for the filter slugs
const fields = await this.prisma.profileField.findMany({
where: { slug: { in: filterSlugs } },
select: {
id: true,
slug: true,
fieldType: true,
options: true,
},
});
// Group fields by slug (same slug can exist in multiple type-specific sections)
const slugToFields = new Map<string, typeof fields>();
for (const f of fields) {
const existing = slugToFields.get(f.slug) || [];
existing.push(f);
slugToFields.set(f.slug, existing);
}
// For each filter, find agents that have ANY of the selected values
// Use AND logic between different filter slugs, OR logic within same slug
const filterConditions: string[][] = [];
for (const [slug, rawSelectedValues] of Object.entries(parsedFilters)) {
const slugFields = slugToFields.get(slug);
if (!slugFields || slugFields.length === 0 || !rawSelectedValues || rawSelectedValues.length === 0) continue;
const selectedValues = expandRadioValuesFromMap(slug, rawSelectedValues);
// Search across ALL fields with this slug (e.g. loan_type in both Professional and Lender sections)
const allMatchingAgentIds: string[] = [];
const fieldIds = slugFields.map((f) => f.id);
const matchingValues = await this.prisma.agentProfileFieldValue.findMany({
where: { fieldId: { in: fieldIds } },
select: {
agentProfileId: true,
jsonValue: true,
textValue: true,
},
});
const matchingIds = matchingValues
.filter((fv) => {
const jsonVal = fv.jsonValue as unknown;
// RADIO / SELECT values live in textValue (single string)
if (typeof fv.textValue === 'string' && fv.textValue) {
if (selectedValues.includes(fv.textValue)) return true;
}
if (!Array.isArray(jsonVal)) return false;
// Direct match (normalized option values stored in jsonVal)
if (selectedValues.some((sv) => jsonVal.includes(sv))) return true;
// TAG_INPUT fallback: strip " - N yrs" suffix and normalize
const normalized = (jsonVal as unknown[])
.map((item) => typeof item === 'string' ? item : '')
.map((s) => s.replace(/\s*[-]\s*\d+\s*(yrs?|years?)\s*$/i, '').trim())
.map((s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''))
.filter(Boolean);
return selectedValues.some((sv) => normalized.includes(sv));
})
.map((fv) => fv.agentProfileId);
allMatchingAgentIds.push(...matchingIds);
filterConditions.push([...new Set(allMatchingAgentIds)]);
}
// AND all filter conditions - find intersection of all matching agent IDs
if (filterConditions.length > 0) {
const intersection = filterConditions.reduce((acc, curr) => {
const currSet = new Set(curr);
return acc.filter((id) => currSet.has(id));
});
// If no agents match all filters, return empty result
if (intersection.length === 0) {
return {
data: [],
meta: {
total: 0,
page,
limit,
totalPages: 0,
},
};
}
where.id = { in: intersection };
}
}
} catch {
// Invalid JSON, ignore filters
}
}
// Build orderBy
const orderBy: Prisma.AgentProfileOrderByWithRelationInput = {};
if (sortBy === 'averageRating') {
orderBy.averageRating = sortOrder;
} else if (sortBy === 'totalReviews') {
orderBy.totalReviews = sortOrder;
} else if (sortBy === 'firstName') {
orderBy.firstName = sortOrder;
} else {
orderBy.createdAt = sortOrder;
}
const [agents, total] = await Promise.all([
this.prisma.agentProfile.findMany({
where,
skip,
take: limit,
orderBy,
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
privacyPreferences: true,
},
},
agentType: true,
fieldValues: {
include: {
field: {
select: {
slug: true,
name: true,
fieldType: true,
},
},
},
},
},
}),
this.prisma.agentProfile.count({ where }),
]);
// Get connected user IDs for filtering "connections only" profiles
let connectedUserIds: string[] = [];
if (requestingUserId) {
connectedUserIds = await this.connectionRequestsService.getConnectedUserIds(requestingUserId);
}
// Filter out private and connections-only (for non-connected users) profiles
const visibleAgents = agents.filter((agent: any) => {
const visibility = this.getProfileVisibility(agent.user?.privacyPreferences);
if (visibility === 'private') return false;
if (visibility === 'connections') {
// Own profile always visible
if (requestingUserId && agent.user?.id === requestingUserId) return true;
// Only show if requesting user is connected
return requestingUserId && connectedUserIds.includes(agent.user?.id);
}
return true; // 'public' or no setting
});
// Determine if any search/filter criteria is active
const hasActiveSearch = !!(search || location || city || state || country || agentTypeId || filters);
// Compute match score only when user has applied search/filter criteria
const agentsWithScore = visibleAgents.map((agent: any) => {
// Strip privacyPreferences from response
const { user, ...agentWithoutUser } = agent;
const { privacyPreferences, ...userWithoutPrefs } = user || {};
// No scoring when browsing without filters
if (!hasActiveSearch) {
return { ...agentWithoutUser, user: userWithoutPrefs, matchPercentage: null };
}
let score = 0;
let maxScore = 0;
// 1. Profile completeness (15 points)
maxScore += 15;
score += Math.round(((agent.profileCompleteness || 0) / 100) * 15);
// 2. Verification (15 points)
maxScore += 15;
if (agent.isVerified) score += 15;
// 3. Rating (15 points) - normalized from 0-5 scale
maxScore += 15;
if (agent.averageRating) {
score += Math.round((agent.averageRating / 5) * 15);
}
// 4. Location match (15 points)
maxScore += 15;
if (city && agent.city?.toLowerCase() === city.toLowerCase()) {
score += 15;
} else if (state && agent.state?.toLowerCase() === state.toLowerCase()) {
score += 10;
} else if (country && agent.country?.toLowerCase() === country.toLowerCase()) {
score += 5;
} else if (location) {
const loc = location.toLowerCase();
if (agent.city?.toLowerCase().includes(loc) || agent.state?.toLowerCase().includes(loc)) {
score += 10;
}
}
// 5. Filter match (40 points) - how well dynamic field values match search filters
if (filters) {
maxScore += 40;
try {
const parsedFilters: Record<string, string[]> = JSON.parse(filters);
const filterKeys = Object.keys(parsedFilters).filter(k => parsedFilters[k]?.length > 0);
if (filterKeys.length > 0) {
let matchedFilters = 0;
for (const filterSlug of filterKeys) {
// Expand RADIO filters so "2+" also matches "5+", "10+", etc.
const rawSelected = parsedFilters[filterSlug];
const selectedValues = expandRadioValuesFromMap(filterSlug, rawSelected);
const agentFieldValues = (agent.fieldValues || []) as any[];
const fieldMatch = agentFieldValues.some((fv: any) => {
if (fv.field?.slug !== filterSlug) return false;
// RADIO / SELECT values live in textValue
if (typeof fv.textValue === 'string' && fv.textValue) {
if (selectedValues.includes(fv.textValue)) return true;
}
const jsonVal = fv.jsonValue as unknown;
if (!Array.isArray(jsonVal)) return false;
// Direct match (normalized option values already stored in jsonVal)
if (selectedValues.some(sv => jsonVal.includes(sv))) return true;
// TAG_INPUT fallback: strip " - N yrs" suffix and normalize to match aggregated expertise values
const normalized = (jsonVal as unknown[])
.map((item) => typeof item === 'string' ? item : '')
.map((s) => s.replace(/\s*[-]\s*\d+\s*(yrs?|years?)\s*$/i, '').trim())
.map((s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''))
.filter(Boolean);
return selectedValues.some(sv => normalized.includes(sv));
});
if (fieldMatch) matchedFilters++;
}
score += Math.round((matchedFilters / filterKeys.length) * 40);
}
} catch {
// Invalid JSON, skip filter scoring
}
}
// Calculate percentage (minimum 50% for any matched agent)
const rawPercentage = maxScore > 0 ? Math.round((score / maxScore) * 100) : 50;
const matchPercentage = Math.max(50, Math.min(99, rawPercentage));
return { ...agentWithoutUser, user: userWithoutPrefs, matchPercentage };
});
// Sort by match percentage (highest first) only when scoring is active
if (hasActiveSearch) {
agentsWithScore.sort((a: any, b: any) => (b.matchPercentage || 0) - (a.matchPercentage || 0));
}
return {
data: agentsWithScore,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async getFeaturedAgents(limit = 10) {
const agents = await this.prisma.agentProfile.findMany({
where: {
isVerified: true,
isFeatured: true,
verificationStatus: 'APPROVED',
},
take: limit,
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
},
});
return agents;
}
async getTopRatedAgents(limit = 10) {
const agents = await this.prisma.agentProfile.findMany({
where: {
isVerified: true,
totalReviews: { gte: 5 },
verificationStatus: 'APPROVED',
},
take: limit,
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
},
});
return agents;
}
// Admin methods
async adminUpdateProfile(profileId: string, dto: UpdateAgentProfileDto) {
const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
const updatedProfile = await this.prisma.agentProfile.update({
where: { id: profileId },
data: dto,
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
},
});
return updatedProfile;
}
async adminDeleteProfile(profileId: string) {
const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
await this.prisma.agentProfile.delete({
where: { id: profileId },
});
return { message: 'Agent profile deleted successfully' };
}
async adminVerifyAgent(
profileId: string,
data: {
status: VerificationStatus;
note?: string;
adminId: string;
},
) {
const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
const updateData: Prisma.AgentProfileUpdateInput = {
verificationStatus: data.status,
verificationNote: data.note || null,
};
if (data.status === VerificationStatus.APPROVED) {
updateData.isVerified = true;
updateData.verifiedAt = new Date();
updateData.verifiedBy = data.adminId;
} else if (data.status === VerificationStatus.REJECTED) {
updateData.isVerified = false;
updateData.verifiedAt = null;
updateData.verifiedBy = null;
}
const updatedProfile = await this.prisma.agentProfile.update({
where: { id: profileId },
data: updateData,
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
},
});
return updatedProfile;
}
async getVerificationDocuments(agentProfileId: string) {
// Find the upload-documents section field
const field = await this.prisma.profileField.findFirst({
where: {
section: { slug: 'upload-documents' },
slug: 'documents',
},
});
if (!field) return [];
const fieldValue = await this.prisma.agentProfileFieldValue.findUnique({
where: {
agentProfileId_fieldId: { agentProfileId, fieldId: field.id },
},
});
return fieldValue?.jsonValue || [];
}
async adminSetFeatured(profileId: string, isFeatured: boolean) {
const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
const updatedProfile = await this.prisma.agentProfile.update({
where: { id: profileId },
data: { isFeatured },
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
},
});
return updatedProfile;
}
async getAllAgentsAdmin(dto: SearchAgentsDto) {
// Same as searchAgents but returns all data for admin
return this.searchAgents(dto);
}
// Field Values methods
async saveFieldValues(userId: string, dto: SaveFieldValuesDto) {
// Get agent profile
const profile = await this.prisma.agentProfile.findUnique({
where: { userId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
// Process each field value
const results: Array<{
id: string;
agentProfileId: string;
fieldId: string;
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
jsonValue: Prisma.JsonValue;
dateValue: Date | null;
createdAt: Date;
updatedAt: Date;
field: {
id: string;
slug: string;
name: string;
fieldType: string;
};
}> = [];
for (const fieldValue of dto.fieldValues) {
// Find the field by slug, preferring the one from a section assigned to this agent's type
const candidateFields = await this.prisma.profileField.findMany({
where: { slug: fieldValue.fieldSlug },
include: {
section: {
include: {
agentTypeSections: { select: { agentTypeId: true } },
},
},
},
});
if (candidateFields.length === 0) {
// Skip fields that don't exist
continue;
}
// Pick the field from a section assigned to this agent's type, or global, or fallback to first
const field =
candidateFields.find(
(f) =>
f.section.agentTypeSections.some(
(ats) => ats.agentTypeId === profile.agentTypeId,
),
) ||
candidateFields.find((f) => f.section.isGlobal) ||
candidateFields[0];
// Sanitize enumerated fields: drop values that aren't in current options,
// and de-dup. Prevents stale option values from accumulating in the stored array.
let sanitizedValue: unknown = fieldValue.value;
const isEnumType =
field.fieldType === 'CHECKBOX_GROUP' ||
field.fieldType === 'MULTI_SELECT' ||
field.fieldType === 'SELECT' ||
field.fieldType === 'RADIO';
if (isEnumType && Array.isArray(field.options)) {
const validValues = new Set(
(field.options as Array<{ value: string }>).map((o) => String(o.value)),
);
if (Array.isArray(sanitizedValue)) {
sanitizedValue = (sanitizedValue as unknown[])
.filter((v) => validValues.has(String(v)))
.filter((v, i, arr) => arr.indexOf(v) === i);
} else if (
typeof sanitizedValue === 'string' &&
!validValues.has(sanitizedValue)
) {
sanitizedValue = null;
}
}
// Determine which column to use based on value type and field type
const valueData = this.getValueData(
field.fieldType,
sanitizedValue as string | number | boolean | string[] | Record<string, unknown> | null,
);
// Upsert the field value
const result = await this.prisma.agentProfileFieldValue.upsert({
where: {
agentProfileId_fieldId: {
agentProfileId: profile.id,
fieldId: field.id,
},
},
create: {
agentProfileId: profile.id,
fieldId: field.id,
...valueData,
},
update: valueData,
include: {
field: {
select: {
id: true,
slug: true,
name: true,
fieldType: true,
},
},
},
});
results.push(result);
}
// Sync phone from field values to AgentProfile.phone
// This keeps both storage locations in sync
const phoneSlugs = ['phone', 'phone_number', 'cell_number', 'office_number'];
const phoneFieldValue = dto.fieldValues.find((fv) =>
phoneSlugs.includes(fv.fieldSlug),
);
if (phoneFieldValue !== undefined) {
const phoneValue = phoneFieldValue.value;
await this.prisma.agentProfile.update({
where: { id: profile.id },
data: {
phone:
phoneValue === '' || phoneValue === null
? null
: String(phoneValue),
},
});
}
return {
message: 'Field values saved successfully',
savedCount: results.length,
data: results,
};
}
async getFieldValues(userId: string) {
// Get agent profile
const profile = await this.prisma.agentProfile.findUnique({
where: { userId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
// Get all field values for this profile, ordered by admin-defined section + field sort order
const fieldValues = await this.prisma.agentProfileFieldValue.findMany({
where: { agentProfileId: profile.id },
include: {
field: {
select: {
id: true,
slug: true,
name: true,
fieldType: true,
options: true,
section: {
select: {
id: true,
slug: true,
name: true,
},
},
},
},
},
orderBy: [
{ field: { section: { sortOrder: 'asc' } } },
{ field: { sortOrder: 'asc' } },
],
});
// Helper to resolve a raw value to its option label (for SELECT/RADIO/MULTI_SELECT fields)
// Returns `null` when no matching option is found — the caller can treat those as orphans.
const resolveLabel = (rawValue: any, options: any): any => {
if (!options || !Array.isArray(options) || options.length === 0) return rawValue;
const lookup = (val: any): string | null => {
const match = (options as any[]).find((o: any) => o.value === val || o.value === String(val));
return match?.label ?? null;
};
if (Array.isArray(rawValue)) return rawValue.map(lookup);
return lookup(rawValue);
};
// Transform to a more usable format. For enumerated field types, drop any array entry
// whose value no longer resolves to a current option so stale selections never leak through.
const transformedValues = fieldValues.map((fv) => {
const rawValue = this.extractValue(fv);
let value: unknown = rawValue;
let valueLabel: unknown = resolveLabel(rawValue, fv.field.options);
const isEnum =
fv.field.fieldType === 'CHECKBOX_GROUP' ||
fv.field.fieldType === 'MULTI_SELECT' ||
fv.field.fieldType === 'SELECT' ||
fv.field.fieldType === 'RADIO';
if (isEnum && Array.isArray(value) && Array.isArray(valueLabel)) {
const pairs = (value as unknown[]).map((v, i) => ({ v, l: (valueLabel as unknown[])[i] }))
.filter((p) => p.l !== null && p.l !== undefined);
value = pairs.map((p) => p.v);
valueLabel = pairs.map((p) => p.l);
}
return {
fieldSlug: fv.field.slug,
fieldName: fv.field.name,
fieldType: fv.field.fieldType,
sectionSlug: fv.field.section.slug,
sectionName: fv.field.section.name,
value,
valueLabel,
};
});
return {
agentProfileId: profile.id,
fieldValues: transformedValues,
};
}
async getFieldValuesByAgentId(agentId: string) {
// Get agent profile by ID
const profile = await this.prisma.agentProfile.findUnique({
where: { id: agentId },
});
if (!profile) {
throw new NotFoundException('Agent profile not found');
}
// Get all field values for this profile, ordered by admin-defined section + field sort order
const fieldValues = await this.prisma.agentProfileFieldValue.findMany({
where: { agentProfileId: profile.id },
include: {
field: {
select: {
id: true,
slug: true,
name: true,
fieldType: true,
options: true,
section: {
select: {
id: true,
slug: true,
name: true,
},
},
},
},
},
orderBy: [
{ field: { section: { sortOrder: 'asc' } } },
{ field: { sortOrder: 'asc' } },
],
});
// Helper to resolve a raw value to its option label (for SELECT/RADIO/MULTI_SELECT fields)
// Returns `null` when no matching option is found — the caller can treat those as orphans.
const resolveLabel = (rawValue: any, options: any): any => {
if (!options || !Array.isArray(options) || options.length === 0) return rawValue;
const lookup = (val: any): string | null => {
const match = (options as any[]).find((o: any) => o.value === val || o.value === String(val));
return match?.label ?? null;
};
if (Array.isArray(rawValue)) return rawValue.map(lookup);
return lookup(rawValue);
};
// Transform to a more usable format. For enumerated field types, drop any array entry
// whose value no longer resolves to a current option so stale selections never leak through.
const transformedValues = fieldValues.map((fv) => {
const rawValue = this.extractValue(fv);
let value: unknown = rawValue;
let valueLabel: unknown = resolveLabel(rawValue, fv.field.options);
const isEnum =
fv.field.fieldType === 'CHECKBOX_GROUP' ||
fv.field.fieldType === 'MULTI_SELECT' ||
fv.field.fieldType === 'SELECT' ||
fv.field.fieldType === 'RADIO';
if (isEnum && Array.isArray(value) && Array.isArray(valueLabel)) {
const pairs = (value as unknown[]).map((v, i) => ({ v, l: (valueLabel as unknown[])[i] }))
.filter((p) => p.l !== null && p.l !== undefined);
value = pairs.map((p) => p.v);
valueLabel = pairs.map((p) => p.l);
}
return {
fieldSlug: fv.field.slug,
fieldName: fv.field.name,
fieldType: fv.field.fieldType,
sectionSlug: fv.field.section.slug,
sectionName: fv.field.section.name,
value,
valueLabel,
};
});
return {
agentProfileId: profile.id,
fieldValues: transformedValues,
};
}
private getValueData(
fieldType: string,
value: string | number | boolean | string[] | Record<string, unknown> | null,
): {
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
jsonValue: PrismaTypes.InputJsonValue | typeof PrismaTypes.DbNull;
dateValue: Date | null;
} {
// Reset all value columns
const valueData: {
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
jsonValue: PrismaTypes.InputJsonValue | typeof PrismaTypes.DbNull;
dateValue: Date | null;
} = {
textValue: null,
numberValue: null,
booleanValue: null,
jsonValue: PrismaTypes.DbNull,
dateValue: null,
};
if (value === null || value === undefined) {
return valueData;
}
switch (fieldType) {
case 'TEXT':
case 'TEXTAREA':
valueData.textValue = String(value);
break;
case 'NUMBER':
case 'RANGE':
valueData.numberValue =
typeof value === 'number' ? value : parseFloat(String(value));
break;
case 'CHECKBOX':
valueData.booleanValue =
typeof value === 'boolean' ? value : value === 'true';
break;
case 'DATE':
valueData.dateValue = new Date(String(value));
break;
case 'SELECT':
case 'RADIO':
// Single selection - store as text
valueData.textValue = String(value);
break;
case 'MULTI_SELECT':
case 'CHECKBOX_GROUP':
case 'TAG_INPUT':
case 'FILE':
case 'FILE_UPLOAD':
case 'REPEATER':
// Arrays and complex objects - store as JSON
valueData.jsonValue = value as PrismaTypes.InputJsonValue;
break;
default:
// Default to JSON for unknown types
if (typeof value === 'object') {
valueData.jsonValue = value as PrismaTypes.InputJsonValue;
} else {
valueData.textValue = String(value);
}
}
return valueData;
}
private extractValue(fieldValue: {
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
jsonValue: unknown;
dateValue: Date | null;
field: { fieldType: string };
}): unknown {
const { fieldType } = fieldValue.field;
switch (fieldType) {
case 'TEXT':
case 'TEXTAREA':
case 'SELECT':
case 'RADIO':
return fieldValue.textValue;
case 'NUMBER':
case 'RANGE':
return fieldValue.numberValue;
case 'CHECKBOX':
return fieldValue.booleanValue;
case 'DATE':
return fieldValue.dateValue?.toISOString();
case 'MULTI_SELECT':
case 'CHECKBOX_GROUP':
case 'TAG_INPUT':
case 'FILE':
case 'FILE_UPLOAD':
case 'REPEATER':
return fieldValue.jsonValue;
default:
// Return first non-null value
return (
fieldValue.jsonValue ??
fieldValue.textValue ??
fieldValue.numberValue ??
fieldValue.booleanValue ??
fieldValue.dateValue?.toISOString()
);
}
}
// =============================================
// NOTIFICATION PREFERENCES
// =============================================
async getNotificationPreferences(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { notificationPreferences: true, role: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.role !== 'AGENT') {
throw new BadRequestException('This endpoint is for agents only');
}
// Return preferences or default empty object
return user.notificationPreferences || { email: {}, push: {} };
}
async updateNotificationPreferences(
userId: string,
preferences: { email?: Record<string, boolean>; push?: Record<string, boolean> },
) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.role !== 'AGENT') {
throw new BadRequestException('This endpoint is for agents only');
}
const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: {
notificationPreferences: preferences,
},
select: { notificationPreferences: true },
});
return updatedUser.notificationPreferences;
}
// =============================================
// PRIVACY PREFERENCES
// =============================================
async getPrivacyPreferences(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { privacyPreferences: true, role: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.role !== 'AGENT') {
throw new BadRequestException('This endpoint is for agents only');
}
// Return preferences or default empty object
return user.privacyPreferences || { privacySettings: {}, dataSettings: {} };
}
async updatePrivacyPreferences(
userId: string,
preferences: {
privacySettings?: Record<string, string>;
dataSettings?: Record<string, boolean>;
},
) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.role !== 'AGENT') {
throw new BadRequestException('This endpoint is for agents only');
}
const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: {
privacyPreferences: preferences,
},
select: { privacyPreferences: true },
});
return updatedUser.privacyPreferences;
}
// =============================================
// DELETE ACCOUNT
// =============================================
async deleteAccount(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.role !== 'AGENT') {
throw new BadRequestException('This endpoint is for agents only');
}
// Delete in a transaction to ensure data consistency
await this.prisma.$transaction(async (tx) => {
// First, delete the agent profile and related data
const agentProfile = await tx.agentProfile.findUnique({
where: { userId },
});
if (agentProfile) {
// Delete agent profile field values
await tx.agentProfileFieldValue.deleteMany({
where: { agentProfileId: agentProfile.id },
});
// Delete the agent profile
await tx.agentProfile.delete({
where: { userId },
});
}
// Finally, delete the user account
await tx.user.delete({
where: { id: userId },
});
});
return { message: 'Account deleted successfully' };
}
}