SEO
This commit is contained in:
@@ -1,96 +1,96 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
|
||||
import * as winston from 'winston';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { FileService } from './file/file.service';
|
||||
import { GeoModule } from './geo/geo.module';
|
||||
import { ImageModule } from './image/image.module';
|
||||
import { ListingsModule } from './listings/listings.module';
|
||||
import { LogController } from './log/log.controller';
|
||||
import { LogModule } from './log/log.module';
|
||||
|
||||
import { EventModule } from './event/event.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ClsMiddleware, ClsModule } from 'nestjs-cls';
|
||||
import path from 'path';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
|
||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
import { UserInterceptor } from './interceptors/user.interceptor';
|
||||
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
|
||||
import { SelectOptionsModule } from './select-options/select-options.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
//loadEnvFiles();
|
||||
console.log('Loaded environment variables:');
|
||||
//console.log(JSON.stringify(process.env, null, 2));
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRoot({
|
||||
global: true, // Macht den ClsService global verfügbar
|
||||
middleware: { mount: true }, // Registriert automatisch die ClsMiddleware
|
||||
}),
|
||||
//ConfigModule.forRoot({ envFilePath: '.env' }),
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: [path.resolve(__dirname, '..', '.env')],
|
||||
}),
|
||||
MailModule,
|
||||
AuthModule,
|
||||
WinstonModule.forRoot({
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
|
||||
}),
|
||||
winston.format.ms(),
|
||||
nestWinstonModuleUtilities.format.nestLike('Bizmatch', {
|
||||
colors: true,
|
||||
prettyPrint: true,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
// other transports...
|
||||
],
|
||||
// other options
|
||||
}),
|
||||
GeoModule,
|
||||
UserModule,
|
||||
ListingsModule,
|
||||
SelectOptionsModule,
|
||||
ImageModule,
|
||||
AiModule,
|
||||
LogModule,
|
||||
// PaymentModule,
|
||||
EventModule,
|
||||
FirebaseAdminModule,
|
||||
SitemapModule,
|
||||
],
|
||||
controllers: [AppController, LogController],
|
||||
providers: [
|
||||
AppService,
|
||||
FileService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: UserInterceptor, // Registriere den Interceptor global
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: LoggingInterceptor, // Registriere den LoggingInterceptor global
|
||||
},
|
||||
AuthService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(ClsMiddleware).forRoutes('*');
|
||||
consumer.apply(RequestDurationMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
|
||||
import * as winston from 'winston';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { FileService } from './file/file.service';
|
||||
import { GeoModule } from './geo/geo.module';
|
||||
import { ImageModule } from './image/image.module';
|
||||
import { ListingsModule } from './listings/listings.module';
|
||||
import { LogController } from './log/log.controller';
|
||||
import { LogModule } from './log/log.module';
|
||||
|
||||
import { EventModule } from './event/event.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ClsMiddleware, ClsModule } from 'nestjs-cls';
|
||||
import path from 'path';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
|
||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
import { UserInterceptor } from './interceptors/user.interceptor';
|
||||
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
|
||||
import { SelectOptionsModule } from './select-options/select-options.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
//loadEnvFiles();
|
||||
console.log('Loaded environment variables:');
|
||||
//console.log(JSON.stringify(process.env, null, 2));
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRoot({
|
||||
global: true, // Macht den ClsService global verfügbar
|
||||
middleware: { mount: true }, // Registriert automatisch die ClsMiddleware
|
||||
}),
|
||||
//ConfigModule.forRoot({ envFilePath: '.env' }),
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: [path.resolve(__dirname, '..', '.env')],
|
||||
}),
|
||||
MailModule,
|
||||
AuthModule,
|
||||
WinstonModule.forRoot({
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
|
||||
}),
|
||||
winston.format.ms(),
|
||||
nestWinstonModuleUtilities.format.nestLike('Bizmatch', {
|
||||
colors: true,
|
||||
prettyPrint: true,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
// other transports...
|
||||
],
|
||||
// other options
|
||||
}),
|
||||
GeoModule,
|
||||
UserModule,
|
||||
ListingsModule,
|
||||
SelectOptionsModule,
|
||||
ImageModule,
|
||||
AiModule,
|
||||
LogModule,
|
||||
// PaymentModule,
|
||||
EventModule,
|
||||
FirebaseAdminModule,
|
||||
SitemapModule,
|
||||
],
|
||||
controllers: [AppController, LogController],
|
||||
providers: [
|
||||
AppService,
|
||||
FileService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: UserInterceptor, // Registriere den Interceptor global
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: LoggingInterceptor, // Registriere den LoggingInterceptor global
|
||||
},
|
||||
AuthService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(ClsMiddleware).forRoutes('*');
|
||||
consumer.apply(RequestDurationMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,346 +1,346 @@
|
||||
import 'dotenv/config';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
|
||||
import fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { Pool } from 'pg';
|
||||
import { rimraf } from 'rimraf';
|
||||
import sharp from 'sharp';
|
||||
import { BusinessListingService } from 'src/listings/business-listing.service';
|
||||
import { CommercialPropertyService } from 'src/listings/commercial-property.service';
|
||||
import { Geo } from 'src/models/server.model';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import winston from 'winston';
|
||||
import { User, UserData } from '../models/db.model';
|
||||
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName } from '../models/main.model';
|
||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
||||
import * as schema from './schema';
|
||||
interface PropertyImportListing {
|
||||
id: string;
|
||||
userId: string;
|
||||
listingsCategory: 'commercialProperty';
|
||||
title: string;
|
||||
state: string;
|
||||
hasImages: boolean;
|
||||
price: number;
|
||||
city: string;
|
||||
description: string;
|
||||
type: number;
|
||||
imageOrder: any[];
|
||||
}
|
||||
interface BusinessImportListing {
|
||||
userId: string;
|
||||
listingsCategory: 'business';
|
||||
title: string;
|
||||
description: string;
|
||||
type: number;
|
||||
state: string;
|
||||
city: string;
|
||||
id: string;
|
||||
price: number;
|
||||
salesRevenue: number;
|
||||
leasedLocation: boolean;
|
||||
established: number;
|
||||
employees: number;
|
||||
reasonForSale: string;
|
||||
supportAndTraining: string;
|
||||
cashFlow: number;
|
||||
brokerLicencing: string;
|
||||
internalListingNumber: number;
|
||||
realEstateIncluded: boolean;
|
||||
franchiseResale: boolean;
|
||||
draft: boolean;
|
||||
internals: string;
|
||||
created: string;
|
||||
}
|
||||
// const typesOfBusiness: Array<KeyValueStyle> = [
|
||||
// { name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
|
||||
// { name: 'Industrial Services', value: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
|
||||
// { name: 'Real Estate', value: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
||||
// { name: 'Uncategorized', value: '4', icon: 'fa-solid fa-question', textColorClass: 'text-cyan-400' },
|
||||
// { name: 'Retail', value: '5', icon: 'fa-solid fa-money-bill-wave', textColorClass: 'text-pink-400' },
|
||||
// { name: 'Oilfield SVE and MFG.', value: '6', icon: 'fa-solid fa-oil-well', textColorClass: 'text-indigo-400' },
|
||||
// { name: 'Service', value: '7', icon: 'fa-solid fa-umbrella', textColorClass: 'text-teal-400' },
|
||||
// { name: 'Advertising', value: '8', icon: 'fa-solid fa-rectangle-ad', textColorClass: 'text-orange-400' },
|
||||
// { name: 'Agriculture', value: '9', icon: 'fa-solid fa-wheat-awn', textColorClass: 'text-sky-400' },
|
||||
// { name: 'Franchise', value: '10', icon: 'fa-solid fa-star', textColorClass: 'text-purple-400' },
|
||||
// { name: 'Professional', value: '11', icon: 'fa-solid fa-user-gear', textColorClass: 'text-gray-400' },
|
||||
// { name: 'Manufacturing', value: '12', icon: 'fa-solid fa-industry', textColorClass: 'text-red-400' },
|
||||
// { name: 'Food and Restaurant', value: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
|
||||
// ];
|
||||
// const { Pool } = pkg;
|
||||
|
||||
// const openai = new OpenAI({
|
||||
// apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen
|
||||
// });
|
||||
(async () => {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
// const pool = new Pool({connectionString})
|
||||
const client = new Pool({ connectionString });
|
||||
const db = drizzle(client, { schema, logger: true });
|
||||
const logger = winston.createLogger({
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
const commService = new CommercialPropertyService(null, db);
|
||||
const businessService = new BusinessListingService(null, db);
|
||||
const userService = new UserService(null, db, null, null);
|
||||
//Delete Content
|
||||
await db.delete(schema.commercials);
|
||||
await db.delete(schema.businesses);
|
||||
await db.delete(schema.users);
|
||||
let filePath = `./src/assets/geo.json`;
|
||||
const rawData = readFileSync(filePath, 'utf8');
|
||||
const geos = JSON.parse(rawData) as Geo;
|
||||
|
||||
const sso = new SelectOptionsService();
|
||||
//Broker
|
||||
filePath = `./data/broker.json`;
|
||||
let data: string = readFileSync(filePath, 'utf8');
|
||||
const usersData: UserData[] = JSON.parse(data); // Erwartet ein Array von Objekten
|
||||
const generatedUserData = [];
|
||||
console.log(usersData.length);
|
||||
let i = 0,
|
||||
male = 0,
|
||||
female = 0;
|
||||
const targetPathProfile = `./pictures/profile`;
|
||||
deleteFilesOfDir(targetPathProfile);
|
||||
const targetPathLogo = `./pictures/logo`;
|
||||
deleteFilesOfDir(targetPathLogo);
|
||||
const targetPathProperty = `./pictures/property`;
|
||||
deleteFilesOfDir(targetPathProperty);
|
||||
fs.ensureDirSync(`./pictures/logo`);
|
||||
fs.ensureDirSync(`./pictures/profile`);
|
||||
fs.ensureDirSync(`./pictures/property`);
|
||||
|
||||
//User
|
||||
for (let index = 0; index < usersData.length; index++) {
|
||||
const userData = usersData[index];
|
||||
const user: User = createDefaultUser('', '', '', null);
|
||||
user.licensedIn = [];
|
||||
userData.licensedIn.forEach(l => {
|
||||
console.log(l['value'], l['name']);
|
||||
user.licensedIn.push({ registerNo: l['value'], state: l['name'] });
|
||||
});
|
||||
user.areasServed = [];
|
||||
user.areasServed = userData.areasServed.map(l => {
|
||||
return { county: l.split(',')[0].trim(), state: l.split(',')[1].trim() };
|
||||
});
|
||||
user.hasCompanyLogo = true;
|
||||
user.hasProfile = true;
|
||||
user.firstname = userData.firstname;
|
||||
user.lastname = userData.lastname;
|
||||
user.email = userData.email;
|
||||
user.phoneNumber = userData.phoneNumber;
|
||||
user.description = userData.description;
|
||||
user.companyName = userData.companyName;
|
||||
user.companyOverview = userData.companyOverview;
|
||||
user.companyWebsite = userData.companyWebsite;
|
||||
const [city, state] = userData.companyLocation.split('-').map(e => e.trim());
|
||||
user.location = {};
|
||||
user.location.name = city;
|
||||
user.location.state = state;
|
||||
const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city);
|
||||
user.location.latitude = cityGeo.latitude;
|
||||
user.location.longitude = cityGeo.longitude;
|
||||
user.offeredServices = userData.offeredServices;
|
||||
user.gender = userData.gender;
|
||||
user.customerType = 'professional';
|
||||
user.customerSubType = 'broker';
|
||||
user.created = new Date();
|
||||
user.updated = new Date();
|
||||
|
||||
// const u = await db
|
||||
// .insert(schema.users)
|
||||
// .values(convertUserToDrizzleUser(user))
|
||||
// .returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname });
|
||||
const u = await userService.saveUser(user);
|
||||
generatedUserData.push(u);
|
||||
i++;
|
||||
logger.info(`user_${index} inserted`);
|
||||
if (u.gender === 'male') {
|
||||
male++;
|
||||
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
|
||||
await storeProfilePicture(data, emailToDirName(u.email));
|
||||
} else {
|
||||
female++;
|
||||
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`);
|
||||
await storeProfilePicture(data, emailToDirName(u.email));
|
||||
}
|
||||
const data = readFileSync(`./pictures_base/logo/${i}.jpg`);
|
||||
await storeCompanyLogo(data, emailToDirName(u.email));
|
||||
}
|
||||
|
||||
//Corporate Listings
|
||||
filePath = `./data/commercials.json`;
|
||||
data = readFileSync(filePath, 'utf8');
|
||||
const commercialJsonData = JSON.parse(data) as PropertyImportListing[]; // Erwartet ein Array von Objekten
|
||||
for (let index = 0; index < commercialJsonData.length; index++) {
|
||||
const user = getRandomItem(generatedUserData);
|
||||
const commercial = createDefaultCommercialPropertyListing();
|
||||
const id = commercialJsonData[index].id;
|
||||
delete commercial.id;
|
||||
|
||||
commercial.email = user.email;
|
||||
commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercialJsonData[index].type)).value;
|
||||
commercial.title = commercialJsonData[index].title;
|
||||
commercial.description = commercialJsonData[index].description;
|
||||
try {
|
||||
const cityGeo = geos.states.find(s => s.state_code === commercialJsonData[index].state).cities.find(c => c.name === commercialJsonData[index].city);
|
||||
commercial.location = {};
|
||||
commercial.location.latitude = cityGeo.latitude;
|
||||
commercial.location.longitude = cityGeo.longitude;
|
||||
commercial.location.name = commercialJsonData[index].city;
|
||||
commercial.location.state = commercialJsonData[index].state;
|
||||
// console.log(JSON.stringify(commercial.location));
|
||||
} catch (e) {
|
||||
console.log(`----------------> ERROR ${commercialJsonData[index].state} - ${commercialJsonData[index].city}`);
|
||||
continue;
|
||||
}
|
||||
commercial.price = commercialJsonData[index].price;
|
||||
commercial.listingsCategory = 'commercialProperty';
|
||||
commercial.draft = false;
|
||||
commercial.imageOrder = getFilenames(id);
|
||||
commercial.imagePath = emailToDirName(user.email);
|
||||
const insertionDate = getRandomDateWithinLastYear();
|
||||
commercial.created = insertionDate;
|
||||
commercial.updated = insertionDate;
|
||||
|
||||
const result = await commService.createListing(commercial); //await db.insert(schema.commercials).values(commercial).returning();
|
||||
try {
|
||||
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result.imagePath}/${result.serialId}`);
|
||||
} catch (err) {
|
||||
console.log(`----- No pictures available for ${id} ------ ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
//Business Listings
|
||||
filePath = `./data/businesses.json`;
|
||||
data = readFileSync(filePath, 'utf8');
|
||||
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
|
||||
for (let index = 0; index < businessJsonData.length; index++) {
|
||||
const business = createDefaultBusinessListing(); //businessJsonData[index];
|
||||
delete business.id;
|
||||
const user = getRandomItem(generatedUserData);
|
||||
business.email = user.email;
|
||||
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value;
|
||||
business.title = businessJsonData[index].title;
|
||||
business.description = businessJsonData[index].description;
|
||||
try {
|
||||
const cityGeo = geos.states.find(s => s.state_code === businessJsonData[index].state).cities.find(c => c.name === businessJsonData[index].city);
|
||||
business.location = {};
|
||||
business.location.latitude = cityGeo.latitude;
|
||||
business.location.longitude = cityGeo.longitude;
|
||||
business.location.name = businessJsonData[index].city;
|
||||
business.location.state = businessJsonData[index].state;
|
||||
} catch (e) {
|
||||
console.log(`----------------> ERROR ${businessJsonData[index].state} - ${businessJsonData[index].city}`);
|
||||
continue;
|
||||
}
|
||||
business.price = businessJsonData[index].price;
|
||||
business.title = businessJsonData[index].title;
|
||||
business.draft = businessJsonData[index].draft;
|
||||
business.listingsCategory = 'business';
|
||||
business.realEstateIncluded = businessJsonData[index].realEstateIncluded;
|
||||
business.leasedLocation = businessJsonData[index].leasedLocation;
|
||||
business.franchiseResale = businessJsonData[index].franchiseResale;
|
||||
|
||||
business.salesRevenue = businessJsonData[index].salesRevenue;
|
||||
business.cashFlow = businessJsonData[index].cashFlow;
|
||||
business.supportAndTraining = businessJsonData[index].supportAndTraining;
|
||||
business.employees = businessJsonData[index].employees;
|
||||
business.established = businessJsonData[index].established;
|
||||
business.internalListingNumber = businessJsonData[index].internalListingNumber;
|
||||
business.reasonForSale = businessJsonData[index].reasonForSale;
|
||||
business.brokerLicencing = businessJsonData[index].brokerLicencing;
|
||||
business.internals = businessJsonData[index].internals;
|
||||
business.imageName = emailToDirName(user.email);
|
||||
business.created = new Date(businessJsonData[index].created);
|
||||
business.updated = new Date(businessJsonData[index].created);
|
||||
|
||||
await businessService.createListing(business); //db.insert(schema.businesses).values(business);
|
||||
}
|
||||
|
||||
//End
|
||||
await client.end();
|
||||
})();
|
||||
// function sleep(ms) {
|
||||
// return new Promise(resolve => setTimeout(resolve, ms));
|
||||
// }
|
||||
// async function createEmbedding(text: string): Promise<number[]> {
|
||||
// const response = await openai.embeddings.create({
|
||||
// model: 'text-embedding-3-small',
|
||||
// input: text,
|
||||
// });
|
||||
// return response.data[0].embedding;
|
||||
// }
|
||||
|
||||
function getRandomItem<T>(arr: T[]): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('The array is empty.');
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return arr[randomIndex];
|
||||
}
|
||||
function getFilenames(id: string): string[] {
|
||||
try {
|
||||
const filePath = `./pictures_base/property/${id}`;
|
||||
return readdirSync(filePath);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function getRandomDateWithinLastYear(): Date {
|
||||
const currentDate = new Date();
|
||||
const lastYear = new Date(currentDate.getFullYear() - 1, currentDate.getMonth(), currentDate.getDate());
|
||||
|
||||
const timeDiff = currentDate.getTime() - lastYear.getTime();
|
||||
const randomTimeDiff = Math.random() * timeDiff;
|
||||
const randomDate = new Date(lastYear.getTime() + randomTimeDiff);
|
||||
|
||||
return randomDate;
|
||||
}
|
||||
async function storeProfilePicture(buffer: Buffer, userId: string) {
|
||||
const quality = 50;
|
||||
const output = await sharp(buffer)
|
||||
.resize({ width: 300 })
|
||||
.avif({ quality }) // Verwende AVIF
|
||||
//.webp({ quality }) // Verwende Webp
|
||||
.toBuffer();
|
||||
await sharp(output).toFile(`./pictures/profile/${userId}.avif`);
|
||||
}
|
||||
|
||||
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
|
||||
const quality = 50;
|
||||
const output = await sharp(buffer)
|
||||
.resize({ width: 300 })
|
||||
.avif({ quality }) // Verwende AVIF
|
||||
//.webp({ quality }) // Verwende Webp
|
||||
.toBuffer();
|
||||
await sharp(output).toFile(`./pictures/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
||||
// await fs.outputFile(`./pictures/logo/${userId}`, file.buffer);
|
||||
}
|
||||
|
||||
function deleteFilesOfDir(directoryPath) {
|
||||
// Überprüfen, ob das Verzeichnis existiert
|
||||
if (existsSync(directoryPath)) {
|
||||
// Den Inhalt des Verzeichnisses synchron löschen
|
||||
try {
|
||||
readdirSync(directoryPath).forEach(file => {
|
||||
const filePath = join(directoryPath, file);
|
||||
// Wenn es sich um ein Verzeichnis handelt, rekursiv löschen
|
||||
if (statSync(filePath).isDirectory()) {
|
||||
rimraf.sync(filePath);
|
||||
} else {
|
||||
// Wenn es sich um eine Datei handelt, direkt löschen
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
});
|
||||
console.log('Der Inhalt des Verzeichnisses wurde erfolgreich gelöscht.');
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen des Verzeichnisses:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('Das Verzeichnis existiert nicht.');
|
||||
}
|
||||
}
|
||||
import 'dotenv/config';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
|
||||
import fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { Pool } from 'pg';
|
||||
import { rimraf } from 'rimraf';
|
||||
import sharp from 'sharp';
|
||||
import { BusinessListingService } from 'src/listings/business-listing.service';
|
||||
import { CommercialPropertyService } from 'src/listings/commercial-property.service';
|
||||
import { Geo } from 'src/models/server.model';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import winston from 'winston';
|
||||
import { User, UserData } from '../models/db.model';
|
||||
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName } from '../models/main.model';
|
||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
||||
import * as schema from './schema';
|
||||
interface PropertyImportListing {
|
||||
id: string;
|
||||
userId: string;
|
||||
listingsCategory: 'commercialProperty';
|
||||
title: string;
|
||||
state: string;
|
||||
hasImages: boolean;
|
||||
price: number;
|
||||
city: string;
|
||||
description: string;
|
||||
type: number;
|
||||
imageOrder: any[];
|
||||
}
|
||||
interface BusinessImportListing {
|
||||
userId: string;
|
||||
listingsCategory: 'business';
|
||||
title: string;
|
||||
description: string;
|
||||
type: number;
|
||||
state: string;
|
||||
city: string;
|
||||
id: string;
|
||||
price: number;
|
||||
salesRevenue: number;
|
||||
leasedLocation: boolean;
|
||||
established: number;
|
||||
employees: number;
|
||||
reasonForSale: string;
|
||||
supportAndTraining: string;
|
||||
cashFlow: number;
|
||||
brokerLicencing: string;
|
||||
internalListingNumber: number;
|
||||
realEstateIncluded: boolean;
|
||||
franchiseResale: boolean;
|
||||
draft: boolean;
|
||||
internals: string;
|
||||
created: string;
|
||||
}
|
||||
// const typesOfBusiness: Array<KeyValueStyle> = [
|
||||
// { name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
|
||||
// { name: 'Industrial Services', value: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
|
||||
// { name: 'Real Estate', value: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
||||
// { name: 'Uncategorized', value: '4', icon: 'fa-solid fa-question', textColorClass: 'text-cyan-400' },
|
||||
// { name: 'Retail', value: '5', icon: 'fa-solid fa-money-bill-wave', textColorClass: 'text-pink-400' },
|
||||
// { name: 'Oilfield SVE and MFG.', value: '6', icon: 'fa-solid fa-oil-well', textColorClass: 'text-indigo-400' },
|
||||
// { name: 'Service', value: '7', icon: 'fa-solid fa-umbrella', textColorClass: 'text-teal-400' },
|
||||
// { name: 'Advertising', value: '8', icon: 'fa-solid fa-rectangle-ad', textColorClass: 'text-orange-400' },
|
||||
// { name: 'Agriculture', value: '9', icon: 'fa-solid fa-wheat-awn', textColorClass: 'text-sky-400' },
|
||||
// { name: 'Franchise', value: '10', icon: 'fa-solid fa-star', textColorClass: 'text-purple-400' },
|
||||
// { name: 'Professional', value: '11', icon: 'fa-solid fa-user-gear', textColorClass: 'text-gray-400' },
|
||||
// { name: 'Manufacturing', value: '12', icon: 'fa-solid fa-industry', textColorClass: 'text-red-400' },
|
||||
// { name: 'Food and Restaurant', value: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
|
||||
// ];
|
||||
// const { Pool } = pkg;
|
||||
|
||||
// const openai = new OpenAI({
|
||||
// apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen
|
||||
// });
|
||||
(async () => {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
// const pool = new Pool({connectionString})
|
||||
const client = new Pool({ connectionString });
|
||||
const db = drizzle(client, { schema, logger: true });
|
||||
const logger = winston.createLogger({
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
const commService = new CommercialPropertyService(null, db);
|
||||
const businessService = new BusinessListingService(null, db);
|
||||
const userService = new UserService(null, db, null, null);
|
||||
//Delete Content
|
||||
await db.delete(schema.commercials);
|
||||
await db.delete(schema.businesses);
|
||||
await db.delete(schema.users);
|
||||
let filePath = `./src/assets/geo.json`;
|
||||
const rawData = readFileSync(filePath, 'utf8');
|
||||
const geos = JSON.parse(rawData) as Geo;
|
||||
|
||||
const sso = new SelectOptionsService();
|
||||
//Broker
|
||||
filePath = `./data/broker.json`;
|
||||
let data: string = readFileSync(filePath, 'utf8');
|
||||
const usersData: UserData[] = JSON.parse(data); // Erwartet ein Array von Objekten
|
||||
const generatedUserData = [];
|
||||
console.log(usersData.length);
|
||||
let i = 0,
|
||||
male = 0,
|
||||
female = 0;
|
||||
const targetPathProfile = `./pictures/profile`;
|
||||
deleteFilesOfDir(targetPathProfile);
|
||||
const targetPathLogo = `./pictures/logo`;
|
||||
deleteFilesOfDir(targetPathLogo);
|
||||
const targetPathProperty = `./pictures/property`;
|
||||
deleteFilesOfDir(targetPathProperty);
|
||||
fs.ensureDirSync(`./pictures/logo`);
|
||||
fs.ensureDirSync(`./pictures/profile`);
|
||||
fs.ensureDirSync(`./pictures/property`);
|
||||
|
||||
//User
|
||||
for (let index = 0; index < usersData.length; index++) {
|
||||
const userData = usersData[index];
|
||||
const user: User = createDefaultUser('', '', '', null);
|
||||
user.licensedIn = [];
|
||||
userData.licensedIn.forEach(l => {
|
||||
console.log(l['value'], l['name']);
|
||||
user.licensedIn.push({ registerNo: l['value'], state: l['name'] });
|
||||
});
|
||||
user.areasServed = [];
|
||||
user.areasServed = userData.areasServed.map(l => {
|
||||
return { county: l.split(',')[0].trim(), state: l.split(',')[1].trim() };
|
||||
});
|
||||
user.hasCompanyLogo = true;
|
||||
user.hasProfile = true;
|
||||
user.firstname = userData.firstname;
|
||||
user.lastname = userData.lastname;
|
||||
user.email = userData.email;
|
||||
user.phoneNumber = userData.phoneNumber;
|
||||
user.description = userData.description;
|
||||
user.companyName = userData.companyName;
|
||||
user.companyOverview = userData.companyOverview;
|
||||
user.companyWebsite = userData.companyWebsite;
|
||||
const [city, state] = userData.companyLocation.split('-').map(e => e.trim());
|
||||
user.location = {};
|
||||
user.location.name = city;
|
||||
user.location.state = state;
|
||||
const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city);
|
||||
user.location.latitude = cityGeo.latitude;
|
||||
user.location.longitude = cityGeo.longitude;
|
||||
user.offeredServices = userData.offeredServices;
|
||||
user.gender = userData.gender;
|
||||
user.customerType = 'professional';
|
||||
user.customerSubType = 'broker';
|
||||
user.created = new Date();
|
||||
user.updated = new Date();
|
||||
|
||||
// const u = await db
|
||||
// .insert(schema.users)
|
||||
// .values(convertUserToDrizzleUser(user))
|
||||
// .returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname });
|
||||
const u = await userService.saveUser(user);
|
||||
generatedUserData.push(u);
|
||||
i++;
|
||||
logger.info(`user_${index} inserted`);
|
||||
if (u.gender === 'male') {
|
||||
male++;
|
||||
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
|
||||
await storeProfilePicture(data, emailToDirName(u.email));
|
||||
} else {
|
||||
female++;
|
||||
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`);
|
||||
await storeProfilePicture(data, emailToDirName(u.email));
|
||||
}
|
||||
const data = readFileSync(`./pictures_base/logo/${i}.jpg`);
|
||||
await storeCompanyLogo(data, emailToDirName(u.email));
|
||||
}
|
||||
|
||||
//Corporate Listings
|
||||
filePath = `./data/commercials.json`;
|
||||
data = readFileSync(filePath, 'utf8');
|
||||
const commercialJsonData = JSON.parse(data) as PropertyImportListing[]; // Erwartet ein Array von Objekten
|
||||
for (let index = 0; index < commercialJsonData.length; index++) {
|
||||
const user = getRandomItem(generatedUserData);
|
||||
const commercial = createDefaultCommercialPropertyListing();
|
||||
const id = commercialJsonData[index].id;
|
||||
delete commercial.id;
|
||||
|
||||
commercial.email = user.email;
|
||||
commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercialJsonData[index].type)).value;
|
||||
commercial.title = commercialJsonData[index].title;
|
||||
commercial.description = commercialJsonData[index].description;
|
||||
try {
|
||||
const cityGeo = geos.states.find(s => s.state_code === commercialJsonData[index].state).cities.find(c => c.name === commercialJsonData[index].city);
|
||||
commercial.location = {};
|
||||
commercial.location.latitude = cityGeo.latitude;
|
||||
commercial.location.longitude = cityGeo.longitude;
|
||||
commercial.location.name = commercialJsonData[index].city;
|
||||
commercial.location.state = commercialJsonData[index].state;
|
||||
// console.log(JSON.stringify(commercial.location));
|
||||
} catch (e) {
|
||||
console.log(`----------------> ERROR ${commercialJsonData[index].state} - ${commercialJsonData[index].city}`);
|
||||
continue;
|
||||
}
|
||||
commercial.price = commercialJsonData[index].price;
|
||||
commercial.listingsCategory = 'commercialProperty';
|
||||
commercial.draft = false;
|
||||
commercial.imageOrder = getFilenames(id);
|
||||
commercial.imagePath = emailToDirName(user.email);
|
||||
const insertionDate = getRandomDateWithinLastYear();
|
||||
commercial.created = insertionDate;
|
||||
commercial.updated = insertionDate;
|
||||
|
||||
const result = await commService.createListing(commercial); //await db.insert(schema.commercials).values(commercial).returning();
|
||||
try {
|
||||
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result.imagePath}/${result.serialId}`);
|
||||
} catch (err) {
|
||||
console.log(`----- No pictures available for ${id} ------ ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
//Business Listings
|
||||
filePath = `./data/businesses.json`;
|
||||
data = readFileSync(filePath, 'utf8');
|
||||
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
|
||||
for (let index = 0; index < businessJsonData.length; index++) {
|
||||
const business = createDefaultBusinessListing(); //businessJsonData[index];
|
||||
delete business.id;
|
||||
const user = getRandomItem(generatedUserData);
|
||||
business.email = user.email;
|
||||
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value;
|
||||
business.title = businessJsonData[index].title;
|
||||
business.description = businessJsonData[index].description;
|
||||
try {
|
||||
const cityGeo = geos.states.find(s => s.state_code === businessJsonData[index].state).cities.find(c => c.name === businessJsonData[index].city);
|
||||
business.location = {};
|
||||
business.location.latitude = cityGeo.latitude;
|
||||
business.location.longitude = cityGeo.longitude;
|
||||
business.location.name = businessJsonData[index].city;
|
||||
business.location.state = businessJsonData[index].state;
|
||||
} catch (e) {
|
||||
console.log(`----------------> ERROR ${businessJsonData[index].state} - ${businessJsonData[index].city}`);
|
||||
continue;
|
||||
}
|
||||
business.price = businessJsonData[index].price;
|
||||
business.title = businessJsonData[index].title;
|
||||
business.draft = businessJsonData[index].draft;
|
||||
business.listingsCategory = 'business';
|
||||
business.realEstateIncluded = businessJsonData[index].realEstateIncluded;
|
||||
business.leasedLocation = businessJsonData[index].leasedLocation;
|
||||
business.franchiseResale = businessJsonData[index].franchiseResale;
|
||||
|
||||
business.salesRevenue = businessJsonData[index].salesRevenue;
|
||||
business.cashFlow = businessJsonData[index].cashFlow;
|
||||
business.supportAndTraining = businessJsonData[index].supportAndTraining;
|
||||
business.employees = businessJsonData[index].employees;
|
||||
business.established = businessJsonData[index].established;
|
||||
business.internalListingNumber = businessJsonData[index].internalListingNumber;
|
||||
business.reasonForSale = businessJsonData[index].reasonForSale;
|
||||
business.brokerLicencing = businessJsonData[index].brokerLicencing;
|
||||
business.internals = businessJsonData[index].internals;
|
||||
business.imageName = emailToDirName(user.email);
|
||||
business.created = new Date(businessJsonData[index].created);
|
||||
business.updated = new Date(businessJsonData[index].created);
|
||||
|
||||
await businessService.createListing(business); //db.insert(schema.businesses).values(business);
|
||||
}
|
||||
|
||||
//End
|
||||
await client.end();
|
||||
})();
|
||||
// function sleep(ms) {
|
||||
// return new Promise(resolve => setTimeout(resolve, ms));
|
||||
// }
|
||||
// async function createEmbedding(text: string): Promise<number[]> {
|
||||
// const response = await openai.embeddings.create({
|
||||
// model: 'text-embedding-3-small',
|
||||
// input: text,
|
||||
// });
|
||||
// return response.data[0].embedding;
|
||||
// }
|
||||
|
||||
function getRandomItem<T>(arr: T[]): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('The array is empty.');
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return arr[randomIndex];
|
||||
}
|
||||
function getFilenames(id: string): string[] {
|
||||
try {
|
||||
const filePath = `./pictures_base/property/${id}`;
|
||||
return readdirSync(filePath);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function getRandomDateWithinLastYear(): Date {
|
||||
const currentDate = new Date();
|
||||
const lastYear = new Date(currentDate.getFullYear() - 1, currentDate.getMonth(), currentDate.getDate());
|
||||
|
||||
const timeDiff = currentDate.getTime() - lastYear.getTime();
|
||||
const randomTimeDiff = Math.random() * timeDiff;
|
||||
const randomDate = new Date(lastYear.getTime() + randomTimeDiff);
|
||||
|
||||
return randomDate;
|
||||
}
|
||||
async function storeProfilePicture(buffer: Buffer, userId: string) {
|
||||
const quality = 50;
|
||||
const output = await sharp(buffer)
|
||||
.resize({ width: 300 })
|
||||
.avif({ quality }) // Verwende AVIF
|
||||
//.webp({ quality }) // Verwende Webp
|
||||
.toBuffer();
|
||||
await sharp(output).toFile(`./pictures/profile/${userId}.avif`);
|
||||
}
|
||||
|
||||
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
|
||||
const quality = 50;
|
||||
const output = await sharp(buffer)
|
||||
.resize({ width: 300 })
|
||||
.avif({ quality }) // Verwende AVIF
|
||||
//.webp({ quality }) // Verwende Webp
|
||||
.toBuffer();
|
||||
await sharp(output).toFile(`./pictures/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
||||
// await fs.outputFile(`./pictures/logo/${userId}`, file.buffer);
|
||||
}
|
||||
|
||||
function deleteFilesOfDir(directoryPath) {
|
||||
// Überprüfen, ob das Verzeichnis existiert
|
||||
if (existsSync(directoryPath)) {
|
||||
// Den Inhalt des Verzeichnisses synchron löschen
|
||||
try {
|
||||
readdirSync(directoryPath).forEach(file => {
|
||||
const filePath = join(directoryPath, file);
|
||||
// Wenn es sich um ein Verzeichnis handelt, rekursiv löschen
|
||||
if (statSync(filePath).isDirectory()) {
|
||||
rimraf.sync(filePath);
|
||||
} else {
|
||||
// Wenn es sich um eine Datei handelt, direkt löschen
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
});
|
||||
console.log('Der Inhalt des Verzeichnisses wurde erfolgreich gelöscht.');
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen des Verzeichnisses:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('Das Verzeichnis existiert nicht.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,175 +1,175 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, doublePrecision, index, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
import { AreasServed, LicensedIn } from '../models/db.model';
|
||||
export const PG_CONNECTION = 'PG_CONNECTION';
|
||||
export const genderEnum = pgEnum('gender', ['male', 'female']);
|
||||
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'seller', 'professional']);
|
||||
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
||||
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
|
||||
|
||||
// Neue JSONB-basierte Tabellen
|
||||
export const users_json = pgTable(
|
||||
'users_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_users_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const businesses_json = pgTable(
|
||||
'businesses_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_businesses_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const commercials_json = pgTable(
|
||||
'commercials_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_commercials_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const listing_events_json = pgTable(
|
||||
'listing_events_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_listing_events_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
// Bestehende Tabellen bleiben unverändert
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
firstname: varchar('firstname', { length: 255 }).notNull(),
|
||||
lastname: varchar('lastname', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
phoneNumber: varchar('phoneNumber', { length: 255 }),
|
||||
description: text('description'),
|
||||
companyName: varchar('companyName', { length: 255 }),
|
||||
companyOverview: text('companyOverview'),
|
||||
companyWebsite: varchar('companyWebsite', { length: 255 }),
|
||||
offeredServices: text('offeredServices'),
|
||||
areasServed: jsonb('areasServed').$type<AreasServed[]>(),
|
||||
hasProfile: boolean('hasProfile'),
|
||||
hasCompanyLogo: boolean('hasCompanyLogo'),
|
||||
licensedIn: jsonb('licensedIn').$type<LicensedIn[]>(),
|
||||
gender: genderEnum('gender'),
|
||||
customerType: customerTypeEnum('customerType'),
|
||||
customerSubType: customerSubTypeEnum('customerSubType'),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
subscriptionId: text('subscriptionId'),
|
||||
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
|
||||
location: jsonb('location'),
|
||||
showInDirectory: boolean('showInDirectory').default(true),
|
||||
},
|
||||
table => ({
|
||||
locationUserCityStateIdx: index('idx_user_location_city_state').on(
|
||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const businesses = pgTable(
|
||||
'businesses',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||
type: varchar('type', { length: 255 }),
|
||||
title: varchar('title', { length: 255 }),
|
||||
description: text('description'),
|
||||
price: doublePrecision('price'),
|
||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||
draft: boolean('draft'),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||
realEstateIncluded: boolean('realEstateIncluded'),
|
||||
leasedLocation: boolean('leasedLocation'),
|
||||
franchiseResale: boolean('franchiseResale'),
|
||||
salesRevenue: doublePrecision('salesRevenue'),
|
||||
cashFlow: doublePrecision('cashFlow'),
|
||||
supportAndTraining: text('supportAndTraining'),
|
||||
employees: integer('employees'),
|
||||
established: integer('established'),
|
||||
internalListingNumber: integer('internalListingNumber'),
|
||||
reasonForSale: varchar('reasonForSale', { length: 255 }),
|
||||
brokerLicencing: varchar('brokerLicencing', { length: 255 }),
|
||||
internals: text('internals'),
|
||||
imageName: varchar('imageName', { length: 200 }),
|
||||
slug: varchar('slug', { length: 300 }).unique(),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
location: jsonb('location'),
|
||||
},
|
||||
table => ({
|
||||
locationBusinessCityStateIdx: index('idx_business_location_city_state').on(
|
||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||
),
|
||||
slugIdx: index('idx_business_slug').on(table.slug),
|
||||
}),
|
||||
);
|
||||
|
||||
export const commercials = pgTable(
|
||||
'commercials',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
serialId: serial('serialId'),
|
||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||
type: varchar('type', { length: 255 }),
|
||||
title: varchar('title', { length: 255 }),
|
||||
description: text('description'),
|
||||
price: doublePrecision('price'),
|
||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||
draft: boolean('draft'),
|
||||
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
||||
imagePath: varchar('imagePath', { length: 200 }),
|
||||
slug: varchar('slug', { length: 300 }).unique(),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
location: jsonb('location'),
|
||||
},
|
||||
table => ({
|
||||
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
|
||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||
),
|
||||
slugIdx: index('idx_commercials_slug').on(table.slug),
|
||||
}),
|
||||
);
|
||||
|
||||
export const listing_events = pgTable('listing_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
listingId: varchar('listing_id', { length: 255 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
eventType: varchar('event_type', { length: 50 }),
|
||||
eventTimestamp: timestamp('event_timestamp').defaultNow(),
|
||||
userIp: varchar('user_ip', { length: 45 }),
|
||||
userAgent: varchar('user_agent', { length: 255 }),
|
||||
locationCountry: varchar('location_country', { length: 100 }),
|
||||
locationCity: varchar('location_city', { length: 100 }),
|
||||
locationLat: varchar('location_lat', { length: 20 }),
|
||||
locationLng: varchar('location_lng', { length: 20 }),
|
||||
referrer: varchar('referrer', { length: 255 }),
|
||||
additionalData: jsonb('additional_data'),
|
||||
});
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, doublePrecision, index, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
import { AreasServed, LicensedIn } from '../models/db.model';
|
||||
export const PG_CONNECTION = 'PG_CONNECTION';
|
||||
export const genderEnum = pgEnum('gender', ['male', 'female']);
|
||||
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'seller', 'professional']);
|
||||
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
||||
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
|
||||
|
||||
// Neue JSONB-basierte Tabellen
|
||||
export const users_json = pgTable(
|
||||
'users_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_users_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const businesses_json = pgTable(
|
||||
'businesses_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_businesses_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const commercials_json = pgTable(
|
||||
'commercials_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_commercials_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const listing_events_json = pgTable(
|
||||
'listing_events_json',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }),
|
||||
data: jsonb('data'),
|
||||
},
|
||||
table => ({
|
||||
emailIdx: index('idx_listing_events_json_email').on(table.email),
|
||||
}),
|
||||
);
|
||||
|
||||
// Bestehende Tabellen bleiben unverändert
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
firstname: varchar('firstname', { length: 255 }).notNull(),
|
||||
lastname: varchar('lastname', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
phoneNumber: varchar('phoneNumber', { length: 255 }),
|
||||
description: text('description'),
|
||||
companyName: varchar('companyName', { length: 255 }),
|
||||
companyOverview: text('companyOverview'),
|
||||
companyWebsite: varchar('companyWebsite', { length: 255 }),
|
||||
offeredServices: text('offeredServices'),
|
||||
areasServed: jsonb('areasServed').$type<AreasServed[]>(),
|
||||
hasProfile: boolean('hasProfile'),
|
||||
hasCompanyLogo: boolean('hasCompanyLogo'),
|
||||
licensedIn: jsonb('licensedIn').$type<LicensedIn[]>(),
|
||||
gender: genderEnum('gender'),
|
||||
customerType: customerTypeEnum('customerType'),
|
||||
customerSubType: customerSubTypeEnum('customerSubType'),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
subscriptionId: text('subscriptionId'),
|
||||
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
|
||||
location: jsonb('location'),
|
||||
showInDirectory: boolean('showInDirectory').default(true),
|
||||
},
|
||||
table => ({
|
||||
locationUserCityStateIdx: index('idx_user_location_city_state').on(
|
||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const businesses = pgTable(
|
||||
'businesses',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||
type: varchar('type', { length: 255 }),
|
||||
title: varchar('title', { length: 255 }),
|
||||
description: text('description'),
|
||||
price: doublePrecision('price'),
|
||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||
draft: boolean('draft'),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||
realEstateIncluded: boolean('realEstateIncluded'),
|
||||
leasedLocation: boolean('leasedLocation'),
|
||||
franchiseResale: boolean('franchiseResale'),
|
||||
salesRevenue: doublePrecision('salesRevenue'),
|
||||
cashFlow: doublePrecision('cashFlow'),
|
||||
supportAndTraining: text('supportAndTraining'),
|
||||
employees: integer('employees'),
|
||||
established: integer('established'),
|
||||
internalListingNumber: integer('internalListingNumber'),
|
||||
reasonForSale: varchar('reasonForSale', { length: 255 }),
|
||||
brokerLicencing: varchar('brokerLicencing', { length: 255 }),
|
||||
internals: text('internals'),
|
||||
imageName: varchar('imageName', { length: 200 }),
|
||||
slug: varchar('slug', { length: 300 }).unique(),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
location: jsonb('location'),
|
||||
},
|
||||
table => ({
|
||||
locationBusinessCityStateIdx: index('idx_business_location_city_state').on(
|
||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||
),
|
||||
slugIdx: index('idx_business_slug').on(table.slug),
|
||||
}),
|
||||
);
|
||||
|
||||
export const commercials = pgTable(
|
||||
'commercials',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
serialId: serial('serialId'),
|
||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||
type: varchar('type', { length: 255 }),
|
||||
title: varchar('title', { length: 255 }),
|
||||
description: text('description'),
|
||||
price: doublePrecision('price'),
|
||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||
draft: boolean('draft'),
|
||||
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
||||
imagePath: varchar('imagePath', { length: 200 }),
|
||||
slug: varchar('slug', { length: 300 }).unique(),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
location: jsonb('location'),
|
||||
},
|
||||
table => ({
|
||||
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
|
||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||
),
|
||||
slugIdx: index('idx_commercials_slug').on(table.slug),
|
||||
}),
|
||||
);
|
||||
|
||||
export const listing_events = pgTable('listing_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
listingId: varchar('listing_id', { length: 255 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
eventType: varchar('event_type', { length: 50 }),
|
||||
eventTimestamp: timestamp('event_timestamp').defaultNow(),
|
||||
userIp: varchar('user_ip', { length: 45 }),
|
||||
userAgent: varchar('user_agent', { length: 255 }),
|
||||
locationCountry: varchar('location_country', { length: 100 }),
|
||||
locationCity: varchar('location_city', { length: 100 }),
|
||||
locationLat: varchar('location_lat', { length: 20 }),
|
||||
locationLng: varchar('location_lng', { length: 20 }),
|
||||
referrer: varchar('referrer', { length: 255 }),
|
||||
additionalData: jsonb('additional_data'),
|
||||
});
|
||||
|
||||
@@ -1,431 +1,431 @@
|
||||
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 } 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[] = [];
|
||||
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
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) {
|
||||
this.logger.debug('Adding radius search filter', { city: criteria.city.name, 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 && criteria.types.length > 0) {
|
||||
this.logger.warn('Adding business category filter', { types: criteria.types });
|
||||
// Use explicit SQL with IN for robust JSONB comparison
|
||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
this.logger.debug('Adding state filter', { state: 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(
|
||||
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
||||
);
|
||||
}
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
if (firstname === lastname) {
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
} else {
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (criteria.email) {
|
||||
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
||||
}
|
||||
if (user?.role !== 'admin') {
|
||||
whereConditions.push(
|
||||
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||
);
|
||||
}
|
||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||
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`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||
})
|
||||
.from(businesses_json)
|
||||
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
query.where(sql`(${whereClause})`);
|
||||
|
||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'priceAsc':
|
||||
query.orderBy(asc(sql`(${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(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
countQuery.where(sql`(${whereClause})`);
|
||||
}
|
||||
|
||||
const [{ value: totalCount }] = await countQuery;
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find 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));
|
||||
}
|
||||
}
|
||||
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 } 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[] = [];
|
||||
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
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) {
|
||||
this.logger.debug('Adding radius search filter', { city: criteria.city.name, 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 && criteria.types.length > 0) {
|
||||
this.logger.warn('Adding business category filter', { types: criteria.types });
|
||||
// Use explicit SQL with IN for robust JSONB comparison
|
||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
this.logger.debug('Adding state filter', { state: 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(
|
||||
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
||||
);
|
||||
}
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
if (firstname === lastname) {
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
} else {
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (criteria.email) {
|
||||
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
||||
}
|
||||
if (user?.role !== 'admin') {
|
||||
whereConditions.push(
|
||||
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||
);
|
||||
}
|
||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||
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`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||
})
|
||||
.from(businesses_json)
|
||||
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
query.where(sql`(${whereClause})`);
|
||||
|
||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'priceAsc':
|
||||
query.orderBy(asc(sql`(${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(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
countQuery.where(sql`(${whereClause})`);
|
||||
}
|
||||
|
||||
const [{ value: totalCount }] = await countQuery;
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||
import { BusinessListing } from '../models/db.model';
|
||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { BusinessListingService } from './business-listing.service';
|
||||
|
||||
@Controller('listings/business')
|
||||
export class BusinessListingsController {
|
||||
constructor(
|
||||
private readonly listingsService: BusinessListingService,
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':slugOrId')
|
||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
||||
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
||||
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('user/:userid')
|
||||
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
||||
return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('find')
|
||||
async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
|
||||
return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
|
||||
}
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('findTotal')
|
||||
async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
|
||||
return await this.listingsService.getBusinessListingsCount(criteria, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post()
|
||||
async create(@Body() listing: any) {
|
||||
return await this.listingsService.createListing(listing);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Put()
|
||||
async update(@Request() req, @Body() listing: any) {
|
||||
return await this.listingsService.updateBusinessListing(listing.id, listing, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Delete('listing/:id')
|
||||
async deleteById(@Param('id') id: string) {
|
||||
await this.listingsService.deleteListing(id);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorite/:id')
|
||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Added to favorites' };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('favorite/:id')
|
||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Removed from favorites' };
|
||||
}
|
||||
}
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||
import { BusinessListing } from '../models/db.model';
|
||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { BusinessListingService } from './business-listing.service';
|
||||
|
||||
@Controller('listings/business')
|
||||
export class BusinessListingsController {
|
||||
constructor(
|
||||
private readonly listingsService: BusinessListingService,
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':slugOrId')
|
||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
||||
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
||||
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('user/:userid')
|
||||
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
||||
return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('find')
|
||||
async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
|
||||
return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
|
||||
}
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('findTotal')
|
||||
async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
|
||||
return await this.listingsService.getBusinessListingsCount(criteria, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post()
|
||||
async create(@Body() listing: any) {
|
||||
return await this.listingsService.createListing(listing);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Put()
|
||||
async update(@Request() req, @Body() listing: any) {
|
||||
return await this.listingsService.updateBusinessListing(listing.id, listing, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Delete('listing/:id')
|
||||
async deleteById(@Param('id') id: string) {
|
||||
await this.listingsService.deleteListing(id);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorite/:id')
|
||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Added to favorites' };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('favorite/:id')
|
||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Removed from favorites' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
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 { FileService } from '../file/file.service';
|
||||
|
||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||
import { CommercialPropertyListing } from '../models/db.model';
|
||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { CommercialPropertyService } from './commercial-property.service';
|
||||
|
||||
@Controller('listings/commercialProperty')
|
||||
export class CommercialPropertyListingsController {
|
||||
constructor(
|
||||
private readonly listingsService: CommercialPropertyService,
|
||||
private fileService: FileService,
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':slugOrId')
|
||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
||||
// Support both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
||||
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('user/:email')
|
||||
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
||||
return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('find')
|
||||
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
|
||||
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
|
||||
}
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('findTotal')
|
||||
async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
|
||||
return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post()
|
||||
async create(@Body() listing: any) {
|
||||
return await this.listingsService.createListing(listing);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Put()
|
||||
async update(@Request() req, @Body() listing: any) {
|
||||
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Delete('listing/:id/:imagePath')
|
||||
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
|
||||
await this.listingsService.deleteListing(id);
|
||||
this.fileService.deleteDirectoryIfExists(imagePath);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorite/:id')
|
||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Added to favorites' };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('favorite/:id')
|
||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Removed from favorites' };
|
||||
}
|
||||
}
|
||||
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 { FileService } from '../file/file.service';
|
||||
|
||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||
import { CommercialPropertyListing } from '../models/db.model';
|
||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { CommercialPropertyService } from './commercial-property.service';
|
||||
|
||||
@Controller('listings/commercialProperty')
|
||||
export class CommercialPropertyListingsController {
|
||||
constructor(
|
||||
private readonly listingsService: CommercialPropertyService,
|
||||
private fileService: FileService,
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':slugOrId')
|
||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
||||
// Support both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
||||
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('user/:email')
|
||||
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
||||
return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('find')
|
||||
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
|
||||
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
|
||||
}
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('findTotal')
|
||||
async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
|
||||
return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post()
|
||||
async create(@Body() listing: any) {
|
||||
return await this.listingsService.createListing(listing);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Put()
|
||||
async update(@Request() req, @Body() listing: any) {
|
||||
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Delete('listing/:id/:imagePath')
|
||||
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
|
||||
await this.listingsService.deleteListing(id);
|
||||
this.fileService.deleteDirectoryIfExists(imagePath);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorite/:id')
|
||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Added to favorites' };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('favorite/:id')
|
||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Removed from favorites' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,364 +1,364 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import { ZodError } from 'zod';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { commercials_json, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CommercialPropertyService {
|
||||
constructor(
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<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) {
|
||||
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
|
||||
// Use explicit SQL with IN for robust JSONB comparison
|
||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||
}
|
||||
|
||||
if (criteria.minPrice) {
|
||||
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice));
|
||||
}
|
||||
|
||||
if (criteria.maxPrice) {
|
||||
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice));
|
||||
}
|
||||
|
||||
if (criteria.title) {
|
||||
whereConditions.push(
|
||||
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
|
||||
);
|
||||
}
|
||||
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
if (firstname === lastname) {
|
||||
// Single word: search either first OR last name
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
} else {
|
||||
// Multiple words: search both first AND last name
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
whereConditions.push(
|
||||
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||
);
|
||||
}
|
||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||
return whereConditions;
|
||||
}
|
||||
// #### Find by criteria ########################################
|
||||
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<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);
|
||||
|
||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
query.where(sql`(${whereClause})`);
|
||||
|
||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||
}
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'priceAsc':
|
||||
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'priceDesc':
|
||||
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'creationDateFirst':
|
||||
query.orderBy(asc(sql`${commercials_json.data}->>'created'`));
|
||||
break;
|
||||
case 'creationDateLast':
|
||||
query.orderBy(desc(sql`${commercials_json.data}->>'created'`));
|
||||
break;
|
||||
default:
|
||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||
break;
|
||||
}
|
||||
|
||||
// Paginierung
|
||||
query.limit(length).offset(start);
|
||||
|
||||
const data = await query;
|
||||
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) }));
|
||||
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<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 = sql.join(whereConditions, sql` AND `);
|
||||
countQuery.where(sql`(${whereClause})`);
|
||||
}
|
||||
|
||||
const [{ value: totalCount }] = await countQuery;
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
// #### Find by ID ########################################
|
||||
/**
|
||||
* Find commercial property by slug or ID
|
||||
* Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
||||
*/
|
||||
async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise<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));
|
||||
}
|
||||
}
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import { ZodError } from 'zod';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { commercials_json, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CommercialPropertyService {
|
||||
constructor(
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<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) {
|
||||
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
|
||||
// Use explicit SQL with IN for robust JSONB comparison
|
||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||
}
|
||||
|
||||
if (criteria.minPrice) {
|
||||
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice));
|
||||
}
|
||||
|
||||
if (criteria.maxPrice) {
|
||||
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice));
|
||||
}
|
||||
|
||||
if (criteria.title) {
|
||||
whereConditions.push(
|
||||
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
|
||||
);
|
||||
}
|
||||
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
if (firstname === lastname) {
|
||||
// Single word: search either first OR last name
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
} else {
|
||||
// Multiple words: search both first AND last name
|
||||
whereConditions.push(
|
||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
whereConditions.push(
|
||||
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||
);
|
||||
}
|
||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||
return whereConditions;
|
||||
}
|
||||
// #### Find by criteria ########################################
|
||||
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<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);
|
||||
|
||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
query.where(sql`(${whereClause})`);
|
||||
|
||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||
}
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'priceAsc':
|
||||
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'priceDesc':
|
||||
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'creationDateFirst':
|
||||
query.orderBy(asc(sql`${commercials_json.data}->>'created'`));
|
||||
break;
|
||||
case 'creationDateLast':
|
||||
query.orderBy(desc(sql`${commercials_json.data}->>'created'`));
|
||||
break;
|
||||
default:
|
||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||
break;
|
||||
}
|
||||
|
||||
// Paginierung
|
||||
query.limit(length).offset(start);
|
||||
|
||||
const data = await query;
|
||||
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) }));
|
||||
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<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 = sql.join(whereConditions, sql` AND `);
|
||||
countQuery.where(sql`(${whereClause})`);
|
||||
}
|
||||
|
||||
const [{ value: totalCount }] = await countQuery;
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
// #### Find by ID ########################################
|
||||
/**
|
||||
* Find commercial property by slug or ID
|
||||
* Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
||||
*/
|
||||
async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { BrokerListingsController } from './broker-listings.controller';
|
||||
import { BusinessListingsController } from './business-listings.controller';
|
||||
import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
|
||||
import { UserListingsController } from './user-listings.controller';
|
||||
|
||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
||||
import { GeoModule } from '../geo/geo.module';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { BusinessListingService } from './business-listing.service';
|
||||
import { CommercialPropertyService } from './commercial-property.service';
|
||||
import { UnknownListingsController } from './unknown-listings.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule],
|
||||
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
|
||||
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
||||
exports: [BusinessListingService, CommercialPropertyService],
|
||||
})
|
||||
export class ListingsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { BrokerListingsController } from './broker-listings.controller';
|
||||
import { BusinessListingsController } from './business-listings.controller';
|
||||
import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
|
||||
import { UserListingsController } from './user-listings.controller';
|
||||
|
||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
||||
import { GeoModule } from '../geo/geo.module';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { BusinessListingService } from './business-listing.service';
|
||||
import { CommercialPropertyService } from './commercial-property.service';
|
||||
import { UnknownListingsController } from './unknown-listings.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule],
|
||||
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
|
||||
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
||||
exports: [BusinessListingService, CommercialPropertyService],
|
||||
})
|
||||
export class ListingsModule {}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { LoggerService } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import express from 'express';
|
||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const server = express();
|
||||
server.set('trust proxy', true);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
// const logger = app.get<Logger>(WINSTON_MODULE_NEST_PROVIDER);
|
||||
const logger = app.get<LoggerService>(WINSTON_MODULE_NEST_PROVIDER);
|
||||
app.useLogger(logger);
|
||||
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
||||
// Serve static files from pictures directory
|
||||
app.use('/pictures', express.static('pictures'));
|
||||
|
||||
app.setGlobalPrefix('bizmatch');
|
||||
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
|
||||
});
|
||||
await app.listen(process.env.PORT || 3001);
|
||||
}
|
||||
bootstrap();
|
||||
import { LoggerService } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import express from 'express';
|
||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const server = express();
|
||||
server.set('trust proxy', true);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
// const logger = app.get<Logger>(WINSTON_MODULE_NEST_PROVIDER);
|
||||
const logger = app.get<LoggerService>(WINSTON_MODULE_NEST_PROVIDER);
|
||||
app.useLogger(logger);
|
||||
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
||||
// Serve static files from pictures directory
|
||||
app.use('/pictures', express.static('pictures'));
|
||||
|
||||
app.setGlobalPrefix('bizmatch');
|
||||
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
|
||||
});
|
||||
await app.listen(process.env.PORT || 3001);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,393 +1,393 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface UserData {
|
||||
id?: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
description?: string;
|
||||
companyName?: string;
|
||||
companyOverview?: string;
|
||||
companyWebsite?: string;
|
||||
companyLocation?: string;
|
||||
offeredServices?: string;
|
||||
areasServed?: string[];
|
||||
hasProfile?: boolean;
|
||||
hasCompanyLogo?: boolean;
|
||||
licensedIn?: string[];
|
||||
gender?: 'male' | 'female';
|
||||
customerType?: 'buyer' | 'seller' | 'professional';
|
||||
customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
||||
created?: Date;
|
||||
updated?: Date;
|
||||
}
|
||||
export type SortByOptions = 'priceAsc' | 'priceDesc' | 'creationDateFirst' | 'creationDateLast' | 'nameAsc' | 'nameDesc' | 'srAsc' | 'srDesc' | 'cfAsc' | 'cfDesc';
|
||||
export type SortByTypes = 'professional' | 'listing' | 'business' | 'commercial';
|
||||
export type Gender = 'male' | 'female';
|
||||
export type CustomerType = 'buyer' | 'seller' | 'professional';
|
||||
export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
||||
export type ListingsCategory = 'commercialProperty' | 'business';
|
||||
|
||||
export const GenderEnum = z.enum(['male', 'female']);
|
||||
export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
|
||||
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
||||
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
||||
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
|
||||
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
||||
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||
const TypeEnum = z.enum([
|
||||
'automotive',
|
||||
'industrialServices',
|
||||
'foodAndRestaurant',
|
||||
'realEstate',
|
||||
'retail',
|
||||
'oilfield',
|
||||
'service',
|
||||
'advertising',
|
||||
'agriculture',
|
||||
'franchise',
|
||||
'professional',
|
||||
'manufacturing',
|
||||
'uncategorized',
|
||||
]);
|
||||
|
||||
const USStates = z.enum([
|
||||
'AL',
|
||||
'AK',
|
||||
'AZ',
|
||||
'AR',
|
||||
'CA',
|
||||
'CO',
|
||||
'CT',
|
||||
'DC',
|
||||
'DE',
|
||||
'FL',
|
||||
'GA',
|
||||
'HI',
|
||||
'ID',
|
||||
'IL',
|
||||
'IN',
|
||||
'IA',
|
||||
'KS',
|
||||
'KY',
|
||||
'LA',
|
||||
'ME',
|
||||
'MD',
|
||||
'MA',
|
||||
'MI',
|
||||
'MN',
|
||||
'MS',
|
||||
'MO',
|
||||
'MT',
|
||||
'NE',
|
||||
'NV',
|
||||
'NH',
|
||||
'NJ',
|
||||
'NM',
|
||||
'NY',
|
||||
'NC',
|
||||
'ND',
|
||||
'OH',
|
||||
'OK',
|
||||
'OR',
|
||||
'PA',
|
||||
'RI',
|
||||
'SC',
|
||||
'SD',
|
||||
'TN',
|
||||
'TX',
|
||||
'UT',
|
||||
'VT',
|
||||
'VA',
|
||||
'WA',
|
||||
'WV',
|
||||
'WI',
|
||||
'WY',
|
||||
]);
|
||||
export const AreasServedSchema = z.object({
|
||||
county: z.string().optional().nullable(),
|
||||
state: z
|
||||
.string()
|
||||
.nullable()
|
||||
.refine(val => val !== null && val !== '', {
|
||||
message: 'State is required',
|
||||
}),
|
||||
});
|
||||
|
||||
export const LicensedInSchema = z.object({
|
||||
state: z
|
||||
.string()
|
||||
.nullable()
|
||||
.refine(val => val !== null && val !== '', {
|
||||
message: 'State is required',
|
||||
}),
|
||||
registerNo: z.string().nonempty('License number is required'),
|
||||
});
|
||||
export const GeoSchema = z
|
||||
.object({
|
||||
name: z.string().optional().nullable(),
|
||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||
}),
|
||||
latitude: z.number().refine(
|
||||
value => {
|
||||
return value >= -90 && value <= 90;
|
||||
},
|
||||
{
|
||||
message: 'Latitude muss zwischen -90 und 90 liegen',
|
||||
},
|
||||
),
|
||||
longitude: z.number().refine(
|
||||
value => {
|
||||
return value >= -180 && value <= 180;
|
||||
},
|
||||
{
|
||||
message: 'Longitude muss zwischen -180 und 180 liegen',
|
||||
},
|
||||
),
|
||||
county: z.string().optional().nullable(),
|
||||
housenumber: z.string().optional().nullable(),
|
||||
street: z.string().optional().nullable(),
|
||||
zipCode: z.number().optional().nullable(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.state) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'You need to select at least a state',
|
||||
path: ['name'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
|
||||
export const UserSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional().nullable(),
|
||||
firstname: z.string().min(3, { message: 'First name must contain at least 2 characters' }),
|
||||
lastname: z.string().min(3, { message: 'Last name must contain at least 2 characters' }),
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
phoneNumber: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
companyName: z.string().optional().nullable(),
|
||||
companyOverview: z.string().optional().nullable(),
|
||||
companyWebsite: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||
location: GeoSchema.optional().nullable(),
|
||||
offeredServices: z.string().optional().nullable(),
|
||||
areasServed: z.array(AreasServedSchema).optional().nullable(),
|
||||
hasProfile: z.boolean().optional().nullable(),
|
||||
hasCompanyLogo: z.boolean().optional().nullable(),
|
||||
licensedIn: z.array(LicensedInSchema).optional().nullable(),
|
||||
gender: GenderEnum.optional().nullable(),
|
||||
customerType: CustomerTypeEnum,
|
||||
customerSubType: CustomerSubTypeEnum.optional().nullable(),
|
||||
created: z.date().optional().nullable(),
|
||||
updated: z.date().optional().nullable(),
|
||||
subscriptionId: z.string().optional().nullable(),
|
||||
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
showInDirectory: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.customerType === 'professional') {
|
||||
if (!data.customerSubType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Customer subtype is required for professional customers',
|
||||
path: ['customerSubType'],
|
||||
});
|
||||
}
|
||||
if (!data.companyName || data.companyName.length < 6) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Company Name must contain at least 6 characters for professional customers',
|
||||
path: ['companyName'],
|
||||
});
|
||||
}
|
||||
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Phone number is required and must be in US format (XXX) XXX-XXXX for professional customers',
|
||||
path: ['phoneNumber'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.companyOverview || data.companyOverview.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Company overview must contain at least 10 characters for professional customers',
|
||||
path: ['companyOverview'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.description || data.description.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Description must contain at least 10 characters for professional customers',
|
||||
path: ['description'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.offeredServices || data.offeredServices.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Offered services must contain at least 10 characters for professional customers',
|
||||
path: ['offeredServices'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.location) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Company location is required for professional customers',
|
||||
path: ['location'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.areasServed || data.areasServed.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one area served is required for professional customers',
|
||||
path: ['areasServed'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type AreasServed = z.infer<typeof AreasServedSchema>;
|
||||
export type LicensedIn = z.infer<typeof LicensedInSchema>;
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
export const BusinessListingSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional().nullable(),
|
||||
email: z.string().email(),
|
||||
type: z.string().refine(val => TypeEnum.safeParse(val).success, {
|
||||
message: 'Invalid type. Must be one of: ' + TypeEnum.options.join(', '),
|
||||
}),
|
||||
title: z.string().min(10),
|
||||
description: z.string().min(10),
|
||||
location: GeoSchema,
|
||||
price: z.number().positive().optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
draft: z.boolean(),
|
||||
listingsCategory: ListingsCategoryEnum,
|
||||
realEstateIncluded: z.boolean().optional().nullable(),
|
||||
leasedLocation: z.boolean().optional().nullable(),
|
||||
franchiseResale: z.boolean().optional().nullable(),
|
||||
salesRevenue: z.number().positive().nullable(),
|
||||
cashFlow: z.number().optional().nullable(),
|
||||
ffe: z.number().optional().nullable(),
|
||||
inventory: z.number().optional().nullable(),
|
||||
supportAndTraining: z.string().min(5).optional().nullable(),
|
||||
employees: z.number().int().positive().max(100000).optional().nullable(),
|
||||
established: z.number().int().min(1).max(250).optional().nullable(),
|
||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||
reasonForSale: z.string().min(5).optional().nullable(),
|
||||
brokerLicencing: z.string().optional().nullable(),
|
||||
internals: z.string().min(5).optional().nullable(),
|
||||
imageName: z.string().optional().nullable(),
|
||||
slug: z.string().optional().nullable(),
|
||||
created: z.date(),
|
||||
updated: z.date(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.price && data.price > 1000000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Price must less than or equal $1,000,000,000',
|
||||
path: ['price'],
|
||||
});
|
||||
}
|
||||
if (data.salesRevenue && data.salesRevenue > 100000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'SalesRevenue must less than or equal $100,000,000',
|
||||
path: ['salesRevenue'],
|
||||
});
|
||||
}
|
||||
if (data.cashFlow && data.cashFlow > 100000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CashFlow must less than or equal $100,000,000',
|
||||
path: ['cashFlow'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type BusinessListing = z.infer<typeof BusinessListingSchema>;
|
||||
|
||||
export const CommercialPropertyListingSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional().nullable(),
|
||||
serialId: z.number().int().positive().optional().nullable(),
|
||||
email: z.string().email(),
|
||||
type: z.string().refine(val => PropertyTypeEnum.safeParse(val).success, {
|
||||
message: 'Invalid type. Must be one of: ' + PropertyTypeEnum.options.join(', '),
|
||||
}),
|
||||
title: z.string().min(10),
|
||||
description: z.string().min(10),
|
||||
location: GeoSchema,
|
||||
price: z.number().positive().optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
listingsCategory: ListingsCategoryEnum,
|
||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||
draft: z.boolean(),
|
||||
imageOrder: z.array(z.string()),
|
||||
imagePath: z.string().nullable().optional(),
|
||||
slug: z.string().optional().nullable(),
|
||||
created: z.date(),
|
||||
updated: z.date(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.price && data.price > 1000000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Price must less than or equal $1,000,000,000',
|
||||
path: ['price'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;
|
||||
|
||||
export const SenderSchema = z.object({
|
||||
name: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
phoneNumber: z.string().regex(/^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/, {
|
||||
message: 'Invalid US phone number format',
|
||||
}),
|
||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||
}),
|
||||
comments: z.string().min(10, { message: 'Comments must be at least 10 characters long' }),
|
||||
});
|
||||
export type Sender = z.infer<typeof SenderSchema>;
|
||||
export const ShareByEMailSchema = z.object({
|
||||
yourName: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
||||
recipientEmail: z.string().email({ message: 'Invalid email address' }),
|
||||
yourEmail: z.string().email({ message: 'Invalid email address' }),
|
||||
listingTitle: z.string().optional().nullable(),
|
||||
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||
id: z.string().optional().nullable(),
|
||||
type: ShareCategoryEnum,
|
||||
});
|
||||
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
||||
|
||||
export const ListingEventSchema = z.object({
|
||||
id: z.string().uuid(), // UUID für das Event
|
||||
listingId: z.string().uuid().optional().nullable(), // UUID für das Listing
|
||||
email: z.string().email().optional().nullable(), // EMail des den Benutzer, optional, wenn kein Benutzer eingeloggt ist
|
||||
eventType: ZodEventTypeEnum, // Die Event-Typen
|
||||
eventTimestamp: z.string().datetime().or(z.date()), // Der Zeitstempel des Events, kann ein String im ISO-Format oder ein Date-Objekt sein
|
||||
userIp: z.string().max(45).optional().nullable(), // IP-Adresse des Benutzers, optional
|
||||
userAgent: z.string().max(255).optional().nullable(), // User-Agent des Benutzers, optional
|
||||
locationCountry: z.string().max(100).optional().nullable(), // Land, optional
|
||||
locationCity: z.string().max(100).optional().nullable(), // Stadt, optional
|
||||
locationLat: z.string().max(20).optional().nullable(), // Latitude, als String
|
||||
locationLng: z.string().max(20).optional().nullable(), // Longitude, als String
|
||||
referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional
|
||||
additionalData: z.record(z.string(), z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
|
||||
});
|
||||
export type ListingEvent = z.infer<typeof ListingEventSchema>;
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface UserData {
|
||||
id?: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
description?: string;
|
||||
companyName?: string;
|
||||
companyOverview?: string;
|
||||
companyWebsite?: string;
|
||||
companyLocation?: string;
|
||||
offeredServices?: string;
|
||||
areasServed?: string[];
|
||||
hasProfile?: boolean;
|
||||
hasCompanyLogo?: boolean;
|
||||
licensedIn?: string[];
|
||||
gender?: 'male' | 'female';
|
||||
customerType?: 'buyer' | 'seller' | 'professional';
|
||||
customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
||||
created?: Date;
|
||||
updated?: Date;
|
||||
}
|
||||
export type SortByOptions = 'priceAsc' | 'priceDesc' | 'creationDateFirst' | 'creationDateLast' | 'nameAsc' | 'nameDesc' | 'srAsc' | 'srDesc' | 'cfAsc' | 'cfDesc';
|
||||
export type SortByTypes = 'professional' | 'listing' | 'business' | 'commercial';
|
||||
export type Gender = 'male' | 'female';
|
||||
export type CustomerType = 'buyer' | 'seller' | 'professional';
|
||||
export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
||||
export type ListingsCategory = 'commercialProperty' | 'business';
|
||||
|
||||
export const GenderEnum = z.enum(['male', 'female']);
|
||||
export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
|
||||
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
||||
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
||||
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
|
||||
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
||||
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||
const TypeEnum = z.enum([
|
||||
'automotive',
|
||||
'industrialServices',
|
||||
'foodAndRestaurant',
|
||||
'realEstate',
|
||||
'retail',
|
||||
'oilfield',
|
||||
'service',
|
||||
'advertising',
|
||||
'agriculture',
|
||||
'franchise',
|
||||
'professional',
|
||||
'manufacturing',
|
||||
'uncategorized',
|
||||
]);
|
||||
|
||||
const USStates = z.enum([
|
||||
'AL',
|
||||
'AK',
|
||||
'AZ',
|
||||
'AR',
|
||||
'CA',
|
||||
'CO',
|
||||
'CT',
|
||||
'DC',
|
||||
'DE',
|
||||
'FL',
|
||||
'GA',
|
||||
'HI',
|
||||
'ID',
|
||||
'IL',
|
||||
'IN',
|
||||
'IA',
|
||||
'KS',
|
||||
'KY',
|
||||
'LA',
|
||||
'ME',
|
||||
'MD',
|
||||
'MA',
|
||||
'MI',
|
||||
'MN',
|
||||
'MS',
|
||||
'MO',
|
||||
'MT',
|
||||
'NE',
|
||||
'NV',
|
||||
'NH',
|
||||
'NJ',
|
||||
'NM',
|
||||
'NY',
|
||||
'NC',
|
||||
'ND',
|
||||
'OH',
|
||||
'OK',
|
||||
'OR',
|
||||
'PA',
|
||||
'RI',
|
||||
'SC',
|
||||
'SD',
|
||||
'TN',
|
||||
'TX',
|
||||
'UT',
|
||||
'VT',
|
||||
'VA',
|
||||
'WA',
|
||||
'WV',
|
||||
'WI',
|
||||
'WY',
|
||||
]);
|
||||
export const AreasServedSchema = z.object({
|
||||
county: z.string().optional().nullable(),
|
||||
state: z
|
||||
.string()
|
||||
.nullable()
|
||||
.refine(val => val !== null && val !== '', {
|
||||
message: 'State is required',
|
||||
}),
|
||||
});
|
||||
|
||||
export const LicensedInSchema = z.object({
|
||||
state: z
|
||||
.string()
|
||||
.nullable()
|
||||
.refine(val => val !== null && val !== '', {
|
||||
message: 'State is required',
|
||||
}),
|
||||
registerNo: z.string().nonempty('License number is required'),
|
||||
});
|
||||
export const GeoSchema = z
|
||||
.object({
|
||||
name: z.string().optional().nullable(),
|
||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||
}),
|
||||
latitude: z.number().refine(
|
||||
value => {
|
||||
return value >= -90 && value <= 90;
|
||||
},
|
||||
{
|
||||
message: 'Latitude muss zwischen -90 und 90 liegen',
|
||||
},
|
||||
),
|
||||
longitude: z.number().refine(
|
||||
value => {
|
||||
return value >= -180 && value <= 180;
|
||||
},
|
||||
{
|
||||
message: 'Longitude muss zwischen -180 und 180 liegen',
|
||||
},
|
||||
),
|
||||
county: z.string().optional().nullable(),
|
||||
housenumber: z.string().optional().nullable(),
|
||||
street: z.string().optional().nullable(),
|
||||
zipCode: z.number().optional().nullable(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.state) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'You need to select at least a state',
|
||||
path: ['name'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
|
||||
export const UserSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional().nullable(),
|
||||
firstname: z.string().min(3, { message: 'First name must contain at least 2 characters' }),
|
||||
lastname: z.string().min(3, { message: 'Last name must contain at least 2 characters' }),
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
phoneNumber: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
companyName: z.string().optional().nullable(),
|
||||
companyOverview: z.string().optional().nullable(),
|
||||
companyWebsite: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||
location: GeoSchema.optional().nullable(),
|
||||
offeredServices: z.string().optional().nullable(),
|
||||
areasServed: z.array(AreasServedSchema).optional().nullable(),
|
||||
hasProfile: z.boolean().optional().nullable(),
|
||||
hasCompanyLogo: z.boolean().optional().nullable(),
|
||||
licensedIn: z.array(LicensedInSchema).optional().nullable(),
|
||||
gender: GenderEnum.optional().nullable(),
|
||||
customerType: CustomerTypeEnum,
|
||||
customerSubType: CustomerSubTypeEnum.optional().nullable(),
|
||||
created: z.date().optional().nullable(),
|
||||
updated: z.date().optional().nullable(),
|
||||
subscriptionId: z.string().optional().nullable(),
|
||||
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
showInDirectory: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.customerType === 'professional') {
|
||||
if (!data.customerSubType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Customer subtype is required for professional customers',
|
||||
path: ['customerSubType'],
|
||||
});
|
||||
}
|
||||
if (!data.companyName || data.companyName.length < 6) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Company Name must contain at least 6 characters for professional customers',
|
||||
path: ['companyName'],
|
||||
});
|
||||
}
|
||||
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Phone number is required and must be in US format (XXX) XXX-XXXX for professional customers',
|
||||
path: ['phoneNumber'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.companyOverview || data.companyOverview.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Company overview must contain at least 10 characters for professional customers',
|
||||
path: ['companyOverview'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.description || data.description.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Description must contain at least 10 characters for professional customers',
|
||||
path: ['description'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.offeredServices || data.offeredServices.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Offered services must contain at least 10 characters for professional customers',
|
||||
path: ['offeredServices'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.location) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Company location is required for professional customers',
|
||||
path: ['location'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.areasServed || data.areasServed.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one area served is required for professional customers',
|
||||
path: ['areasServed'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type AreasServed = z.infer<typeof AreasServedSchema>;
|
||||
export type LicensedIn = z.infer<typeof LicensedInSchema>;
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
export const BusinessListingSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional().nullable(),
|
||||
email: z.string().email(),
|
||||
type: z.string().refine(val => TypeEnum.safeParse(val).success, {
|
||||
message: 'Invalid type. Must be one of: ' + TypeEnum.options.join(', '),
|
||||
}),
|
||||
title: z.string().min(10),
|
||||
description: z.string().min(10),
|
||||
location: GeoSchema,
|
||||
price: z.number().positive().optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
draft: z.boolean(),
|
||||
listingsCategory: ListingsCategoryEnum,
|
||||
realEstateIncluded: z.boolean().optional().nullable(),
|
||||
leasedLocation: z.boolean().optional().nullable(),
|
||||
franchiseResale: z.boolean().optional().nullable(),
|
||||
salesRevenue: z.number().positive().nullable(),
|
||||
cashFlow: z.number().optional().nullable(),
|
||||
ffe: z.number().optional().nullable(),
|
||||
inventory: z.number().optional().nullable(),
|
||||
supportAndTraining: z.string().min(5).optional().nullable(),
|
||||
employees: z.number().int().positive().max(100000).optional().nullable(),
|
||||
established: z.number().int().min(1).max(250).optional().nullable(),
|
||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||
reasonForSale: z.string().min(5).optional().nullable(),
|
||||
brokerLicencing: z.string().optional().nullable(),
|
||||
internals: z.string().min(5).optional().nullable(),
|
||||
imageName: z.string().optional().nullable(),
|
||||
slug: z.string().optional().nullable(),
|
||||
created: z.date(),
|
||||
updated: z.date(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.price && data.price > 1000000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Price must less than or equal $1,000,000,000',
|
||||
path: ['price'],
|
||||
});
|
||||
}
|
||||
if (data.salesRevenue && data.salesRevenue > 100000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'SalesRevenue must less than or equal $100,000,000',
|
||||
path: ['salesRevenue'],
|
||||
});
|
||||
}
|
||||
if (data.cashFlow && data.cashFlow > 100000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CashFlow must less than or equal $100,000,000',
|
||||
path: ['cashFlow'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type BusinessListing = z.infer<typeof BusinessListingSchema>;
|
||||
|
||||
export const CommercialPropertyListingSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional().nullable(),
|
||||
serialId: z.number().int().positive().optional().nullable(),
|
||||
email: z.string().email(),
|
||||
type: z.string().refine(val => PropertyTypeEnum.safeParse(val).success, {
|
||||
message: 'Invalid type. Must be one of: ' + PropertyTypeEnum.options.join(', '),
|
||||
}),
|
||||
title: z.string().min(10),
|
||||
description: z.string().min(10),
|
||||
location: GeoSchema,
|
||||
price: z.number().positive().optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
listingsCategory: ListingsCategoryEnum,
|
||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||
draft: z.boolean(),
|
||||
imageOrder: z.array(z.string()),
|
||||
imagePath: z.string().nullable().optional(),
|
||||
slug: z.string().optional().nullable(),
|
||||
created: z.date(),
|
||||
updated: z.date(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.price && data.price > 1000000000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Price must less than or equal $1,000,000,000',
|
||||
path: ['price'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;
|
||||
|
||||
export const SenderSchema = z.object({
|
||||
name: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
phoneNumber: z.string().regex(/^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/, {
|
||||
message: 'Invalid US phone number format',
|
||||
}),
|
||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||
}),
|
||||
comments: z.string().min(10, { message: 'Comments must be at least 10 characters long' }),
|
||||
});
|
||||
export type Sender = z.infer<typeof SenderSchema>;
|
||||
export const ShareByEMailSchema = z.object({
|
||||
yourName: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
||||
recipientEmail: z.string().email({ message: 'Invalid email address' }),
|
||||
yourEmail: z.string().email({ message: 'Invalid email address' }),
|
||||
listingTitle: z.string().optional().nullable(),
|
||||
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||
id: z.string().optional().nullable(),
|
||||
type: ShareCategoryEnum,
|
||||
});
|
||||
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
||||
|
||||
export const ListingEventSchema = z.object({
|
||||
id: z.string().uuid(), // UUID für das Event
|
||||
listingId: z.string().uuid().optional().nullable(), // UUID für das Listing
|
||||
email: z.string().email().optional().nullable(), // EMail des den Benutzer, optional, wenn kein Benutzer eingeloggt ist
|
||||
eventType: ZodEventTypeEnum, // Die Event-Typen
|
||||
eventTimestamp: z.string().datetime().or(z.date()), // Der Zeitstempel des Events, kann ein String im ISO-Format oder ein Date-Objekt sein
|
||||
userIp: z.string().max(45).optional().nullable(), // IP-Adresse des Benutzers, optional
|
||||
userAgent: z.string().max(255).optional().nullable(), // User-Agent des Benutzers, optional
|
||||
locationCountry: z.string().max(100).optional().nullable(), // Land, optional
|
||||
locationCity: z.string().max(100).optional().nullable(), // Stadt, optional
|
||||
locationLat: z.string().max(20).optional().nullable(), // Latitude, als String
|
||||
locationLng: z.string().max(20).optional().nullable(), // Longitude, als String
|
||||
referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional
|
||||
additionalData: z.record(z.string(), z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
|
||||
});
|
||||
export type ListingEvent = z.infer<typeof ListingEventSchema>;
|
||||
|
||||
@@ -1,430 +1,430 @@
|
||||
import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model';
|
||||
import { State } from './server.model';
|
||||
|
||||
export interface StatesResult {
|
||||
state: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface KeyValue {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
export interface KeyValueAsSortBy {
|
||||
name: string;
|
||||
value: SortByOptions;
|
||||
type?: SortByTypes;
|
||||
selectName?: string;
|
||||
}
|
||||
export interface KeyValueRatio {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
export interface KeyValueStyle {
|
||||
name: string;
|
||||
value: string;
|
||||
oldValue?: string;
|
||||
icon: string;
|
||||
textColorClass: string;
|
||||
}
|
||||
export type SelectOption<T = number> = {
|
||||
value: T;
|
||||
label: string;
|
||||
};
|
||||
export type ImageType = {
|
||||
name: 'propertyPicture' | 'companyLogo' | 'profile';
|
||||
upload: string;
|
||||
delete: string;
|
||||
};
|
||||
export type ListingCategory = {
|
||||
name: 'business' | 'commercialProperty';
|
||||
};
|
||||
|
||||
export type ListingType = BusinessListing | CommercialPropertyListing;
|
||||
|
||||
export type ResponseBusinessListingArray = {
|
||||
results: BusinessListing[];
|
||||
totalCount: number;
|
||||
};
|
||||
export type ResponseBusinessListing = {
|
||||
data: BusinessListing;
|
||||
};
|
||||
export type ResponseCommercialPropertyListingArray = {
|
||||
results: CommercialPropertyListing[];
|
||||
totalCount: number;
|
||||
};
|
||||
export type ResponseCommercialPropertyListing = {
|
||||
data: CommercialPropertyListing;
|
||||
};
|
||||
export type ResponseUsersArray = {
|
||||
results: User[];
|
||||
totalCount: number;
|
||||
};
|
||||
export interface ListCriteria {
|
||||
start: number;
|
||||
length: number;
|
||||
page: number;
|
||||
types: string[];
|
||||
state: string;
|
||||
city: GeoResult;
|
||||
prompt: string;
|
||||
searchType: 'exact' | 'radius';
|
||||
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
|
||||
radius: number;
|
||||
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||
sortBy?: SortByOptions;
|
||||
}
|
||||
export interface BusinessListingCriteria extends ListCriteria {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
minRevenue: number;
|
||||
maxRevenue: number;
|
||||
minCashFlow: number;
|
||||
maxCashFlow: number;
|
||||
minNumberEmployees: number;
|
||||
maxNumberEmployees: number;
|
||||
establishedMin: number;
|
||||
realEstateChecked: boolean;
|
||||
leasedLocation: boolean;
|
||||
franchiseResale: boolean;
|
||||
title: string;
|
||||
brokerName: string;
|
||||
email: string;
|
||||
criteriaType: 'businessListings';
|
||||
}
|
||||
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
title: string;
|
||||
brokerName: string;
|
||||
criteriaType: 'commercialPropertyListings';
|
||||
}
|
||||
export interface UserListingCriteria extends ListCriteria {
|
||||
brokerName: string;
|
||||
companyName: string;
|
||||
counties: string[];
|
||||
criteriaType: 'brokerListings';
|
||||
}
|
||||
|
||||
export interface KeycloakUser {
|
||||
id: string;
|
||||
createdTimestamp?: number;
|
||||
username?: string;
|
||||
enabled?: boolean;
|
||||
totp?: boolean;
|
||||
emailVerified?: boolean;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
disableableCredentialTypes?: any[];
|
||||
requiredActions?: any[];
|
||||
notBefore?: number;
|
||||
access?: Access;
|
||||
attributes?: Attributes;
|
||||
}
|
||||
export interface JwtUser {
|
||||
email: string;
|
||||
role: string;
|
||||
uid: string;
|
||||
}
|
||||
interface Attributes {
|
||||
[key: string]: any;
|
||||
priceID: any;
|
||||
}
|
||||
export interface Access {
|
||||
manageGroupMembership: boolean;
|
||||
view: boolean;
|
||||
mapRoles: boolean;
|
||||
impersonate: boolean;
|
||||
manage: boolean;
|
||||
}
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
level: string;
|
||||
start: Date;
|
||||
modified: Date;
|
||||
end: Date;
|
||||
status: string;
|
||||
invoices: Array<Invoice>;
|
||||
}
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
date: Date;
|
||||
price: number;
|
||||
}
|
||||
export interface JwtToken {
|
||||
exp: number;
|
||||
iat: number;
|
||||
auth_time: number;
|
||||
jti: string;
|
||||
iss: string;
|
||||
aud: string;
|
||||
sub: string;
|
||||
typ: string;
|
||||
azp: string;
|
||||
nonce: string;
|
||||
session_state: string;
|
||||
acr: string;
|
||||
realm_access: Realmaccess;
|
||||
resource_access: Resourceaccess;
|
||||
scope: string;
|
||||
sid: string;
|
||||
email_verified: boolean;
|
||||
name: string;
|
||||
preferred_username: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
email: string;
|
||||
user_id: string;
|
||||
price_id: string;
|
||||
}
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
preferred_username: string;
|
||||
realm_access?: {
|
||||
roles?: string[];
|
||||
};
|
||||
[key: string]: any; // für andere optionale Felder im JWT-Payload
|
||||
}
|
||||
interface Resourceaccess {
|
||||
account: Realmaccess;
|
||||
}
|
||||
interface Realmaccess {
|
||||
roles: string[];
|
||||
}
|
||||
export interface PageEvent {
|
||||
first: number;
|
||||
rows: number;
|
||||
page: number;
|
||||
pageCount: number;
|
||||
}
|
||||
export interface AutoCompleteCompleteEvent {
|
||||
originalEvent: Event;
|
||||
query: string;
|
||||
}
|
||||
export interface MailInfo {
|
||||
sender: Sender;
|
||||
email: string;
|
||||
url: string;
|
||||
listing?: BusinessListing;
|
||||
}
|
||||
// export interface Sender {
|
||||
// name?: string;
|
||||
// email?: string;
|
||||
// phoneNumber?: string;
|
||||
// state?: string;
|
||||
// comments?: string;
|
||||
// }
|
||||
export interface ImageProperty {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
export interface ErrorResponse {
|
||||
fields?: FieldError[];
|
||||
general?: string[];
|
||||
}
|
||||
export interface FieldError {
|
||||
fieldname: string;
|
||||
message: string;
|
||||
}
|
||||
export interface UploadParams {
|
||||
type: 'uploadPropertyPicture' | 'uploadCompanyLogo' | 'uploadProfile';
|
||||
imagePath: string;
|
||||
serialId?: number;
|
||||
}
|
||||
export interface GeoResult {
|
||||
id: number;
|
||||
name: string;
|
||||
street?: string;
|
||||
housenumber?: string;
|
||||
county?: string;
|
||||
zipCode?: number;
|
||||
state: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
interface CityResult {
|
||||
id: number;
|
||||
type: 'city';
|
||||
content: GeoResult;
|
||||
}
|
||||
|
||||
interface StateResult {
|
||||
id: number;
|
||||
type: 'state';
|
||||
content: State;
|
||||
}
|
||||
export type CityAndStateResult = CityResult | StateResult;
|
||||
export interface CountyResult {
|
||||
id: number;
|
||||
name: string;
|
||||
state: string;
|
||||
state_code: string;
|
||||
}
|
||||
export interface LogMessage {
|
||||
severity: 'error' | 'info';
|
||||
text: string;
|
||||
}
|
||||
export interface ModalResult {
|
||||
accepted: boolean;
|
||||
criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||
}
|
||||
export interface Checkout {
|
||||
priceId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
export type UserRole = 'admin' | 'pro' | 'guest' | null;
|
||||
export interface FirebaseUserInfo {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
photoURL: string | null;
|
||||
phoneNumber: string | null;
|
||||
disabled: boolean;
|
||||
emailVerified: boolean;
|
||||
role: UserRole;
|
||||
creationTime?: string;
|
||||
lastSignInTime?: string;
|
||||
customClaims?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
users: FirebaseUserInfo[];
|
||||
totalCount: number;
|
||||
pageToken?: string;
|
||||
}
|
||||
export function isEmpty(value: any): boolean {
|
||||
// Check for undefined or null
|
||||
if (value === undefined || value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for empty string or string with only whitespace
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
|
||||
// Check for number and NaN
|
||||
if (typeof value === 'number') {
|
||||
return isNaN(value);
|
||||
}
|
||||
|
||||
// If it's not a string or number, it's not considered empty by this function
|
||||
return false;
|
||||
}
|
||||
export function emailToDirName(email: string): string {
|
||||
if (email === undefined || email === null) {
|
||||
return null;
|
||||
}
|
||||
// Entferne ungültige Zeichen und ersetze sie durch Unterstriche
|
||||
const sanitizedEmail = email.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
// Entferne führende und nachfolgende Unterstriche
|
||||
const trimmedEmail = sanitizedEmail.replace(/^_+|_+$/g, '');
|
||||
|
||||
// Ersetze mehrfache aufeinanderfolgende Unterstriche durch einen einzelnen Unterstrich
|
||||
const normalizedEmail = trimmedEmail.replace(/_+/g, '_');
|
||||
|
||||
return normalizedEmail;
|
||||
}
|
||||
export const LISTINGS_PER_PAGE = 12;
|
||||
export interface ValidationMessage {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
|
||||
return {
|
||||
id: undefined,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
phoneNumber: null,
|
||||
description: null,
|
||||
companyName: null,
|
||||
companyOverview: null,
|
||||
companyWebsite: null,
|
||||
location: null,
|
||||
offeredServices: null,
|
||||
areasServed: [],
|
||||
hasProfile: false,
|
||||
hasCompanyLogo: false,
|
||||
licensedIn: [],
|
||||
gender: null,
|
||||
customerType: 'buyer',
|
||||
customerSubType: null,
|
||||
created: new Date(),
|
||||
updated: new Date(),
|
||||
subscriptionId: null,
|
||||
subscriptionPlan: subscriptionPlan,
|
||||
favoritesForUser: [],
|
||||
showInDirectory: false,
|
||||
};
|
||||
}
|
||||
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
||||
return {
|
||||
id: undefined,
|
||||
serialId: undefined,
|
||||
email: null,
|
||||
type: null,
|
||||
title: null,
|
||||
description: null,
|
||||
location: null,
|
||||
price: null,
|
||||
favoritesForUser: [],
|
||||
draft: false,
|
||||
imageOrder: [],
|
||||
imagePath: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
listingsCategory: 'commercialProperty',
|
||||
};
|
||||
}
|
||||
export function createDefaultBusinessListing(): BusinessListing {
|
||||
return {
|
||||
id: undefined,
|
||||
email: null,
|
||||
type: null,
|
||||
title: null,
|
||||
description: null,
|
||||
location: null,
|
||||
price: null,
|
||||
favoritesForUser: [],
|
||||
draft: false,
|
||||
realEstateIncluded: false,
|
||||
leasedLocation: false,
|
||||
franchiseResale: false,
|
||||
salesRevenue: null,
|
||||
cashFlow: null,
|
||||
supportAndTraining: null,
|
||||
employees: null,
|
||||
established: null,
|
||||
internalListingNumber: null,
|
||||
reasonForSale: null,
|
||||
brokerLicencing: null,
|
||||
internals: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
listingsCategory: 'business',
|
||||
};
|
||||
}
|
||||
export type IpInfo = {
|
||||
ip: string;
|
||||
city: string;
|
||||
region: string;
|
||||
country: string;
|
||||
loc: string; // Coordinates in "latitude,longitude" format
|
||||
org: string;
|
||||
postal: string;
|
||||
timezone: string;
|
||||
};
|
||||
export interface CombinedUser {
|
||||
keycloakUser?: KeycloakUser;
|
||||
appUser?: User;
|
||||
}
|
||||
export interface RealIpInfo {
|
||||
ip: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model';
|
||||
import { State } from './server.model';
|
||||
|
||||
export interface StatesResult {
|
||||
state: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface KeyValue {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
export interface KeyValueAsSortBy {
|
||||
name: string;
|
||||
value: SortByOptions;
|
||||
type?: SortByTypes;
|
||||
selectName?: string;
|
||||
}
|
||||
export interface KeyValueRatio {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
export interface KeyValueStyle {
|
||||
name: string;
|
||||
value: string;
|
||||
oldValue?: string;
|
||||
icon: string;
|
||||
textColorClass: string;
|
||||
}
|
||||
export type SelectOption<T = number> = {
|
||||
value: T;
|
||||
label: string;
|
||||
};
|
||||
export type ImageType = {
|
||||
name: 'propertyPicture' | 'companyLogo' | 'profile';
|
||||
upload: string;
|
||||
delete: string;
|
||||
};
|
||||
export type ListingCategory = {
|
||||
name: 'business' | 'commercialProperty';
|
||||
};
|
||||
|
||||
export type ListingType = BusinessListing | CommercialPropertyListing;
|
||||
|
||||
export type ResponseBusinessListingArray = {
|
||||
results: BusinessListing[];
|
||||
totalCount: number;
|
||||
};
|
||||
export type ResponseBusinessListing = {
|
||||
data: BusinessListing;
|
||||
};
|
||||
export type ResponseCommercialPropertyListingArray = {
|
||||
results: CommercialPropertyListing[];
|
||||
totalCount: number;
|
||||
};
|
||||
export type ResponseCommercialPropertyListing = {
|
||||
data: CommercialPropertyListing;
|
||||
};
|
||||
export type ResponseUsersArray = {
|
||||
results: User[];
|
||||
totalCount: number;
|
||||
};
|
||||
export interface ListCriteria {
|
||||
start: number;
|
||||
length: number;
|
||||
page: number;
|
||||
types: string[];
|
||||
state: string;
|
||||
city: GeoResult;
|
||||
prompt: string;
|
||||
searchType: 'exact' | 'radius';
|
||||
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
|
||||
radius: number;
|
||||
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||
sortBy?: SortByOptions;
|
||||
}
|
||||
export interface BusinessListingCriteria extends ListCriteria {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
minRevenue: number;
|
||||
maxRevenue: number;
|
||||
minCashFlow: number;
|
||||
maxCashFlow: number;
|
||||
minNumberEmployees: number;
|
||||
maxNumberEmployees: number;
|
||||
establishedMin: number;
|
||||
realEstateChecked: boolean;
|
||||
leasedLocation: boolean;
|
||||
franchiseResale: boolean;
|
||||
title: string;
|
||||
brokerName: string;
|
||||
email: string;
|
||||
criteriaType: 'businessListings';
|
||||
}
|
||||
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
title: string;
|
||||
brokerName: string;
|
||||
criteriaType: 'commercialPropertyListings';
|
||||
}
|
||||
export interface UserListingCriteria extends ListCriteria {
|
||||
brokerName: string;
|
||||
companyName: string;
|
||||
counties: string[];
|
||||
criteriaType: 'brokerListings';
|
||||
}
|
||||
|
||||
export interface KeycloakUser {
|
||||
id: string;
|
||||
createdTimestamp?: number;
|
||||
username?: string;
|
||||
enabled?: boolean;
|
||||
totp?: boolean;
|
||||
emailVerified?: boolean;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
disableableCredentialTypes?: any[];
|
||||
requiredActions?: any[];
|
||||
notBefore?: number;
|
||||
access?: Access;
|
||||
attributes?: Attributes;
|
||||
}
|
||||
export interface JwtUser {
|
||||
email: string;
|
||||
role: string;
|
||||
uid: string;
|
||||
}
|
||||
interface Attributes {
|
||||
[key: string]: any;
|
||||
priceID: any;
|
||||
}
|
||||
export interface Access {
|
||||
manageGroupMembership: boolean;
|
||||
view: boolean;
|
||||
mapRoles: boolean;
|
||||
impersonate: boolean;
|
||||
manage: boolean;
|
||||
}
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
level: string;
|
||||
start: Date;
|
||||
modified: Date;
|
||||
end: Date;
|
||||
status: string;
|
||||
invoices: Array<Invoice>;
|
||||
}
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
date: Date;
|
||||
price: number;
|
||||
}
|
||||
export interface JwtToken {
|
||||
exp: number;
|
||||
iat: number;
|
||||
auth_time: number;
|
||||
jti: string;
|
||||
iss: string;
|
||||
aud: string;
|
||||
sub: string;
|
||||
typ: string;
|
||||
azp: string;
|
||||
nonce: string;
|
||||
session_state: string;
|
||||
acr: string;
|
||||
realm_access: Realmaccess;
|
||||
resource_access: Resourceaccess;
|
||||
scope: string;
|
||||
sid: string;
|
||||
email_verified: boolean;
|
||||
name: string;
|
||||
preferred_username: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
email: string;
|
||||
user_id: string;
|
||||
price_id: string;
|
||||
}
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
preferred_username: string;
|
||||
realm_access?: {
|
||||
roles?: string[];
|
||||
};
|
||||
[key: string]: any; // für andere optionale Felder im JWT-Payload
|
||||
}
|
||||
interface Resourceaccess {
|
||||
account: Realmaccess;
|
||||
}
|
||||
interface Realmaccess {
|
||||
roles: string[];
|
||||
}
|
||||
export interface PageEvent {
|
||||
first: number;
|
||||
rows: number;
|
||||
page: number;
|
||||
pageCount: number;
|
||||
}
|
||||
export interface AutoCompleteCompleteEvent {
|
||||
originalEvent: Event;
|
||||
query: string;
|
||||
}
|
||||
export interface MailInfo {
|
||||
sender: Sender;
|
||||
email: string;
|
||||
url: string;
|
||||
listing?: BusinessListing;
|
||||
}
|
||||
// export interface Sender {
|
||||
// name?: string;
|
||||
// email?: string;
|
||||
// phoneNumber?: string;
|
||||
// state?: string;
|
||||
// comments?: string;
|
||||
// }
|
||||
export interface ImageProperty {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
export interface ErrorResponse {
|
||||
fields?: FieldError[];
|
||||
general?: string[];
|
||||
}
|
||||
export interface FieldError {
|
||||
fieldname: string;
|
||||
message: string;
|
||||
}
|
||||
export interface UploadParams {
|
||||
type: 'uploadPropertyPicture' | 'uploadCompanyLogo' | 'uploadProfile';
|
||||
imagePath: string;
|
||||
serialId?: number;
|
||||
}
|
||||
export interface GeoResult {
|
||||
id: number;
|
||||
name: string;
|
||||
street?: string;
|
||||
housenumber?: string;
|
||||
county?: string;
|
||||
zipCode?: number;
|
||||
state: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
interface CityResult {
|
||||
id: number;
|
||||
type: 'city';
|
||||
content: GeoResult;
|
||||
}
|
||||
|
||||
interface StateResult {
|
||||
id: number;
|
||||
type: 'state';
|
||||
content: State;
|
||||
}
|
||||
export type CityAndStateResult = CityResult | StateResult;
|
||||
export interface CountyResult {
|
||||
id: number;
|
||||
name: string;
|
||||
state: string;
|
||||
state_code: string;
|
||||
}
|
||||
export interface LogMessage {
|
||||
severity: 'error' | 'info';
|
||||
text: string;
|
||||
}
|
||||
export interface ModalResult {
|
||||
accepted: boolean;
|
||||
criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||
}
|
||||
export interface Checkout {
|
||||
priceId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
export type UserRole = 'admin' | 'pro' | 'guest' | null;
|
||||
export interface FirebaseUserInfo {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
photoURL: string | null;
|
||||
phoneNumber: string | null;
|
||||
disabled: boolean;
|
||||
emailVerified: boolean;
|
||||
role: UserRole;
|
||||
creationTime?: string;
|
||||
lastSignInTime?: string;
|
||||
customClaims?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
users: FirebaseUserInfo[];
|
||||
totalCount: number;
|
||||
pageToken?: string;
|
||||
}
|
||||
export function isEmpty(value: any): boolean {
|
||||
// Check for undefined or null
|
||||
if (value === undefined || value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for empty string or string with only whitespace
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
|
||||
// Check for number and NaN
|
||||
if (typeof value === 'number') {
|
||||
return isNaN(value);
|
||||
}
|
||||
|
||||
// If it's not a string or number, it's not considered empty by this function
|
||||
return false;
|
||||
}
|
||||
export function emailToDirName(email: string): string {
|
||||
if (email === undefined || email === null) {
|
||||
return null;
|
||||
}
|
||||
// Entferne ungültige Zeichen und ersetze sie durch Unterstriche
|
||||
const sanitizedEmail = email.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
// Entferne führende und nachfolgende Unterstriche
|
||||
const trimmedEmail = sanitizedEmail.replace(/^_+|_+$/g, '');
|
||||
|
||||
// Ersetze mehrfache aufeinanderfolgende Unterstriche durch einen einzelnen Unterstrich
|
||||
const normalizedEmail = trimmedEmail.replace(/_+/g, '_');
|
||||
|
||||
return normalizedEmail;
|
||||
}
|
||||
export const LISTINGS_PER_PAGE = 12;
|
||||
export interface ValidationMessage {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
|
||||
return {
|
||||
id: undefined,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
phoneNumber: null,
|
||||
description: null,
|
||||
companyName: null,
|
||||
companyOverview: null,
|
||||
companyWebsite: null,
|
||||
location: null,
|
||||
offeredServices: null,
|
||||
areasServed: [],
|
||||
hasProfile: false,
|
||||
hasCompanyLogo: false,
|
||||
licensedIn: [],
|
||||
gender: null,
|
||||
customerType: 'buyer',
|
||||
customerSubType: null,
|
||||
created: new Date(),
|
||||
updated: new Date(),
|
||||
subscriptionId: null,
|
||||
subscriptionPlan: subscriptionPlan,
|
||||
favoritesForUser: [],
|
||||
showInDirectory: false,
|
||||
};
|
||||
}
|
||||
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
||||
return {
|
||||
id: undefined,
|
||||
serialId: undefined,
|
||||
email: null,
|
||||
type: null,
|
||||
title: null,
|
||||
description: null,
|
||||
location: null,
|
||||
price: null,
|
||||
favoritesForUser: [],
|
||||
draft: false,
|
||||
imageOrder: [],
|
||||
imagePath: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
listingsCategory: 'commercialProperty',
|
||||
};
|
||||
}
|
||||
export function createDefaultBusinessListing(): BusinessListing {
|
||||
return {
|
||||
id: undefined,
|
||||
email: null,
|
||||
type: null,
|
||||
title: null,
|
||||
description: null,
|
||||
location: null,
|
||||
price: null,
|
||||
favoritesForUser: [],
|
||||
draft: false,
|
||||
realEstateIncluded: false,
|
||||
leasedLocation: false,
|
||||
franchiseResale: false,
|
||||
salesRevenue: null,
|
||||
cashFlow: null,
|
||||
supportAndTraining: null,
|
||||
employees: null,
|
||||
established: null,
|
||||
internalListingNumber: null,
|
||||
reasonForSale: null,
|
||||
brokerLicencing: null,
|
||||
internals: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
listingsCategory: 'business',
|
||||
};
|
||||
}
|
||||
export type IpInfo = {
|
||||
ip: string;
|
||||
city: string;
|
||||
region: string;
|
||||
country: string;
|
||||
loc: string; // Coordinates in "latitude,longitude" format
|
||||
org: string;
|
||||
postal: string;
|
||||
timezone: string;
|
||||
};
|
||||
export interface CombinedUser {
|
||||
keycloakUser?: KeycloakUser;
|
||||
appUser?: User;
|
||||
}
|
||||
export interface RealIpInfo {
|
||||
ip: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller()
|
||||
export class SitemapController {
|
||||
constructor(private readonly sitemapService: SitemapService) { }
|
||||
|
||||
/**
|
||||
* Main sitemap index - lists all sitemap files
|
||||
* Route: /sitemap.xml
|
||||
*/
|
||||
@Get('sitemap.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getSitemapIndex(): Promise<string> {
|
||||
return await this.sitemapService.generateSitemapIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pages sitemap
|
||||
* Route: /sitemap/static.xml
|
||||
*/
|
||||
@Get('sitemap/static.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getStaticSitemap(): Promise<string> {
|
||||
return await this.sitemapService.generateStaticSitemap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Business listings sitemap (paginated)
|
||||
* Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/business-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateBusinessSitemap(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial property sitemap (paginated)
|
||||
* Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/commercial-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateCommercialSitemap(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broker profiles sitemap (paginated)
|
||||
* Route: /sitemap/brokers-1.xml, /sitemap/brokers-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/brokers-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getBrokerSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateBrokerSitemap(page);
|
||||
}
|
||||
}
|
||||
import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller()
|
||||
export class SitemapController {
|
||||
constructor(private readonly sitemapService: SitemapService) { }
|
||||
|
||||
/**
|
||||
* Main sitemap index - lists all sitemap files
|
||||
* Route: /sitemap.xml
|
||||
*/
|
||||
@Get('sitemap.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getSitemapIndex(): Promise<string> {
|
||||
return await this.sitemapService.generateSitemapIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pages sitemap
|
||||
* Route: /sitemap/static.xml
|
||||
*/
|
||||
@Get('sitemap/static.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getStaticSitemap(): Promise<string> {
|
||||
return await this.sitemapService.generateStaticSitemap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Business listings sitemap (paginated)
|
||||
* Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/business-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateBusinessSitemap(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial property sitemap (paginated)
|
||||
* Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/commercial-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateCommercialSitemap(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broker profiles sitemap (paginated)
|
||||
* Route: /sitemap/brokers-1.xml, /sitemap/brokers-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/brokers-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getBrokerSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateBrokerSitemap(page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [SitemapController],
|
||||
providers: [SitemapService],
|
||||
exports: [SitemapService],
|
||||
})
|
||||
export class SitemapModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [SitemapController],
|
||||
providers: [SitemapService],
|
||||
exports: [SitemapService],
|
||||
})
|
||||
export class SitemapModule {}
|
||||
|
||||
@@ -1,362 +1,362 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { PG_CONNECTION } from '../drizzle/schema';
|
||||
|
||||
interface SitemapUrl {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface SitemapIndexEntry {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private readonly baseUrl = 'https://biz-match.com';
|
||||
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
||||
|
||||
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
|
||||
|
||||
/**
|
||||
* Generate sitemap index (main sitemap.xml)
|
||||
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
|
||||
*/
|
||||
async generateSitemapIndex(): Promise<string> {
|
||||
const sitemaps: SitemapIndexEntry[] = [];
|
||||
|
||||
// Add static pages sitemap
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
|
||||
// Count business listings
|
||||
const businessCount = await this.getBusinessListingsCount();
|
||||
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
|
||||
for (let page = 1; page <= businessPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count commercial property listings
|
||||
const commercialCount = await this.getCommercialPropertiesCount();
|
||||
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
|
||||
for (let page = 1; page <= commercialPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count broker profiles
|
||||
const brokerCount = await this.getBrokerProfilesCount();
|
||||
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
|
||||
for (let page = 1; page <= brokerPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
return this.buildXmlSitemapIndex(sitemaps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static pages sitemap
|
||||
*/
|
||||
async generateStaticSitemap(): Promise<string> {
|
||||
const urls = this.getStaticPageUrls();
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate business listings sitemap (paginated)
|
||||
*/
|
||||
async generateBusinessSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate commercial property sitemap (paginated)
|
||||
*/
|
||||
async generateCommercialSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML sitemap index
|
||||
*/
|
||||
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
|
||||
const sitemapElements = sitemaps
|
||||
.map(sitemap => {
|
||||
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
|
||||
if (sitemap.lastmod) {
|
||||
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
|
||||
}
|
||||
element += '\n </sitemap>';
|
||||
return element;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${sitemapElements}
|
||||
</sitemapindex>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML sitemap string
|
||||
*/
|
||||
private buildXmlSitemap(urls: SitemapUrl[]): string {
|
||||
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urlElements}
|
||||
</urlset>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build single URL element
|
||||
*/
|
||||
private buildUrlElement(url: SitemapUrl): string {
|
||||
let element = `<url>\n <loc>${url.loc}</loc>`;
|
||||
|
||||
if (url.lastmod) {
|
||||
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
||||
}
|
||||
|
||||
if (url.changefreq) {
|
||||
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
||||
}
|
||||
|
||||
if (url.priority !== undefined) {
|
||||
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
||||
}
|
||||
|
||||
element += '\n </url>';
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get static page URLs
|
||||
*/
|
||||
private getStaticPageUrls(): SitemapUrl[] {
|
||||
return [
|
||||
{
|
||||
loc: `${this.baseUrl}/`,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/home`,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/businessListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/commercialPropertyListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/brokerListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/terms-of-use`,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/privacy-statement`,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count business listings (non-draft)
|
||||
*/
|
||||
private async getBusinessListingsCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.businesses_json)
|
||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting business listings:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commercial properties (non-draft)
|
||||
*/
|
||||
private async getCommercialPropertiesCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.commercials_json)
|
||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting commercial properties:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get business listing URLs from database (paginated, slug-based)
|
||||
*/
|
||||
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const listings = await this.db
|
||||
.select({
|
||||
id: schema.businesses_json.id,
|
||||
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
|
||||
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.businesses_json)
|
||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return listings.map(listing => {
|
||||
const urlSlug = listing.slug || listing.id;
|
||||
return {
|
||||
loc: `${this.baseUrl}/business/${urlSlug}`,
|
||||
lastmod: this.formatDate(listing.updated || listing.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching business listings for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commercial property URLs from database (paginated, slug-based)
|
||||
*/
|
||||
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const properties = await this.db
|
||||
.select({
|
||||
id: schema.commercials_json.id,
|
||||
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
|
||||
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.commercials_json)
|
||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return properties.map(property => {
|
||||
const urlSlug = property.slug || property.id;
|
||||
return {
|
||||
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
|
||||
lastmod: this.formatDate(property.updated || property.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching commercial properties for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to ISO 8601 format (YYYY-MM-DD)
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
if (!date) return new Date().toISOString().split('T')[0];
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate broker profiles sitemap (paginated)
|
||||
*/
|
||||
async generateBrokerSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count broker profiles (professionals with showInDirectory=true)
|
||||
*/
|
||||
private async getBrokerProfilesCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.users_json)
|
||||
.where(sql`
|
||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
||||
`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting broker profiles:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get broker profile URLs from database (paginated)
|
||||
*/
|
||||
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const brokers = await this.db
|
||||
.select({
|
||||
email: schema.users_json.email,
|
||||
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.users_json)
|
||||
.where(sql`
|
||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
||||
`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return brokers.map(broker => ({
|
||||
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
|
||||
lastmod: this.formatDate(broker.updated || broker.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching broker profiles for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { PG_CONNECTION } from '../drizzle/schema';
|
||||
|
||||
interface SitemapUrl {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface SitemapIndexEntry {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private readonly baseUrl = 'https://biz-match.com';
|
||||
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
||||
|
||||
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
|
||||
|
||||
/**
|
||||
* Generate sitemap index (main sitemap.xml)
|
||||
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
|
||||
*/
|
||||
async generateSitemapIndex(): Promise<string> {
|
||||
const sitemaps: SitemapIndexEntry[] = [];
|
||||
|
||||
// Add static pages sitemap
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
|
||||
// Count business listings
|
||||
const businessCount = await this.getBusinessListingsCount();
|
||||
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
|
||||
for (let page = 1; page <= businessPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count commercial property listings
|
||||
const commercialCount = await this.getCommercialPropertiesCount();
|
||||
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
|
||||
for (let page = 1; page <= commercialPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count broker profiles
|
||||
const brokerCount = await this.getBrokerProfilesCount();
|
||||
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
|
||||
for (let page = 1; page <= brokerPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
return this.buildXmlSitemapIndex(sitemaps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static pages sitemap
|
||||
*/
|
||||
async generateStaticSitemap(): Promise<string> {
|
||||
const urls = this.getStaticPageUrls();
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate business listings sitemap (paginated)
|
||||
*/
|
||||
async generateBusinessSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate commercial property sitemap (paginated)
|
||||
*/
|
||||
async generateCommercialSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML sitemap index
|
||||
*/
|
||||
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
|
||||
const sitemapElements = sitemaps
|
||||
.map(sitemap => {
|
||||
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
|
||||
if (sitemap.lastmod) {
|
||||
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
|
||||
}
|
||||
element += '\n </sitemap>';
|
||||
return element;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${sitemapElements}
|
||||
</sitemapindex>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML sitemap string
|
||||
*/
|
||||
private buildXmlSitemap(urls: SitemapUrl[]): string {
|
||||
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urlElements}
|
||||
</urlset>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build single URL element
|
||||
*/
|
||||
private buildUrlElement(url: SitemapUrl): string {
|
||||
let element = `<url>\n <loc>${url.loc}</loc>`;
|
||||
|
||||
if (url.lastmod) {
|
||||
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
||||
}
|
||||
|
||||
if (url.changefreq) {
|
||||
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
||||
}
|
||||
|
||||
if (url.priority !== undefined) {
|
||||
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
||||
}
|
||||
|
||||
element += '\n </url>';
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get static page URLs
|
||||
*/
|
||||
private getStaticPageUrls(): SitemapUrl[] {
|
||||
return [
|
||||
{
|
||||
loc: `${this.baseUrl}/`,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/home`,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/businessListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/commercialPropertyListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/brokerListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/terms-of-use`,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/privacy-statement`,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count business listings (non-draft)
|
||||
*/
|
||||
private async getBusinessListingsCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.businesses_json)
|
||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting business listings:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commercial properties (non-draft)
|
||||
*/
|
||||
private async getCommercialPropertiesCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.commercials_json)
|
||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting commercial properties:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get business listing URLs from database (paginated, slug-based)
|
||||
*/
|
||||
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const listings = await this.db
|
||||
.select({
|
||||
id: schema.businesses_json.id,
|
||||
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
|
||||
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.businesses_json)
|
||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return listings.map(listing => {
|
||||
const urlSlug = listing.slug || listing.id;
|
||||
return {
|
||||
loc: `${this.baseUrl}/business/${urlSlug}`,
|
||||
lastmod: this.formatDate(listing.updated || listing.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching business listings for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commercial property URLs from database (paginated, slug-based)
|
||||
*/
|
||||
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const properties = await this.db
|
||||
.select({
|
||||
id: schema.commercials_json.id,
|
||||
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
|
||||
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.commercials_json)
|
||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return properties.map(property => {
|
||||
const urlSlug = property.slug || property.id;
|
||||
return {
|
||||
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
|
||||
lastmod: this.formatDate(property.updated || property.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching commercial properties for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to ISO 8601 format (YYYY-MM-DD)
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
if (!date) return new Date().toISOString().split('T')[0];
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate broker profiles sitemap (paginated)
|
||||
*/
|
||||
async generateBrokerSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count broker profiles (professionals with showInDirectory=true)
|
||||
*/
|
||||
private async getBrokerProfilesCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.users_json)
|
||||
.where(sql`
|
||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
||||
`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting broker profiles:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get broker profile URLs from database (paginated)
|
||||
*/
|
||||
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const brokers = await this.db
|
||||
.select({
|
||||
email: schema.users_json.email,
|
||||
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.users_json)
|
||||
.where(sql`
|
||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
||||
`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return brokers.map(broker => ({
|
||||
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
|
||||
lastmod: this.formatDate(broker.updated || broker.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching broker profiles for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { User, UserSchema } from '../models/db.model';
|
||||
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
|
||||
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
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: UserListingCriteria): SQL[] {
|
||||
const whereConditions: SQL[] = [];
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
|
||||
|
||||
if (criteria.city && criteria.searchType === 'exact') {
|
||||
whereConditions.push(sql`(${schema.users_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);
|
||||
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
|
||||
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
|
||||
}
|
||||
if (criteria.types && criteria.types.length > 0) {
|
||||
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
||||
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[]));
|
||||
}
|
||||
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
||||
}
|
||||
|
||||
if (criteria.companyName) {
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`);
|
||||
}
|
||||
|
||||
if (criteria.counties && criteria.counties.length > 0) {
|
||||
whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`)));
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||
}
|
||||
|
||||
//never show user which denied
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`);
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
||||
const start = criteria.start ? criteria.start : 0;
|
||||
const length = criteria.length ? criteria.length : 12;
|
||||
const query = this.conn.select().from(schema.users_json);
|
||||
const whereConditions = this.getWhereConditions(criteria);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = and(...whereConditions);
|
||||
query.where(whereClause);
|
||||
}
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'nameAsc':
|
||||
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`));
|
||||
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(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
const totalCount = await this.getUserListingsCount(criteria);
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
||||
const countQuery = this.conn.select({ value: count() }).from(schema.users_json);
|
||||
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.select().from(schema.users_json).where(eq(schema.users_json.email, email));
|
||||
if (users.length === 0) {
|
||||
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) };
|
||||
const u = await this.saveUser(user, false);
|
||||
return u;
|
||||
} else {
|
||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||
return user;
|
||||
}
|
||||
}
|
||||
async getUserById(id: string) {
|
||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id));
|
||||
|
||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||
return user;
|
||||
}
|
||||
async getAllUser() {
|
||||
const users = await this.conn.select().from(schema.users_json);
|
||||
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
}
|
||||
async saveUser(user: User, processValidation = true): Promise<User> {
|
||||
try {
|
||||
user.updated = new Date();
|
||||
if (user.id) {
|
||||
user.created = new Date(user.created);
|
||||
} else {
|
||||
user.created = new Date();
|
||||
}
|
||||
let validatedUser = user;
|
||||
if (processValidation) {
|
||||
validatedUser = UserSchema.parse(user);
|
||||
}
|
||||
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
||||
const { id: _, ...rest } = validatedUser;
|
||||
const drizzleUser = { email: user.email, data: rest };
|
||||
if (user.id) {
|
||||
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning();
|
||||
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User;
|
||||
} else {
|
||||
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning();
|
||||
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
const existingUser = await this.getUserById(id);
|
||||
if (!existingUser) return;
|
||||
|
||||
const favorites = existingUser.favoritesForUser || [];
|
||||
if (!favorites.includes(user.email)) {
|
||||
existingUser.favoritesForUser = [...favorites, user.email];
|
||||
const { id: _, ...rest } = existingUser;
|
||||
const drizzleUser = { email: existingUser.email, data: rest };
|
||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
const existingUser = await this.getUserById(id);
|
||||
if (!existingUser) return;
|
||||
|
||||
const favorites = existingUser.favoritesForUser || [];
|
||||
if (favorites.includes(user.email)) {
|
||||
existingUser.favoritesForUser = favorites.filter(email => email !== user.email);
|
||||
const { id: _, ...rest } = existingUser;
|
||||
const drizzleUser = { email: existingUser.email, data: rest };
|
||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async getFavoriteUsers(user: JwtUser): Promise<User[]> {
|
||||
const data = await this.conn
|
||||
.select()
|
||||
.from(schema.users_json)
|
||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||
return data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
}
|
||||
}
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { User, UserSchema } from '../models/db.model';
|
||||
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
|
||||
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
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: UserListingCriteria): SQL[] {
|
||||
const whereConditions: SQL[] = [];
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
|
||||
|
||||
if (criteria.city && criteria.searchType === 'exact') {
|
||||
whereConditions.push(sql`(${schema.users_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);
|
||||
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
|
||||
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
|
||||
}
|
||||
if (criteria.types && criteria.types.length > 0) {
|
||||
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
||||
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[]));
|
||||
}
|
||||
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
||||
}
|
||||
|
||||
if (criteria.companyName) {
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`);
|
||||
}
|
||||
|
||||
if (criteria.counties && criteria.counties.length > 0) {
|
||||
whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`)));
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||
}
|
||||
|
||||
//never show user which denied
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`);
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
||||
const start = criteria.start ? criteria.start : 0;
|
||||
const length = criteria.length ? criteria.length : 12;
|
||||
const query = this.conn.select().from(schema.users_json);
|
||||
const whereConditions = this.getWhereConditions(criteria);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = and(...whereConditions);
|
||||
query.where(whereClause);
|
||||
}
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'nameAsc':
|
||||
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`));
|
||||
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(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
const totalCount = await this.getUserListingsCount(criteria);
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
||||
const countQuery = this.conn.select({ value: count() }).from(schema.users_json);
|
||||
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.select().from(schema.users_json).where(eq(schema.users_json.email, email));
|
||||
if (users.length === 0) {
|
||||
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) };
|
||||
const u = await this.saveUser(user, false);
|
||||
return u;
|
||||
} else {
|
||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||
return user;
|
||||
}
|
||||
}
|
||||
async getUserById(id: string) {
|
||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id));
|
||||
|
||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||
return user;
|
||||
}
|
||||
async getAllUser() {
|
||||
const users = await this.conn.select().from(schema.users_json);
|
||||
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
}
|
||||
async saveUser(user: User, processValidation = true): Promise<User> {
|
||||
try {
|
||||
user.updated = new Date();
|
||||
if (user.id) {
|
||||
user.created = new Date(user.created);
|
||||
} else {
|
||||
user.created = new Date();
|
||||
}
|
||||
let validatedUser = user;
|
||||
if (processValidation) {
|
||||
validatedUser = UserSchema.parse(user);
|
||||
}
|
||||
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
||||
const { id: _, ...rest } = validatedUser;
|
||||
const drizzleUser = { email: user.email, data: rest };
|
||||
if (user.id) {
|
||||
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning();
|
||||
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User;
|
||||
} else {
|
||||
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning();
|
||||
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
const existingUser = await this.getUserById(id);
|
||||
if (!existingUser) return;
|
||||
|
||||
const favorites = existingUser.favoritesForUser || [];
|
||||
if (!favorites.includes(user.email)) {
|
||||
existingUser.favoritesForUser = [...favorites, user.email];
|
||||
const { id: _, ...rest } = existingUser;
|
||||
const drizzleUser = { email: existingUser.email, data: rest };
|
||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
const existingUser = await this.getUserById(id);
|
||||
if (!existingUser) return;
|
||||
|
||||
const favorites = existingUser.favoritesForUser || [];
|
||||
if (favorites.includes(user.email)) {
|
||||
existingUser.favoritesForUser = favorites.filter(email => email !== user.email);
|
||||
const { id: _, ...rest } = existingUser;
|
||||
const drizzleUser = { email: existingUser.email, data: rest };
|
||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async getFavoriteUsers(user: JwtUser): Promise<User[]> {
|
||||
const data = await this.conn
|
||||
.select()
|
||||
.from(schema.users_json)
|
||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||
return data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
/**
|
||||
* Utility functions for generating and parsing SEO-friendly URL slugs
|
||||
*
|
||||
* Slug format: {title}-{location}-{short-id}
|
||||
* Example: italian-restaurant-austin-tx-a3f7b2c1
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a SEO-friendly URL slug from listing data
|
||||
*
|
||||
* @param title - The listing title (e.g., "Italian Restaurant")
|
||||
* @param location - Location object with name, county, and state
|
||||
* @param id - The listing UUID
|
||||
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
||||
*/
|
||||
export function generateSlug(title: string, location: any, id: string): string {
|
||||
if (!title || !id) {
|
||||
throw new Error('Title and ID are required to generate a slug');
|
||||
}
|
||||
|
||||
// Clean and slugify the title
|
||||
const titleSlug = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||
.substring(0, 50); // Limit title to 50 characters
|
||||
|
||||
// Get location string
|
||||
let locationSlug = '';
|
||||
if (location) {
|
||||
const locationName = location.name || location.county || '';
|
||||
const state = location.state || '';
|
||||
|
||||
if (locationName) {
|
||||
locationSlug = locationName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
if (state) {
|
||||
locationSlug = locationSlug
|
||||
? `${locationSlug}-${state.toLowerCase()}`
|
||||
: state.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Get first 8 characters of UUID for uniqueness
|
||||
const shortId = id.substring(0, 8);
|
||||
|
||||
// Combine parts: title-location-id
|
||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
||||
const slug = parts.join('-');
|
||||
|
||||
// Final cleanup
|
||||
return slug
|
||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the UUID from a slug
|
||||
* The UUID is always the last segment (8 characters)
|
||||
*
|
||||
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
||||
* @returns The short ID (e.g., "a3f7b2c1")
|
||||
*/
|
||||
export function extractShortIdFromSlug(slug: string): string {
|
||||
if (!slug) {
|
||||
throw new Error('Slug is required');
|
||||
}
|
||||
|
||||
const parts = slug.split('-');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string looks like a valid slug
|
||||
*
|
||||
* @param slug - The string to validate
|
||||
* @returns true if the string looks like a valid slug
|
||||
*/
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
if (!slug || typeof slug !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if slug contains only lowercase letters, numbers, and hyphens
|
||||
const slugPattern = /^[a-z0-9-]+$/;
|
||||
if (!slugPattern.test(slug)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
|
||||
if (slug.length < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
|
||||
const parts = slug.split('-');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter is a slug (vs a UUID)
|
||||
*
|
||||
* @param param - The URL parameter
|
||||
* @returns true if it's a slug, false if it's likely a UUID
|
||||
*/
|
||||
export function isSlug(param: string): boolean {
|
||||
if (!param) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// UUIDs have a specific format with hyphens at specific positions
|
||||
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
|
||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
|
||||
if (uuidPattern.test(param)) {
|
||||
return false; // It's a UUID
|
||||
}
|
||||
|
||||
// If it contains at least 3 parts (e.g., title-state-id or title-city-state-id) and looks like our slug format, it's probably a slug
|
||||
return param.split('-').length >= 3 && isValidSlug(param);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate slug from updated listing data
|
||||
* Useful when title or location changes
|
||||
*
|
||||
* @param title - Updated title
|
||||
* @param location - Updated location
|
||||
* @param existingSlug - The current slug (to preserve short-id)
|
||||
* @returns New slug with same short-id
|
||||
*/
|
||||
export function regenerateSlug(title: string, location: any, existingSlug: string): string {
|
||||
if (!existingSlug) {
|
||||
throw new Error('Existing slug is required to regenerate');
|
||||
}
|
||||
|
||||
const shortId = extractShortIdFromSlug(existingSlug);
|
||||
|
||||
// Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID)
|
||||
// In practice, you'd need the full UUID from the database
|
||||
// For now, we'll construct a new slug with the short-id
|
||||
const titleSlug = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 50);
|
||||
|
||||
let locationSlug = '';
|
||||
if (location) {
|
||||
const locationName = location.name || location.county || '';
|
||||
const state = location.state || '';
|
||||
|
||||
if (locationName) {
|
||||
locationSlug = locationName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
if (state) {
|
||||
locationSlug = locationSlug
|
||||
? `${locationSlug}-${state.toLowerCase()}`
|
||||
: state.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
||||
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||
}
|
||||
/**
|
||||
* Utility functions for generating and parsing SEO-friendly URL slugs
|
||||
*
|
||||
* Slug format: {title}-{location}-{short-id}
|
||||
* Example: italian-restaurant-austin-tx-a3f7b2c1
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a SEO-friendly URL slug from listing data
|
||||
*
|
||||
* @param title - The listing title (e.g., "Italian Restaurant")
|
||||
* @param location - Location object with name, county, and state
|
||||
* @param id - The listing UUID
|
||||
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
||||
*/
|
||||
export function generateSlug(title: string, location: any, id: string): string {
|
||||
if (!title || !id) {
|
||||
throw new Error('Title and ID are required to generate a slug');
|
||||
}
|
||||
|
||||
// Clean and slugify the title
|
||||
const titleSlug = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||
.substring(0, 50); // Limit title to 50 characters
|
||||
|
||||
// Get location string
|
||||
let locationSlug = '';
|
||||
if (location) {
|
||||
const locationName = location.name || location.county || '';
|
||||
const state = location.state || '';
|
||||
|
||||
if (locationName) {
|
||||
locationSlug = locationName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
if (state) {
|
||||
locationSlug = locationSlug
|
||||
? `${locationSlug}-${state.toLowerCase()}`
|
||||
: state.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Get first 8 characters of UUID for uniqueness
|
||||
const shortId = id.substring(0, 8);
|
||||
|
||||
// Combine parts: title-location-id
|
||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
||||
const slug = parts.join('-');
|
||||
|
||||
// Final cleanup
|
||||
return slug
|
||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the UUID from a slug
|
||||
* The UUID is always the last segment (8 characters)
|
||||
*
|
||||
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
||||
* @returns The short ID (e.g., "a3f7b2c1")
|
||||
*/
|
||||
export function extractShortIdFromSlug(slug: string): string {
|
||||
if (!slug) {
|
||||
throw new Error('Slug is required');
|
||||
}
|
||||
|
||||
const parts = slug.split('-');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string looks like a valid slug
|
||||
*
|
||||
* @param slug - The string to validate
|
||||
* @returns true if the string looks like a valid slug
|
||||
*/
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
if (!slug || typeof slug !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if slug contains only lowercase letters, numbers, and hyphens
|
||||
const slugPattern = /^[a-z0-9-]+$/;
|
||||
if (!slugPattern.test(slug)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
|
||||
if (slug.length < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
|
||||
const parts = slug.split('-');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter is a slug (vs a UUID)
|
||||
*
|
||||
* @param param - The URL parameter
|
||||
* @returns true if it's a slug, false if it's likely a UUID
|
||||
*/
|
||||
export function isSlug(param: string): boolean {
|
||||
if (!param) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// UUIDs have a specific format with hyphens at specific positions
|
||||
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
|
||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
|
||||
if (uuidPattern.test(param)) {
|
||||
return false; // It's a UUID
|
||||
}
|
||||
|
||||
// If it contains at least 3 parts (e.g., title-state-id or title-city-state-id) and looks like our slug format, it's probably a slug
|
||||
return param.split('-').length >= 3 && isValidSlug(param);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate slug from updated listing data
|
||||
* Useful when title or location changes
|
||||
*
|
||||
* @param title - Updated title
|
||||
* @param location - Updated location
|
||||
* @param existingSlug - The current slug (to preserve short-id)
|
||||
* @returns New slug with same short-id
|
||||
*/
|
||||
export function regenerateSlug(title: string, location: any, existingSlug: string): string {
|
||||
if (!existingSlug) {
|
||||
throw new Error('Existing slug is required to regenerate');
|
||||
}
|
||||
|
||||
const shortId = extractShortIdFromSlug(existingSlug);
|
||||
|
||||
// Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID)
|
||||
// In practice, you'd need the full UUID from the database
|
||||
// For now, we'll construct a new slug with the short-id
|
||||
const titleSlug = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 50);
|
||||
|
||||
let locationSlug = '';
|
||||
if (location) {
|
||||
const locationName = location.name || location.county || '';
|
||||
const state = location.state || '';
|
||||
|
||||
if (locationName) {
|
||||
locationSlug = locationName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
if (state) {
|
||||
locationSlug = locationSlug
|
||||
? `${locationSlug}-${state.toLowerCase()}`
|
||||
: state.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
||||
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user