diff --git a/bizmatch-server/src/drizzle/import.ts b/bizmatch-server/src/drizzle/import.ts index c816ab3..ab241ac 100644 --- a/bizmatch-server/src/drizzle/import.ts +++ b/bizmatch-server/src/drizzle/import.ts @@ -7,10 +7,10 @@ import { join } from 'path'; import pkg from 'pg'; import { rimraf } from 'rimraf'; import sharp from 'sharp'; -import { SelectOptionsService } from 'src/select-options/select-options.service.js'; import winston from 'winston'; import { BusinessListing, CommercialPropertyListing, User, UserData } from '../models/db.model.js'; import { emailToDirName, KeyValueStyle } from '../models/main.model.js'; +import { SelectOptionsService } from '../select-options/select-options.service.js'; import * as schema from './schema.js'; const typesOfBusiness: Array = [ { name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' }, diff --git a/bizmatch-server/src/geo/geo.service.ts b/bizmatch-server/src/geo/geo.service.ts index 91d59ab..c8edfbd 100644 --- a/bizmatch-server/src/geo/geo.service.ts +++ b/bizmatch-server/src/geo/geo.service.ts @@ -1,40 +1,41 @@ import { Injectable } from '@nestjs/common'; import { readFileSync } from 'fs'; import path, { join } from 'path'; -import { City, Geo, State } from 'src/models/server.model.js'; +import { GeoResult } from 'src/models/main.model.js'; import { fileURLToPath } from 'url'; +import { City, Geo, State } from '../models/server.model.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @Injectable() export class GeoService { - geo:Geo; - constructor() { - this.loadGeo(); - } - private loadGeo(): void { - const filePath = join(__dirname,'../..', 'assets', 'geo.json'); - const rawData = readFileSync(filePath, 'utf8'); - this.geo = JSON.parse(rawData); - } - - findCitiesStartingWith( prefix: string, state?:string): { city: string; state: string; state_code: string }[] { - const result: { city: string; state: string; state_code: string }[] = []; - - this.geo.states.forEach((state: State) => { - state.cities.forEach((city: City) => { - if (city.name.toLowerCase().startsWith(prefix.toLowerCase())) { - result.push({ - city: city.name, - state: state.name, - state_code: state.state_code - }); - } - }); - }); - - return state ? result.filter(e=>e.state_code.toLowerCase()===state.toLowerCase()) :result; - } -} + geo: Geo; + constructor() { + this.loadGeo(); + } + private loadGeo(): void { + const filePath = join(__dirname, '../..', 'assets', 'geo.json'); + const rawData = readFileSync(filePath, 'utf8'); + this.geo = JSON.parse(rawData); + } + findCitiesStartingWith(prefix: string, state?: string): { city: string; state: string; state_code: string }[] { + const result: GeoResult[] = []; + + this.geo.states.forEach((state: State) => { + state.cities.forEach((city: City) => { + if (city.name.toLowerCase().startsWith(prefix.toLowerCase())) { + result.push({ + id: city.id, + city: city.name, + state: state.name, + state_code: state.state_code, + }); + } + }); + }); + + return state ? result.filter(e => e.state_code.toLowerCase() === state.toLowerCase()) : result; + } +} diff --git a/bizmatch-server/src/image/image.controller.ts b/bizmatch-server/src/image/image.controller.ts index 6e30e17..7374127 100644 --- a/bizmatch-server/src/image/image.controller.ts +++ b/bizmatch-server/src/image/image.controller.ts @@ -3,14 +3,14 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; import { FileService } from '../file/file.service.js'; -import { ListingsService } from '../listings/listings.service.js'; +import { CommercialPropertyService } from '../listings/commercial-property.service.js'; import { SelectOptionsService } from '../select-options/select-options.service.js'; @Controller('image') export class ImageController { constructor( private fileService: FileService, - private listingService: ListingsService, + private listingService: CommercialPropertyService, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, private selectOptions: SelectOptionsService, ) {} diff --git a/bizmatch-server/src/image/image.module.ts b/bizmatch-server/src/image/image.module.ts index 57d2b89..e5f1211 100644 --- a/bizmatch-server/src/image/image.module.ts +++ b/bizmatch-server/src/image/image.module.ts @@ -1,14 +1,13 @@ import { Module } from '@nestjs/common'; +import { FileService } from '../file/file.service.js'; +import { ListingsModule } from '../listings/listings.module.js'; +import { SelectOptionsService } from '../select-options/select-options.service.js'; import { ImageController } from './image.controller.js'; import { ImageService } from './image.service.js'; -import { FileService } from '../file/file.service.js'; -import { SelectOptionsService } from '../select-options/select-options.service.js'; -import { ListingsService } from '../listings/listings.service.js'; -import { ListingsModule } from '../listings/listings.module.js'; @Module({ imports: [ListingsModule], controllers: [ImageController], - providers: [ImageService,FileService,SelectOptionsService] + providers: [ImageService, FileService, SelectOptionsService], }) export class ImageModule {} diff --git a/bizmatch-server/src/listings/broker-listings.controller.ts b/bizmatch-server/src/listings/broker-listings.controller.ts index f04034c..da02dc2 100644 --- a/bizmatch-server/src/listings/broker-listings.controller.ts +++ b/bizmatch-server/src/listings/broker-listings.controller.ts @@ -1,21 +1,18 @@ -import { Body, Controller, Delete, Get, Inject, Param, Post, Put } from '@nestjs/common'; -import { FileService } from '../file/file.service.js'; -import { convertStringToNullUndefined } from '../utils.js'; -import { ListingsService } from './listings.service.js'; +import { Body, Controller, Inject, Post } from '@nestjs/common'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { UserListingCriteria } from 'src/models/main.model.js'; import { Logger } from 'winston'; import { UserService } from '../user/user.service.js'; @Controller('listings/professionals_brokers') export class BrokerListingsController { - - constructor(private readonly userService:UserService,@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) { - } + constructor( + private readonly userService: UserService, + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + ) {} @Post('search') - find(@Body() criteria: any): any { - return this.userService.findUser(criteria); + find(@Body() criteria: UserListingCriteria): any { + return this.userService.searchUserListings(criteria); } - } - diff --git a/bizmatch-server/src/listings/business-listing.service.ts b/bizmatch-server/src/listings/business-listing.service.ts new file mode 100644 index 0000000..54504ae --- /dev/null +++ b/bizmatch-server/src/listings/business-listing.service.ts @@ -0,0 +1,192 @@ +import { 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 * as schema from '../drizzle/schema.js'; +import { businesses, PG_CONNECTION } from '../drizzle/schema.js'; +import { FileService } from '../file/file.service.js'; +import { BusinessListing, CommercialPropertyListing } from '../models/db.model'; +import { BusinessListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; + +@Injectable() +export class BusinessListingService { + constructor( + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + @Inject(PG_CONNECTION) private conn: NodePgDatabase, + private fileService: FileService, + ) {} + + private getWhereConditions(criteria: BusinessListingCriteria): SQL[] { + const whereConditions: SQL[] = []; + + if (criteria.city) { + whereConditions.push(ilike(businesses.city, `%${criteria.city}%`)); + } + + if (criteria.types && criteria.types.length > 0) { + whereConditions.push(inArray(businesses.type, criteria.types)); + } + + if (criteria.state) { + whereConditions.push(eq(businesses.state, criteria.state)); + } + + if (criteria.county) { + whereConditions.push(ilike(businesses.city, `%${criteria.county}%`)); // Assuming county is part of city, adjust if necessary + } + + if (criteria.minPrice) { + whereConditions.push(gte(businesses.price, criteria.minPrice)); + } + + if (criteria.maxPrice) { + whereConditions.push(lte(businesses.price, criteria.maxPrice)); + } + + if (criteria.minRevenue) { + whereConditions.push(gte(businesses.salesRevenue, criteria.minRevenue)); + } + + if (criteria.maxRevenue) { + whereConditions.push(lte(businesses.salesRevenue, criteria.maxRevenue)); + } + + if (criteria.minCashFlow) { + whereConditions.push(gte(businesses.cashFlow, criteria.minCashFlow)); + } + + if (criteria.maxCashFlow) { + whereConditions.push(lte(businesses.cashFlow, criteria.maxCashFlow)); + } + + if (criteria.minNumberEmployees) { + whereConditions.push(gte(businesses.employees, criteria.minNumberEmployees)); + } + + if (criteria.maxNumberEmployees) { + whereConditions.push(lte(businesses.employees, criteria.maxNumberEmployees)); + } + + if (criteria.establishedSince) { + whereConditions.push(gte(businesses.established, criteria.establishedSince)); + } + + if (criteria.establishedUntil) { + whereConditions.push(lte(businesses.established, criteria.establishedUntil)); + } + + if (criteria.realEstateChecked) { + whereConditions.push(eq(businesses.realEstateIncluded, criteria.realEstateChecked)); + } + + if (criteria.leasedLocation) { + whereConditions.push(eq(businesses.leasedLocation, criteria.leasedLocation)); + } + + if (criteria.franchiseResale) { + whereConditions.push(eq(businesses.franchiseResale, criteria.franchiseResale)); + } + + if (criteria.title) { + whereConditions.push(or(ilike(businesses.title, `%${criteria.title}%`), ilike(businesses.description, `%${criteria.title}%`))); + } + + if (criteria.brokerName) { + whereConditions.push(or(ilike(schema.users.firstname, `%${criteria.brokerName}%`), ilike(schema.users.lastname, `%${criteria.brokerName}%`))); + } + + 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, + brokerFirstName: schema.users.firstname, + brokerLastName: schema.users.lastname, + }) + .from(businesses) + .leftJoin(schema.users, eq(businesses.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 totalCount = await this.getBusinessListingsCount(criteria); + const results = data.map(r => r.business); + return { + results, + totalCount, + }; + } + + async getBusinessListingsCount(criteria: BusinessListingCriteria): Promise { + const countQuery = this.conn.select({ value: count() }).from(businesses).leftJoin(schema.users, eq(businesses.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; + } + async findBusinessesById(id: string, user: JwtUser): Promise { + let result = await this.conn + .select() + .from(businesses) + .where(and(sql`${businesses.id} = ${id}`)); + result = result.filter(r => !r.draft || r.imageName === emailToDirName(user?.username) || user?.roles.includes('ADMIN')); + return result[0] as BusinessListing; + } + async findBusinessesByEmail(email: string, user: JwtUser): Promise { + const conditions = []; + conditions.push(eq(businesses.imageName, emailToDirName(email))); + if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) { + conditions.push(ne(businesses.draft, true)); + } + return (await this.conn + .select() + .from(businesses) + .where(and(...conditions))) as CommercialPropertyListing[]; + } + // #### CREATE ######################################## + async createListing(data: BusinessListing): Promise { + data.created = new Date(); + data.updated = new Date(); + const [createdListing] = await this.conn.insert(businesses).values(data).returning(); + return createdListing as BusinessListing; + } + // #### UPDATE Business ######################################## + async updateBusinessListing(id: string, data: BusinessListing): Promise { + data.updated = new Date(); + data.created = new Date(data.created); + const [updateListing] = await this.conn.update(businesses).set(data).where(eq(businesses.id, id)).returning(); + return updateListing as BusinessListing | CommercialPropertyListing; + } + // #### DELETE ######################################## + async deleteListing(id: string): Promise { + await this.conn.delete(businesses).where(eq(businesses.id, id)); + } + // ############################################################## + // States + // ############################################################## + async getStates(): Promise { + return await this.conn + .select({ state: businesses.state, count: sql`count(${businesses.id})`.mapWith(Number) }) + .from(businesses) + .groupBy(sql`${businesses.state}`) + .orderBy(sql`count desc`); + } +} diff --git a/bizmatch-server/src/listings/business-listings.controller.ts b/bizmatch-server/src/listings/business-listings.controller.ts index 8c85f49..e459f13 100644 --- a/bizmatch-server/src/listings/business-listings.controller.ts +++ b/bizmatch-server/src/listings/business-listings.controller.ts @@ -1,15 +1,14 @@ import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; -import { businesses } from '../drizzle/schema.js'; import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js'; import { BusinessListingCriteria, JwtUser } from '../models/main.model.js'; -import { ListingsService } from './listings.service.js'; +import { BusinessListingService } from './business-listing.service.js'; @Controller('listings/business') export class BusinessListingsController { constructor( - private readonly listingsService: ListingsService, + private readonly listingsService: BusinessListingService, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, ) {} @@ -28,19 +27,19 @@ export class BusinessListingsController { @UseGuards(OptionalJwtAuthGuard) @Post('find') find(@Request() req, @Body() criteria: BusinessListingCriteria): any { - return this.listingsService.findBusinessListings(criteria, req.user as JwtUser); + return this.listingsService.searchBusinessListings(criteria, req.user as JwtUser); } - @UseGuards(OptionalJwtAuthGuard) - @Post('search') - search(@Request() req, @Body() criteria: BusinessListingCriteria): any { - return this.listingsService.searchBusinessListings(criteria.prompt); - } + // @UseGuards(OptionalJwtAuthGuard) + // @Post('search') + // search(@Request() req, @Body() criteria: BusinessListingCriteria): any { + // return this.listingsService.searchBusinessListings(criteria.prompt); + // } @Post() create(@Body() listing: any) { this.logger.info(`Save Listing`); - return this.listingsService.createListing(listing, businesses); + return this.listingsService.createListing(listing); } @Put() update(@Body() listing: any) { @@ -49,10 +48,10 @@ export class BusinessListingsController { } @Delete(':id') deleteById(@Param('id') id: string) { - this.listingsService.deleteListing(id, businesses); + this.listingsService.deleteListing(id); } @Get('states/all') getStates(): any { - return this.listingsService.getStates(businesses); + return this.listingsService.getStates(); } } diff --git a/bizmatch-server/src/listings/commercial-property-listings.controller.ts b/bizmatch-server/src/listings/commercial-property-listings.controller.ts index 3c89899..00bcc4b 100644 --- a/bizmatch-server/src/listings/commercial-property-listings.controller.ts +++ b/bizmatch-server/src/listings/commercial-property-listings.controller.ts @@ -1,17 +1,16 @@ import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; -import { commercials } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js'; import { CommercialPropertyListing } from '../models/db.model'; import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model.js'; -import { ListingsService } from './listings.service.js'; +import { CommercialPropertyService } from './commercial-property.service.js'; @Controller('listings/commercialProperty') export class CommercialPropertyListingsController { constructor( - private readonly listingsService: ListingsService, + private readonly listingsService: CommercialPropertyService, private fileService: FileService, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, ) {} @@ -30,16 +29,16 @@ export class CommercialPropertyListingsController { @UseGuards(OptionalJwtAuthGuard) @Post('find') async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise { - return await this.listingsService.findCommercialPropertyListings(criteria, req.user as JwtUser); + return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser); } @Get('states/all') getStates(): any { - return this.listingsService.getStates(commercials); + return this.listingsService.getStates(); } @Post() async create(@Body() listing: any) { this.logger.info(`Save Listing`); - return await this.listingsService.createListing(listing, commercials); + return await this.listingsService.createListing(listing); } @Put() async update(@Body() listing: any) { @@ -48,7 +47,7 @@ export class CommercialPropertyListingsController { } @Delete(':id/:imagePath') deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) { - this.listingsService.deleteListing(id, commercials); + this.listingsService.deleteListing(id); this.fileService.deleteDirectoryIfExists(imagePath); } } diff --git a/bizmatch-server/src/listings/commercial-property.service.ts b/bizmatch-server/src/listings/commercial-property.service.ts new file mode 100644 index 0000000..834a5ac --- /dev/null +++ b/bizmatch-server/src/listings/commercial-property.service.ts @@ -0,0 +1,168 @@ +import { 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 * as schema from '../drizzle/schema.js'; +import { commercials, PG_CONNECTION } from '../drizzle/schema.js'; +import { FileService } from '../file/file.service.js'; +import { BusinessListing, CommercialPropertyListing } from '../models/db.model'; +import { CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; + +@Injectable() +export class CommercialPropertyService { + constructor( + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + @Inject(PG_CONNECTION) private conn: NodePgDatabase, + private fileService: FileService, + ) {} + private getWhereConditions(criteria: CommercialPropertyListingCriteria): SQL[] { + const whereConditions: SQL[] = []; + + if (criteria.city) { + whereConditions.push(ilike(schema.commercials.city, `%${criteria.city}%`)); + } + + 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}%`))); + } + + 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().from(schema.commercials); + const whereConditions = this.getWhereConditions(criteria); + + if (whereConditions.length > 0) { + const whereClause = and(...whereConditions); + query.where(whereClause); + } + + // Paginierung + query.limit(length).offset(start); + + const results = await query; + const totalCount = await this.getCommercialPropertiesCount(criteria); + + return { + results, + totalCount, + }; + } + async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria): Promise { + const countQuery = this.conn.select({ value: count() }).from(schema.commercials); + 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 { + data.created = new Date(); + data.updated = new Date(); + const [createdListing] = await this.conn.insert(commercials).values(data).returning(); + return createdListing as CommercialPropertyListing; + } + // #### UPDATE CommercialProps ######################################## + async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing): Promise { + data.updated = new Date(); + data.created = new Date(data.created); + 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 BusinessListing | CommercialPropertyListing; + } + // ############################################################## + // 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`); + } +} diff --git a/bizmatch-server/src/listings/listings.module.ts b/bizmatch-server/src/listings/listings.module.ts index 60a1ae4..ce0b754 100644 --- a/bizmatch-server/src/listings/listings.module.ts +++ b/bizmatch-server/src/listings/listings.module.ts @@ -6,13 +6,15 @@ import { UserService } from '../user/user.service.js'; import { BrokerListingsController } from './broker-listings.controller.js'; import { BusinessListingsController } from './business-listings.controller.js'; import { CommercialPropertyListingsController } from './commercial-property-listings.controller.js'; -import { ListingsService } from './listings.service.js'; + +import { BusinessListingService } from './business-listing.service.js'; +import { CommercialPropertyService } from './commercial-property.service.js'; import { UnknownListingsController } from './unknown-listings.controller.js'; @Module({ imports: [DrizzleModule, AuthModule], controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController], - providers: [ListingsService, FileService, UserService], - exports: [ListingsService], + providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService], + exports: [BusinessListingService, CommercialPropertyService], }) export class ListingsModule {} diff --git a/bizmatch-server/src/listings/listings.service.ts b/bizmatch-server/src/listings/listings.service.ts deleted file mode 100644 index ec9d5e0..0000000 --- a/bizmatch-server/src/listings/listings.service.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { and, eq, gte, ilike, inArray, lte, ne, or, sql } from 'drizzle-orm'; -import { NodePgDatabase } from 'drizzle-orm/node-postgres'; -import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; -import OpenAI from 'openai'; -import { Logger } from 'winston'; -import * as schema from '../drizzle/schema.js'; -import { businesses, commercials, PG_CONNECTION } from '../drizzle/schema.js'; -import { FileService } from '../file/file.service.js'; -import { BusinessListing, CommercialPropertyListing } from '../models/db.model'; -import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; - -@Injectable() -export class ListingsService { - openai: OpenAI; - constructor( - @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, - @Inject(PG_CONNECTION) private conn: NodePgDatabase, - private fileService: FileService, - ) { - this.openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen - }); - } - private getConditions(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria, table: typeof businesses | typeof commercials, user: JwtUser): any[] { - const conditions = []; - if (criteria.types?.length > 0) { - conditions.push(inArray(table.type, criteria.types)); - } - if (criteria.state) { - conditions.push(eq(table.state, criteria.state)); - } - if (criteria.minPrice) { - conditions.push(gte(table.price, criteria.minPrice)); - } - if (criteria.maxPrice) { - conditions.push(lte(table.price, criteria.maxPrice)); - } - if (criteria.title) { - conditions.push(ilike(table.title, `%${criteria.title}%`)); - } - return conditions; - } - // ############################################################## - // Listings general - // ############################################################## - - // #### Find by embeddng ######################################## - async searchBusinessListings(query: string, limit: number = 20): Promise { - const queryEmbedding = await this.createEmbedding(query); - - const results = await this.conn - .select() - .from(businesses) - .orderBy(sql`embedding <-> ${JSON.stringify(queryEmbedding)}`) - .limit(limit); - - return results as BusinessListing[]; - } - // #### Find by criteria ######################################## - async findCommercialPropertyListings(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise { - const start = criteria.start ? criteria.start : 0; - const length = criteria.length ? criteria.length : 12; - const conditions = this.getConditions(criteria, commercials, user); - if (!user || (!user?.roles?.includes('ADMIN') ?? false)) { - conditions.push(or(eq(commercials.draft, false), eq(commercials.imagePath, emailToDirName(user?.username)))); - } - const [data, total] = await Promise.all([ - this.conn - .select() - .from(commercials) - .where(and(...conditions)) - .offset(start) - .limit(length), - this.conn - .select({ count: sql`count(*)` }) - .from(commercials) - .where(and(...conditions)) - .then(result => Number(result[0].count)), - ]); - return { total, data }; - } - async findBusinessListings(criteria: BusinessListingCriteria, user: JwtUser): Promise { - const start = criteria.start ? criteria.start : 0; - const length = criteria.length ? criteria.length : 12; - const conditions = this.getConditions(criteria, businesses, user); - if (!user || (!user?.roles?.includes('ADMIN') ?? false)) { - conditions.push(or(eq(businesses.draft, false), eq(businesses.imageName, emailToDirName(user?.username)))); - } - const [data, total] = await Promise.all([ - this.conn - .select() - .from(businesses) - .where(and(...conditions)) - .offset(start) - .limit(length), - this.conn - .select({ count: sql`count(*)` }) - .from(businesses) - .where(and(...conditions)) - .then(result => Number(result[0].count)), - ]); - return { total, data }; - } - - // #### 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; - } - async findBusinessesById(id: string, user: JwtUser): Promise { - let result = await this.conn - .select() - .from(businesses) - .where(and(sql`${businesses.id} = ${id}`)); - result = result.filter(r => !r.draft || r.imageName === emailToDirName(user?.username) || user?.roles.includes('ADMIN')); - return result[0] as BusinessListing; - } - - // #### 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[]; - } - async findBusinessesByEmail(email: string, user: JwtUser): Promise { - const conditions = []; - conditions.push(eq(businesses.imageName, emailToDirName(email))); - if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) { - conditions.push(ne(businesses.draft, true)); - } - return (await this.conn - .select() - .from(businesses) - .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: BusinessListing | CommercialPropertyListing, table: typeof businesses | typeof commercials): Promise { - data.created = new Date(); - data.updated = new Date(); - const [createdListing] = await this.conn.insert(table).values(data).returning(); - return createdListing as BusinessListing | CommercialPropertyListing; - } - // #### UPDATE CommercialProps ######################################## - async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing): Promise { - data.updated = new Date(); - data.created = new Date(data.created); - 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 BusinessListing | CommercialPropertyListing; - } - // #### UPDATE Business ######################################## - async updateBusinessListing(id: string, data: BusinessListing): Promise { - data.updated = new Date(); - data.created = new Date(data.created); - const [updateListing] = await this.conn.update(businesses).set(data).where(eq(businesses.id, id)).returning(); - return updateListing as BusinessListing | CommercialPropertyListing; - } - // #### DELETE ######################################## - async deleteListing(id: string, table: typeof businesses | typeof commercials): Promise { - await this.conn.delete(table).where(eq(table.id, id)); - } - - // ############################################################## - // 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); - } - // ############################################################## - // States - // ############################################################## - async getStates(table: typeof businesses | typeof commercials): Promise { - return await this.conn - .select({ state: table.state, count: sql`count(${table.id})`.mapWith(Number) }) - .from(table) - .groupBy(sql`${table.state}`) - .orderBy(sql`count desc`); - } - // ############################################################## - // Embedding - // ############################################################## - async createEmbedding(text: string): Promise { - const response = await this.openai.embeddings.create({ - model: 'text-embedding-3-small', - input: text, - }); - return response.data[0].embedding; - } -} diff --git a/bizmatch-server/src/listings/unknown-listings.controller.ts b/bizmatch-server/src/listings/unknown-listings.controller.ts index 8e197ad..c152808 100644 --- a/bizmatch-server/src/listings/unknown-listings.controller.ts +++ b/bizmatch-server/src/listings/unknown-listings.controller.ts @@ -1,14 +1,10 @@ import { Controller, Inject } from '@nestjs/common'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; -import { ListingsService } from './listings.service.js'; @Controller('listings/undefined') export class UnknownListingsController { - constructor( - private readonly listingsService: ListingsService, - @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, - ) {} + constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {} // @Get(':id') // async findById(@Param('id') id: string): Promise { diff --git a/bizmatch-server/src/models/main.model.ts b/bizmatch-server/src/models/main.model.ts index 5eb49f3..93c5977 100644 --- a/bizmatch-server/src/models/main.model.ts +++ b/bizmatch-server/src/models/main.model.ts @@ -36,32 +36,32 @@ export type ListingCategory = { export type ListingType = BusinessListing | CommercialPropertyListing; export type ResponseBusinessListingArray = { - data: BusinessListing[]; - total: number; + results: BusinessListing[]; + totalCount: number; }; export type ResponseBusinessListing = { data: BusinessListing; }; export type ResponseCommercialPropertyListingArray = { - data: CommercialPropertyListing[]; - total: number; + results: CommercialPropertyListing[]; + totalCount: number; }; export type ResponseCommercialPropertyListing = { data: CommercialPropertyListing; }; export type ResponseUsersArray = { - data: User[]; - total: number; + results: User[]; + totalCount: number; }; export interface ListCriteria { start: number; length: number; page: number; pageCount: number; - city: string; types: string[]; + city: string; prompt: string; - criteriaType: 'business' | 'commercialProperty' | 'user'; + criteriaType: 'business' | 'commercialProperty' | 'broker'; } export interface BusinessListingCriteria extends ListCriteria { state: string; @@ -97,7 +97,7 @@ export interface UserListingCriteria extends ListCriteria { companyName: string; counties: string[]; states: string[]; - criteriaType: 'user'; + criteriaType: 'broker'; } export interface KeycloakUser { @@ -224,6 +224,12 @@ export interface UploadParams { imagePath: string; serialId?: number; } +export interface GeoResult { + id: number; + city: string; + state: string; + state_code: string; +} export function isEmpty(value: any): boolean { // Check for undefined or null if (value === undefined || value === null) { @@ -258,3 +264,4 @@ export function emailToDirName(email: string): string { return normalizedEmail; } +export const LISTINGS_PER_PAGE = 12; diff --git a/bizmatch-server/src/user/user.controller.ts b/bizmatch-server/src/user/user.controller.ts index 9f08cba..41cc00f 100644 --- a/bizmatch-server/src/user/user.controller.ts +++ b/bizmatch-server/src/user/user.controller.ts @@ -4,7 +4,7 @@ import { Logger } from 'winston'; import { FileService } from '../file/file.service.js'; import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js'; import { User } from '../models/db.model'; -import { JwtUser, Subscription } from '../models/main.model.js'; +import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model.js'; import { UserService } from './user.service.js'; @Controller('user') @@ -39,9 +39,9 @@ export class UserController { } @Post('search') - find(@Body() criteria: any): any { + find(@Body() criteria: UserListingCriteria): any { this.logger.info(`Searching for users with criteria: ${JSON.stringify(criteria)}`); - const foundUsers = this.userService.findUser(criteria); + const foundUsers = this.userService.searchUserListings(criteria); this.logger.info(`Found users: ${JSON.stringify(foundUsers)}`); return foundUsers; } diff --git a/bizmatch-server/src/user/user.service.ts b/bizmatch-server/src/user/user.service.ts index 89cdd54..5e69070 100644 --- a/bizmatch-server/src/user/user.service.ts +++ b/bizmatch-server/src/user/user.service.ts @@ -1,14 +1,15 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, eq, ilike, or, sql } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; import * as schema from '../drizzle/schema.js'; -import { PG_CONNECTION } from '../drizzle/schema.js'; +import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service.js'; import { User } from '../models/db.model.js'; -import { JwtUser, UserListingCriteria, emailToDirName } from '../models/main.model.js'; +import { emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model.js'; +type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number]; @Injectable() export class UserService { constructor( @@ -16,17 +17,85 @@ export class UserService { @Inject(PG_CONNECTION) private conn: NodePgDatabase, private fileService: FileService, ) {} - private getConditions(criteria: UserListingCriteria): any[] { - const conditions = []; - if (criteria.states?.length > 0) { - criteria.states.forEach(state => { - conditions.push(sql`${schema.users.areasServed} @> ${JSON.stringify([{ state: state }])}`); - }); + // private getConditions(criteria: UserListingCriteria): any[] { + // const conditions = []; + // if (criteria.states?.length > 0) { + // criteria.states.forEach(state => { + // conditions.push(sql`${schema.users.areasServed} @> ${JSON.stringify([{ state: state }])}`); + // }); + // } + // if (criteria.firstname || criteria.lastname) { + // conditions.push(or(ilike(schema.users.firstname, `%${criteria.lastname}%`), ilike(schema.users.lastname, `%${criteria.lastname}%`))); + // } + // return conditions; + // } + private getWhereConditions(criteria: UserListingCriteria): SQL[] { + const whereConditions: SQL[] = []; + + if (criteria.city) { + whereConditions.push(ilike(schema.users.companyLocation, `%${criteria.city}%`)); } - if (criteria.firstname || criteria.lastname) { - conditions.push(or(ilike(schema.users.firstname, `%${criteria.lastname}%`), ilike(schema.users.lastname, `%${criteria.lastname}%`))); + + if (criteria.types && criteria.types.length > 0) { + // whereConditions.push(inArray(schema.users.customerSubType, criteria.types)); + whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[])); } - return conditions; + + if (criteria.firstname) { + whereConditions.push(ilike(schema.users.firstname, `%${criteria.firstname}%`)); + } + + if (criteria.lastname) { + whereConditions.push(ilike(schema.users.lastname, `%${criteria.lastname}%`)); + } + + if (criteria.companyName) { + whereConditions.push(ilike(schema.users.companyName, `%${criteria.companyName}%`)); + } + + if (criteria.counties && criteria.counties.length > 0) { + whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'county' ILIKE ${`%${county}%`})`))); + } + + if (criteria.states && criteria.states.length > 0) { + whereConditions.push(or(...criteria.states.map(state => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${state})`))); + } + + return whereConditions; + } + async searchUserListings(criteria: UserListingCriteria) { + const start = criteria.start ? criteria.start : 0; + const length = criteria.length ? criteria.length : 12; + const query = this.conn.select().from(schema.users); + const whereConditions = this.getWhereConditions(criteria); + + if (whereConditions.length > 0) { + const whereClause = and(...whereConditions); + query.where(whereClause); + } + + // Paginierung + query.limit(length).offset(start); + + const results = await query; + const totalCount = await this.getUserListingsCount(criteria); + + return { + results, + totalCount, + }; + } + async getUserListingsCount(criteria: UserListingCriteria): Promise { + const countQuery = this.conn.select({ value: count() }).from(schema.users); + const whereConditions = this.getWhereConditions(criteria); + + if (whereConditions.length > 0) { + const whereClause = and(...whereConditions); + countQuery.where(whereClause); + } + + const [{ value: totalCount }] = await countQuery; + return totalCount; } async getUserByMail(email: string, jwtuser?: JwtUser) { const users = (await this.conn @@ -68,25 +137,25 @@ export class UserService { return newUser as User; } } - async findUser(criteria: UserListingCriteria) { - const start = criteria.start ? criteria.start : 0; - const length = criteria.length ? criteria.length : 12; - const conditions = this.getConditions(criteria); - const [data, total] = await Promise.all([ - this.conn - .select() - .from(schema.users) - .where(and(...conditions)) - .offset(start) - .limit(length), - this.conn - .select({ count: sql`count(*)` }) - .from(schema.users) - .where(and(...conditions)) - .then(result => Number(result[0].count)), - ]); - return { total, data }; - } + // async findUser(criteria: UserListingCriteria) { + // const start = criteria.start ? criteria.start : 0; + // const length = criteria.length ? criteria.length : 12; + // const conditions = this.getConditions(criteria); + // const [data, total] = await Promise.all([ + // this.conn + // .select() + // .from(schema.users) + // .where(and(...conditions)) + // .offset(start) + // .limit(length), + // this.conn + // .select({ count: sql`count(*)` }) + // .from(schema.users) + // .where(and(...conditions)) + // .then(result => Number(result[0].count)), + // ]); + // return { total, data }; + // } async getStates(): Promise { const query = sql`SELECT jsonb_array_elements(${schema.users.areasServed}) ->> 'state' AS state, COUNT(DISTINCT ${schema.users.id}) AS count FROM ${schema.users} GROUP BY state ORDER BY count DESC`; const result = await this.conn.execute(query); diff --git a/bizmatch/src/app/components/header/header.component.ts b/bizmatch/src/app/components/header/header.component.ts index 7ceb868..a471288 100644 --- a/bizmatch/src/app/components/header/header.component.ts +++ b/bizmatch/src/app/components/header/header.component.ts @@ -11,9 +11,10 @@ import { filter, Observable, Subject, Subscription } from 'rxjs'; import { User } from '../../../../../bizmatch-server/src/models/db.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../environments/environment'; +import { SearchService } from '../../services/search.service'; import { SharedService } from '../../services/shared.service'; import { UserService } from '../../services/user.service'; -import { createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaStateObject, getSessionStorageHandlerWrapper, map2User } from '../../utils/utils'; +import { getCriteriaStateObject, getSessionStorageHandlerWrapper, map2User } from '../../utils/utils'; import { DropdownComponent } from '../dropdown/dropdown.component'; import { ModalService } from '../search-modal/modal.service'; @Component({ @@ -46,9 +47,8 @@ export class HeaderComponent { private sharedService: SharedService, private breakpointObserver: BreakpointObserver, private modalService: ModalService, - ) { - //this.criteria = onChange(getCriteriaStateObject('business'), getSessionStorageHandlerWrapper(this.activeTabAction)); - } + private searchService: SearchService, + ) {} async ngOnInit() { const token = await this.keycloakService.getToken(); @@ -89,14 +89,18 @@ export class HeaderComponent { } ngAfterViewInit() {} - openModal() { - if (this.isActive('/businessListings')) { - this.modalService.showModal(createEmptyBusinessListingCriteria()); - } else if (this.isActive('/commercialPropertyListings')) { - this.modalService.showModal(createEmptyCommercialPropertyListingCriteria()); - } else if (this.isActive('/brokerListings')) { - this.modalService.showModal(createEmptyUserListingCriteria()); + async openModal() { + const accepted = await this.modalService.showModal(this.criteria); + if (accepted) { + this.searchService.search(this.criteria); } + // if (this.isActive('/businessListings')) { + // this.modalService.showModal(createEmptyBusinessListingCriteria()); + // } else if (this.isActive('/commercialPropertyListings')) { + // this.modalService.showModal(createEmptyCommercialPropertyListingCriteria()); + // } else if (this.isActive('/brokerListings')) { + // this.modalService.showModal(createEmptyUserListingCriteria()); + // } } navigateWithState(dest: string, state: any) { this.router.navigate([dest], { state: state }); diff --git a/bizmatch/src/app/components/paginator/paginator.component.html b/bizmatch/src/app/components/paginator/paginator.component.html new file mode 100644 index 0000000..1c2a7d6 --- /dev/null +++ b/bizmatch/src/app/components/paginator/paginator.component.html @@ -0,0 +1 @@ +

paginator works!

diff --git a/bizmatch/src/app/components/paginator/paginator.component.scss b/bizmatch/src/app/components/paginator/paginator.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/bizmatch/src/app/components/paginator/paginator.component.ts b/bizmatch/src/app/components/paginator/paginator.component.ts new file mode 100644 index 0000000..6399a64 --- /dev/null +++ b/bizmatch/src/app/components/paginator/paginator.component.ts @@ -0,0 +1,98 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; + +@Component({ + selector: 'app-paginator', + standalone: true, + imports: [CommonModule], + template: ` + + `, +}) +export class PaginatorComponent implements OnChanges { + @Input() page = 1; + @Input() pageCount = 1; + @Output() pageChange = new EventEmitter(); + + currentPage = 1; + visiblePages: (number | string)[] = []; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['page'] || changes['pageCount']) { + this.currentPage = this.page; + this.updateVisiblePages(); + } + } + + updateVisiblePages(): void { + const totalPages = this.pageCount; + const current = this.currentPage; + + if (totalPages <= 6) { + this.visiblePages = Array.from({ length: totalPages }, (_, i) => i + 1); + } else { + if (current <= 3) { + this.visiblePages = [1, 2, 3, 4, '...', totalPages]; + } else if (current >= totalPages - 2) { + this.visiblePages = [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages]; + } else { + this.visiblePages = [1, '...', current - 1, current, current + 1, '...', totalPages]; + } + } + } + + onPageChange(page: number | string): void { + if (typeof page === 'string') { + return; + } + if (page >= 1 && page <= this.pageCount && page !== this.currentPage) { + this.currentPage = page; + this.pageChange.emit(page); + this.updateVisiblePages(); + } + } +} diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.html b/bizmatch/src/app/components/search-modal/search-modal.component.html index 004f588..3947f11 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.html +++ b/bizmatch/src/app/components/search-modal/search-modal.component.html @@ -28,13 +28,28 @@
- + /> --> + + @for (city of cities$ | async; track city.id) { + {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} + } +
@@ -205,13 +220,28 @@
- + /> --> + + @for (city of cities$ | async; track city.id) { + {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} + } +
@@ -265,7 +295,7 @@
- } @if(criteria.criteriaType==='user'){ + } @if(criteria.criteriaType==='broker'){
@@ -280,13 +310,28 @@
- + /> --> + + @for (city of cities$ | async; track city.id) { + {{ city.city }} - {{ selectOptions.getStateInitials(city.state) }} + } +
diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.scss b/bizmatch/src/app/components/search-modal/search-modal.component.scss index 6613ac2..ad9122b 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.scss +++ b/bizmatch/src/app/components/search-modal/search-modal.component.scss @@ -3,4 +3,7 @@ background-color: rgb(249 250 251 / var(--tw-bg-opacity)); height: 46px; border-radius: 0.5rem; + .ng-value-container .ng-input { + top: 10px; + } } diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.ts b/bizmatch/src/app/components/search-modal/search-modal.component.ts index 638bf81..a6b091c 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.ts +++ b/bizmatch/src/app/components/search-modal/search-modal.component.ts @@ -1,7 +1,9 @@ import { AsyncPipe, NgIf } from '@angular/common'; import { Component } from '@angular/core'; import { NgSelectModule } from '@ng-select/ng-select'; -import { BusinessListingCriteria, CommercialPropertyListingCriteria, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; +import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; +import { BusinessListingCriteria, CommercialPropertyListingCriteria, GeoResult, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; +import { GeoService } from '../../services/geo.service'; import { SelectOptionsService } from '../../services/select-options.service'; import { SharedModule } from '../../shared/shared/shared.module'; import { ModalService } from './modal.service'; @@ -14,11 +16,15 @@ import { ModalService } from './modal.service'; styleUrl: './search-modal.component.scss', }) export class SearchModalComponent { - constructor(public selectOptions: SelectOptionsService, public modalService: ModalService) {} + cities$: Observable; + cityLoading = false; + cityInput$ = new Subject(); + constructor(public selectOptions: SelectOptionsService, public modalService: ModalService, private geoService: GeoService) {} ngOnInit() { this.modalService.message$.subscribe(msg => { this.criteria = msg; }); + this.loadCities(); } public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; @@ -32,6 +38,25 @@ export class SearchModalComponent { } } } + private loadCities() { + this.cities$ = concat( + of([]), // default items + this.cityInput$.pipe( + distinctUntilChanged(), + tap(() => (this.cityLoading = true)), + switchMap(term => + this.geoService.findCitiesStartingWith(term).pipe( + catchError(() => of([])), // empty list on error + // map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names + tap(() => (this.cityLoading = false)), + ), + ), + ), + ); + } + trackByFn(item: GeoResult) { + return item.id; + } search() { console.log('Search criteria:', this.criteria); } diff --git a/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts b/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts index 3e3e6eb..d98c752 100644 --- a/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts +++ b/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts @@ -2,15 +2,15 @@ import { CommonModule, NgOptimizedImage } from '@angular/common'; import { ChangeDetectorRef, Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import onChange from 'on-change'; import { BusinessListing, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../../environments/environment'; import { ImageService } from '../../../services/image.service'; import { ListingsService } from '../../../services/listings.service'; +import { SearchService } from '../../../services/search.service'; import { SelectOptionsService } from '../../../services/select-options.service'; import { UserService } from '../../../services/user.service'; -import { getCriteriaStateObject, getSessionStorageHandlerWrapper } from '../../../utils/utils'; +import { getCriteriaStateObject } from '../../../utils/utils'; @Component({ selector: 'app-broker-listings', @@ -48,15 +48,23 @@ export class BrokerListingsComponent { private cdRef: ChangeDetectorRef, private imageService: ImageService, private route: ActivatedRoute, + private searchService: SearchService, ) { - this.criteria = onChange(getCriteriaStateObject('broker'), getSessionStorageHandlerWrapper('broker')); - this.route.data.subscribe(async () => { - if (this.router.getCurrentNavigation().extras.state) { - } else { - this.first = this.criteria.page * this.criteria.length; - this.rows = this.criteria.length; + this.criteria = getCriteriaStateObject('broker'); + // this.route.data.subscribe(async () => { + // if (this.router.getCurrentNavigation().extras.state) { + // } else { + // this.first = this.criteria.page * this.criteria.length; + // this.rows = this.criteria.length; + // } + // this.init(); + // }); + this.init(); + this.searchService.currentCriteria.subscribe(criteria => { + if (criteria && criteria.criteriaType === 'broker') { + this.criteria = criteria as UserListingCriteria; + this.search(); } - this.init(); }); } async ngOnInit() { @@ -73,8 +81,8 @@ export class BrokerListingsComponent { } async search() { const usersReponse = await this.userService.search(this.criteria); - this.users = usersReponse.data; - this.totalRecords = usersReponse.total; + this.users = usersReponse.results; + this.totalRecords = usersReponse.totalCount; this.cdRef.markForCheck(); this.cdRef.detectChanges(); } diff --git a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html index 5810025..17991c6 100644 --- a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html +++ b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.html @@ -112,6 +112,7 @@ }
+