338 lines
16 KiB
TypeScript
338 lines
16 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 { 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 } 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<typeof schema>,
|
|
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) {
|
|
whereConditions.push(inArray(sql`${commercials_json.data}->>'type'`, criteria.types));
|
|
}
|
|
|
|
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 (user?.role !== 'admin') {
|
|
whereConditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
|
}
|
|
// whereConditions.push(and(eq(schema.users.customerType, 'professional')));
|
|
return whereConditions;
|
|
}
|
|
// #### Find by criteria ########################################
|
|
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
|
|
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);
|
|
|
|
if (whereConditions.length > 0) {
|
|
const whereClause = and(...whereConditions);
|
|
query.where(whereClause);
|
|
}
|
|
// 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<number> {
|
|
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 = and(...whereConditions);
|
|
countQuery.where(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<CommercialPropertyListing> {
|
|
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<CommercialPropertyListing | null> {
|
|
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<CommercialPropertyListing> {
|
|
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<CommercialPropertyListing[]> {
|
|
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<CommercialPropertyListing[]> {
|
|
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<CommercialPropertyListing> {
|
|
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<CommercialPropertyListing> {
|
|
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<CommercialPropertyListing> {
|
|
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 = (<CommercialPropertyListing>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<void> {
|
|
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id));
|
|
}
|
|
// #### ADD Favorite ######################################
|
|
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
|
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<void> {
|
|
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));
|
|
}
|
|
}
|