1535 lines
47 KiB
TypeScript
1535 lines
47 KiB
TypeScript
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' };
|
||
}
|
||
}
|