429 lines
17 KiB
TypeScript
429 lines
17 KiB
TypeScript
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<typeof schema>,
|
|
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<number> {
|
|
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<BusinessListing> {
|
|
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<BusinessListing | null> {
|
|
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<BusinessListing> {
|
|
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<BusinessListing[]> {
|
|
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<BusinessListing[]> {
|
|
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<BusinessListing> {
|
|
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<BusinessListing> {
|
|
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 = (<BusinessListing>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<void> {
|
|
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id));
|
|
}
|
|
|
|
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
|
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<void> {
|
|
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));
|
|
}
|
|
}
|