import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, 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'; import { commercials_json, PG_CONNECTION } from '../drizzle/schema'; import { FileService } from '../file/file.service'; import { GeoService } from '../geo/geo.service'; import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model'; import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model'; import { getDistanceQuery, splitName } from '../utils'; import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils'; @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, user: JwtUser): SQL[] { const whereConditions: SQL[] = []; if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(sql`(${commercials_json.data}->'location'->>'name') ILIKE ${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(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); } if (criteria.types && criteria.types.length > 0) { this.logger.warn('Adding commercial property type filter', { types: criteria.types }); // Use explicit SQL with IN for robust JSONB comparison const typeValues = criteria.types.map(t => sql`${t}`); whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`); } if (criteria.state) { whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`); } if (criteria.minPrice) { whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice)); } if (criteria.maxPrice) { whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice)); } if (criteria.title) { whereConditions.push( sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})` ); } if (criteria.brokerName) { const { firstname, lastname } = splitName(criteria.brokerName); if (firstname === lastname) { // Single word: search either first OR last name whereConditions.push( sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})` ); } else { // Multiple words: search both first AND last name whereConditions.push( sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})` ); } } if (user?.role !== 'admin') { whereConditions.push( sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)` ); } this.logger.warn('whereConditions count', { count: whereConditions.length }); 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_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email)); const whereConditions = this.getWhereConditions(criteria, user); this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) }); if (whereConditions.length > 0) { const whereClause = sql.join(whereConditions, sql` AND `); query.where(sql`(${whereClause})`); this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params }); } // Sortierung switch (criteria.sortBy) { case 'priceAsc': query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`)); break; case 'priceDesc': query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`)); break; case 'creationDateFirst': query.orderBy(asc(sql`${commercials_json.data}->>'created'`)); break; case 'creationDateLast': query.orderBy(desc(sql`${commercials_json.data}->>'created'`)); break; default: // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden break; } // Paginierung query.limit(length).offset(start); const data = await query; const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) })); const totalCount = await this.getCommercialPropertiesCount(criteria, user); return { results, totalCount, }; } async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise { const countQuery = this.conn.select({ value: count() }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email)); const whereConditions = this.getWhereConditions(criteria, user); if (whereConditions.length > 0) { const whereClause = sql.join(whereConditions, sql` AND `); countQuery.where(sql`(${whereClause})`); } const [{ value: totalCount }] = await countQuery; return totalCount; } // #### Find by ID ######################################## /** * Find commercial property by slug or ID * Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID */ async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise { this.logger.debug(`findCommercialBySlugOrId called with: ${slugOrId}`); let id = slugOrId; // Check if it's a slug (contains multiple hyphens) vs UUID if (isSlug(slugOrId)) { this.logger.debug(`Detected as slug: ${slugOrId}`); // Extract short ID from slug and find by slug field const listing = await this.findCommercialBySlug(slugOrId); if (listing) { this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`); id = listing.id; } else { this.logger.warn(`Slug not found in database: ${slugOrId}`); throw new NotFoundException( `Commercial property listing not found with slug: ${slugOrId}. ` + `The listing may have been deleted or the URL may be incorrect.` ); } } else { this.logger.debug(`Detected as UUID: ${slugOrId}`); } return this.findCommercialPropertiesById(id, user); } /** * Find commercial property by slug */ async findCommercialBySlug(slug: string): Promise { const result = await this.conn .select() .from(commercials_json) .where(sql`${commercials_json.data}->>'slug' = ${slug}`) .limit(1); if (result.length > 0) { return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing; } return null; } async findCommercialPropertiesById(id: string, user: JwtUser): Promise { const conditions = []; if (user?.role !== 'admin') { conditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`)); } conditions.push(eq(commercials_json.id, id)); const result = await this.conn .select() .from(commercials_json) .where(and(...conditions)); if (result.length > 0) { return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing; } else { throw new BadRequestException(`No entry available for ${id}`); } } // #### Find by User EMail ######################################## async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise { const conditions = []; conditions.push(eq(commercials_json.email, email)); if (email !== user?.email && user?.role !== 'admin') { conditions.push(sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`); } const listings = await this.conn .select() .from(commercials_json) .where(and(...conditions)); return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing); } // #### Find Favorites ######################################## async findFavoriteListings(user: JwtUser): Promise { const userFavorites = await this.conn .select() .from(commercials_json) .where(sql`${commercials_json.data}->'favoritesForUser' ? ${user.email}`); return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing); } // #### Find by imagePath ######################################## async findByImagePath(imagePath: string, serial: string): Promise { const result = await this.conn .select() .from(commercials_json) .where(and(sql`(${commercials_json.data}->>'imagePath') = ${imagePath}`, sql`(${commercials_json.data}->>'serialId')::integer = ${serial}`)); if (result.length > 0) { return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing; } } // #### CREATE ######################################## async createListing(data: CommercialPropertyListing): Promise { try { // Generate serialId based on timestamp + random number (temporary solution until sequence is created) // This ensures uniqueness without requiring a database sequence const serialId = Date.now() % 1000000 + Math.floor(Math.random() * 1000); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.updated = new Date(); data.serialId = Number(serialId); CommercialPropertyListingSchema.parse(data); const { id, email, ...rest } = data; const convertedCommercialPropertyListing = { email, data: rest }; const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning(); // Generate and update slug after creation (we need the ID first) const slug = generateSlug(data.title, data.location, createdListing.id); const listingWithSlug = { ...(createdListing.data as any), slug }; await this.conn.update(commercials_json).set({ data: listingWithSlug }).where(eq(commercials_json.id, createdListing.id)); return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing), slug } as any; } 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 CommercialProps ######################################## async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing, user: JwtUser): Promise { try { const [existingListing] = await this.conn.select().from(commercials_json).where(eq(commercials_json.id, id)); if (!existingListing) { throw new NotFoundException(`Business listing with id ${id} not found`); } data.updated = new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); if (existingListing.email === user?.email || !user) { data.favoritesForUser = (existingListing.data).favoritesForUser || []; } // Regenerate slug if title or location changed const existingData = existingListing.data as CommercialPropertyListing; let slug: string; if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) { slug = generateSlug(data.title, data.location, id); } else { // Keep existing slug slug = (existingData as any).slug || generateSlug(data.title, data.location, id); } // Add slug to data before validation const dataWithSlug = { ...data, slug }; CommercialPropertyListingSchema.parse(dataWithSlug); const imageOrder = await this.fileService.getPropertyImages(dataWithSlug.imagePath, String(dataWithSlug.serialId)); const difference = imageOrder.filter(x => !dataWithSlug.imageOrder.includes(x)).concat(dataWithSlug.imageOrder.filter(x => !imageOrder.includes(x))); if (difference.length > 0) { this.logger.warn(`changes between image directory and imageOrder in listing ${dataWithSlug.serialId}: ${difference.join(',')}`); dataWithSlug.imageOrder = imageOrder; } const { id: _, email, ...rest } = dataWithSlug; const convertedCommercialPropertyListing = { email, data: rest }; const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning(); return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) }; } 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; } } // ############################################################## // Images for commercial Properties // ############################################################## async deleteImage(imagePath: string, serial: string, name: string) { const listing = await this.findByImagePath(imagePath, serial); const index = listing.imageOrder.findIndex(im => im === name); if (index > -1) { listing.imageOrder.splice(index, 1); await this.updateCommercialPropertyListing(listing.id, listing, null); } } async addImage(imagePath: string, serial: string, imagename: string) { const listing = await this.findByImagePath(imagePath, serial); listing.imageOrder.push(imagename); await this.updateCommercialPropertyListing(listing.id, listing, null); } // #### DELETE ######################################## async deleteListing(id: string): Promise { await this.conn.delete(commercials_json).where(eq(commercials_json.id, id)); } // #### ADD Favorite ###################################### async addFavorite(id: string, user: JwtUser): Promise { await this.conn .update(commercials_json) .set({ data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', coalesce((${commercials_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`, }) .where(eq(commercials_json.id, id)); } // #### DELETE Favorite ################################### async deleteFavorite(id: string, user: JwtUser): Promise { await this.conn .update(commercials_json) .set({ data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', (SELECT coalesce(jsonb_agg(elem), '[]'::jsonb) FROM jsonb_array_elements(coalesce(${commercials_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem WHERE elem::text != to_jsonb(${user.email}::text)::text))`, }) .where(eq(commercials_json.id, id)); } }