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

1340 lines
38 KiB
TypeScript
Raw Normal View History

2026-01-11 20:59:12 +05:30
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
ForbiddenException,
2026-01-11 20:59:12 +05:30
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateAgentProfileDto,
UpdateAgentProfileDto,
SearchAgentsDto,
SaveFieldValuesDto,
2026-01-11 20:59:12 +05:30
} from './dto';
import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client';
import { ConnectionRequestsService } from '../connection-requests/connection-requests.service';
2026-01-11 20:59:12 +05:30
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';
}
2026-01-11 20:59:12 +05:30
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,
2026-01-11 20:59:12 +05:30
userId,
slug,
},
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
2026-01-11 20:59:12 +05:30
},
});
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,
2026-01-11 20:59:12 +05:30
},
});
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 };
2026-01-11 20:59:12 +05:30
}
async getProfileById(profileId: string, requestingUserId?: string) {
2026-01-11 20:59:12 +05:30
const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId },
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
privacyPreferences: true,
2026-01-11 20:59:12 +05:30
},
},
agentType: true,
2026-01-11 20:59:12 +05:30
},
});
if (!profile) {
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 };
}
if (visibility === 'private') {
throw new ForbiddenException('This profile is not available');
}
if (visibility === 'connections' && requestingUserId) {
const connected = await this.connectionRequestsService.isConnected(requestingUserId, profile.user.id);
if (!connected) {
throw new ForbiddenException('This profile is only visible to connections');
}
} else if (visibility === 'connections' && !requestingUserId) {
throw new ForbiddenException('This profile is only visible to connections');
}
// Strip privacyPreferences from response
const { privacyPreferences, ...userWithoutPrefs } = profile.user;
return { ...profile, user: userWithoutPrefs };
2026-01-11 20:59:12 +05:30
}
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[];
};
2026-01-11 20:59:12 +05:30
const updatedProfile = await this.prisma.agentProfile.update({
where: { userId },
data: profileData,
2026-01-11 20:59:12 +05:30
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
2026-01-11 20:59:12 +05:30
},
});
// 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);
}
2026-01-11 20:59:12 +05:30
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,
},
});
}
}
2026-01-11 20:59:12 +05:30
async searchAgents(dto: SearchAgentsDto) {
const {
search,
location,
2026-01-11 20:59:12 +05:30
city,
state,
country,
agentTypeId,
2026-01-11 20:59:12 +05:30
isVerified,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
filters,
2026-01-11 20:59:12 +05:30
} = dto;
const skip = (page - 1) * limit;
// Build where clause — only show admin-approved profiles in search
const where: Prisma.AgentProfileWhereInput = {
verificationStatus: 'APPROVED',
};
2026-01-11 20:59:12 +05:30
if (search) {
2026-03-19 11:34:27 +05:30
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' } },
];
}
2026-01-11 20:59:12 +05:30
}
// 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 } }]
: []),
];
}
2026-01-11 20:59:12 +05:30
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;
2026-01-11 20:59:12 +05:30
}
// 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 for the filter slugs
const fields = await this.prisma.profileField.findMany({
where: { slug: { in: filterSlugs } },
select: {
id: true,
slug: 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, selectedValues] of Object.entries(parsedFilters)) {
const slugFields = slugToFields.get(slug);
if (!slugFields || slugFields.length === 0 || !selectedValues || selectedValues.length === 0) continue;
// 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,
},
});
const matchingIds = matchingValues
.filter((fv) => {
const jsonVal = fv.jsonValue as string[] | null;
if (!jsonVal || !Array.isArray(jsonVal)) return false;
return selectedValues.some((sv) => jsonVal.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
}
}
2026-01-11 20:59:12 +05:30
// 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,
2026-01-11 20:59:12 +05:30
},
},
agentType: true,
fieldValues: {
include: {
field: {
select: {
slug: true,
name: true,
fieldType: true,
},
},
},
},
2026-01-11 20:59:12 +05:30
},
}),
this.prisma.agentProfile.count({ where }),
]);
// Filter out private profiles (safe for null privacyPreferences)
const visibleAgents = agents.filter((agent: any) => {
const visibility = this.getProfileVisibility(agent.user?.privacyPreferences);
return visibility !== 'private';
});
2026-03-28 20:09:03 +05:30
// Compute match score for each agent based on search context
const agentsWithScore = visibleAgents.map((agent: any) => {
2026-03-28 20:09:03 +05:30
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;
}
} else {
// No location filter — give base points
score += 8;
}
// 5. Filter match (40 points) - how well dynamic field values match search filters
maxScore += 40;
if (filters) {
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) {
const selectedValues = parsedFilters[filterSlug];
const agentFieldValues = (agent.fieldValues || []) as any[];
const fieldMatch = agentFieldValues.some((fv: any) => {
if (fv.field?.slug !== filterSlug) return false;
const jsonVal = fv.jsonValue as string[] | null;
if (jsonVal && Array.isArray(jsonVal)) {
return selectedValues.some(sv => jsonVal.includes(sv));
}
return false;
});
if (fieldMatch) matchedFilters++;
}
score += Math.round((matchedFilters / filterKeys.length) * 40);
} else {
score += 20; // No specific filters, give base
}
} catch {
score += 20;
}
} else {
score += 20; // No filters applied, give base
}
// Calculate percentage (minimum 50% for any approved agent)
const rawPercentage = maxScore > 0 ? Math.round((score / maxScore) * 100) : 50;
const matchPercentage = Math.max(50, Math.min(99, rawPercentage));
// Strip privacyPreferences from response
const { user, ...agentWithoutUser } = agent;
const { privacyPreferences, ...userWithoutPrefs } = user || {};
return { ...agentWithoutUser, user: userWithoutPrefs, matchPercentage };
2026-03-28 20:09:03 +05:30
});
// Sort by match percentage (highest first)
agentsWithScore.sort((a: any, b: any) => b.matchPercentage - a.matchPercentage);
2026-01-11 20:59:12 +05:30
return {
2026-03-28 20:09:03 +05:30
data: agentsWithScore,
2026-01-11 20:59:12 +05:30
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',
2026-01-11 20:59:12 +05:30
},
take: limit,
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
2026-01-11 20:59:12 +05:30
},
});
return agents;
}
async getTopRatedAgents(limit = 10) {
const agents = await this.prisma.agentProfile.findMany({
where: {
isVerified: true,
totalReviews: { gte: 5 },
verificationStatus: 'APPROVED',
2026-01-11 20:59:12 +05:30
},
take: limit,
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
2026-01-11 20:59:12 +05:30
},
});
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,
2026-01-11 20:59:12 +05:30
include: {
user: {
select: {
id: true,
email: true,
avatar: true,
},
},
agentType: true,
2026-01-11 20:59:12 +05:30
},
});
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;
},
) {
2026-01-11 20:59:12 +05:30
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;
}
2026-01-11 20:59:12 +05:30
const updatedProfile = await this.prisma.agentProfile.update({
where: { id: profileId },
data: updateData,
2026-01-11 20:59:12 +05:30
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 || [];
}
2026-01-11 20:59:12 +05:30
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];
// Determine which column to use based on value type and field type
const valueData = this.getValueData(field.fieldType, fieldValue.value);
// 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
2026-01-24 23:10:05 +05:30
const fieldValues = await this.prisma.agentProfileFieldValue.findMany({
where: { agentProfileId: profile.id },
include: {
field: {
select: {
id: true,
slug: true,
name: true,
fieldType: true,
section: {
select: {
id: true,
slug: true,
name: true,
},
},
},
},
},
});
// Transform to a more usable format
const transformedValues = fieldValues.map((fv) => ({
fieldSlug: fv.field.slug,
fieldName: fv.field.name,
fieldType: fv.field.fieldType,
sectionSlug: fv.field.section.slug,
sectionName: fv.field.section.name,
value: this.extractValue(fv),
}));
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
const fieldValues = await this.prisma.agentProfileFieldValue.findMany({
where: { agentProfileId: profile.id },
include: {
field: {
select: {
id: true,
slug: true,
name: true,
fieldType: true,
section: {
select: {
id: true,
slug: true,
name: true,
},
},
},
},
},
});
// Transform to a more usable format
const transformedValues = fieldValues.map((fv) => ({
fieldSlug: fv.field.slug,
fieldName: fv.field.name,
fieldType: fv.field.fieldType,
sectionSlug: fv.field.section.slug,
sectionName: fv.field.section.name,
value: this.extractValue(fv),
}));
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':
2026-01-24 22:31:12 +05:30
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':
2026-01-24 22:31:12 +05:30
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' };
}
2026-01-11 20:59:12 +05:30
}