import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { and, count, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; import { ZodError } from 'zod'; import * as schema from '../drizzle/schema.js'; import { businesses, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; import { GeoService } from '../geo/geo.service.js'; import { BusinessListing, BusinessListingSchema } from '../models/db.model.js'; import { BusinessListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; import { convertBusinessToDrizzleBusiness, convertDrizzleBusinessToBusiness, getDistanceQuery, splitName } from '../utils.js'; @Injectable() export class BusinessListingService { constructor( @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(PG_CONNECTION) private conn: NodePgDatabase, private fileService?: FileService, private geoService?: GeoService, ) {} private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] { const whereConditions: SQL[] = []; if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(ilike(businesses.city, `%${criteria.city.name}%`)); } if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name); whereConditions.push(sql`${getDistanceQuery(businesses, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); } if (criteria.types && criteria.types.length > 0) { whereConditions.push(inArray(businesses.type, criteria.types)); } if (criteria.state) { whereConditions.push(eq(businesses.state, criteria.state)); } if (criteria.minPrice) { whereConditions.push(gte(businesses.price, criteria.minPrice)); } if (criteria.maxPrice) { whereConditions.push(lte(businesses.price, criteria.maxPrice)); } if (criteria.minRevenue) { whereConditions.push(gte(businesses.salesRevenue, criteria.minRevenue)); } if (criteria.maxRevenue) { whereConditions.push(lte(businesses.salesRevenue, criteria.maxRevenue)); } if (criteria.minCashFlow) { whereConditions.push(gte(businesses.cashFlow, criteria.minCashFlow)); } if (criteria.maxCashFlow) { whereConditions.push(lte(businesses.cashFlow, criteria.maxCashFlow)); } if (criteria.minNumberEmployees) { whereConditions.push(gte(businesses.employees, criteria.minNumberEmployees)); } if (criteria.maxNumberEmployees) { whereConditions.push(lte(businesses.employees, criteria.maxNumberEmployees)); } if (criteria.establishedSince) { whereConditions.push(gte(businesses.established, criteria.establishedSince)); } if (criteria.establishedUntil) { whereConditions.push(lte(businesses.established, criteria.establishedUntil)); } if (criteria.realEstateChecked) { whereConditions.push(eq(businesses.realEstateIncluded, criteria.realEstateChecked)); } if (criteria.leasedLocation) { whereConditions.push(eq(businesses.leasedLocation, criteria.leasedLocation)); } if (criteria.franchiseResale) { whereConditions.push(eq(businesses.franchiseResale, criteria.franchiseResale)); } if (criteria.title) { whereConditions.push(or(ilike(businesses.title, `%${criteria.title}%`), ilike(businesses.description, `%${criteria.title}%`))); } if (criteria.brokerName) { const { firstname, lastname } = splitName(criteria.brokerName); if (firstname === lastname) { whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`))); } else { whereConditions.push(and(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`))); } } // if (criteria.brokerName) { // whereConditions.push(or(ilike(schema.users.firstname, `%${criteria.brokerName}%`), ilike(schema.users.lastname, `%${criteria.brokerName}%`))); // } if (!user?.roles?.includes('ADMIN') ?? false) { whereConditions.push(or(eq(businesses.email, user?.username), ne(businesses.draft, true))); } whereConditions.push(and(eq(schema.users.customerType, 'professional'), eq(schema.users.customerSubType, 'broker'))); return whereConditions; } async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) { const start = criteria.start ? criteria.start : 0; const length = criteria.length ? criteria.length : 12; const query = this.conn .select({ business: businesses, brokerFirstName: schema.users.firstname, brokerLastName: schema.users.lastname, }) .from(businesses) .leftJoin(schema.users, eq(businesses.email, schema.users.email)); const whereConditions = this.getWhereConditions(criteria, user); if (whereConditions.length > 0) { const whereClause = and(...whereConditions); query.where(whereClause); } // Paginierung query.limit(length).offset(start); const data = await query; const totalCount = await this.getBusinessListingsCount(criteria, user); const results = data.map(r => r.business).map(r => convertDrizzleBusinessToBusiness(r)); return { results, totalCount, }; } async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise { const countQuery = this.conn.select({ value: count() }).from(businesses).leftJoin(schema.users, eq(businesses.email, schema.users.email)); const whereConditions = this.getWhereConditions(criteria, user); if (whereConditions.length > 0) { const whereClause = and(...whereConditions); countQuery.where(whereClause); } const [{ value: totalCount }] = await countQuery; return totalCount; } async findBusinessesById(id: string, user: JwtUser): Promise { const conditions = []; if (!user?.roles?.includes('ADMIN') ?? false) { conditions.push(or(eq(businesses.email, user?.username), ne(businesses.draft, true))); } conditions.push(sql`${businesses.id} = ${id}`); let result = await this.conn .select() .from(businesses) .where(and(...conditions)); if (result.length > 0) { return convertDrizzleBusinessToBusiness(result[0]) as BusinessListing; } else { throw new BadRequestException(`No entry available for ${id}`); } } async findBusinessesByEmail(email: string, user: JwtUser): Promise { const conditions = []; conditions.push(eq(businesses.imageName, emailToDirName(email))); if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) { conditions.push(ne(businesses.draft, true)); } const listings = (await this.conn .select() .from(businesses) .where(and(...conditions))) as BusinessListing[]; return listings.map(l => convertDrizzleBusinessToBusiness(l)); } // #### CREATE ######################################## async createListing(data: BusinessListing): Promise { try { data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.updated = new Date(); const validatedBusinessListing = BusinessListingSchema.parse(data); const convertedBusinessListing = convertBusinessToDrizzleBusiness(data); delete convertedBusinessListing.id; const [createdListing] = await this.conn.insert(businesses).values(convertedBusinessListing).returning(); return convertDrizzleBusinessToBusiness(createdListing); } catch (error) { if (error instanceof ZodError) { const filteredErrors = error.errors .map(item => ({ ...item, field: item.path[0], })) .filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0])); throw new BadRequestException(filteredErrors); } throw error; } } // #### UPDATE Business ######################################## async updateBusinessListing(id: string, data: BusinessListing): Promise { try { data.updated = new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); const validatedBusinessListing = BusinessListingSchema.parse(data); const convertedBusinessListing = convertBusinessToDrizzleBusiness(data); const [updateListing] = await this.conn.update(businesses).set(convertedBusinessListing).where(eq(businesses.id, id)).returning(); return convertDrizzleBusinessToBusiness(updateListing); } catch (error) { if (error instanceof ZodError) { const filteredErrors = error.errors .map(item => ({ ...item, field: item.path[0], })) .filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0])); throw new BadRequestException(filteredErrors); } throw error; } } // #### DELETE ######################################## async deleteListing(id: string): Promise { await this.conn.delete(businesses).where(eq(businesses.id, id)); } // ############################################################## // States // ############################################################## async getStates(): Promise { return await this.conn .select({ state: businesses.state, count: sql`count(${businesses.id})`.mapWith(Number) }) .from(businesses) .groupBy(sql`${businesses.state}`) .orderBy(sql`count desc`); } }