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 { businesses_json, PG_CONNECTION, users_json } from '../drizzle/schema'; import { GeoService } from '../geo/geo.service'; import { BusinessListing, BusinessListingSchema } from '../models/db.model'; import { BusinessListingCriteria, JwtUser } from '../models/main.model'; import { getDistanceQuery, splitName } from '../utils'; import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils'; @Injectable() export class BusinessListingService { constructor( @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(PG_CONNECTION) private conn: NodePgDatabase, private geoService?: GeoService, ) {} private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] { const whereConditions: SQL[] = []; if (criteria.city && criteria.searchType === 'exact') { whereConditions.push(sql`(${businesses_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(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); } if (criteria.types && Array.isArray(criteria.types) && criteria.types.length > 0) { const validTypes = criteria.types.filter(t => t !== null && t !== undefined && t !== ''); if (validTypes.length > 0) { whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, validTypes)); } } if (criteria.state) { whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`); } if (criteria.minPrice !== undefined && criteria.minPrice !== null) { whereConditions.push( and( sql`(${businesses_json.data}->>'price') IS NOT NULL`, sql`(${businesses_json.data}->>'price') != ''`, gte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.minPrice) ) ); } if (criteria.maxPrice !== undefined && criteria.maxPrice !== null) { whereConditions.push( and( sql`(${businesses_json.data}->>'price') IS NOT NULL`, sql`(${businesses_json.data}->>'price') != ''`, lte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.maxPrice) ) ); } if (criteria.minRevenue) { whereConditions.push(gte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.minRevenue)); } if (criteria.maxRevenue) { whereConditions.push(lte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.maxRevenue)); } if (criteria.minCashFlow) { whereConditions.push(gte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.minCashFlow)); } if (criteria.maxCashFlow) { whereConditions.push(lte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.maxCashFlow)); } if (criteria.minNumberEmployees) { whereConditions.push(gte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.minNumberEmployees)); } if (criteria.maxNumberEmployees) { whereConditions.push(lte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.maxNumberEmployees)); } if (criteria.establishedMin) { whereConditions.push(gte(sql`(${businesses_json.data}->>'established')::integer`, criteria.establishedMin)); } if (criteria.realEstateChecked) { whereConditions.push(eq(sql`(${businesses_json.data}->>'realEstateIncluded')::boolean`, criteria.realEstateChecked)); } if (criteria.leasedLocation) { whereConditions.push(eq(sql`(${businesses_json.data}->>'leasedLocation')::boolean`, criteria.leasedLocation)); } if (criteria.franchiseResale) { whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale)); } if (criteria.title && criteria.title.trim() !== '') { const searchTerm = `%${criteria.title.trim()}%`; whereConditions.push( or( sql`(${businesses_json.data}->>'title') ILIKE ${searchTerm}`, sql`(${businesses_json.data}->>'description') ILIKE ${searchTerm}` ) ); } if (criteria.brokerName) { const { firstname, lastname } = splitName(criteria.brokerName); if (firstname === lastname) { whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`); } else { whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`); } } if (criteria.email) { whereConditions.push(eq(users_json.email, criteria.email)); } if (user?.role !== 'admin') { whereConditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`)); } whereConditions.push(and(sql`(${users_json.data}->>'customerType') = 'professional'`, sql`(${users_json.data}->>'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_json, brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'), brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'), }) .from(businesses_json) .leftJoin(users_json, eq(businesses_json.email, users_json.email)); const whereConditions = this.getWhereConditions(criteria, user); // Uncomment for debugging filter issues: // this.logger.info('Filter Criteria:', { criteria }); // this.logger.info('Where Conditions Count:', { count: whereConditions.length }); if (whereConditions.length > 0) { const whereClause = and(...whereConditions); query.where(whereClause); // Uncomment for debugging SQL queries: // this.logger.info('Generated SQL:', { sql: query.toSQL() }); } // Sortierung switch (criteria.sortBy) { case 'priceAsc': query.orderBy(asc(sql`(${businesses_json.data}->>'price')::double precision`)); break; case 'priceDesc': query.orderBy(desc(sql`(${businesses_json.data}->>'price')::double precision`)); break; case 'srAsc': query.orderBy(asc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`)); break; case 'srDesc': query.orderBy(desc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`)); break; case 'cfAsc': query.orderBy(asc(sql`(${businesses_json.data}->>'cashFlow')::double precision`)); break; case 'cfDesc': query.orderBy(desc(sql`(${businesses_json.data}->>'cashFlow')::double precision`)); break; case 'creationDateFirst': query.orderBy(asc(sql`${businesses_json.data}->>'created'`)); break; case 'creationDateLast': query.orderBy(desc(sql`${businesses_json.data}->>'created'`)); break; default: { // NEU (created < 14 Tage) > UPDATED (updated < 14 Tage) > Rest const recencyRank = sql` CASE WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days')) THEN 2 WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days')) THEN 1 ELSE 0 END `; // Innerhalb der Gruppe: // NEW → created DESC // UPDATED → updated DESC // Rest → created DESC const groupTimestamp = sql` CASE WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days')) THEN (${businesses_json.data}->>'created')::timestamptz WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days')) THEN (${businesses_json.data}->>'updated')::timestamptz ELSE (${businesses_json.data}->>'created')::timestamptz END `; query.orderBy(desc(recencyRank), desc(groupTimestamp), desc(sql`(${businesses_json.data}->>'created')::timestamptz`)); break; } } // Paginierung query.limit(length).offset(start); const data = await query; const totalCount = await this.getBusinessListingsCount(criteria, user); const results = data.map(r => ({ id: r.business.id, email: r.business.email, ...(r.business.data as BusinessListing), brokerFirstName: r.brokerFirstName, brokerLastName: r.brokerLastName, })); return { results, totalCount, }; } async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise { const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(users_json, eq(businesses_json.email, users_json.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; } /** * Find business by slug or ID * Supports both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID */ async findBusinessBySlugOrId(slugOrId: string, user: JwtUser): Promise { this.logger.debug(`findBusinessBySlugOrId 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.findBusinessBySlug(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( `Business 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.findBusinessesById(id, user); } /** * Find business by slug */ async findBusinessBySlug(slug: string): Promise { const result = await this.conn .select() .from(businesses_json) .where(sql`${businesses_json.data}->>'slug' = ${slug}`) .limit(1); if (result.length > 0) { return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing; } return null; } async findBusinessesById(id: string, user: JwtUser): Promise { const conditions = []; if (user?.role !== 'admin') { conditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`)); } conditions.push(eq(businesses_json.id, id)); const result = await this.conn .select() .from(businesses_json) .where(and(...conditions)); if (result.length > 0) { return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing; } else { throw new BadRequestException(`No entry available for ${id}`); } } async findBusinessesByEmail(email: string, user: JwtUser): Promise { const conditions = []; conditions.push(eq(businesses_json.email, email)); if (email !== user?.email && user?.role !== 'admin') { conditions.push(sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`); } const listings = await this.conn .select() .from(businesses_json) .where(and(...conditions)); return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing); } async findFavoriteListings(user: JwtUser): Promise { const userFavorites = await this.conn .select() .from(businesses_json) .where(sql`${businesses_json.data}->'favoritesForUser' ? ${user.email}`); return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing); } 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(); BusinessListingSchema.parse(data); const { id, email, ...rest } = data; const convertedBusinessListing = { email, data: rest }; const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).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(businesses_json).set({ data: listingWithSlug }).where(eq(businesses_json.id, createdListing.id)); return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing), 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; } } async updateBusinessListing(id: string, data: BusinessListing, user: JwtUser): Promise { try { const [existingListing] = await this.conn.select().from(businesses_json).where(eq(businesses_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) { data.favoritesForUser = (existingListing.data).favoritesForUser || []; } // Regenerate slug if title or location changed const existingData = existingListing.data as BusinessListing; 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 }; BusinessListingSchema.parse(dataWithSlug); const { id: _, email, ...rest } = dataWithSlug; const convertedBusinessListing = { email, data: rest }; const [updateListing] = await this.conn.update(businesses_json).set(convertedBusinessListing).where(eq(businesses_json.id, id)).returning(); return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as BusinessListing) }; } 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; } } async deleteListing(id: string): Promise { await this.conn.delete(businesses_json).where(eq(businesses_json.id, id)); } async addFavorite(id: string, user: JwtUser): Promise { await this.conn .update(businesses_json) .set({ data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', coalesce((${businesses_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`, }) .where(eq(businesses_json.id, id)); } async deleteFavorite(id: string, user: JwtUser): Promise { await this.conn .update(businesses_json) .set({ data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', (SELECT coalesce(jsonb_agg(elem), '[]'::jsonb) FROM jsonb_array_elements(coalesce(${businesses_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem WHERE elem::text != to_jsonb(${user.email}::text)::text))`, }) .where(eq(businesses_json.id, id)); } }