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 { commercials, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; import { GeoService } from '../geo/geo.service.js'; import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model.js'; import { CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; import { getDistanceQuery } from '../utils.js'; @Injectable() export class CommercialPropertyService { constructor( @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(PG_CONNECTION) private conn: NodePgDatabase, private fileService: FileService, private geoService: GeoService, ) {} private getWhereConditions(criteria: CommercialPropertyListingCriteria): SQL[] { const whereConditions: SQL[] = []; if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(ilike(schema.commercials.city, `%${criteria.city}%`)); } if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city); whereConditions.push(sql`${getDistanceQuery(commercials, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); } if (criteria.types && criteria.types.length > 0) { whereConditions.push(inArray(schema.commercials.type, criteria.types)); } if (criteria.state) { whereConditions.push(eq(schema.commercials.state, criteria.state)); } if (criteria.county) { whereConditions.push(ilike(schema.commercials.county, `%${criteria.county}%`)); } if (criteria.minPrice) { whereConditions.push(gte(schema.commercials.price, criteria.minPrice)); } if (criteria.maxPrice) { whereConditions.push(lte(schema.commercials.price, criteria.maxPrice)); } if (criteria.title) { whereConditions.push(or(ilike(schema.commercials.title, `%${criteria.title}%`), ilike(schema.commercials.description, `%${criteria.title}%`))); } whereConditions.push(and(eq(schema.users.customerType, 'professional'))); return whereConditions; } // #### Find by criteria ######################################## async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise { const start = criteria.start ? criteria.start : 0; const length = criteria.length ? criteria.length : 12; const query = this.conn.select({ commercial: commercials }).from(commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email)); const whereConditions = this.getWhereConditions(criteria); if (whereConditions.length > 0) { const whereClause = and(...whereConditions); query.where(whereClause); } // Paginierung query.limit(length).offset(start); const data = await query; const results = data.map(r => r.commercial); const totalCount = await this.getCommercialPropertiesCount(criteria); return { results, totalCount, }; } async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria): Promise { const countQuery = this.conn.select({ value: count() }).from(schema.commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email)); const whereConditions = this.getWhereConditions(criteria); if (whereConditions.length > 0) { const whereClause = and(...whereConditions); countQuery.where(whereClause); } const [{ value: totalCount }] = await countQuery; return totalCount; } // #### Find by ID ######################################## async findCommercialPropertiesById(id: string, user: JwtUser): Promise { let result = await this.conn .select() .from(commercials) .where(and(sql`${commercials.id} = ${id}`)); result = result.filter(r => !r.draft || r.imagePath === emailToDirName(user?.username) || user?.roles.includes('ADMIN')); return result[0] as CommercialPropertyListing; } // #### Find by User EMail ######################################## async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise { const conditions = []; conditions.push(eq(commercials.imagePath, emailToDirName(email))); if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) { conditions.push(ne(commercials.draft, true)); } return (await this.conn .select() .from(commercials) .where(and(...conditions))) as CommercialPropertyListing[]; } // #### Find by imagePath ######################################## async findByImagePath(imagePath: string, serial: string): Promise { const result = await this.conn .select() .from(commercials) .where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`)); return result[0] as CommercialPropertyListing; } // #### CREATE ######################################## async createListing(data: CommercialPropertyListing): Promise { try { data.created = new Date(); data.updated = new Date(); const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data); const [createdListing] = await this.conn.insert(commercials).values(validatedCommercialPropertyListing).returning(); return createdListing as CommercialPropertyListing; } catch (error) { if (error instanceof ZodError) { const formattedErrors = error.errors.map(err => ({ field: err.path.join('.'), message: err.message, })); throw new BadRequestException(formattedErrors); } throw error; } } // #### UPDATE CommercialProps ######################################## async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing): Promise { try { data.updated = new Date(); data.created = new Date(data.created); const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data); const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId)); let difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x))); if (difference.length > 0) { this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`); data.imageOrder = imageOrder; } const [updateListing] = await this.conn.update(commercials).set(data).where(eq(commercials.id, id)).returning(); return updateListing as CommercialPropertyListing; } catch (error) { if (error instanceof ZodError) { const formattedErrors = error.errors.map(err => ({ field: err.path.join('.'), message: err.message, })); throw new BadRequestException(formattedErrors); } throw error; } } // ############################################################## // Images for commercial Properties // ############################################################## async deleteImage(imagePath: string, serial: string, name: string) { const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing; const index = listing.imageOrder.findIndex(im => im === name); if (index > -1) { listing.imageOrder.splice(index, 1); await this.updateCommercialPropertyListing(listing.id, listing); } } async addImage(imagePath: string, serial: string, imagename: string) { const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing; listing.imageOrder.push(imagename); await this.updateCommercialPropertyListing(listing.id, listing); } // #### DELETE ######################################## async deleteListing(id: string): Promise { await this.conn.delete(commercials).where(eq(commercials.id, id)); } // ############################################################## // States // ############################################################## async getStates(): Promise { return await this.conn .select({ state: commercials.state, count: sql`count(${commercials.id})`.mapWith(Number) }) .from(commercials) .groupBy(sql`${commercials.state}`) .orderBy(sql`count desc`); } }