58 Commits

Author SHA1 Message Date
b9a9b983e9 admin care 2024-10-11 13:33:28 -05:00
1282d30b49 Logging Update - IPadress & user Email if possible 2024-09-23 13:45:46 +02:00
974a6503ef ClsService for async request messages & logging 2024-09-23 11:20:00 +02:00
860d30b16f BugFix: Proxy data, Logging with IP adresses 2024-09-20 18:28:43 +02:00
178f2b4810 changed imports 2024-09-20 16:18:07 +02:00
bb26972377 BugFix: import fixed 2024-09-20 16:02:03 +02:00
16b880384b BugFix: IP Adresse & Subject instead of BehaviourSubject 2024-09-20 15:59:45 +02:00
3e84b82c92 BugFixes bzgl. Observables, Logging Anpassungen, Endpunkt hardening, dotenv-flow usage 2024-09-20 13:49:50 +02:00
205793faab BugFix #118 renaming 2024-09-19 11:29:15 +02:00
eaa8a5064f keep plan selection during keycloak hop 2024-09-17 16:51:30 +02:00
c00c2caccc Issue #55 View on map 2024-09-16 13:42:22 +02:00
8595e70ceb Feature #99 + BugFixes 2024-09-14 19:46:18 +02:00
8dd13d5472 brokerLicencing check removed 2024-09-13 17:36:03 +02:00
f36d9fb4d7 BugFixes 2024-09-13 17:05:14 +02:00
eb5a334868 Close terms of use & mobile menu home page on login 2024-09-13 15:55:34 +02:00
446d568378 BugFix mobile menu & criteria state 2024-09-13 15:38:41 +02:00
d4ec9d067f kleinere Korrekturen zu #60 2024-09-13 09:45:48 +02:00
7c9a47cf4e BugFix 2024-09-12 19:51:25 +02:00
40ba402c70 Fixes für input fields, #60 -> AuditService 2024-09-12 19:48:29 +02:00
d2f6b3ec3f Adaptions according cloudflare 2024-09-12 16:14:57 +02:00
77c9973256 Anpassungen zum Thema IP Resolving 2024-09-12 16:01:41 +02:00
68d2615f0f export DB, Event creation, broker with city/state 2024-09-12 15:13:56 +02:00
60866473f7 BugFixes: #115, #114, #113. #112 2024-09-11 16:51:42 +02:00
8a7e26d2b6 Timeout nur für /ai Calls, URL compare change 2024-09-11 12:26:28 +02:00
fe759f953f empty strings or zero will be converted to null 2024-09-11 11:49:47 +02:00
83307684ee Issue: #109 2024-09-10 21:30:03 +02:00
17213ba4b0 Fix: #104 2024-09-09 20:13:11 +02:00
24ed50a48f Fix for undefined Errors 2024-09-09 18:38:04 +02:00
06d83a478d Ticket Fixing: #111, #110, #108 (SortBy) 2024-09-09 17:35:08 +02:00
9ecc0c2429 BugFix: ERROR [ExceptionsHandler] column "users.areasServed" must appear in the GROUP BY clause or be used in an aggregate function resolved 2024-09-04 18:15:16 +02:00
7807afbad3 BugFix:#100 handle BadRequestException 2024-09-04 17:59:16 +02:00
624fa74eb6 BugFix: #103,#101, heart instead disk icon 2024-09-04 17:43:23 +02:00
3b012a8113 saveGuaranteed in almost all cases except updateProfile 2024-09-04 15:32:51 +02:00
d8429f9b4a Umstellung auf nodeclassic 2024-09-04 14:56:24 +02:00
c5577969c8 BugFix #87: neue Bild Gallery verwedet 2024-08-30 17:56:42 +02:00
f4f576d4a9 Feture #52: Social Media Integration, BugFix: #89 Sates as ng-select, send Listing as EMail to friend 2024-08-30 14:25:29 +02:00
630c31cfc9 Favorites #19, Social Media Start #53 2024-08-29 17:13:24 +02:00
ede8b66d83 Ai Search finished, some makeup changes, guards, brokerSearch 2024-08-28 15:41:59 +02:00
8721be4a90 Feature: #84, BugFix #93, Init Free Plan, Selection type of Listing 2024-08-27 20:01:56 +02:00
c1b72bbc12 Bug Fix: #91, #89 2024-08-26 18:43:14 +02:00
0f301fb534 BugFix: #88 2024-08-26 18:12:08 +02:00
8157dcc376 BugFix: #90,#86 + Restrictions on seller 2024-08-26 17:38:38 +02:00
f66badbfb1 Auth Methoden überarbeitet 2024-08-26 11:58:30 +02:00
74d5f92aba Überarbeitung des Stripe Prozesses 2024-08-23 19:54:55 +02:00
7a286e3519 Mail Modul überarbeitet, Korrekturen am PaymentService, neuer customerType seller 2024-08-22 22:59:33 +02:00
b4609d07ba Stripe Pricing + Subscriptions 2024-08-21 21:13:43 +02:00
48bff89526 Stripe Integration 2024-08-20 23:27:07 +02:00
056db7b199 pricing page 2024-08-15 20:12:41 +02:00
8c6c6e3dbd BugFixes: #8 2024-08-15 12:36:54 +02:00
7f756a71e8 Fixes for #36 and #72 2024-08-14 19:47:19 +02:00
a8bb163acf not-found page, #85, client logging 2024-08-13 19:06:32 +02:00
1f8febc479 BugFix for https://gitea.bizmatch.net/aknuth/bizmatch-project/issues/35 2024-08-13 12:20:24 +02:00
ec0576e7b8 diverse BugFixes 2024-08-12 17:18:32 +02:00
3a6a64cce9 autocomplete off bei numeric fields, Mail Mask emptied after Mail dispatch, unknown listing controller for EMail Listings 2024-08-12 13:55:23 +02:00
245e76f697 Update on numeric inputs 2024-08-12 11:06:15 +02:00
d71a5c25c3 remove id before create Listing, set location/companyLocation to null if not set 2024-08-11 13:06:48 +02:00
1e1d5cea57 Überarbeitung 2024-08-09 17:59:49 +02:00
6d1c50d5df remove migrations 2024-08-09 16:45:33 +02:00
178 changed files with 6803 additions and 4809 deletions

View File

@@ -0,0 +1,4 @@
REALM=bizmatch-dev
usersURL=/admin/realms/bizmatch-dev/users
WEB_HOST=https://dev.bizmatch.net
STRIPE_WEBHOOK_SECRET=whsec_w2yvJY8qFMfO5wJgyNHCn6oYT7o2J5pS

View File

@@ -0,0 +1,2 @@
REALM=bizmatch
WEB_HOST=https://www.bizmatch.net

View File

@@ -2,6 +2,7 @@
/dist
/node_modules
/build
/data
# Logs
logs
@@ -60,3 +61,8 @@ pictures_base
src/*.js
bun.lockb
#drizzle migrations
src/drizzle/migrations
importlog.txt

View File

@@ -14,7 +14,8 @@
"console": "integratedTerminal",
"env": {
"HOST_NAME": "localhost"
}
},
"preLaunchTask": "Start Stripe Listener"
},
{
"type": "node",
@@ -60,5 +61,30 @@
"sourceMaps": true,
"smartStep": true
}
],
"tasks": [
{
"label": "Start Stripe Listener",
"type": "shell",
"command": "stripe listen -e checkout.session.completed --forward-to http://localhost:3000/bizmatch/payment/webhook",
"isBackground": true,
"problemMatcher": [
{
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "."
}
}
]
}
]
}

31
bizmatch-server/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,31 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Stripe Listener",
"type": "shell",
"command": "stripe listen -e checkout.session.completed --forward-to http://localhost:3000/bizmatch/payment/webhook",
"problemMatcher": [],
"isBackground": true, // Task läuft im Hintergrund
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
}
},
{
"label": "Start Nest.js",
"type": "npm",
"script": "start:debug",
"isBackground": false,
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
}
}
]
}

View File

@@ -1,239 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* AUTO-GENERATED FILE - DO NOT EDIT!
*
* This file was automatically generated by pg-to-ts v.4.1.1
* $ pg-to-ts generate -c postgresql://username:password@localhost:5432/bizmatch -t businesses -t commercials -t users -s public
*
*/
export type Json = unknown;
export type customerSubType = 'appraiser' | 'attorney' | 'broker' | 'cpa' | 'surveyor' | 'titleCompany';
export type customerType = 'buyer' | 'professional';
export type gender = 'female' | 'male';
export type listingsCategory = 'business' | 'commercialProperty';
// Table businesses
export interface Businesses {
id: string;
email: string | null;
type: string | null;
title: string | null;
description: string | null;
city: string | null;
state: string | null;
zipCode: number | null;
county: string | null;
price: number | null;
favoritesForUser: string[] | null;
draft: boolean | null;
listingsCategory: listingsCategory | null;
realEstateIncluded: boolean | null;
leasedLocation: boolean | null;
franchiseResale: boolean | null;
salesRevenue: number | null;
cashFlow: number | null;
supportAndTraining: string | null;
employees: number | null;
established: number | null;
internalListingNumber: number | null;
reasonForSale: string | null;
brokerLicencing: string | null;
internals: string | null;
imageName: string | null;
created: Date | null;
updated: Date | null;
visits: number | null;
lastVisit: Date | null;
latitude: number | null;
longitude: number | null;
}
export interface BusinessesInput {
id?: string;
email?: string | null;
type?: string | null;
title?: string | null;
description?: string | null;
city?: string | null;
state?: string | null;
zipCode?: number | null;
county?: string | null;
price?: number | null;
favoritesForUser?: string[] | null;
draft?: boolean | null;
listingsCategory?: listingsCategory | null;
realEstateIncluded?: boolean | null;
leasedLocation?: boolean | null;
franchiseResale?: boolean | null;
salesRevenue?: number | null;
cashFlow?: number | null;
supportAndTraining?: string | null;
employees?: number | null;
established?: number | null;
internalListingNumber?: number | null;
reasonForSale?: string | null;
brokerLicencing?: string | null;
internals?: string | null;
imageName?: string | null;
created?: Date | null;
updated?: Date | null;
visits?: number | null;
lastVisit?: Date | null;
latitude?: number | null;
longitude?: number | null;
}
const businesses = {
tableName: 'businesses',
columns: ['id', 'email', 'type', 'title', 'description', 'city', 'state', 'zipCode', 'county', 'price', 'favoritesForUser', 'draft', 'listingsCategory', 'realEstateIncluded', 'leasedLocation', 'franchiseResale', 'salesRevenue', 'cashFlow', 'supportAndTraining', 'employees', 'established', 'internalListingNumber', 'reasonForSale', 'brokerLicencing', 'internals', 'imageName', 'created', 'updated', 'visits', 'lastVisit', 'latitude', 'longitude'],
requiredForInsert: [],
primaryKey: 'id',
foreignKeys: { email: { table: 'users', column: 'email', $type: null as unknown as Users }, },
$type: null as unknown as Businesses,
$input: null as unknown as BusinessesInput
} as const;
// Table commercials
export interface Commercials {
id: string;
serialId: number;
email: string | null;
type: string | null;
title: string | null;
description: string | null;
city: string | null;
state: string | null;
price: number | null;
favoritesForUser: string[] | null;
listingsCategory: listingsCategory | null;
hideImage: boolean | null;
draft: boolean | null;
zipCode: number | null;
county: string | null;
imageOrder: string[] | null;
imagePath: string | null;
created: Date | null;
updated: Date | null;
visits: number | null;
lastVisit: Date | null;
latitude: number | null;
longitude: number | null;
}
export interface CommercialsInput {
id?: string;
serialId?: number;
email?: string | null;
type?: string | null;
title?: string | null;
description?: string | null;
city?: string | null;
state?: string | null;
price?: number | null;
favoritesForUser?: string[] | null;
listingsCategory?: listingsCategory | null;
hideImage?: boolean | null;
draft?: boolean | null;
zipCode?: number | null;
county?: string | null;
imageOrder?: string[] | null;
imagePath?: string | null;
created?: Date | null;
updated?: Date | null;
visits?: number | null;
lastVisit?: Date | null;
latitude?: number | null;
longitude?: number | null;
}
const commercials = {
tableName: 'commercials',
columns: ['id', 'serialId', 'email', 'type', 'title', 'description', 'city', 'state', 'price', 'favoritesForUser', 'listingsCategory', 'hideImage', 'draft', 'zipCode', 'county', 'imageOrder', 'imagePath', 'created', 'updated', 'visits', 'lastVisit', 'latitude', 'longitude'],
requiredForInsert: [],
primaryKey: 'id',
foreignKeys: { email: { table: 'users', column: 'email', $type: null as unknown as Users }, },
$type: null as unknown as Commercials,
$input: null as unknown as CommercialsInput
} as const;
// Table users
export interface Users {
id: string;
firstname: string;
lastname: string;
email: string;
phoneNumber: string | null;
description: string | null;
companyName: string | null;
companyOverview: string | null;
companyWebsite: string | null;
companyLocation: string | null;
offeredServices: string | null;
areasServed: Json | null;
hasProfile: boolean | null;
hasCompanyLogo: boolean | null;
licensedIn: Json | null;
gender: gender | null;
customerType: customerType | null;
customerSubType: customerSubType | null;
created: Date | null;
updated: Date | null;
latitude: number | null;
longitude: number | null;
}
export interface UsersInput {
id?: string;
firstname: string;
lastname: string;
email: string;
phoneNumber?: string | null;
description?: string | null;
companyName?: string | null;
companyOverview?: string | null;
companyWebsite?: string | null;
companyLocation?: string | null;
offeredServices?: string | null;
areasServed?: Json | null;
hasProfile?: boolean | null;
hasCompanyLogo?: boolean | null;
licensedIn?: Json | null;
gender?: gender | null;
customerType?: customerType | null;
customerSubType?: customerSubType | null;
created?: Date | null;
updated?: Date | null;
latitude?: number | null;
longitude?: number | null;
}
const users = {
tableName: 'users',
columns: ['id', 'firstname', 'lastname', 'email', 'phoneNumber', 'description', 'companyName', 'companyOverview', 'companyWebsite', 'companyLocation', 'offeredServices', 'areasServed', 'hasProfile', 'hasCompanyLogo', 'licensedIn', 'gender', 'customerType', 'customerSubType', 'created', 'updated', 'latitude', 'longitude'],
requiredForInsert: ['firstname', 'lastname', 'email'],
primaryKey: 'id',
foreignKeys: {},
$type: null as unknown as Users,
$input: null as unknown as UsersInput
} as const;
export interface TableTypes {
businesses: {
select: Businesses;
input: BusinessesInput;
};
commercials: {
select: Commercials;
input: CommercialsInput;
};
users: {
select: Users;
input: UsersInput;
};
}
export const tables = {
businesses,
commercials,
users,
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,14 @@
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "HOST_NAME=localhost nest start",
"start:dev": "HOST_NAME=dev.bizmatch.net nest start --watch",
"start": "nest start",
"start:local": "HOST_NAME=localhost node dist/src/main",
"start:dev": "NODE_ENV=development node dist/src/main",
"start:debug": "nest start --debug --watch",
"start:prod": "HOST_NAME=www.bizmatch.net node dist/main",
"start:prod": "NODE_ENV=production node dist/src/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -34,15 +34,22 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/serve-static": "^4.0.1",
"@types/stripe": "^8.0.417",
"body-parser": "^1.20.2",
"cls-hooked": "^4.2.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-flow": "^4.1.0",
"drizzle-orm": "^0.32.0",
"fs-extra": "^11.2.0",
"groq-sdk": "^0.5.0",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwk-to-pem": "^2.0.6",
"jwks-rsa": "^3.1.0",
"ky": "^1.4.0",
"nest-winston": "^1.9.4",
"nestjs-cls": "^4.4.1",
"nodemailer": "^6.9.10",
"nodemailer-smtp-transport": "^2.7.4",
"openai": "^4.52.6",
@@ -55,6 +62,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.2",
"stripe": "^16.8.0",
"tsx": "^4.16.2",
"urlcat": "^3.1.0",
"winston": "^3.11.0",
@@ -68,6 +76,8 @@
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/jwk-to-pem": "^2.0.3",
"@types/multer": "^1.4.11",
"@types/node": "^20.11.19",
"@types/nodemailer": "^6.4.14",
@@ -114,4 +124,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AiService } from './ai.service.js';
import { AiService } from './ai.service';
@Controller('ai')
export class AiController {

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller.js';
import { AiService } from './ai.service.js';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
controllers: [AiController],

View File

@@ -3,30 +3,85 @@ import Groq from 'groq-sdk';
import OpenAI from 'openai';
import { BusinessListingCriteria } from '../models/main.model';
const businessListingCriteriaStructure = {
criteriaType: 'business | commercialProperty | broker',
types: "'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'",
city: 'string',
state: 'string',
county: 'string',
minPrice: 'number',
maxPrice: 'number',
minRevenue: 'number',
maxRevenue: 'number',
minCashFlow: 'number',
maxCashFlow: 'number',
minNumberEmployees: 'number',
maxNumberEmployees: 'number',
establishedSince: 'number',
establishedUntil: 'number',
realEstateChecked: 'boolean',
leasedLocation: 'boolean',
franchiseResale: 'boolean',
title: 'string',
brokerName: 'string',
searchType: "'exact' | 'radius'",
radius: "'0' | '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'",
};
// const businessListingCriteriaStructure = {
// criteriaType: 'business | commercialProperty | broker',
// types: "'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'",
// city: 'string',
// state: 'string',
// county: 'string',
// minPrice: 'number',
// maxPrice: 'number',
// minRevenue: 'number',
// maxRevenue: 'number',
// minCashFlow: 'number',
// maxCashFlow: 'number',
// minNumberEmployees: 'number',
// maxNumberEmployees: 'number',
// establishedSince: 'number',
// establishedUntil: 'number',
// realEstateChecked: 'boolean',
// leasedLocation: 'boolean',
// franchiseResale: 'boolean',
// title: 'string',
// brokerName: 'string',
// searchType: "'exact' | 'radius'",
// radius: "'0' | '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'",
// };
const BusinessListingCriteriaStructure = `
export interface BusinessListingCriteria {
state: string;
city: string;
searchType: 'exact' | 'radius';
radius: '20' | '50' | '100' | '200' | '300' | '400' | '500';
minPrice: number;
maxPrice: number;
minRevenue: number;
maxRevenue: number;
minCashFlow: number;
maxCashFlow: number;
minNumberEmployees: number;
maxNumberEmployees: number;
establishedSince: number;
establishedUntil: number;
realEstateChecked: boolean;
leasedLocation: boolean;
franchiseResale: boolean;
//title: string;
brokerName: string;
//types:"'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'",
criteriaType: 'businessListings';
}
`;
const CommercialPropertyListingCriteriaStructure = `
export interface CommercialPropertyListingCriteria {
state: string;
city: string;
searchType: 'exact' | 'radius';
radius: '20' | '50' | '100' | '200' | '300' | '400' | '500';
minPrice: number;
maxPrice: number;
//title: string;
//types:"'Retail'|'Land'|'Industrial'|'Office'|'Mixed Use'|'Multifamily'|'Uncategorized'"
criteriaType: 'commercialPropertyListings';
}
`;
const UserListingCriteriaStructure = `
export interface UserListingCriteria {
state: string;
city: string;
searchType: 'exact' | 'radius';
radius: '20' | '50' | '100' | '200' | '300' | '400' | '500';
brokerName: string;
companyName: string;
counties: string[];
criteriaType: 'brokerListings';
}
`;
@Injectable()
export class AiService {
private readonly openai: OpenAI;
@@ -67,8 +122,10 @@ export class AiService {
{
role: 'system',
content: `Please create unformatted JSON Object from a user input.
The type must be: ${JSON.stringify(businessListingCriteriaStructure)}.
If location details available please fill city, county and state as State Code`,
The criteriaType must be only either 'businessListings' or 'commercialPropertyListings' or 'brokerListings' !!!!
The format of the object (depending on your choice of criteriaType) must be either ${BusinessListingCriteriaStructure}, ${CommercialPropertyListingCriteriaStructure} or ${UserListingCriteriaStructure} !!!!
If location details available please fill city and state as State Code and only county if explicitly mentioned.
If you decide for searchType==='exact', please do not set the attribute radius`,
},
{
role: 'user',
@@ -77,6 +134,8 @@ export class AiService {
],
model: 'llama-3.1-70b-versatile',
//model: 'llama-3.1-8b-instant',
// model: 'mixtral-8x7b-32768',
//model: 'gemma2-9b-it',
temperature: 0.2,
max_tokens: 300,
response_format: { type: 'json_object' },

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
import { AppService } from './app.service.js';
import { AuthService } from './auth/auth.service.js';
import { JwtAuthGuard } from './jwt-auth/jwt-auth.guard.js';
import { AppService } from './app.service';
import { AuthService } from './auth/auth.service';
import { JwtAuthGuard } from './jwt-auth/jwt-auth.guard';
@Controller()
export class AppController {
@@ -14,6 +14,5 @@ export class AppController {
@Get()
getHello(@Request() req): string {
return req.user;
//return 'dfgdf';
}
}

View File

@@ -1,54 +1,46 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import * as dotenv from 'dotenv';
import fs from 'fs-extra';
import { WinstonModule, utilities as nestWinstonModuleUtilities } from 'nest-winston';
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import { AiModule } from './ai/ai.module.js';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { AuthModule } from './auth/auth.module.js';
import { FileService } from './file/file.service.js';
import { GeoModule } from './geo/geo.module.js';
import { ImageModule } from './image/image.module.js';
import { ListingsModule } from './listings/listings.module.js';
import { MailModule } from './mail/mail.module.js';
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware.js';
import { SelectOptionsModule } from './select-options/select-options.module.js';
import { UserModule } from './user/user.module.js';
// const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);
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';
function loadEnvFiles() {
// Load the .env file
dotenv.config();
console.log('Loaded .env file');
import dotenvFlow from 'dotenv-flow';
import { EventModule } from './event/event.module';
import { JwtStrategy } from './jwt.strategy';
import { MailModule } from './mail/mail.module';
// Determine which additional env file to load
let envFilePath = '';
const host = process.env.HOST_NAME || '';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ClsMiddleware, ClsModule } from 'nestjs-cls';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { UserInterceptor } from './interceptors/user.interceptor';
import { PaymentModule } from './payment/payment.module';
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
import { SelectOptionsModule } from './select-options/select-options.module';
import { UserModule } from './user/user.module';
if (host.includes('localhost')) {
envFilePath = '.env.local';
} else if (host.includes('dev.bizmatch.net')) {
envFilePath = '.env.dev';
} else if (host.includes('www.bizmatch.net') || host.includes('bizmatch.net')) {
envFilePath = '.env.prod';
}
// Load the additional env file if it exists
if (fs.existsSync(envFilePath)) {
dotenv.config({ path: envFilePath });
console.log(`Loaded ${envFilePath} file`);
} else {
console.log(`No additional .env file found for HOST_NAME: ${host}`);
}
}
loadEnvFiles();
//loadEnvFiles();
dotenvFlow.config();
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
// setup: clsService => {
// // Optional: zusätzliche Setup-Logik
// },
}),
ConfigModule.forRoot({ isGlobal: true }),
MailModule,
AuthModule,
@@ -56,7 +48,9 @@ loadEnvFiles();
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.timestamp({
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
}),
winston.format.ms(),
nestWinstonModuleUtilities.format.nestLike('Bizmatch', {
colors: true,
@@ -75,12 +69,28 @@ loadEnvFiles();
ImageModule,
PassportModule,
AiModule,
LogModule,
PaymentModule,
EventModule,
],
controllers: [AppController, LogController],
providers: [
AppService,
FileService,
JwtStrategy,
{
provide: APP_INTERCEPTOR,
useClass: UserInterceptor, // Registriere den Interceptor global
},
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor, // Registriere den LoggingInterceptor global
},
],
controllers: [AppController],
providers: [AppService, FileService],
})
export class AppModule {
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(ClsMiddleware).forRoutes('*');
consumer.apply(RequestDurationMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,30 @@
{
"keys": [
{
"kid": "0NxHr10meEVrGYmGlWz_WHiTPxbuNaU6vmShQYWFBh8",
"kty": "RSA",
"alg": "RSA-OAEP",
"use": "enc",
"n": "7hzWTnW6WOrZQmeZ26fD5Fu0NvxiQP8pVfesK9MXO4R1gjGlPViGWCdUKrG9Ux6h9X6SXHOWPWZmbfmjNeK7kQOjYPS_06GQ3X19tFikdWoufZMTpAb6p9CENsIbpzX9c1JZRs1xSJ9B505NjLVp29WzhugQfQR2ctv4nLZYmo1ojGjUQMGPNO_4bMqzO_luBQGEAqnRojZzxHVp-ruNyR9DmQbPbUULrOOXfGjCeAYukZ-5UHl6pngk8b6NKdGq6E_qxNsZVStWxbeGAG5UhxSl6oaGL8R0fP9JiAtlWfubJsCtibk712MaMb59JEdr_f3R3pXN7He8brS3smPgcQ",
"e": "AQAB",
"x5c": [
"MIIClTCCAX0CBgGN9oQZDTANBgkqhkiG9w0BAQsFADAOMQwwCgYDVQQDDANkZXYwHhcNMjQwMjI5MjAxNjA4WhcNMzQwMjI4MjAxNzQ4WjAOMQwwCgYDVQQDDANkZXYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDuHNZOdbpY6tlCZ5nbp8PkW7Q2/GJA/ylV96wr0xc7hHWCMaU9WIZYJ1Qqsb1THqH1fpJcc5Y9ZmZt+aM14ruRA6Ng9L/ToZDdfX20WKR1ai59kxOkBvqn0IQ2whunNf1zUllGzXFIn0HnTk2MtWnb1bOG6BB9BHZy2/ictliajWiMaNRAwY807/hsyrM7+W4FAYQCqdGiNnPEdWn6u43JH0OZBs9tRQus45d8aMJ4Bi6Rn7lQeXqmeCTxvo0p0aroT+rE2xlVK1bFt4YAblSHFKXqhoYvxHR8/0mIC2VZ+5smwK2JuTvXYxoxvn0kR2v9/dHelc3sd7xutLeyY+BxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAL5CFk/T8Thoi6yNRg7CSiWuehCwwzC+FfsoVQNkcq2loZYgWjO34b9fXysT0xXLJOAnw0+xvwAhbVdBwTathQb2PJST5Ei6RGIOsT2gfE91Je3BGpXnsNNDja0be1bS/uN07aa3MshkgVBOYVDe2FoK7g4zSgViMXLEzGpRdio9mIrH3KADdEAFrhiNClu19gefONT86vUvIpSCS4XJ+nSUPbNkbhe9MlvZ8TRWFMoUzuZML6Xf+FbimAv1ZBk1NWobWPtyaDFF9Lgse7LHGiKPKvBHonVMbWYf7Lk8nGA7/90WVOX5Fd2LItH/13rPNlwbspAcz/nB2groa8/DrdE="
],
"x5t": "3ZyfzL7Gn0dcNq8H8X1L0uagQMI",
"x5t#S256": "Wwu30X3ZnchcXsJHJmOHT8BLOFCH6y2TpO3hyzojhdk"
},
{
"kid": "yAfIWlA3TFvR_h112X4sJHK0kog4_4xDLkRnJnzTv98",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "xpYiq2XOtKV-xeLmFM-4sUWDpzw1UJlN9NXj833MZKsW_bwWixlsJTsB-2kfQ6mXUTbfxsuoZuWMZdQVpsWoKOPeK1Gsd8Gsoa0v2pv3uzPA8_SLqDrBNtIz9mDJc6jf-XkOdtAfPzW_aMf4TzThzIkEH5ptUde0gDKNd8je2lFo4loFJkLhOO2HZ7cLQcspXB_vNqpjAMED15GmGRizeTsA4IWC9WjGyziVvlbgQqC0MqCieT2r4dB0FZGWFwzlm-EhvyHu6G1Hw55jn5AcEHh5fke9XvTBzF6MmM_MQEDc9QWHj16ekVdQB7fxzBHbyLMr3ivQizcHAGYvemNhHw",
"e": "AQAB",
"x5c": [
"MIIClTCCAX0CBgGN9oQYYzANBgkqhkiG9w0BAQsFADAOMQwwCgYDVQQDDANkZXYwHhcNMjQwMjI5MjAxNjA4WhcNMzQwMjI4MjAxNzQ4WjAOMQwwCgYDVQQDDANkZXYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDGliKrZc60pX7F4uYUz7ixRYOnPDVQmU301ePzfcxkqxb9vBaLGWwlOwH7aR9DqZdRNt/Gy6hm5Yxl1BWmxago494rUax3wayhrS/am/e7M8Dz9IuoOsE20jP2YMlzqN/5eQ520B8/Nb9ox/hPNOHMiQQfmm1R17SAMo13yN7aUWjiWgUmQuE47YdntwtByylcH+82qmMAwQPXkaYZGLN5OwDghYL1aMbLOJW+VuBCoLQyoKJ5Pavh0HQVkZYXDOWb4SG/Ie7obUfDnmOfkBwQeHl+R71e9MHMXoyYz8xAQNz1BYePXp6RV1AHt/HMEdvIsyveK9CLNwcAZi96Y2EfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABQaqejZ5iWybWeiK0j9iKTn5DNr8LFXdJNRk+odI5TwCtaVDTCQRrF1KKT6F6RmzQyc6xyKojtnI1mKjs+Wo8vYE483pDgoGkv7UquKeQAWbXRajbkpGKasIux7m0MgDhPGKtxoha3kI2Yi2dOFYGdRuqv35/ZD+9nfHfk03fylrf5saroOYBGW6RRpdygB14zQ5ZbXin6gVJSBuJWMiWpxzAB05llZVaHOJ7kO+402YV2/l2TJm0bc883HZuIKxh11PI20lZop9ZwctVtmwf2iFfMfQgQ5wZpV/1gEMynVypxe6OY7biQyIERX6oEFWmZIOrnytSawLyy5gCFrStY="
],
"x5t": "L27m4VtyyHlrajDI_47_mmRSP08",
"x5t#S256": "KOcIpGLNb4ZGg_G2jc6ieZC_86-QQjoaSsMDoV0RWZg"
}
]
}

View File

@@ -1,40 +1,38 @@
import { Controller, Get, Param, Put } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from 'src/jwt-auth/jwt-auth.guard';
import { KeycloakUser } from 'src/models/main.model';
import { AdminAuthGuard } from '../jwt-auth/admin-auth.guard';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(AdminAuthGuard)
@Get()
getAccessToken(): any {
return this.authService.getAccessToken();
async getAccessToken(): Promise<any> {
return await this.authService.getAccessToken();
}
@Get('users')
getUsers(): any {
return this.authService.getUsers();
}
@Get('user/:userid')
getUser(@Param('userid') userId: string): any {
return this.authService.getUser(userId);
}
@Get('groups')
getGroups(): any {
return this.authService.getGroups();
@UseGuards(AdminAuthGuard)
@Get('user/all')
async getUsers(): Promise<any> {
return await this.authService.getUsers();
}
@Get('user/:userid/groups') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth
getGroupsForUsers(@Param('userid') userId: string): any {
return this.authService.getGroupsForUser(userId);
@UseGuards(JwtAuthGuard)
@Get('users/:userid')
async getUser(@Param('userid') userId: string): Promise<any> {
return await this.authService.getUser(userId);
}
@Get('user/:userid/lastlogin') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth
getLastLogin(@Param('userid') userId: string): any {
return this.authService.getLastLogin(userId);
}
@Put('user/:userid/group/:groupid') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth //
addUser2Group(@Param('userid') userId: string,@Param('groupid') groupId: string): any {
return this.authService.addUser2Group(userId,groupId);
@UseGuards(JwtAuthGuard)
@Put('users/:userid')
async updateKeycloakUser(@Body() keycloakUser: KeycloakUser): Promise<any> {
return await this.authService.updateKeycloakUser(keycloakUser);
}
// @UseGuards(AdminAuthGuard)
// @Get('user/:userid/lastlogin') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth
// getLastLogin(@Param('userid') userId: string): any {
// return this.authService.getLastLogin(userId);
// }
}

View File

@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from '../jwt.strategy.js';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [PassportModule],
providers: [AuthService, JwtStrategy],
providers: [AuthService],
controllers: [AuthController],
exports: [AuthService],
})

View File

@@ -1,32 +1,29 @@
import { Injectable } from '@nestjs/common';
import ky from 'ky';
import { KeycloakUser } from 'src/models/main.model';
import urlcat from 'urlcat';
@Injectable()
export class AuthService {
public async getAccessToken() {
const form = new FormData();
form.append('grant_type', 'password');
form.append('username', process.env.user);
form.append('password', process.env.password);
try {
const params = new URLSearchParams();
params.append('grant_type', 'password');
params.append('username', process.env.user);
params.append('password', process.env.password);
const URL = `${process.env.host}${process.env.tokenURL}`;
params.append('username', process.env.KEYCLOAK_ADMIN_USER);
params.append('password', process.env.KEYCLOAK_ADMIN_PASSWORD);
const URL = `${process.env.KEYCLOAK_HOST}${process.env.KEYCLOAK_TOKEN_URL}`;
const response = await ky
.post(URL, {
body: params.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic YWRtaW4tY2xpOnE0RmJnazFkd0NaelFQZmt5VzhhM3NnckV5UHZlRUY3',
},
})
.json();
return (<any>response).access_token;
const response = await fetch(URL, {
method: 'POST',
body: params.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: process.env.KEYCLOAK_ADMIN_TOKEN,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return (<any>data).access_token;
} catch (error) {
if (error.name === 'HTTPError') {
const errorJson = await error.response.json();
@@ -37,83 +34,68 @@ export class AuthService {
}
}
public async getUsers() {
public async getUsers(): Promise<KeycloakUser[]> {
const token = await this.getAccessToken();
const URL = `${process.env.host}${process.env.usersURL}`;
const response = await ky
.get(URL, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
.json();
return response;
const URL = `${process.env.KEYCLOAK_HOST}${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_USERS_URL}`;
const response = await fetch(URL, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data as KeycloakUser[];
}
public async getUser(userid: string) {
public async getUser(userid: string): Promise<KeycloakUser> {
const token = await this.getAccessToken();
const URL = urlcat(process.env.host, process.env.userURL, { userid });
const response = await ky
.get(URL, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
.json();
return response;
const URLPATH = `${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_USER_URL}`;
const URL = urlcat(process.env.KEYCLOAK_HOST, URLPATH, { userid });
const response = await fetch(URL, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data as KeycloakUser;
}
public async getGroups() {
public async updateKeycloakUser(keycloakUser: KeycloakUser): Promise<void> {
const token = await this.getAccessToken();
const URL = `${process.env.host}${process.env.groupsURL}`;
const response = await ky
.get(URL, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
.json();
return response;
}
public async getGroupsForUser(userid: string) {
const token = await this.getAccessToken();
const URL = urlcat(process.env.host, process.env.userGroupsURL, { userid });
const response = await ky
.get(URL, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
.json();
return response;
}
public async getLastLogin(userid: string) {
const token = await this.getAccessToken();
const URL = urlcat(process.env.host, process.env.lastLoginURL, { userid });
const response = await ky
.get(URL, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
.json();
return response;
}
public async addUser2Group(userid: string, groupid: string) {
const token = await this.getAccessToken();
const URL = urlcat(process.env.host, process.env.addUser2GroupURL, { userid, groupid });
const response = await ky
.put(URL, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
.json();
return response;
const userid = keycloakUser.id;
const URLPATH = `${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_USER_URL}`;
const URL = urlcat(process.env.KEYCLOAK_HOST, URLPATH, { userid });
const response = await fetch(URL, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(keycloakUser),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// public async getLastLogin(userid: string) {
// const token = await this.getAccessToken();
// const URLPATH = `${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_LASTLOGIN_URL}`;
// const URL = urlcat(process.env.KEYCLOAK_HOST, URLPATH, { userid });
// const response = await ky
// .get(URL, {
// headers: {
// 'Content-Type': 'application/x-www-form-urlencoded',
// Authorization: `Bearer ${token}`,
// },
// })
// .json();
// return response;
// }
}

View File

@@ -0,0 +1,8 @@
// src/decorators/real-ip.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { getRealIpInfo, RealIpInfo } from '../utils/ip.util';
export const RealIp = createParamDecorator((data: unknown, ctx: ExecutionContext): RealIpInfo => {
const request = ctx.switchToHttp().getRequest();
return getRealIpInfo(request);
});

View File

@@ -1,24 +1,36 @@
import { Module } from '@nestjs/common';
import { drizzle } from 'drizzle-orm/node-postgres';
import pkg from 'pg';
const { Pool } = pkg;
import * as schema from './schema.js';
import { ConfigService } from '@nestjs/config';
import { jsonb, varchar } from 'drizzle-orm/pg-core';
import { PG_CONNECTION } from './schema.js';
import { drizzle } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { ClsService } from 'nestjs-cls';
import pkg from 'pg';
import { Logger } from 'winston';
import * as schema from './schema';
import { PG_CONNECTION } from './schema';
const { Pool } = pkg;
@Module({
providers: [
{
provide: PG_CONNECTION,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
inject: [ConfigService, WINSTON_MODULE_PROVIDER, ClsService],
useFactory: async (configService: ConfigService, logger: Logger, cls: ClsService) => {
const connectionString = configService.get<string>('DATABASE_URL');
const pool = new Pool({
connectionString,
// ssl: true,
// ssl: true, // Falls benötigt
});
return drizzle(pool, { schema, logger:true });
// Definiere einen benutzerdefinierten Logger für Drizzle
const drizzleLogger = {
logQuery(query: string, params: unknown[]): void {
const ip = cls.get('ip') || 'unknown';
const countryCode = cls.get('countryCode') || 'unknown';
const username = cls.get('username') || 'unknown';
logger.info(`IP: ${ip} (${countryCode}) (${username}) - Query: ${query} - Params: ${JSON.stringify(params)}`);
},
};
return drizzle(pool, { schema, logger: drizzleLogger });
},
},
],

View File

@@ -0,0 +1,35 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { promises as fs } from 'fs';
import { Pool } from 'pg';
import * as schema from './schema';
// Drizzle-Tabellen-Definitionen (hier hast du bereits die Tabellen definiert, wir nehmen an, sie werden hier importiert)
import { businesses, commercials, users } from './schema'; // Anpassen je nach tatsächlicher Struktur
const connectionString = process.env.DATABASE_URL;
console.log(connectionString);
const client = new Pool({ connectionString });
const db = drizzle(client, { schema, logger: true });
(async () => {
try {
// Abfrage der Daten für jede Tabelle
const usersData = await db.select().from(users).execute();
const businessesData = await db.select().from(businesses).execute();
const commercialsData = await db.select().from(commercials).execute();
// Speichern der Daten in JSON-Dateien
await fs.writeFile('./data/users_export.json', JSON.stringify(usersData, null, 2));
console.log('Users exportiert in users.json');
await fs.writeFile('./data/businesses_export.json', JSON.stringify(businessesData, null, 2));
console.log('Businesses exportiert in businesses.json');
await fs.writeFile('./data/commercials_export.json', JSON.stringify(commercialsData, null, 2));
console.log('Commercials exportiert in commercials.json');
} catch (error) {
console.error('Fehler beim Exportieren der Tabellen:', error);
} finally {
await client.end();
}
})();

View File

@@ -2,20 +2,19 @@ import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
import fs from 'fs-extra';
import OpenAI from 'openai';
import { join } from 'path';
import pkg from 'pg';
import { Pool } from 'pg';
import { rimraf } from 'rimraf';
import sharp from 'sharp';
import { BusinessListingService } from 'src/listings/business-listing.service.js';
import { CommercialPropertyService } from 'src/listings/commercial-property.service.js';
import { Geo } from 'src/models/server.model.js';
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.js';
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName, KeyValueStyle } from '../models/main.model.js';
import { SelectOptionsService } from '../select-options/select-options.service.js';
import { convertUserToDrizzleUser } from '../utils.js';
import * as schema from './schema.js';
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;
@@ -54,224 +53,226 @@ interface BusinessImportListing {
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 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
});
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);
//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('', '', '');
user.licensedIn = [];
userData.licensedIn.forEach(l => {
console.log(l['value'], l['name']);
user.licensedIn.push({ registerNo: l['value'], state: l['name'] });
// 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()],
});
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.companyLocation = {};
user.companyLocation.city = city;
user.companyLocation.state = state;
const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city);
user.companyLocation.latitude = cityGeo.latitude;
user.companyLocation.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 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 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 });
generatedUserData.push(u[0]);
i++;
logger.info(`user_${index} inserted`);
if (u[0].gender === 'male') {
male++;
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
await storeProfilePicture(data, emailToDirName(u[0].email));
} else {
female++;
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`);
await storeProfilePicture(data, emailToDirName(u[0].email));
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));
}
const data = readFileSync(`./pictures_base/logo/${i}.jpg`);
await storeCompanyLogo(data, emailToDirName(u[0].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;
//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.city = 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.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}`);
}
}
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);
}
}
//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.city = 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;
}
//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) {
@@ -283,7 +284,7 @@ function getRandomItem<T>(arr: T[]): T {
}
function getFilenames(id: string): string[] {
try {
let filePath = `./pictures_base/property/${id}`;
const filePath = `./pictures_base/property/${id}`;
return readdirSync(filePath);
} catch (e) {
return [];
@@ -300,7 +301,7 @@ function getRandomDateWithinLastYear(): Date {
return randomDate;
}
async function storeProfilePicture(buffer: Buffer, userId: string) {
let quality = 50;
const quality = 50;
const output = await sharp(buffer)
.resize({ width: 300 })
.avif({ quality }) // Verwende AVIF
@@ -310,7 +311,7 @@ async function storeProfilePicture(buffer: Buffer, userId: string) {
}
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
let quality = 50;
const quality = 50;
const output = await sharp(buffer)
.resize({ width: 300 })
.avif({ quality }) // Verwende AVIF

View File

@@ -1,7 +1,7 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import pkg from 'pg';
import * as schema from './schema.js';
import * as schema from './schema';
const { Pool } = pkg;
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });

View File

@@ -1,114 +0,0 @@
DO $$ BEGIN
CREATE TYPE "public"."customerSubType" AS ENUM('broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."customerType" AS ENUM('buyer', 'professional');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."gender" AS ENUM('male', 'female');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."listingsCategory" AS ENUM('commercialProperty', 'business');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "businesses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255),
"type" varchar(255),
"title" varchar(255),
"description" text,
"city" varchar(255),
"state" char(2),
"price" double precision,
"favoritesForUser" varchar(30)[],
"draft" boolean,
"listingsCategory" "listingsCategory",
"realEstateIncluded" boolean,
"leasedLocation" boolean,
"franchiseResale" boolean,
"salesRevenue" double precision,
"cashFlow" double precision,
"supportAndTraining" text,
"employees" integer,
"established" integer,
"internalListingNumber" integer,
"reasonForSale" varchar(255),
"brokerLicencing" varchar(255),
"internals" text,
"imageName" varchar(200),
"created" timestamp,
"updated" timestamp,
"latitude" double precision,
"longitude" double precision
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "commercials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"serialId" serial NOT NULL,
"email" varchar(255),
"type" varchar(255),
"title" varchar(255),
"description" text,
"city" varchar(255),
"state" char(2),
"price" double precision,
"favoritesForUser" varchar(30)[],
"listingsCategory" "listingsCategory",
"draft" boolean,
"imageOrder" varchar(200)[],
"imagePath" varchar(200),
"created" timestamp,
"updated" timestamp,
"latitude" double precision,
"longitude" double precision
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"firstname" varchar(255) NOT NULL,
"lastname" varchar(255) NOT NULL,
"email" varchar(255) NOT NULL,
"phoneNumber" varchar(255),
"description" text,
"companyName" varchar(255),
"companyOverview" text,
"companyWebsite" varchar(255),
"city" varchar(255),
"state" char(2),
"offeredServices" text,
"areasServed" jsonb,
"hasProfile" boolean,
"hasCompanyLogo" boolean,
"licensedIn" jsonb,
"gender" "gender",
"customerType" "customerType",
"customerSubType" "customerSubType",
"created" timestamp,
"updated" timestamp,
"latitude" double precision,
"longitude" double precision,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "businesses" ADD CONSTRAINT "businesses_email_users_email_fk" FOREIGN KEY ("email") REFERENCES "public"."users"("email") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "commercials" ADD CONSTRAINT "commercials_email_users_email_fk" FOREIGN KEY ("email") REFERENCES "public"."users"("email") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -1,541 +0,0 @@
{
"id": "a8283ca6-2c10-42bb-a640-ca984544ba30",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.businesses": {
"name": "businesses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "char(2)",
"primaryKey": false,
"notNull": false
},
"price": {
"name": "price",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"favoritesForUser": {
"name": "favoritesForUser",
"type": "varchar(30)[]",
"primaryKey": false,
"notNull": false
},
"draft": {
"name": "draft",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"listingsCategory": {
"name": "listingsCategory",
"type": "listingsCategory",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"realEstateIncluded": {
"name": "realEstateIncluded",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"leasedLocation": {
"name": "leasedLocation",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"franchiseResale": {
"name": "franchiseResale",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"salesRevenue": {
"name": "salesRevenue",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"cashFlow": {
"name": "cashFlow",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"supportAndTraining": {
"name": "supportAndTraining",
"type": "text",
"primaryKey": false,
"notNull": false
},
"employees": {
"name": "employees",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"established": {
"name": "established",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"internalListingNumber": {
"name": "internalListingNumber",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"reasonForSale": {
"name": "reasonForSale",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"brokerLicencing": {
"name": "brokerLicencing",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"internals": {
"name": "internals",
"type": "text",
"primaryKey": false,
"notNull": false
},
"imageName": {
"name": "imageName",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false
},
"created": {
"name": "created",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated": {
"name": "updated",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"longitude": {
"name": "longitude",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"businesses_email_users_email_fk": {
"name": "businesses_email_users_email_fk",
"tableFrom": "businesses",
"tableTo": "users",
"columnsFrom": [
"email"
],
"columnsTo": [
"email"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.commercials": {
"name": "commercials",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"serialId": {
"name": "serialId",
"type": "serial",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "char(2)",
"primaryKey": false,
"notNull": false
},
"price": {
"name": "price",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"favoritesForUser": {
"name": "favoritesForUser",
"type": "varchar(30)[]",
"primaryKey": false,
"notNull": false
},
"listingsCategory": {
"name": "listingsCategory",
"type": "listingsCategory",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"draft": {
"name": "draft",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"imageOrder": {
"name": "imageOrder",
"type": "varchar(200)[]",
"primaryKey": false,
"notNull": false
},
"imagePath": {
"name": "imagePath",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false
},
"created": {
"name": "created",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated": {
"name": "updated",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"longitude": {
"name": "longitude",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"commercials_email_users_email_fk": {
"name": "commercials_email_users_email_fk",
"tableFrom": "commercials",
"tableTo": "users",
"columnsFrom": [
"email"
],
"columnsTo": [
"email"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"firstname": {
"name": "firstname",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"lastname": {
"name": "lastname",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"phoneNumber": {
"name": "phoneNumber",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"companyName": {
"name": "companyName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"companyOverview": {
"name": "companyOverview",
"type": "text",
"primaryKey": false,
"notNull": false
},
"companyWebsite": {
"name": "companyWebsite",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "char(2)",
"primaryKey": false,
"notNull": false
},
"offeredServices": {
"name": "offeredServices",
"type": "text",
"primaryKey": false,
"notNull": false
},
"areasServed": {
"name": "areasServed",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"hasProfile": {
"name": "hasProfile",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"hasCompanyLogo": {
"name": "hasCompanyLogo",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"licensedIn": {
"name": "licensedIn",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"gender": {
"name": "gender",
"type": "gender",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"customerType": {
"name": "customerType",
"type": "customerType",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"customerSubType": {
"name": "customerSubType",
"type": "customerSubType",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"created": {
"name": "created",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated": {
"name": "updated",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"longitude": {
"name": "longitude",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
}
}
},
"enums": {
"public.customerSubType": {
"name": "customerSubType",
"schema": "public",
"values": [
"broker",
"cpa",
"attorney",
"titleCompany",
"surveyor",
"appraiser"
]
},
"public.customerType": {
"name": "customerType",
"schema": "public",
"values": [
"buyer",
"professional"
]
},
"public.gender": {
"name": "gender",
"schema": "public",
"values": [
"male",
"female"
]
},
"public.listingsCategory": {
"name": "listingsCategory",
"schema": "public",
"values": [
"commercialProperty",
"business"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1723045357281,
"tag": "0000_lean_marvex",
"breakpoints": true
}
]
}

View File

@@ -1,92 +1,149 @@
import { boolean, char, doublePrecision, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
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', 'professional']);
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']);
export const users = pgTable('users', {
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'),
// city: varchar('city', { length: 255 }),
// state: char('state', { length: 2 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
},
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'), //varchar('listingsCategory', { length: 255 }),
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 }),
created: timestamp('created'),
updated: timestamp('updated'),
location: jsonb('location'),
// city: varchar('city', { length: 255 }),
// state: char('state', { length: 2 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
// street: varchar('street', { length: 255 }),
// housenumber: varchar('housenumber', { length: 10 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
},
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)`,
),
}),
);
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'), //listingsCategory: varchar('listingsCategory', { length: 255 }),
draft: boolean('draft'),
imageOrder: varchar('imageOrder', { length: 200 }).array(),
imagePath: varchar('imagePath', { length: 200 }),
created: timestamp('created'),
updated: timestamp('updated'),
location: jsonb('location'),
// city: varchar('city', { length: 255 }),
// state: char('state', { length: 2 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
// street: varchar('street', { length: 255 }),
// housenumber: varchar('housenumber', { length: 10 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
},
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)`,
),
}),
);
// export const geo = pgTable('geo', {
// id: uuid('id').primaryKey().defaultRandom().notNull(),
// country: varchar('country', { length: 255 }).default('us'),
// state: char('state', { length: 2 }),
// city: varchar('city', { length: 255 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
// street: varchar('street', { length: 255 }),
// housenumber: varchar('housenumber', { length: 10 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
// });
export const listingEvents = pgTable('listing_events', {
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 }),
city: varchar('city', { length: 255 }),
state: char('state', { length: 2 }),
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'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
// embedding: vector('embedding', { dimensions: 1536 }),
});
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'),
city: varchar('city', { length: 255 }),
state: char('state', { length: 2 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
price: doublePrecision('price'),
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
draft: boolean('draft'),
listingsCategory: listingsCategoryEnum('listingsCategory'), //varchar('listingsCategory', { length: 255 }),
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 }),
created: timestamp('created'),
updated: timestamp('updated'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
// embedding: vector('embedding', { dimensions: 1536 }),
});
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'),
city: varchar('city', { length: 255 }),
state: char('state', { length: 2 }),
price: doublePrecision('price'),
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
listingsCategory: listingsCategoryEnum('listingsCategory'), //listingsCategory: varchar('listingsCategory', { length: 255 }),
draft: boolean('draft'),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
imageOrder: varchar('imageOrder', { length: 200 }).array(),
imagePath: varchar('imagePath', { length: 200 }),
created: timestamp('created'),
updated: timestamp('updated'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
// embedding: vector('embedding', { dimensions: 1536 }),
listingId: varchar('listing_id', { length: 255 }), // Assuming listings are referenced by UUID, adjust as necessary
email: varchar('email', { length: 255 }),
eventType: varchar('event_type', { length: 50 }), // 'view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact'
eventTimestamp: timestamp('event_timestamp').defaultNow(),
userIp: varchar('user_ip', { length: 45 }), // Optional if you choose to track IP in frontend or backend
userAgent: varchar('user_agent', { length: 255 }), // Store User-Agent as string
locationCountry: varchar('location_country', { length: 100 }), // Country from IP
locationCity: varchar('location_city', { length: 100 }), // City from IP
locationLat: varchar('location_lat', { length: 20 }), // Latitude from IP, stored as varchar
locationLng: varchar('location_lng', { length: 20 }), // Longitude from IP, stored as varchar
referrer: varchar('referrer', { length: 255 }), // Referrer URL if applicable
additionalData: jsonb('additional_data'), // JSON for any other optional data (like email, social shares etc.)
});

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
// Angenommen, du hast eine Datei `databaseModels.js` mit deinen pgTable-Definitionen
const { users } = require('./schema.js');
const { users } = require('./schema');
function generateTypeScriptInterface(tableDefinition, tableName) {
let interfaceString = `export interface ${tableName} {\n`;

View File

@@ -0,0 +1,24 @@
import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common';
import { RealIp } from 'src/decorators/real-ip.decorator';
import { OptionalJwtAuthGuard } from 'src/jwt-auth/optional-jwt-auth.guard';
import { ListingEvent } from 'src/models/db.model';
import { RealIpInfo } from 'src/models/main.model';
import { EventService } from './event.service';
@Controller('event')
export class EventController {
constructor(private eventService: EventService) {}
@UseGuards(OptionalJwtAuthGuard)
@Post()
async createEvent(
@Body() event: ListingEvent, // Struktur des Body-Objekts entsprechend anpassen
@RealIp() ipInfo: RealIpInfo, // IP Adresse des Clients
@Headers('user-agent') userAgent: string, // User-Agent des Clients
) {
event.userIp = ipInfo.ip;
event.userAgent = userAgent;
await this.eventService.createEvent(event);
return { message: 'Event gespeichert' };
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DrizzleModule } from 'src/drizzle/drizzle.module';
import { EventController } from './event.controller';
import { EventService } from './event.service';
@Module({
imports: [DrizzleModule],
controllers: [EventController],
providers: [EventService],
})
export class EventModule {}

View File

@@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { ListingEvent } from 'src/models/db.model';
import * as schema from '../drizzle/schema';
import { listingEvents, PG_CONNECTION } from '../drizzle/schema';
@Injectable()
export class EventService {
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
) {}
async createEvent(event: ListingEvent) {
// Speichere das Event in der Datenbank
event.eventTimestamp = new Date();
await this.conn.insert(listingEvents).values(event).execute();
}
}

View File

@@ -1,41 +1,22 @@
import { Inject, Injectable } from '@nestjs/common';
import { readFileSync } from 'fs';
import fs from 'fs-extra';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import path, { join } from 'path';
import sharp from 'sharp';
import { fileURLToPath } from 'url';
import { Logger } from 'winston';
import { ImageProperty, Subscription } from '../models/main.model.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@Injectable()
export class FileService {
private subscriptions: any;
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
this.loadSubscriptions();
fs.ensureDirSync(`./pictures`);
fs.ensureDirSync(`./pictures/profile`);
fs.ensureDirSync(`./pictures/logo`);
fs.ensureDirSync(`./pictures/property`);
}
// ############
// Subscriptions
// ############
private loadSubscriptions(): void {
const filePath = join(__dirname, '../..', 'assets', 'subscriptions.json');
const rawData = readFileSync(filePath, 'utf8');
this.subscriptions = JSON.parse(rawData);
}
getSubscriptions(): Subscription[] {
return this.subscriptions;
}
// ############
// Profile
// ############
async storeProfilePicture(file: Express.Multer.File, adjustedEmail: string) {
let quality = 50;
const quality = 50;
const output = await sharp(file.buffer)
.resize({ width: 300 })
.avif({ quality }) // Verwende AVIF
@@ -50,7 +31,7 @@ export class FileService {
// Logo
// ############
async storeCompanyLogo(file: Express.Multer.File, adjustedEmail: string) {
let quality = 50;
const quality = 50;
const output = await sharp(file.buffer)
.resize({ width: 300 })
.avif({ quality }) // Verwende AVIF
@@ -79,7 +60,6 @@ export class FileService {
}
}
async hasPropertyImages(imagePath: string, serial: string): Promise<boolean> {
const result: ImageProperty[] = [];
const directory = `./pictures/property/${imagePath}/${serial}`;
if (fs.existsSync(directory)) {
const files = await fs.readdir(directory);
@@ -89,7 +69,6 @@ export class FileService {
}
}
async storePropertyPicture(file: Express.Multer.File, imagePath: string, serial: string): Promise<string> {
const suffix = file.mimetype.includes('png') ? 'png' : 'jpg';
const directory = `./pictures/property/${imagePath}/${serial}`;
fs.ensureDirSync(`${directory}`);
const imageName = await this.getNextImageName(directory);
@@ -116,16 +95,15 @@ export class FileService {
}
}
async resizeImageToAVIF(buffer: Buffer, maxSize: number, imageName: string, directory: string) {
let quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen
let output;
let start = Date.now();
output = await sharp(buffer)
const quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen
const start = Date.now();
const output = await sharp(buffer)
.resize({ width: 1500 })
.avif({ quality }) // Verwende AVIF
//.webp({ quality }) // Verwende Webp
.toBuffer();
await sharp(output).toFile(`${directory}/${imageName}.avif`); // Ersetze Dateierweiterung
let timeTaken = Date.now() - start;
const timeTaken = Date.now() - start;
this.logger.info(`Quality: ${quality} - Time: ${timeTaken} milliseconds`);
}
deleteImage(path: string) {

View File

@@ -1,27 +1,41 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { CountyRequest } from 'src/models/server.model.js';
import { GeoService } from './geo.service.js';
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { RealIp } from 'src/decorators/real-ip.decorator';
import { OptionalJwtAuthGuard } from 'src/jwt-auth/optional-jwt-auth.guard';
import { RealIpInfo } from 'src/models/main.model';
import { CountyRequest } from 'src/models/server.model';
import { GeoService } from './geo.service';
@Controller('geo')
export class GeoController {
constructor(private geoService: GeoService) {}
@UseGuards(OptionalJwtAuthGuard)
@Get(':prefix')
findByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesStartingWith(prefix);
}
@UseGuards(OptionalJwtAuthGuard)
@Get('citiesandstates/:prefix')
findByCitiesAndStatesByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesAndStatesStartingWith(prefix);
}
@UseGuards(OptionalJwtAuthGuard)
@Get(':prefix/:state')
findByPrefixAndState(@Param('prefix') prefix: string, @Param('state') state: string): any {
return this.geoService.findCitiesStartingWith(prefix, state);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('counties')
findByPrefixAndStates(@Body() countyRequest: CountyRequest): any {
return this.geoService.findCountiesStartingWith(countyRequest.prefix, countyRequest.states);
}
@UseGuards(OptionalJwtAuthGuard)
@Get('ipinfo/georesult/wysiwyg')
async fetchIpAndGeoLocation(@RealIp() ipInfo: RealIpInfo): Promise<any> {
return await this.geoService.fetchIpAndGeoLocation(ipInfo);
}
}

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { GeoController } from './geo.controller.js';
import { GeoService } from './geo.service.js';
import { GeoController } from './geo.controller';
import { GeoService } from './geo.service';
@Module({
controllers: [GeoController],
providers: [GeoService]
providers: [GeoService],
})
export class GeoModule {}

View File

@@ -1,18 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { readFileSync } from 'fs';
import path, { join } from 'path';
import { CountyResult, GeoResult } from 'src/models/main.model.js';
import { fileURLToPath } from 'url';
import { City, CountyData, Geo, State } from '../models/server.model.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { join } from 'path';
import { CityAndStateResult, CountyResult, GeoResult, IpInfo, RealIpInfo } from 'src/models/main.model';
import { Logger } from 'winston';
import { City, CountyData, Geo, State } from '../models/server.model';
// const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);
@Injectable()
export class GeoService {
geo: Geo;
counties: CountyData[];
constructor() {
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
this.loadGeo();
}
private loadGeo(): void {
@@ -24,13 +24,13 @@ export class GeoService {
this.counties = JSON.parse(rawCountiesData);
}
findCountiesStartingWith(prefix: string, states?: string[]) {
let results: CountyResult[] = [];
const results: CountyResult[] = [];
let idCounter = 1;
this.counties.forEach(stateData => {
if (!states || states.includes(stateData.state)) {
stateData.counties.forEach(county => {
if (county.startsWith(prefix.toUpperCase())) {
if (county.startsWith(prefix?.toUpperCase())) {
results.push({
id: idCounter++,
name: county,
@@ -52,7 +52,7 @@ export class GeoService {
if (city.name.toLowerCase().startsWith(prefix.toLowerCase())) {
result.push({
id: city.id,
city: city.name,
name: city.name,
state: state.state_code,
//state_code: state.state_code,
latitude: city.latitude,
@@ -63,8 +63,8 @@ export class GeoService {
});
return state ? result.filter(e => e.state.toLowerCase() === state.toLowerCase()) : result;
}
findCitiesAndStatesStartingWith(prefix: string, state?: string): Array<{ id: string; name: string; type: 'city' | 'state'; state: string }> {
const results: Array<{ id: string; name: string; type: 'city' | 'state'; state: string }> = [];
findCitiesAndStatesStartingWith(prefix: string): Array<CityAndStateResult> {
const results: Array<CityAndStateResult> = [];
const lowercasePrefix = prefix.toLowerCase();
@@ -73,10 +73,9 @@ export class GeoService {
for (const state of this.geo.states) {
if (state.name.toLowerCase().startsWith(lowercasePrefix)) {
results.push({
id: state.id.toString(),
name: state.name,
id: state.id,
type: 'state',
state: state.state_code,
content: state,
});
}
@@ -84,10 +83,9 @@ export class GeoService {
for (const city of state.cities) {
if (city.name.toLowerCase().startsWith(lowercasePrefix)) {
results.push({
id: city.id.toString(),
name: city.name,
id: city.id,
type: 'city',
state: state.state_code,
content: { state: state.state_code, ...city },
});
}
}
@@ -97,10 +95,27 @@ export class GeoService {
return results.sort((a, b) => {
if (a.type === 'state' && b.type === 'city') return -1;
if (a.type === 'city' && b.type === 'state') return 1;
return a.name.localeCompare(b.name);
return a.content.name.localeCompare(b.content.name);
});
}
getCityWithCoords(state: string, city: string): City {
return this.geo.states.find(s => s.state_code === state).cities.find(c => c.name === city);
}
async fetchIpAndGeoLocation(ipInfo: RealIpInfo): Promise<IpInfo> {
this.logger.info(`IP:${ipInfo.ip} - CountryCode:${ipInfo.countryCode}`);
const response = await fetch(`${process.env.IP_INFO_URL}/${ipInfo.ip}/geo?token=${process.env.IP_INFO_TOKEN}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Fügen Sie den Ländercode aus Cloudflare hinzu, falls verfügbar
if (ipInfo.countryCode) {
data.cloudflareCountry = ipInfo.countryCode;
}
return data;
}
}

View File

@@ -1,10 +1,11 @@
import { Controller, Delete, Inject, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { Controller, Delete, Inject, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { JwtAuthGuard } from 'src/jwt-auth/jwt-auth.guard';
import { Logger } from 'winston';
import { FileService } from '../file/file.service.js';
import { CommercialPropertyService } from '../listings/commercial-property.service.js';
import { SelectOptionsService } from '../select-options/select-options.service.js';
import { FileService } from '../file/file.service';
import { CommercialPropertyService } from '../listings/commercial-property.service';
import { SelectOptionsService } from '../select-options/select-options.service';
@Controller('image')
export class ImageController {
@@ -17,12 +18,14 @@ export class ImageController {
// ############
// Property
// ############
@UseGuards(JwtAuthGuard)
@Post('uploadPropertyPicture/:imagePath/:serial')
@UseInterceptors(FileInterceptor('file'))
async uploadPropertyPicture(@UploadedFile() file: Express.Multer.File, @Param('imagePath') imagePath: string, @Param('serial') serial: string) {
const imagename = await this.fileService.storePropertyPicture(file, imagePath, serial);
await this.listingService.addImage(imagePath, serial, imagename);
}
@UseGuards(JwtAuthGuard)
@Delete('propertyPicture/:imagePath/:serial/:imagename')
async deletePropertyImagesById(@Param('imagePath') imagePath: string, @Param('serial') serial: string, @Param('imagename') imagename: string): Promise<any> {
this.fileService.deleteImage(`pictures/property/${imagePath}/${serial}/${imagename}`);
@@ -31,11 +34,13 @@ export class ImageController {
// ############
// Profile
// ############
@UseGuards(JwtAuthGuard)
@Post('uploadProfile/:email')
@UseInterceptors(FileInterceptor('file'))
async uploadProfile(@UploadedFile() file: Express.Multer.File, @Param('email') adjustedEmail: string) {
await this.fileService.storeProfilePicture(file, adjustedEmail);
}
@UseGuards(JwtAuthGuard)
@Delete('profile/:email/')
async deleteProfileImagesById(@Param('email') email: string): Promise<any> {
this.fileService.deleteImage(`pictures/profile/${email}.avif`);
@@ -43,11 +48,13 @@ export class ImageController {
// ############
// Logo
// ############
@UseGuards(JwtAuthGuard)
@Post('uploadCompanyLogo/:email')
@UseInterceptors(FileInterceptor('file'))
async uploadCompanyLogo(@UploadedFile() file: Express.Multer.File, @Param('email') adjustedEmail: string) {
await this.fileService.storeCompanyLogo(file, adjustedEmail);
}
@UseGuards(JwtAuthGuard)
@Delete('logo/:email/')
async deleteLogoImagesById(@Param('email') adjustedEmail: string): Promise<any> {
this.fileService.deleteImage(`pictures/logo/${adjustedEmail}.avif`);

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { FileService } from '../file/file.service.js';
import { ListingsModule } from '../listings/listings.module.js';
import { SelectOptionsService } from '../select-options/select-options.service.js';
import { ImageController } from './image.controller.js';
import { ImageService } from './image.service.js';
import { FileService } from '../file/file.service';
import { ListingsModule } from '../listings/listings.module';
import { SelectOptionsService } from '../select-options/select-options.service';
import { ImageController } from './image.controller';
import { ImageService } from './image.service';
@Module({
imports: [ListingsModule],

View File

@@ -0,0 +1,40 @@
// src/interceptors/logging.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const ip = this.cls.get('ip') || 'unknown';
const countryCode = this.cls.get('countryCode') || 'unknown';
const username = this.cls.get('username') || 'unknown';
const method = request.method;
const url = request.originalUrl;
const start = Date.now();
this.logger.log(`Entering ${method} ${url} from ${ip} (${countryCode})- User: ${username}`);
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
let logMessage = `${method} ${url} - ${duration}ms - IP: ${ip} - User: ${username}`;
if (method === 'POST' || method === 'PUT') {
const body = JSON.stringify(request.body);
logMessage += ` - Incoming Body: ${body}`;
}
this.logger.log(logMessage);
}),
);
}
}

View File

@@ -0,0 +1,29 @@
// src/interceptors/user.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Observable } from 'rxjs';
@Injectable()
export class UserInterceptor implements NestInterceptor {
private readonly logger = new Logger(UserInterceptor.name);
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
// Überprüfe, ob der Benutzer authentifiziert ist
if (request.user && request.user.username) {
try {
this.cls.set('username', request.user.username);
this.logger.log(`CLS context gesetzt: Username=${request.user.username}`);
} catch (error) {
this.logger.error('Fehler beim Setzen der Username im CLS-Kontext', error);
}
} else {
this.logger.log('Kein authentifizierter Benutzer gefunden');
}
return next.handle();
}
}

View File

@@ -0,0 +1,18 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class AdminAuthGuard extends AuthGuard('jwt') implements CanActivate {
canActivate(context: ExecutionContext) {
// Add your custom authentication logic here
// for example, call super.logIn(request) to establish a session.
return super.canActivate(context);
}
handleRequest(err, user, info) {
// You can throw an exception based on either "info" or "err" arguments
if (err || !user || !user.roles.includes('ADMIN')) {
throw err || new UnauthorizedException(info);
}
return user;
}
}

View File

@@ -1,11 +1,17 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import fs from 'fs';
import { passportJwtSecret } from 'jwks-rsa';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { ExtractJwt, Strategy } from 'passport-jwt';
import path from 'path';
import { Logger } from 'winston';
import { JwtPayload, JwtUser } from './models/main.model';
// const logger = winston.createLogger({
// transports: [new winston.transports.Console()],
// });
// const pemCache = new Map();
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@@ -13,13 +19,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {
const realm = configService.get<string>('REALM');
// const staticCerts = loadStaticCerts();
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
rateLimit: false,
// jwksRequestsPerMinute: 5,
jwksUri: `https://auth.bizmatch.net/realms/${realm}/protocol/openid-connect/certs`,
}),
audience: 'account', // Keycloak Client ID
@@ -28,7 +35,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
algorithms: ['RS256'],
});
}
async validate(payload: JwtPayload): Promise<JwtUser> {
if (!payload) {
this.logger.error('Invalid payload');
@@ -39,7 +45,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException();
}
const result = { userId: payload.sub, firstname: payload.given_name, lastname: payload.family_name, username: payload.preferred_username, roles: payload.realm_access?.roles };
this.logger.info(`JWT User: ${JSON.stringify(result)}`); // Debugging: JWT Payload anzeigen
return result;
}
}
export function loadStaticCerts() {
const certsPath = path.join(__dirname, '../', 'assets', 'keycloak-certs.json');
const certsData = fs.readFileSync(certsPath, 'utf8');
return JSON.parse(certsData);
}

View File

@@ -1,8 +1,9 @@
import { Body, Controller, Inject, Post } from '@nestjs/common';
import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { UserListingCriteria } from 'src/models/main.model.js';
import { OptionalJwtAuthGuard } from 'src/jwt-auth/optional-jwt-auth.guard';
import { UserListingCriteria } from 'src/models/main.model';
import { Logger } from 'winston';
import { UserService } from '../user/user.service.js';
import { UserService } from '../user/user.service';
@Controller('listings/professionals_brokers')
export class BrokerListingsController {
@@ -11,8 +12,9 @@ export class BrokerListingsController {
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {}
@UseGuards(OptionalJwtAuthGuard)
@Post('search')
find(@Body() criteria: UserListingCriteria): any {
return this.userService.searchUserListings(criteria);
async find(@Body() criteria: UserListingCriteria): Promise<any> {
return await this.userService.searchUserListings(criteria);
}
}

View File

@@ -1,16 +1,16 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { and, count, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { ZodError } from 'zod';
import * as schema from '../drizzle/schema.js';
import { businesses, PG_CONNECTION } from '../drizzle/schema.js';
import { FileService } from '../file/file.service.js';
import { GeoService } from '../geo/geo.service.js';
import { BusinessListing, BusinessListingSchema } from '../models/db.model.js';
import { BusinessListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js';
import { convertBusinessToDrizzleBusiness, convertDrizzleBusinessToBusiness, getDistanceQuery } from '../utils.js';
import * as schema from '../drizzle/schema';
import { businesses, PG_CONNECTION } from '../drizzle/schema';
import { FileService } from '../file/file.service';
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';
@Injectable()
export class BusinessListingService {
@@ -21,14 +21,15 @@ export class BusinessListingService {
private geoService?: GeoService,
) {}
private getWhereConditions(criteria: BusinessListingCriteria): SQL[] {
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = [];
if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(ilike(businesses.city, `%${criteria.city}%`));
whereConditions.push(sql`${businesses.location}->>'name' ilike ${criteria.city.name}`);
//whereConditions.push(ilike(businesses.location-->'city', `%${criteria.city.name}%`));
}
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city);
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(businesses, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
}
if (criteria.types && criteria.types.length > 0) {
@@ -36,7 +37,7 @@ export class BusinessListingService {
}
if (criteria.state) {
whereConditions.push(eq(businesses.state, criteria.state));
whereConditions.push(sql`${businesses.location}->>'state' = ${criteria.state}`);
}
if (criteria.minPrice) {
@@ -94,9 +95,16 @@ export class BusinessListingService {
if (criteria.title) {
whereConditions.push(or(ilike(businesses.title, `%${criteria.title}%`), ilike(businesses.description, `%${criteria.title}%`)));
}
if (criteria.brokerName) {
whereConditions.push(or(ilike(schema.users.firstname, `%${criteria.brokerName}%`), ilike(schema.users.lastname, `%${criteria.brokerName}%`)));
const { firstname, lastname } = splitName(criteria.brokerName);
if (firstname === lastname) {
whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
} else {
whereConditions.push(and(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
}
}
if (!user?.roles?.includes('ADMIN') ?? false) {
whereConditions.push(or(eq(businesses.email, user?.username), ne(businesses.draft, true)));
}
whereConditions.push(and(eq(schema.users.customerType, 'professional'), eq(schema.users.customerSubType, 'broker')));
return whereConditions;
@@ -113,29 +121,59 @@ export class BusinessListingService {
.from(businesses)
.leftJoin(schema.users, eq(businesses.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria);
const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
query.where(whereClause);
}
// Sortierung
switch (criteria.sortBy) {
case 'priceAsc':
query.orderBy(asc(businesses.price));
break;
case 'priceDesc':
query.orderBy(desc(businesses.price));
break;
case 'srAsc':
query.orderBy(asc(businesses.salesRevenue));
break;
case 'srDesc':
query.orderBy(desc(businesses.salesRevenue));
break;
case 'cfAsc':
query.orderBy(asc(businesses.cashFlow));
break;
case 'cfDesc':
query.orderBy(desc(businesses.cashFlow));
break;
case 'creationDateFirst':
query.orderBy(asc(businesses.created));
break;
case 'creationDateLast':
query.orderBy(desc(businesses.created));
break;
default:
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
break;
}
// Paginierung
query.limit(length).offset(start);
const data = await query;
const totalCount = await this.getBusinessListingsCount(criteria);
const results = data.map(r => r.business).map(r => convertDrizzleBusinessToBusiness(r));
const totalCount = await this.getBusinessListingsCount(criteria, user);
const results = data.map(r => r.business);
return {
results,
totalCount,
};
}
async getBusinessListingsCount(criteria: BusinessListingCriteria): Promise<number> {
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
const countQuery = this.conn.select({ value: count() }).from(businesses).leftJoin(schema.users, eq(businesses.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria);
const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
@@ -147,17 +185,25 @@ export class BusinessListingService {
}
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
let result = await this.conn
const conditions = [];
if (!user?.roles?.includes('ADMIN') ?? false) {
conditions.push(or(eq(businesses.email, user?.username), ne(businesses.draft, true)));
}
conditions.push(sql`${businesses.id} = ${id}`);
const result = await this.conn
.select()
.from(businesses)
.where(and(sql`${businesses.id} = ${id}`));
result = result.filter(r => !r.draft || r.imageName === emailToDirName(user?.username) || user?.roles.includes('ADMIN'));
return convertDrizzleBusinessToBusiness(result[0]) as BusinessListing;
.where(and(...conditions));
if (result.length > 0) {
return result[0] 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.imageName, emailToDirName(email)));
conditions.push(eq(businesses.email, email));
if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) {
conditions.push(ne(businesses.draft, true));
}
@@ -166,25 +212,35 @@ export class BusinessListingService {
.from(businesses)
.where(and(...conditions))) as BusinessListing[];
return listings.map(l => convertDrizzleBusinessToBusiness(l));
return listings;
}
// #### Find Favorites ########################################
async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> {
const userFavorites = await this.conn
.select()
.from(businesses)
.where(arrayContains(businesses.favoritesForUser, [user.username]));
return userFavorites;
}
// #### CREATE ########################################
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();
const validatedBusinessListing = BusinessListingSchema.parse(data);
const convertedBusinessListing = convertBusinessToDrizzleBusiness(data);
BusinessListingSchema.parse(data);
const convertedBusinessListing = data;
delete convertedBusinessListing.id;
const [createdListing] = await this.conn.insert(businesses).values(convertedBusinessListing).returning();
return convertDrizzleBusinessToBusiness(createdListing);
return createdListing;
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new BadRequestException(formattedErrors);
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;
}
@@ -194,17 +250,19 @@ export class BusinessListingService {
try {
data.updated = new Date();
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
const validatedBusinessListing = BusinessListingSchema.parse(data);
const convertedBusinessListing = convertBusinessToDrizzleBusiness(data);
BusinessListingSchema.parse(data);
const convertedBusinessListing = data;
const [updateListing] = await this.conn.update(businesses).set(convertedBusinessListing).where(eq(businesses.id, id)).returning();
return convertDrizzleBusinessToBusiness(updateListing);
return updateListing;
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new BadRequestException(formattedErrors);
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;
}
@@ -213,14 +271,13 @@ export class BusinessListingService {
async deleteListing(id: string): Promise<void> {
await this.conn.delete(businesses).where(eq(businesses.id, id));
}
// ##############################################################
// States
// ##############################################################
async getStates(): Promise<any[]> {
return await this.conn
.select({ state: businesses.state, count: sql<number>`count(${businesses.id})`.mapWith(Number) })
.from(businesses)
.groupBy(sql`${businesses.state}`)
.orderBy(sql`count desc`);
// #### DELETE Favorite ###################################
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn
.update(businesses)
.set({
favoritesForUser: sql`array_remove(${businesses.favoritesForUser}, ${user.username})`,
})
.where(sql`${businesses.id} = ${id}`);
}
}

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { BusinessListing } from 'src/models/db.model.js';
import { Logger } from 'winston';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js';
import { BusinessListingCriteria, JwtUser } from '../models/main.model.js';
import { BusinessListingService } from './business-listing.service.js';
import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-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 {
@@ -15,47 +16,52 @@ export class BusinessListingsController {
@UseGuards(OptionalJwtAuthGuard)
@Get(':id')
findById(@Request() req, @Param('id') id: string): any {
return this.listingsService.findBusinessesById(id, req.user as JwtUser);
async findById(@Request() req, @Param('id') id: string): Promise<any> {
return await this.listingsService.findBusinessesById(id, req.user as JwtUser);
}
@UseGuards(JwtAuthGuard)
@Get('favorites/all')
async findFavorites(@Request() req): Promise<any> {
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Get('user/:userid')
findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
return this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('find')
find(@Request() req, @Body() criteria: BusinessListingCriteria): any {
return this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('findTotal')
findTotal(@Body() criteria: BusinessListingCriteria): Promise<number> {
return this.listingsService.getBusinessListingsCount(criteria);
async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
return await this.listingsService.getBusinessListingsCount(criteria, req.user as JwtUser);
}
// @UseGuards(OptionalJwtAuthGuard)
// @Post('search')
// search(@Request() req, @Body() criteria: BusinessListingCriteria): any {
// return this.listingsService.searchBusinessListings(criteria.prompt);
// }
@UseGuards(OptionalJwtAuthGuard)
@Post()
create(@Body() listing: any) {
this.logger.info(`Save Listing`);
return this.listingsService.createListing(listing);
async create(@Body() listing: any) {
return await this.listingsService.createListing(listing);
}
@UseGuards(OptionalJwtAuthGuard)
@Put()
update(@Body() listing: any) {
this.logger.info(`Save Listing`);
return this.listingsService.updateBusinessListing(listing.id, listing);
async update(@Body() listing: any) {
return await this.listingsService.updateBusinessListing(listing.id, listing);
}
@Delete(':id')
deleteById(@Param('id') id: string) {
this.listingsService.deleteListing(id);
@UseGuards(OptionalJwtAuthGuard)
@Delete('listing/:id')
async deleteById(@Param('id') id: string) {
await this.listingsService.deleteListing(id);
}
@Get('states/all')
getStates(): any {
return this.listingsService.getStates();
@UseGuards(JwtAuthGuard)
@Delete('favorite/:id')
async deleteFavorite(@Request() req, @Param('id') id: string) {
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
}
}

View File

@@ -1,11 +1,12 @@
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.js';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js';
import { FileService } from '../file/file.service';
import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard';
import { CommercialPropertyListing } from '../models/db.model';
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model.js';
import { CommercialPropertyService } from './commercial-property.service.js';
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
import { CommercialPropertyService } from './commercial-property.service';
@Controller('listings/commercialProperty')
export class CommercialPropertyListingsController {
@@ -17,41 +18,54 @@ export class CommercialPropertyListingsController {
@UseGuards(OptionalJwtAuthGuard)
@Get(':id')
findById(@Request() req, @Param('id') id: string): any {
return this.listingsService.findCommercialPropertiesById(id, req.user as JwtUser);
async findById(@Request() req, @Param('id') id: string): Promise<any> {
return await this.listingsService.findCommercialPropertiesById(id, req.user as JwtUser);
}
@UseGuards(JwtAuthGuard)
@Get('favorites/all')
async findFavorites(@Request() req): Promise<any> {
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Get('user/:email')
findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
return this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('find')
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('findTotal')
findTotal(@Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
return this.listingsService.getCommercialPropertiesCount(criteria);
}
@Get('states/all')
getStates(): any {
return this.listingsService.getStates();
async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post()
async create(@Body() listing: any) {
this.logger.info(`Save Listing`);
return await this.listingsService.createListing(listing);
}
@UseGuards(OptionalJwtAuthGuard)
@Put()
async update(@Body() listing: any) {
this.logger.info(`Save Listing`);
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing);
}
@Delete(':id/:imagePath')
deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
this.listingsService.deleteListing(id);
@UseGuards(OptionalJwtAuthGuard)
@Delete('listing/:id/:imagePath')
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
await this.listingsService.deleteListing(id);
this.fileService.deleteDirectoryIfExists(imagePath);
}
@UseGuards(JwtAuthGuard)
@Delete('favorite/:id')
async deleteFavorite(@Request() req, @Param('id') id: string) {
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
}
}

View File

@@ -1,16 +1,16 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { and, count, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { ZodError } from 'zod';
import * as schema from '../drizzle/schema.js';
import { commercials, PG_CONNECTION } from '../drizzle/schema.js';
import { FileService } from '../file/file.service.js';
import { GeoService } from '../geo/geo.service.js';
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model.js';
import { CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js';
import { convertCommercialToDrizzleCommercial, convertDrizzleCommercialToCommercial, getDistanceQuery } from '../utils.js';
import * as schema from '../drizzle/schema';
import { commercials, PG_CONNECTION } from '../drizzle/schema';
import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service';
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
import { getDistanceQuery } from '../utils';
@Injectable()
export class CommercialPropertyService {
@@ -20,14 +20,14 @@ export class CommercialPropertyService {
private fileService?: FileService,
private geoService?: GeoService,
) {}
private getWhereConditions(criteria: CommercialPropertyListingCriteria): SQL[] {
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = [];
if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(ilike(schema.commercials.city, `%${criteria.city}%`));
whereConditions.push(sql`${commercials.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);
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(commercials, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
}
if (criteria.types && criteria.types.length > 0) {
@@ -35,7 +35,7 @@ export class CommercialPropertyService {
}
if (criteria.state) {
whereConditions.push(eq(schema.commercials.state, criteria.state));
whereConditions.push(sql`${schema.commercials.location}->>'state' = ${criteria.state}`);
}
if (criteria.minPrice) {
@@ -49,7 +49,10 @@ export class CommercialPropertyService {
if (criteria.title) {
whereConditions.push(or(ilike(schema.commercials.title, `%${criteria.title}%`), ilike(schema.commercials.description, `%${criteria.title}%`)));
}
whereConditions.push(and(eq(schema.users.customerType, 'professional')));
if (!user?.roles?.includes('ADMIN') ?? false) {
whereConditions.push(or(eq(commercials.email, user?.username), ne(commercials.draft, true)));
}
// whereConditions.push(and(eq(schema.users.customerType, 'professional')));
return whereConditions;
}
// #### Find by criteria ########################################
@@ -57,28 +60,46 @@ export class CommercialPropertyService {
const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12;
const query = this.conn.select({ commercial: commercials }).from(commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria);
const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
query.where(whereClause);
}
// Sortierung
switch (criteria.sortBy) {
case 'priceAsc':
query.orderBy(asc(commercials.price));
break;
case 'priceDesc':
query.orderBy(desc(commercials.price));
break;
case 'creationDateFirst':
query.orderBy(asc(commercials.created));
break;
case 'creationDateLast':
query.orderBy(desc(commercials.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 => r.commercial).map(r => convertDrizzleCommercialToCommercial(r));
const totalCount = await this.getCommercialPropertiesCount(criteria);
const results = data.map(r => r.commercial);
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
return {
results,
totalCount,
};
}
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria): Promise<number> {
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> {
const countQuery = this.conn.select({ value: count() }).from(schema.commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria);
const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
@@ -91,18 +112,26 @@ export class CommercialPropertyService {
// #### Find by ID ########################################
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
let result = await this.conn
const conditions = [];
if (!user?.roles?.includes('ADMIN') ?? false) {
conditions.push(or(eq(commercials.email, user?.username), ne(commercials.draft, true)));
}
conditions.push(sql`${commercials.id} = ${id}`);
const result = await this.conn
.select()
.from(commercials)
.where(and(sql`${commercials.id} = ${id}`));
result = result.filter(r => !r.draft || r.imagePath === emailToDirName(user?.username) || user?.roles.includes('ADMIN'));
return convertDrizzleCommercialToCommercial(result[0]) as CommercialPropertyListing;
.where(and(...conditions));
if (result.length > 0) {
return result[0] 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.imagePath, emailToDirName(email)));
conditions.push(eq(commercials.email, email));
if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) {
conditions.push(ne(commercials.draft, true));
}
@@ -110,7 +139,15 @@ export class CommercialPropertyService {
.select()
.from(commercials)
.where(and(...conditions))) as CommercialPropertyListing[];
return listings.map(l => convertDrizzleCommercialToCommercial(l)) as CommercialPropertyListing[];
return listings as CommercialPropertyListing[];
}
// #### Find Favorites ########################################
async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> {
const userFavorites = await this.conn
.select()
.from(commercials)
.where(arrayContains(commercials.favoritesForUser, [user.username]));
return userFavorites;
}
// #### Find by imagePath ########################################
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
@@ -118,24 +155,27 @@ export class CommercialPropertyService {
.select()
.from(commercials)
.where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`));
return convertDrizzleCommercialToCommercial(result[0]) as CommercialPropertyListing;
return result[0] as CommercialPropertyListing;
}
// #### CREATE ########################################
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
try {
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
data.updated = new Date();
const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data);
const convertedCommercialPropertyListing = convertCommercialToDrizzleCommercial(data);
CommercialPropertyListingSchema.parse(data);
const convertedCommercialPropertyListing = data;
delete convertedCommercialPropertyListing.id;
const [createdListing] = await this.conn.insert(commercials).values(convertedCommercialPropertyListing).returning();
return convertDrizzleCommercialToCommercial(createdListing);
return createdListing;
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new BadRequestException(formattedErrors);
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;
}
@@ -145,23 +185,25 @@ export class CommercialPropertyService {
try {
data.updated = new Date();
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data);
CommercialPropertyListingSchema.parse(data);
const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId));
let difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x)));
const difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x)));
if (difference.length > 0) {
this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`);
data.imageOrder = imageOrder;
}
const convertedCommercialPropertyListing = convertCommercialToDrizzleCommercial(data);
const convertedCommercialPropertyListing = data;
const [updateListing] = await this.conn.update(commercials).set(convertedCommercialPropertyListing).where(eq(commercials.id, id)).returning();
return convertDrizzleCommercialToCommercial(updateListing);
return updateListing;
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new BadRequestException(formattedErrors);
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;
}
@@ -186,14 +228,23 @@ export class CommercialPropertyService {
async deleteListing(id: string): Promise<void> {
await this.conn.delete(commercials).where(eq(commercials.id, id));
}
// #### DELETE Favorite ###################################
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn
.update(commercials)
.set({
favoritesForUser: sql`array_remove(${commercials.favoritesForUser}, ${user.username})`,
})
.where(sql`${commercials.id} = ${id}`);
}
// ##############################################################
// States
// ##############################################################
async getStates(): Promise<any[]> {
return await this.conn
.select({ state: commercials.state, count: sql<number>`count(${commercials.id})`.mapWith(Number) })
.from(commercials)
.groupBy(sql`${commercials.state}`)
.orderBy(sql`count desc`);
}
// async getStates(): Promise<any[]> {
// return await this.conn
// .select({ state: commercials.state, count: sql<number>`count(${commercials.id})`.mapWith(Number) })
// .from(commercials)
// .groupBy(sql`${commercials.state}`)
// .orderBy(sql`count desc`);
// }
}

View File

@@ -1,17 +1,17 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { DrizzleModule } from '../drizzle/drizzle.module.js';
import { FileService } from '../file/file.service.js';
import { UserService } from '../user/user.service.js';
import { BrokerListingsController } from './broker-listings.controller.js';
import { BusinessListingsController } from './business-listings.controller.js';
import { CommercialPropertyListingsController } from './commercial-property-listings.controller.js';
import { 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 { GeoModule } from '../geo/geo.module.js';
import { GeoService } from '../geo/geo.service.js';
import { BusinessListingService } from './business-listing.service.js';
import { CommercialPropertyService } from './commercial-property.service.js';
import { UnknownListingsController } from './unknown-listings.controller.js';
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],

View File

@@ -1,18 +1,25 @@
import { Controller, Inject } from '@nestjs/common';
import { Controller, Get, Inject, Param, Request, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard';
import { BusinessListingService } from './business-listing.service';
import { CommercialPropertyService } from './commercial-property.service';
@Controller('listings/undefined')
export class UnknownListingsController {
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
private readonly businessListingsService: BusinessListingService,
private readonly propertyListingsService: CommercialPropertyService,
) {}
// @Get(':id')
// async findById(@Param('id') id: string): Promise<any> {
// const result = await this.listingsService.findById(id, businesses);
// if (result) {
// return result;
// } else {
// return await this.listingsService.findById(id, commercials);
// }
// }
@UseGuards(OptionalJwtAuthGuard)
@Get(':id')
async findById(@Request() req, @Param('id') id: string): Promise<any> {
try {
return await this.businessListingsService.findBusinessesById(id, req.user);
} catch (error) {
return await this.propertyListingsService.findCommercialPropertiesById(id, req.user);
}
}
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, Inject, Post, Request, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard';
import { LogMessage } from '../models/main.model';
@Controller('log')
export class LogController {
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
@UseGuards(OptionalJwtAuthGuard)
@Post()
log(@Request() req, @Body() message: LogMessage) {
if (message.severity === 'info') {
this.logger.info(message.text);
} else {
this.logger.error(message.text);
}
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { LogController } from './log.controller';
@Module({
controllers: [LogController],
})
export class LogModule {}

View File

@@ -0,0 +1,14 @@
import { registerAs } from '@nestjs/config';
export default registerAs('mail', () => ({
host: 'email-smtp.us-east-2.amazonaws.com',
port: 587,
secure: false,
auth: {
user: process.env.AMAZON_USER,
pass: process.env.AMAZON_PASSWORD,
},
defaults: {
from: '"No Reply" <noreply@example.com>',
},
}));

View File

@@ -1,16 +1,32 @@
import { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { OptionalJwtAuthGuard } from 'src/jwt-auth/optional-jwt-auth.guard';
import { ShareByEMail, User } from 'src/models/db.model';
import { ErrorResponse, MailInfo } from '../models/main.model';
import { MailService } from './mail.service.js';
import { MailService } from './mail.service';
@Controller('mail')
export class MailController {
constructor(private mailService: MailService) {}
@UseGuards(OptionalJwtAuthGuard)
@Post()
sendEMail(@Body() mailInfo: MailInfo): Promise<void | ErrorResponse> {
async sendEMail(@Body() mailInfo: MailInfo): Promise<void | ErrorResponse> {
if (mailInfo.listing) {
return this.mailService.sendInquiry(mailInfo);
return await this.mailService.sendInquiry(mailInfo);
} else {
return this.mailService.sendRequest(mailInfo);
return await this.mailService.sendRequest(mailInfo);
}
}
@UseGuards(OptionalJwtAuthGuard)
@Post('subscriptionConfirmation')
async sendSubscriptionConfirmation(@Body() user: User): Promise<void | ErrorResponse> {
return await this.mailService.sendSubscriptionConfirmation(user);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('send2Friend')
async send2Friend(@Body() shareByEMail: ShareByEMail): Promise<void | ErrorResponse> {
return await this.mailService.send2Friend(shareByEMail);
}
}

View File

@@ -1,45 +1,75 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter.js';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { Module } from '@nestjs/common';
import path, { join } from 'path';
import { fileURLToPath } from 'url';
import { DrizzleModule } from '../drizzle/drizzle.module.js';
import { FileService } from '../file/file.service.js';
import { GeoModule } from '../geo/geo.module.js';
import { GeoService } from '../geo/geo.service.js';
import { UserModule } from '../user/user.module.js';
import { UserService } from '../user/user.service.js';
import { MailController } from './mail.controller.js';
import { MailService } from './mail.service.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const user = process.env.amazon_user;
const password = process.env.amazon_password;
import { join } from 'path';
import { DrizzleModule } from '../drizzle/drizzle.module';
import { FileService } from '../file/file.service';
import { GeoModule } from '../geo/geo.module';
import { GeoService } from '../geo/geo.service';
import { UserModule } from '../user/user.module';
import { UserService } from '../user/user.service';
import { MailController } from './mail.controller';
import { MailService } from './mail.service';
// const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);
@Module({
imports: [
DrizzleModule,
UserModule,
GeoModule,
MailerModule.forRoot({
transport: {
host: 'email-smtp.us-east-2.amazonaws.com',
secure: false,
port: 587,
auth: {
user: 'AKIAU6GDWVAQ2QNFLNWN',
pass: 'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7',
// ConfigModule.forFeature(mailConfig),
// MailerModule.forRoot({
// transport: {
// host: 'email-smtp.us-east-2.amazonaws.com',
// secure: false,
// port: 587,
// auth: {
// user: user, //'AKIAU6GDWVAQ2QNFLNWN',
// pass: password, //'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7',
// },
// },
// defaults: {
// from: '"No Reply" <noreply@example.com>',
// },
// template: {
// dir: join(__dirname, 'templates'),
// adapter: new HandlebarsAdapter({
// eq: function (a, b) {
// return a === b;
// },
// }),
// options: {
// strict: true,
// },
// },
// }),
MailerModule.forRootAsync({
useFactory: () => ({
transport: {
host: 'email-smtp.us-east-2.amazonaws.com',
secure: false,
port: 587,
auth: {
user: process.env.AMAZON_USER, //'AKIAU6GDWVAQ2QNFLNWN',
pass: process.env.AMAZON_PASSWORD, //'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7',
},
},
},
defaults: {
from: '"No Reply" <noreply@example.com>',
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(), // or new PugAdapter() or new EjsAdapter()
options: {
strict: true,
defaults: {
from: '"No Reply" <noreply@example.com>',
},
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter({
eq: function (a, b) {
return a === b;
},
}),
options: {
strict: true,
},
},
}),
}),
],
providers: [MailService, UserService, FileService, GeoService],

View File

@@ -1,13 +1,12 @@
import { MailerService } from '@nestjs-modules/mailer';
import { BadRequestException, Injectable } from '@nestjs/common';
import path, { join } from 'path';
import { fileURLToPath } from 'url';
import { join } from 'path';
import { ZodError } from 'zod';
import { SenderSchema } from '../models/db.model.js';
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model.js';
import { UserService } from '../user/user.service.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { SenderSchema, ShareByEMail, ShareByEMailSchema, User } from '../models/db.model';
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model';
import { UserService } from '../user/user.service';
// const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);
@Injectable()
export class MailService {
@@ -18,7 +17,7 @@ export class MailService {
async sendInquiry(mailInfo: MailInfo): Promise<void | ErrorResponse> {
try {
const validatedSender = SenderSchema.parse(mailInfo.sender);
SenderSchema.parse(mailInfo.sender);
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
@@ -55,7 +54,7 @@ export class MailService {
}
async sendRequest(mailInfo: MailInfo): Promise<void | ErrorResponse> {
try {
const validatedSender = SenderSchema.parse(mailInfo.sender);
SenderSchema.parse(mailInfo.sender);
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
@@ -81,4 +80,48 @@ export class MailService {
},
});
}
async sendSubscriptionConfirmation(user: User): Promise<void> {
await this.mailerService.sendMail({
to: user.email,
from: `"Bizmatch Support Team" <info@bizmatch.net>`,
subject: `Subscription Confirmation`,
//template: './inquiry', // `.hbs` extension is appended automatically
template: join(__dirname, '../..', 'mail/templates/subscriptionConfirmation.hbs'),
context: {
// ✏️ filling curly brackets with content
firstname: user.firstname,
lastname: user.lastname,
subscriptionPlan: user.subscriptionPlan,
},
});
}
async send2Friend(shareByEMail: ShareByEMail): Promise<void | ErrorResponse> {
try {
ShareByEMailSchema.parse(shareByEMail);
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new BadRequestException(formattedErrors);
}
throw error;
}
await this.mailerService.sendMail({
to: shareByEMail.recipientEmail,
from: `"Bizmatch.net" <info@bizmatch.net>`,
subject: `${shareByEMail.type === 'business' ? 'Business' : 'Commercial Property'} For Sale: ${shareByEMail.listingTitle}`,
//template: './inquiry', // `.hbs` extension is appended automatically
template: join(__dirname, '../..', 'mail/templates/send2Friend.hbs'),
context: {
name: shareByEMail.yourName,
email: shareByEMail.yourEmail,
listingTitle: shareByEMail.listingTitle,
url: shareByEMail.url,
id: shareByEMail.id,
type: shareByEMail.type,
},
});
}
}

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notification</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333333;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #dddddd;
padding-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #333333;
}
.content {
margin-top: 20px;
}
.content p {
font-size: 16px;
line-height: 1.6;
}
.content .plan-info {
font-weight: bold;
color: #0056b3;
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 14px;
color: #888888;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>Notification</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>Your friend {{name}} ({{email}}) believed you might find this <b>{{#if (eq type "commercialProperty")}}Commercial Property{{else if (eq type "business")}}Business{{/if}} for sale listing </b> on <a href="{{url}}">bizmatch.net</a> interesting.</p>
<span class="info-value"><a href="{{url}}/listing/{{id}}">{{listingTitle}}</a></span>
<p>Bizmatch is one of the most reliable platforms for buying and selling businesses.</p>
<p>Best regards,</p>
<p>The Bizmatch Support Team</p>
</div>
<div class="footer">
<p>© 2024 Bizmatch. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subscription Confirmation</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333333;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #dddddd;
padding-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #333333;
}
.content {
margin-top: 20px;
}
.content p {
font-size: 16px;
line-height: 1.6;
}
.content .plan-info {
font-weight: bold;
color: #0056b3;
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 14px;
color: #888888;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>Subscription Confirmation</h1>
</div>
<div class="content">
<p>Dear {{firstname}} {{lastname}},</p>
<p>Thank you for subscribing to our service! We are thrilled to have you on board.</p>
<p>Your subscription details are as follows:</p>
<p><span class="plan-info">{{#if (eq subscriptionPlan "professional")}}Professional Plan (CPA, Attorney, Title Company, Surveyor, Appraiser){{else if (eq subscriptionPlan "broker")}}Business Broker Plan{{/if}}</span></p>
<p>If you have any questions or need further assistance, please feel free to contact our support team at any time.</p>
<p>Thank you for choosing Bizmatch!</p>
<p>Best regards,</p>
<p>The Bizmatch Support Team</p>
</div>
<div class="footer">
<p>© 2024 Bizmatch. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,18 +1,25 @@
import { LoggerService } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import bodyParser from 'body-parser';
import express from 'express';
import { AppModule } from './app.module.js';
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' }));
app.setGlobalPrefix('bizmatch');
app.enableCors({
origin: '*',
//origin: 'http://localhost:4200', // Die URL Ihrer Angular-App
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
allowedHeaders: 'Content-Type, Accept, Authorization',
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
});
//origin: 'http://localhost:4200',
await app.listen(3000);
}
bootstrap();

View File

@@ -17,20 +17,25 @@ export interface UserData {
hasCompanyLogo?: boolean;
licensedIn?: string[];
gender?: 'male' | 'female';
customerType?: 'buyer' | 'broker' | 'professional';
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' | 'professional';
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', 'professional']);
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 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',
@@ -102,38 +107,61 @@ const USStates = z.enum([
'WY',
]);
export const AreasServedSchema = z.object({
county: z.string().nonempty('County is required'),
state: z.string().nonempty('State is required'),
county: z.string().optional().nullable(),
state: z
.string()
.nullable()
.refine(val => val !== null && val !== '', {
message: 'State is required',
}),
});
export const LicensedInSchema = z.object({
registerNo: z.string().nonempty('Registration number is required'),
state: z.string().nonempty('State is required'),
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({
city: z.string(),
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',
},
),
});
const phoneRegex = /^\(\d{3}\)\s\d{3}-\d{4}$/;
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.name && !data.county) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'You need to select either a city or a county',
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(),
@@ -145,7 +173,7 @@ export const UserSchema = z
companyName: z.string().optional().nullable(),
companyOverview: z.string().optional().nullable(),
companyWebsite: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
companyLocation: GeoSchema.optional().nullable(),
location: GeoSchema.optional().nullable(),
offeredServices: z.string().optional().nullable(),
areasServed: z.array(AreasServedSchema).optional().nullable(),
hasProfile: z.boolean().optional().nullable(),
@@ -156,6 +184,8 @@ export const UserSchema = z
customerSubType: CustomerSubTypeEnum.optional().nullable(),
created: z.date().optional().nullable(),
updated: z.date().optional().nullable(),
subscriptionId: z.string().optional().nullable(),
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
})
.superRefine((data, ctx) => {
if (data.customerType === 'professional') {
@@ -199,7 +229,7 @@ export const UserSchema = z
});
}
if (!data.companyLocation) {
if (!data.location) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Company location is required for professional customers',
@@ -244,7 +274,7 @@ export const BusinessListingSchema = z.object({
established: z.number().int().min(1800).max(2030).optional().nullable(),
internalListingNumber: z.number().int().positive().optional().nullable(),
reasonForSale: z.string().min(5).optional().nullable(),
brokerLicencing: z.string().min(5).optional().nullable(),
brokerLicencing: z.string().optional().nullable(),
internals: z.string().min(5).optional().nullable(),
imageName: z.string().optional().nullable(),
created: z.date(),
@@ -288,3 +318,30 @@ export const SenderSchema = z.object({
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: ListingsCategoryEnum,
});
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.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
});
export type ListingEvent = z.infer<typeof ListingEventSchema>;

View File

@@ -1,4 +1,6 @@
import { BusinessListing, CommercialPropertyListing, Sender, User } from './db.model.js';
import Stripe from 'stripe';
import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model';
import { State } from './server.model';
export interface StatesResult {
state: string;
@@ -9,6 +11,12 @@ 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;
@@ -59,8 +67,9 @@ export interface ListCriteria {
page: number;
types: string[];
state: string;
city: string;
city: GeoResult;
prompt: string;
sortBy: SortByOptions;
searchType: 'exact' | 'radius';
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
radius: number;
@@ -91,8 +100,7 @@ export interface CommercialPropertyListingCriteria extends ListCriteria {
criteriaType: 'commercialPropertyListings';
}
export interface UserListingCriteria extends ListCriteria {
firstname: string;
lastname: string;
brokerName: string;
companyName: string;
counties: string[];
criteriaType: 'brokerListings';
@@ -112,6 +120,7 @@ export interface KeycloakUser {
requiredActions?: any[];
notBefore?: number;
access?: Access;
attributes?: Attributes;
}
export interface JwtUser {
userId: string;
@@ -120,6 +129,10 @@ export interface JwtUser {
lastname: string;
roles: string[];
}
interface Attributes {
[key: string]: any;
priceID: any;
}
export interface Access {
manageGroupMembership: boolean;
view: boolean;
@@ -166,6 +179,7 @@ export interface JwtToken {
family_name: string;
email: string;
user_id: string;
price_id: string;
}
export interface JwtPayload {
sub: string;
@@ -224,24 +238,46 @@ export interface UploadParams {
}
export interface GeoResult {
id: number;
city: string;
name: string;
street?: string;
housenumber?: string;
county?: string;
zipCode?: number;
state: string;
// state_code: string;
latitude: number;
longitude: number;
}
export interface CityAndStateResult {
interface CityResult {
id: number;
name: string;
type: string;
state: string;
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 function isEmpty(value: any): boolean {
// Check for undefined or null
if (value === undefined || value === null) {
@@ -281,7 +317,7 @@ export interface ValidationMessage {
field: string;
message: string;
}
export function createDefaultUser(email: string, firstname: string, lastname: string): User {
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
return {
id: undefined,
email,
@@ -292,7 +328,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
companyName: null,
companyOverview: null,
companyWebsite: null,
companyLocation: null,
location: null,
offeredServices: null,
areasServed: [],
hasProfile: false,
@@ -303,6 +339,8 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
customerSubType: null,
created: new Date(),
updated: new Date(),
subscriptionId: null,
subscriptionPlan: subscriptionPlan,
};
}
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
@@ -352,3 +390,25 @@ export function createDefaultBusinessListing(): BusinessListing {
listingsCategory: 'business',
};
}
export type StripeSubscription = Stripe.Subscription;
export type StripeUser = Stripe.Customer;
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;
stripeUser?: StripeUser;
stripeSubscription?: StripeSubscription;
}
export interface RealIpInfo {
ip: string;
countryCode?: string;
}

View File

@@ -70,3 +70,34 @@ export interface CountyRequest {
prefix: string;
states: string[];
}
export interface Address {
house_number: string;
road: string;
quarter: string;
suburb: string;
city: string;
county: string;
state: string;
ISO3166_2_lvl4: string;
postcode: string;
country: string;
country_code: string;
}
export interface Place {
place_id: number;
licence: string;
osm_type: string;
osm_id: number;
lat: string;
lon: string;
class: string;
type: string;
place_rank: number;
importance: number;
addresstype: string;
name: string;
display_name: string;
address: Address;
boundingbox: [string, string, string, string];
}

View File

@@ -0,0 +1,77 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, HttpStatus, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { AdminAuthGuard } from 'src/jwt-auth/admin-auth.guard';
import { OptionalJwtAuthGuard } from 'src/jwt-auth/optional-jwt-auth.guard';
import { Checkout } from 'src/models/main.model';
import Stripe from 'stripe';
import { PaymentService } from './payment.service';
@Controller('payment')
export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
// @Post()
// async createSubscription(@Body() subscriptionData: any) {
// return this.paymentService.createSubscription(subscriptionData);
// }
@UseGuards(AdminAuthGuard)
@Get('user/all')
async getAllStripeCustomer(): Promise<Stripe.Customer[]> {
return await this.paymentService.getAllStripeCustomer();
}
@UseGuards(AdminAuthGuard)
@Get('subscription/all')
async getAllStripeSubscriptions(): Promise<Stripe.Subscription[]> {
return await this.paymentService.getAllStripeSubscriptions();
}
@UseGuards(AdminAuthGuard)
@Get('paymentmethod/:email')
async getStripePaymentMethods(@Param('email') email: string): Promise<Stripe.PaymentMethod[]> {
return await this.paymentService.getStripePaymentMethod(email);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('create-checkout-session')
async createCheckoutSession(@Body() checkout: Checkout) {
return await this.paymentService.createCheckoutSession(checkout);
}
@Post('webhook')
async handleWebhook(@Req() req: Request, @Res() res: Response): Promise<void> {
const signature = req.headers['stripe-signature'] as string;
try {
// Konvertieren Sie den req.body Buffer in einen lesbaren String
const payload = req.body instanceof Buffer ? req.body.toString('utf8') : req.body;
const event = await this.paymentService.constructEvent(payload, signature);
// const event = await this.paymentService.constructEvent(req.body, signature);
if (event.type === 'checkout.session.completed') {
await this.paymentService.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
}
res.status(200).send('Webhook received');
} catch (error) {
console.error(`Webhook Error: ${error.message}`);
throw new HttpException('Webhook Error', HttpStatus.BAD_REQUEST);
}
}
@UseGuards(OptionalJwtAuthGuard)
@Get('subscriptions/:email')
async findSubscriptionsById(@Param('email') email: string): Promise<any> {
return await this.paymentService.getSubscription(email);
}
/**
* Endpoint zum Löschen eines Stripe-Kunden.
* Beispiel: DELETE /stripe/customer/cus_12345
*/
@UseGuards(AdminAuthGuard)
@Delete('customer/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteCustomer(@Param('id') customerId: string): Promise<void> {
await this.paymentService.deleteCustomerCompletely(customerId);
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AuthService } from '../auth/auth.service';
import { DrizzleModule } from '../drizzle/drizzle.module';
import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service';
import { MailModule } from '../mail/mail.module';
import { MailService } from '../mail/mail.service';
import { UserModule } from '../user/user.module';
import { UserService } from '../user/user.service';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
@Module({
imports: [DrizzleModule, UserModule, MailModule, AuthModule],
providers: [PaymentService, UserService, MailService, FileService, GeoService, AuthService],
controllers: [PaymentController],
})
export class PaymentModule {}

View File

@@ -0,0 +1,218 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import Stripe from 'stripe';
import { Logger } from 'winston';
import { AuthService } from '../auth/auth.service';
import * as schema from '../drizzle/schema';
import { PG_CONNECTION } from '../drizzle/schema';
import { MailService } from '../mail/mail.service';
import { Checkout } from '../models/main.model';
import { UserService } from '../user/user.service';
export interface BillingAddress {
country: string;
state: string;
}
@Injectable()
export class PaymentService {
private stripe: Stripe;
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private readonly userService: UserService,
private readonly mailService: MailService,
private readonly authService: AuthService,
) {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
});
}
async createCheckoutSession(checkout: Checkout) {
try {
let customerId;
const existingCustomers = await this.stripe.customers.list({
email: checkout.email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
// Kunde existiert
customerId = existingCustomers.data[0].id;
} else {
// Kunde existiert nicht, neuen Kunden erstellen
const newCustomer = await this.stripe.customers.create({
email: checkout.email,
name: checkout.name,
shipping: {
name: checkout.name,
address: {
city: '',
state: '',
country: 'US',
},
},
});
customerId = newCustomer.id;
}
const price = await this.stripe.prices.retrieve(checkout.priceId);
if (price.product) {
const product = await this.stripe.products.retrieve(price.product as string);
const session = await this.stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: checkout.priceId,
quantity: 1,
},
],
success_url: `${process.env.WEB_HOST}/success`,
cancel_url: `${process.env.WEB_HOST}/pricing`,
customer: customerId,
shipping_address_collection: {
allowed_countries: ['US'],
},
client_reference_id: btoa(checkout.name),
locale: 'en',
subscription_data: {
trial_end: Math.floor(new Date().setMonth(new Date().getMonth() + 3) / 1000),
metadata: { plan: product.name },
},
});
return session;
} else {
return null;
}
} catch (e) {
throw new BadRequestException(`error during checkout: ${e}`);
}
}
async constructEvent(body: string | Buffer, signature: string) {
return this.stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
}
async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
try {
const keycloakUsers = await this.authService.getUsers();
const keycloakUser = keycloakUsers.find(u => u.email === session.customer_details.email);
const user = await this.userService.getUserByMail(session.customer_details.email, {
userId: keycloakUser.id,
firstname: keycloakUser.firstName,
lastname: keycloakUser.lastName,
username: keycloakUser.email,
roles: [],
});
user.subscriptionId = session.subscription as string;
const subscription = await this.stripe.subscriptions.retrieve(user.subscriptionId);
user.customerType = 'professional';
if (subscription.metadata['plan'] === 'Broker Plan') {
user.customerSubType = 'broker';
}
user.subscriptionPlan = subscription.metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional'; //session.metadata['subscriptionPlan'] as 'free' | 'professional' | 'broker';
await this.userService.saveUser(user, false);
await this.mailService.sendSubscriptionConfirmation(user);
} catch (error) {
this.logger.error(error);
}
}
async getSubscription(email: string): Promise<Stripe.Subscription[]> {
const existingCustomers = await this.stripe.customers.list({
email: email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
const subscriptions = await this.stripe.subscriptions.list({
customer: existingCustomers.data[0].id,
status: 'all', // Optional: Gibt Abos in allen Status zurück, wie 'active', 'canceled', etc.
limit: 20, // Optional: Begrenze die Anzahl der zurückgegebenen Abonnements
});
return subscriptions.data.filter(s => s.status === 'active' || s.status === 'trialing');
} else {
return [];
}
}
/**
* Ruft alle Stripe-Kunden ab, indem die Paginierung gehandhabt wird.
* @returns Ein Array von Stripe.Customer Objekten.
*/
async getAllStripeCustomer(): Promise<Stripe.Customer[]> {
const allCustomers: Stripe.Customer[] = [];
let hasMore = true;
let startingAfter: string | undefined = undefined;
try {
while (hasMore) {
const response = await this.stripe.customers.list({
limit: 100, // Maximale Anzahl pro Anfrage
starting_after: startingAfter,
});
allCustomers.push(...response.data);
hasMore = response.has_more;
if (hasMore && response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
}
return allCustomers;
} catch (error) {
console.error('Fehler beim Abrufen der Stripe-Kunden:', error);
throw new Error('Kunden konnten nicht abgerufen werden.');
}
}
async getAllStripeSubscriptions(): Promise<Stripe.Subscription[]> {
const allSubscriptions: Stripe.Subscription[] = [];
const response = await this.stripe.subscriptions.list({
limit: 100,
});
allSubscriptions.push(...response.data);
return allSubscriptions;
}
async getStripePaymentMethod(email: string): Promise<Stripe.PaymentMethod[]> {
const existingCustomers = await this.stripe.customers.list({
email: email,
limit: 1,
});
const allPayments: Stripe.PaymentMethod[] = [];
if (existingCustomers.data.length > 0) {
const response = await this.stripe.paymentMethods.list({
customer: existingCustomers.data[0].id,
limit: 10,
});
allPayments.push(...response.data);
}
return allPayments;
}
async deleteCustomerCompletely(customerId: string): Promise<void> {
try {
// 1. Abonnements kündigen und löschen
const subscriptions = await this.stripe.subscriptions.list({
customer: customerId,
limit: 100,
});
for (const subscription of subscriptions.data) {
await this.stripe.subscriptions.cancel(subscription.id);
this.logger.info(`Abonnement ${subscription.id} gelöscht.`);
}
// 2. Zahlungsmethoden entfernen
const paymentMethods = await this.stripe.paymentMethods.list({
customer: customerId,
type: 'card',
});
for (const paymentMethod of paymentMethods.data) {
await this.stripe.paymentMethods.detach(paymentMethod.id);
this.logger.info(`Zahlungsmethode ${paymentMethod.id} entfernt.`);
}
// 4. Kunden löschen
await this.stripe.customers.del(customerId);
this.logger.info(`Kunde ${customerId} erfolgreich gelöscht.`);
} catch (error) {
this.logger.error(`Fehler beim Löschen des Kunden ${customerId}:`, error);
throw new InternalServerErrorException('Fehler beim Löschen des Stripe-Kunden.');
}
}
}

View File

@@ -1,25 +1,42 @@
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { getRealIpInfo } from 'src/utils/ip.util';
@Injectable()
export class RequestDurationMiddleware implements NestMiddleware {
private readonly logger = new Logger(RequestDurationMiddleware.name);
constructor(private readonly cls: ClsService) {}
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () => {
// const duration = Date.now() - start;
// this.logger.log(`${req.method} ${req.url} - ${duration}ms`);
const duration = Date.now() - start;
let logMessage = `${req.method} ${req.url} - ${duration}ms`;
const { ip, countryCode } = getRealIpInfo(req);
if (req.method === 'POST' || req.method === 'PUT') {
const body = JSON.stringify(req.body);
logMessage += ` - Body: ${body}`;
}
// Setze die IP-Adresse und den Ländercode im CLS-Kontext
try {
this.cls.set('ip', ip);
this.cls.set('countryCode', countryCode);
} catch (error) {
this.logger.error('Failed to set CLS context', error);
}
// const start = Date.now();
// this.logger.log(`Entering ${req.method} ${req.originalUrl} from ${ip}`);
// res.on('finish', () => {
// const duration = Date.now() - start;
// const userEmail = this.cls.get('userEmail') || 'unknown';
// let logMessage = `${req.method} ${req.originalUrl} - ${duration}ms - IP: ${ip} - User: ${userEmail}`;
// if (req.method === 'POST' || req.method === 'PUT') {
// const body = JSON.stringify(req.body);
// logMessage += ` - Incoming Body: ${body}`;
// }
// this.logger.log(logMessage);
// });
this.logger.log(logMessage);
});
next();
}
}

View File

@@ -1,9 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { SelectOptionsService } from './select-options.service.js';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { OptionalJwtAuthGuard } from 'src/jwt-auth/optional-jwt-auth.guard';
import { SelectOptionsService } from './select-options.service';
@Controller('select-options')
export class SelectOptionsController {
constructor(private selectOptionsService: SelectOptionsService) {}
@UseGuards(OptionalJwtAuthGuard)
@Get()
getSelectOption(): any {
return {
@@ -15,6 +18,7 @@ export class SelectOptionsController {
typesOfCommercialProperty: this.selectOptionsService.typesOfCommercialProperty,
customerSubTypes: this.selectOptionsService.customerSubTypes,
distances: this.selectOptionsService.distances,
sortByOptions: this.selectOptionsService.sortByOptions,
};
}
}

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { SelectOptionsController } from './select-options.controller.js';
import { SelectOptionsService } from './select-options.service.js';
import { SelectOptionsController } from './select-options.controller';
import { SelectOptionsService } from './select-options.service';
@Module({
controllers: [SelectOptionsController],
providers: [SelectOptionsService]
})
controllers: [SelectOptionsController],
providers: [SelectOptionsService],
})
export class SelectOptionsModule {}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ImageType, KeyValue, KeyValueStyle } from '../models/main.model.js';
import { ImageType, KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../models/main.model';
@Injectable()
export class SelectOptionsService {
@@ -35,7 +35,19 @@ export class SelectOptionsService {
{ name: '$1M', value: '1000000' },
{ name: '$5M', value: '5000000' },
];
public sortByOptions: Array<KeyValueAsSortBy> = [
{ name: 'Price Asc', value: 'priceAsc', type: 'listing' },
{ name: 'Price Desc', value: 'priceDesc', type: 'listing' },
{ name: 'Sales Revenue Asc', value: 'srAsc', type: 'business' },
{ name: 'Sales Revenue Desc', value: 'srDesc', type: 'business' },
{ name: 'Cash Flow Asc', value: 'cfAsc', type: 'business' },
{ name: 'Cash Flow Desc', value: 'cfDesc', type: 'business' },
{ name: 'Creation Date First', value: 'creationDateFirst', type: 'listing' },
{ name: 'Creation Date Last', value: 'creationDateLast', type: 'listing' },
{ name: 'Name Asc', value: 'nameAsc', type: 'professional' },
{ name: 'Name Desc', value: 'nameDesc', type: 'professional' },
{ name: 'Sort', value: null, selectName: 'Default Sorting' },
];
public distances: Array<KeyValue> = [
{ name: '5 miles', value: '5' },
{ name: '20 miles', value: '20' },
@@ -52,6 +64,7 @@ export class SelectOptionsService {
];
public customerTypes: Array<KeyValue> = [
{ name: 'Buyer', value: 'buyer' },
{ name: 'Commercial Property Seller', value: 'seller' },
{ name: 'Professional', value: 'professional' },
];
public customerSubTypes: Array<KeyValue> = [

View File

@@ -1,11 +1,14 @@
import { Body, Controller, Get, Inject, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Inject, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { AdminAuthGuard } from 'src/jwt-auth/admin-auth.guard';
import { Logger } from 'winston';
import { FileService } from '../file/file.service.js';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js';
import { ZodError } from 'zod';
import { FileService } from '../file/file.service';
import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard';
import { User } from '../models/db.model';
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model.js';
import { UserService } from './user.service.js';
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
@@ -14,52 +17,70 @@ export class UserController {
private fileService: FileService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {}
@UseGuards(OptionalJwtAuthGuard)
@Get()
findByMail(@Request() req, @Query('mail') mail: string): any {
this.logger.info(`Searching for user with EMail: ${mail}`);
const user = this.userService.getUserByMail(mail, req.user as JwtUser);
this.logger.info(`Found user: ${JSON.stringify(user)}`);
async findByMail(@Request() req, @Query('mail') mail: string): Promise<User> {
const user = await this.userService.getUserByMail(mail, req.user as JwtUser);
return user;
}
@UseGuards(OptionalJwtAuthGuard)
@Get(':id')
findById(@Param('id') id: string): any {
this.logger.info(`Searching for user with ID: ${id}`);
const user = this.userService.getUserById(id);
this.logger.info(`Found user: ${JSON.stringify(user)}`);
async findById(@Param('id') id: string): Promise<User> {
const user = await this.userService.getUserById(id);
return user;
}
@UseGuards(AdminAuthGuard)
@Get('user/all')
async getAllUser(): Promise<User[]> {
return await this.userService.getAllUser();
}
@UseGuards(OptionalJwtAuthGuard)
@Post()
save(@Body() user: any): Promise<User> {
this.logger.info(`Saving user: ${JSON.stringify(user)}`);
const savedUser = this.userService.saveUser(user);
this.logger.info(`User persisted: ${JSON.stringify(savedUser)}`);
async save(@Body() user: any): Promise<User> {
try {
const savedUser = await this.userService.saveUser(user);
return savedUser;
} 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; // Andere Fehler einfach durchreichen
}
}
@UseGuards(OptionalJwtAuthGuard)
@Post('guaranteed')
async saveGuaranteed(@Body() user: any): Promise<User> {
const savedUser = await this.userService.saveUser(user, false);
return savedUser;
}
@UseGuards(OptionalJwtAuthGuard)
@Post('search')
find(@Body() criteria: UserListingCriteria): any {
this.logger.info(`Searching for users with criteria: ${JSON.stringify(criteria)}`);
const foundUsers = this.userService.searchUserListings(criteria);
this.logger.info(`Found users: ${JSON.stringify(foundUsers)}`);
async find(@Body() criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
const foundUsers = await this.userService.searchUserListings(criteria);
return foundUsers;
}
@UseGuards(OptionalJwtAuthGuard)
@Post('findTotal')
findTotal(@Body() criteria: UserListingCriteria): Promise<number> {
return this.userService.getUserListingsCount(criteria);
}
@Get('states/all')
async getStates(): Promise<any[]> {
this.logger.info(`Getting all states for users`);
const result = await this.userService.getStates();
this.logger.info(`Found ${result.length} entries`);
return result;
async findTotal(@Body() criteria: UserListingCriteria): Promise<number> {
return await this.userService.getUserListingsCount(criteria);
}
@UseGuards(JwtAuthGuard)
@Get('subscriptions/:id')
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
const subscriptions = this.fileService.getSubscriptions();
const subscriptions = [];
const user = await this.userService.getUserById(id);
subscriptions.forEach(s => {
s.userId = user.id;

View File

@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { DrizzleModule } from '../drizzle/drizzle.module.js';
import { FileService } from '../file/file.service.js';
import { GeoModule } from '../geo/geo.module.js';
import { GeoService } from '../geo/geo.service.js';
import { UserController } from './user.controller.js';
import { UserService } from './user.service.js';
import { DrizzleModule } from '../drizzle/drizzle.module';
import { FileService } from '../file/file.service';
import { GeoModule } from '../geo/geo.module';
import { GeoService } from '../geo/geo.service';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [DrizzleModule, GeoModule],

View File

@@ -1,16 +1,15 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { and, count, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js';
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, count, desc, eq, ilike, 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 { ZodError } from 'zod';
import * as schema from '../drizzle/schema.js';
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema.js';
import { FileService } from '../file/file.service.js';
import { GeoService } from '../geo/geo.service.js';
import { User, UserSchema } from '../models/db.model.js';
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model.js';
import { convertDrizzleUserToUser, convertUserToDrizzleUser, getDistanceQuery } from '../utils.js';
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 { DrizzleUser, getDistanceQuery, splitName } from '../utils';
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
@Injectable()
@@ -26,10 +25,10 @@ export class UserService {
const whereConditions: SQL[] = [];
whereConditions.push(eq(schema.users.customerType, 'professional'));
if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(ilike(schema.users.city, `%${criteria.city}%`));
whereConditions.push(sql`${schema.users.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);
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(schema.users, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
}
if (criteria.types && criteria.types.length > 0) {
@@ -37,12 +36,9 @@ export class UserService {
whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[]));
}
if (criteria.firstname) {
whereConditions.push(ilike(schema.users.firstname, `%${criteria.firstname}%`));
}
if (criteria.lastname) {
whereConditions.push(ilike(schema.users.lastname, `%${criteria.lastname}%`));
if (criteria.brokerName) {
const { firstname, lastname } = splitName(criteria.brokerName);
whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
}
if (criteria.companyName) {
@@ -58,7 +54,7 @@ export class UserService {
}
return whereConditions;
}
async searchUserListings(criteria: UserListingCriteria) {
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);
@@ -68,12 +64,23 @@ export class UserService {
const whereClause = and(...whereConditions);
query.where(whereClause);
}
// Sortierung
switch (criteria.sortBy) {
case 'nameAsc':
query.orderBy(asc(schema.users.lastname));
break;
case 'nameDesc':
query.orderBy(desc(schema.users.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(r => convertDrizzleUserToUser(r));
const results = data;
const totalCount = await this.getUserListingsCount(criteria);
return {
@@ -99,14 +106,14 @@ export class UserService {
.from(schema.users)
.where(sql`email = ${email}`)) as User[];
if (users.length === 0) {
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname) };
const u = await this.saveUser(user);
return convertDrizzleUserToUser(u);
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname, null) };
const u = await this.saveUser(user, false);
return u;
} else {
const user = users[0];
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
return convertDrizzleUserToUser(user);
return user;
}
}
async getUserById(id: string) {
@@ -118,9 +125,13 @@ export class UserService {
const user = users[0];
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
return convertDrizzleUserToUser(user);
return user;
}
async saveUser(user: User): Promise<User> {
async getAllUser() {
const users = await this.conn.select().from(schema.users);
return users;
}
async saveUser(user: User, processValidation = true): Promise<User> {
try {
user.updated = new Date();
if (user.id) {
@@ -128,29 +139,21 @@ export class UserService {
} else {
user.created = new Date();
}
const validatedUser = UserSchema.parse(user);
const drizzleUser = convertUserToDrizzleUser(validatedUser);
let validatedUser = user;
if (processValidation) {
validatedUser = UserSchema.parse(user);
}
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
const drizzleUser = validatedUser as DrizzleUser;
if (user.id) {
const [updateUser] = await this.conn.update(schema.users).set(drizzleUser).where(eq(schema.users.id, user.id)).returning();
return convertDrizzleUserToUser(updateUser) as User;
return updateUser as User;
} else {
const [newUser] = await this.conn.insert(schema.users).values(drizzleUser).returning();
return convertDrizzleUserToUser(newUser) as User;
return newUser as User;
}
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
}));
throw new BadRequestException(formattedErrors);
}
throw error;
}
}
async getStates(): Promise<any[]> {
const query = sql`SELECT jsonb_array_elements(${schema.users.areasServed}) ->> 'state' AS state, COUNT(DISTINCT ${schema.users.id}) AS count FROM ${schema.users} GROUP BY state ORDER BY count DESC`;
const result = await this.conn.execute(query);
return result.rows;
}
}

View File

@@ -1,6 +1,5 @@
import { sql } from 'drizzle-orm';
import { businesses, commercials, users } from './drizzle/schema.js';
import { BusinessListing, CommercialPropertyListing, User } from './models/db.model.js';
import { businesses, commercials, users } from './drizzle/schema';
export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern
export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen
export function convertStringToNullUndefined(value) {
@@ -20,106 +19,150 @@ export function convertStringToNullUndefined(value) {
export const getDistanceQuery = (schema: typeof businesses | typeof commercials | typeof users, lat: number, lon: number, unit: 'km' | 'miles' = 'miles') => {
const radius = unit === 'km' ? EARTH_RADIUS_KM : EARTH_RADIUS_MILES;
// return sql`
// ${radius} * 2 * ASIN(SQRT(
// POWER(SIN((${lat} - ${schema.latitude}) * PI() / 180 / 2), 2) +
// COS(${lat} * PI() / 180) * COS(${schema.latitude} * PI() / 180) *
// POWER(SIN((${lon} - ${schema.longitude}) * PI() / 180 / 2), 2)
// ))
// `;
return sql`
${radius} * 2 * ASIN(SQRT(
POWER(SIN((${lat} - ${schema.latitude}) * PI() / 180 / 2), 2) +
COS(${lat} * PI() / 180) * COS(${schema.latitude} * PI() / 180) *
POWER(SIN((${lon} - ${schema.longitude}) * PI() / 180 / 2), 2)
POWER(SIN((${lat} - (${schema.location}->>'latitude')::float) * PI() / 180 / 2), 2) +
COS(${lat} * PI() / 180) * COS((${schema.location}->>'latitude')::float * PI() / 180) *
POWER(SIN((${lon} - (${schema.location}->>'longitude')::float) * PI() / 180 / 2), 2)
))
`;
};
type DrizzleUser = typeof users.$inferSelect;
type DrizzleBusinessListing = typeof businesses.$inferSelect;
type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect;
export function convertBusinessToDrizzleBusiness(businessListing: Partial<BusinessListing>): DrizzleBusinessListing {
return flattenObject(businessListing);
}
export function convertDrizzleBusinessToBusiness(drizzleBusinessListing: Partial<DrizzleBusinessListing>): BusinessListing {
const o = {
location_city: drizzleBusinessListing.city,
location_state: drizzleBusinessListing.state,
location_latitude: drizzleBusinessListing.latitude,
location_longitude: drizzleBusinessListing.longitude,
...drizzleBusinessListing,
};
delete o.city;
delete o.state;
delete o.latitude;
delete o.longitude;
return unflattenObject(o);
}
export function convertCommercialToDrizzleCommercial(commercialPropertyListing: Partial<CommercialPropertyListing>): DrizzleCommercialPropertyListing {
return flattenObject(commercialPropertyListing);
}
export function convertDrizzleCommercialToCommercial(drizzleCommercialPropertyListing: Partial<DrizzleCommercialPropertyListing>): CommercialPropertyListing {
const o = {
location_city: drizzleCommercialPropertyListing.city,
location_state: drizzleCommercialPropertyListing.state,
location_latitude: drizzleCommercialPropertyListing.latitude,
location_longitude: drizzleCommercialPropertyListing.longitude,
...drizzleCommercialPropertyListing,
};
delete o.city;
delete o.state;
delete o.latitude;
delete o.longitude;
return unflattenObject(o);
}
export function convertUserToDrizzleUser(user: Partial<User>): DrizzleUser {
return flattenObject(user);
}
export type DrizzleUser = typeof users.$inferSelect;
export type DrizzleBusinessListing = typeof businesses.$inferSelect;
export type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect;
// export function convertBusinessToDrizzleBusiness(businessListing: Partial<BusinessListing>): DrizzleBusinessListing {
// const drizzleBusinessListing = flattenObject(businessListing);
// drizzleBusinessListing.city = drizzleBusinessListing.name;
// delete drizzleBusinessListing.name;
// return drizzleBusinessListing;
// }
// export function convertDrizzleBusinessToBusiness(drizzleBusinessListing: Partial<DrizzleBusinessListing>): BusinessListing {
// const o = {
// location: drizzleBusinessListing.city ? undefined : null,
// location_name: drizzleBusinessListing.city ? drizzleBusinessListing.city : undefined,
// location_state: drizzleBusinessListing.state ? drizzleBusinessListing.state : undefined,
// location_latitude: drizzleBusinessListing.latitude ? drizzleBusinessListing.latitude : undefined,
// location_longitude: drizzleBusinessListing.longitude ? drizzleBusinessListing.longitude : undefined,
// ...drizzleBusinessListing,
// };
// Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
// delete o.city;
// delete o.state;
// delete o.latitude;
// delete o.longitude;
// return unflattenObject(o);
// }
// export function convertCommercialToDrizzleCommercial(commercialPropertyListing: Partial<CommercialPropertyListing>): DrizzleCommercialPropertyListing {
// const drizzleCommercialPropertyListing = flattenObject(commercialPropertyListing);
// drizzleCommercialPropertyListing.city = drizzleCommercialPropertyListing.name;
// delete drizzleCommercialPropertyListing.name;
// return drizzleCommercialPropertyListing;
// }
// export function convertDrizzleCommercialToCommercial(drizzleCommercialPropertyListing: Partial<DrizzleCommercialPropertyListing>): CommercialPropertyListing {
// const o = {
// location: drizzleCommercialPropertyListing.city ? undefined : null,
// location_name: drizzleCommercialPropertyListing.city ? drizzleCommercialPropertyListing.city : undefined,
// location_state: drizzleCommercialPropertyListing.state ? drizzleCommercialPropertyListing.state : undefined,
// location_street: drizzleCommercialPropertyListing.street ? drizzleCommercialPropertyListing.street : undefined,
// location_housenumber: drizzleCommercialPropertyListing.housenumber ? drizzleCommercialPropertyListing.housenumber : undefined,
// location_county: drizzleCommercialPropertyListing.county ? drizzleCommercialPropertyListing.county : undefined,
// location_zipCode: drizzleCommercialPropertyListing.zipCode ? drizzleCommercialPropertyListing.zipCode : undefined,
// location_latitude: drizzleCommercialPropertyListing.latitude ? drizzleCommercialPropertyListing.latitude : undefined,
// location_longitude: drizzleCommercialPropertyListing.longitude ? drizzleCommercialPropertyListing.longitude : undefined,
// ...drizzleCommercialPropertyListing,
// };
// Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
// delete o.city;
// delete o.state;
// delete o.street;
// delete o.housenumber;
// delete o.county;
// delete o.zipCode;
// delete o.latitude;
// delete o.longitude;
// return unflattenObject(o);
// }
// export function convertUserToDrizzleUser(user: Partial<User>): DrizzleUser {
// const drizzleUser = flattenObject(user);
// drizzleUser.city = drizzleUser.name;
// delete drizzleUser.name;
// return drizzleUser;
// }
export function convertDrizzleUserToUser(drizzleUser: Partial<DrizzleUser>): User {
const o = {
companyLocation_city: drizzleUser.city,
companyLocation_state: drizzleUser.state,
companyLocation_latitude: drizzleUser.latitude,
companyLocation_longitude: drizzleUser.longitude,
...drizzleUser,
};
delete o.city;
delete o.state;
delete o.latitude;
delete o.longitude;
return unflattenObject(o);
}
function flattenObject(obj: any, res: any = {}): any {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
// export function convertDrizzleUserToUser(drizzleUser: Partial<DrizzleUser>): User {
// const o: any = {
// companyLocation: drizzleUser.city ? undefined : null,
// companyLocation_name: drizzleUser.city ? drizzleUser.city : undefined,
// companyLocation_state: drizzleUser.state ? drizzleUser.state : undefined,
// companyLocation_latitude: drizzleUser.latitude ? drizzleUser.latitude : undefined,
// companyLocation_longitude: drizzleUser.longitude ? drizzleUser.longitude : undefined,
// ...drizzleUser,
// };
// Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
// delete o.city;
// delete o.state;
// delete o.latitude;
// delete o.longitude;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
if (value instanceof Date) {
res[key] = value;
} else {
flattenObject(value, res);
}
} else {
res[key] = value;
}
}
// return unflattenObject(o);
// }
// function flattenObject(obj: any, res: any = {}): any {
// for (const key in obj) {
// if (obj.hasOwnProperty(key)) {
// const value = obj[key];
// if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// if (value instanceof Date) {
// res[key] = value;
// } else {
// flattenObject(value, res);
// }
// } else {
// res[key] = value;
// }
// }
// }
// return res;
// }
// function unflattenObject(obj: any, separator: string = '_'): any {
// const result: any = {};
// for (const key in obj) {
// if (obj.hasOwnProperty(key)) {
// const keys = key.split(separator);
// keys.reduce((acc, curr, idx) => {
// if (idx === keys.length - 1) {
// acc[curr] = obj[key];
// } else {
// if (!acc[curr]) {
// acc[curr] = {};
// }
// }
// return acc[curr];
// }, result);
// }
// }
// return result;
// }
export function splitName(fullName: string): { firstname: string; lastname: string } {
const parts = fullName.trim().split(/\s+/); // Teile den Namen am Leerzeichen auf
if (parts.length === 1) {
// Falls es nur ein Teil gibt, ist firstname und lastname gleich
return { firstname: parts[0], lastname: parts[0] };
} else {
// Ansonsten ist der letzte Teil der lastname, der Rest der firstname
const lastname = parts.pop()!;
const firstname = parts.join(' ');
return { firstname, lastname };
}
return res;
}
function unflattenObject(obj: any, separator: string = '_'): any {
const result: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const keys = key.split(separator);
keys.reduce((acc, curr, idx) => {
if (idx === keys.length - 1) {
acc[curr] = obj[key];
} else {
if (!acc[curr]) {
acc[curr] = {};
}
}
return acc[curr];
}, result);
}
}
return result;
}

View File

@@ -0,0 +1,16 @@
import { Request } from 'express';
export interface RealIpInfo {
ip: string | undefined;
countryCode?: string;
}
export function getRealIpInfo(req: Request): RealIpInfo {
const ip =
(req.headers['cf-connecting-ip'] as string) ||
(req.headers['x-real-ip'] as string) ||
(typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'].split(',')[0] : req.connection.remoteAddress);
const countryCode = req.headers['cf-ipcountry'] as string;
return { ip, countryCode };
}

View File

@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "src/drizzle/import.ts"]
}

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "Node",
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
@@ -18,6 +18,6 @@
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"esModuleInterop":true
"esModuleInterop": true
}
}

View File

@@ -32,7 +32,8 @@
],
"styles": [
"src/styles.scss",
"node_modules/quill/dist/quill.snow.css"
"node_modules/quill/dist/quill.snow.css",
"node_modules/leaflet/dist/leaflet.css"
]
},
"configurations": {
@@ -81,7 +82,9 @@
}
},
"defaultConfiguration": "development",
"options": {"proxyConfig": "proxy.conf.json"}
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
@@ -102,7 +105,12 @@
"src/assets",
"cropped-Favicon-32x32.png",
"cropped-Favicon-180x180.png",
"cropped-Favicon-191x192.png"
"cropped-Favicon-191x192.png",
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
],
"styles": [
"src/styles.scss"
@@ -116,4 +124,4 @@
"cli": {
"analytics": false
}
}
}

View File

@@ -23,6 +23,7 @@
"@angular/platform-browser-dynamic": "^18.1.3",
"@angular/platform-server": "^18.1.3",
"@angular/router": "^18.1.3",
"@bluehalo/ngx-leaflet": "^18.0.2",
"@fortawesome/angular-fontawesome": "^0.15.0",
"@fortawesome/fontawesome-free": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
@@ -31,7 +32,9 @@
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@ng-select/ng-select": "^13.4.1",
"@ngneat/until-destroy": "^10.0.0",
"@stripe/stripe-js": "^4.3.0",
"@types/cropperjs": "^1.3.0",
"@types/leaflet": "^1.9.12",
"@types/uuid": "^10.0.0",
"browser-bunyan": "^1.8.0",
"dayjs": "^1.11.11",
@@ -40,11 +43,15 @@
"jwt-decode": "^4.0.0",
"keycloak-angular": "^16.0.1",
"keycloak-js": "^25.0.1",
"leaflet": "^1.9.4",
"memoize-one": "^6.0.0",
"ng-gallery": "^11.0.0",
"ngx-currency": "^18.0.0",
"ngx-image-cropper": "^8.0.0",
"ngx-mask": "^18.0.0",
"ngx-quill": "^26.0.5",
"ngx-sharebuttons": "^15.0.3",
"ngx-stripe": "^18.1.0",
"on-change": "^5.0.1",
"rxjs": "~7.8.1",
"tslib": "^2.6.3",

View File

@@ -1,10 +1,28 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/pictures": {
"target": "http://localhost:8080",
"secure": false
},
"/ipify": {
"target": "https://api.ipify.org",
"secure": true,
"changeOrigin": true,
"pathRewrite": {
"^/ipify": ""
}
},
"/ipinfo": {
"target": "https://ipinfo.io",
"secure": true,
"changeOrigin": true,
"pathRewrite": {
"^/ipinfo": ""
}
}
}

View File

@@ -1,9 +1,11 @@
<!-- <div class="container"> -->
<div [ngClass]="{ 'bg-slate-100': actualRoute !== 'home' }">
@if (actualRoute !=='home' && actualRoute !=='pricing'){
<div class="flex flex-col" [ngClass]="{ 'bg-slate-100 print:bg-white': actualRoute !== 'home' }">
@if (actualRoute !=='home'){
<header></header>
}
<router-outlet></router-outlet>
<main class="flex-grow">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
</div>
@@ -30,26 +32,8 @@
</div>
</div>
}
<!-- <div *ngIf="loadingService.isLoading$ | async" class="spinner-overlay">
<div class="spinner-container">
<ng-container *ngIf="loadingService.loadingText$ | async as loadingText">
<div *ngIf="loadingText" class="spinner-text">{{ loadingText }}</div>
</ng-container>
<div role="status">
<svg aria-hidden="true" class="inline w-10 h-10 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
</div> -->
<app-message-container></app-message-container>
<app-search-modal></app-search-modal>
<app-confirmation></app-confirmation>
<app-email></app-email>

View File

@@ -1,23 +1,26 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { KeycloakService } from 'keycloak-angular';
import { KeycloakEventType, KeycloakService } from 'keycloak-angular';
import { filter } from 'rxjs/operators';
import build from '../build';
import { ConfirmationComponent } from './components/confirmation/confirmation.component';
import { ConfirmationService } from './components/confirmation/confirmation.service';
import { EMailComponent } from './components/email/email.component';
import { FooterComponent } from './components/footer/footer.component';
import { HeaderComponent } from './components/header/header.component';
import { MessageContainerComponent } from './components/message/message-container.component';
import { SearchModalComponent } from './components/search-modal/search-modal.component';
import { AuditService } from './services/audit.service';
import { GeoService } from './services/geo.service';
import { LoadingService } from './services/loading.service';
import { UserService } from './services/user.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent],
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent, EMailComponent],
providers: [],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
@@ -34,6 +37,8 @@ export class AppComponent {
private keycloakService: KeycloakService,
private userService: UserService,
private confirmationService: ConfirmationService,
private auditService: AuditService,
private geoService: GeoService,
) {
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
let currentRoute = this.activatedRoute.root;
@@ -44,14 +49,38 @@ export class AppComponent {
this.actualRoute = currentRoute.snapshot.url[0].path;
});
}
ngOnInit() {}
ngOnInit() {
// Überwache Keycloak-Events, um den Token-Refresh zu kontrollieren
this.keycloakService.keycloakEvents$.subscribe({
next: event => {
if (event.type === KeycloakEventType.OnTokenExpired) {
// Wenn der Token abgelaufen ist, versuchen wir einen Refresh
this.handleTokenExpiration();
}
},
});
}
private async handleTokenExpiration(): Promise<void> {
try {
// Versuche, den Token zu erneuern
const refreshed = await this.keycloakService.updateToken();
if (!refreshed) {
// Wenn der Token nicht erneuert werden kann, leite zur Login-Seite weiter
this.keycloakService.login({
redirectUri: window.location.href, // oder eine andere Seite
});
}
} catch (error) {
if (error.error === 'invalid_grant' && error.error_description === 'Token is not active') {
// Hier wird der Fehler "invalid_grant" abgefangen
this.keycloakService.login({
redirectUri: window.location.href,
});
}
}
}
@HostListener('window:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
// this.router.events.subscribe(event => {
// if (event instanceof NavigationEnd) {
// initFlowbite();
// }
// });
if (event.shiftKey && event.ctrlKey && event.key === 'V') {
this.showVersionDialog();
}

View File

@@ -1,14 +1,18 @@
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler } from '@angular/core';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { KeycloakBearerInterceptor, KeycloakService } from 'keycloak-angular';
import { GALLERY_CONFIG, GalleryConfig } from 'ng-gallery';
import { provideQuillConfig } from 'ngx-quill';
import { environment } from '../environments/environment';
import { customKeycloakAdapter } from '../keycloak';
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { shareIcons } from 'ngx-sharebuttons/icons';
import { provideNgxStripe } from 'ngx-stripe';
import { routes } from './app.routes';
import { LoadingInterceptor } from './interceptors/loading.interceptor';
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
import { GlobalErrorHandler } from './services/globalErrorHandler';
import { KeycloakInitializerService } from './services/keycloak-initializer.service';
import { SelectOptionsService } from './services/select-options.service';
import { createLogger } from './utils/utils';
@@ -20,9 +24,9 @@ export const appConfig: ApplicationConfig = {
{ provide: KeycloakService },
{
provide: APP_INITIALIZER,
// useFactory: initializeKeycloak,
//useFactory: initializeKeycloak,
useFactory: initializeKeycloak3,
// useFactory: initializeKeycloak1,
//useFactory: initializeKeycloak2,
useFactory: initializeKeycloak,
multi: true,
//deps: [KeycloakService],
deps: [KeycloakInitializerService],
@@ -43,6 +47,30 @@ export const appConfig: ApplicationConfig = {
useClass: KeycloakBearerInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: TimeoutInterceptor,
multi: true,
},
{
provide: 'TIMEOUT_DURATION',
useValue: 5000, // Standard-Timeout von 5 Sekunden
},
{
provide: GALLERY_CONFIG,
useValue: {
autoHeight: true,
imageSize: 'cover',
} as GalleryConfig,
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler }, // Registriere den globalen ErrorHandler
provideShareButtonsOptions(
shareIcons(),
withConfig({
debug: true,
sharerMethod: SharerMethods.Anchor,
}),
),
provideRouter(
routes,
withEnabledBlockingInitialNavigation(),
@@ -52,6 +80,7 @@ export const appConfig: ApplicationConfig = {
}),
),
provideAnimations(),
provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'),
provideQuillConfig({
modules: {
syntax: true,
@@ -71,47 +100,47 @@ function initServices(selectOptions: SelectOptionsService) {
await selectOptions.init();
};
}
export function initializeKeycloak3(keycloak: KeycloakInitializerService) {
export function initializeKeycloak(keycloak: KeycloakInitializerService) {
return () => keycloak.initialize();
}
export function initializeKeycloak2(keycloak: KeycloakService): () => Promise<void> {
return async () => {
const { url, realm, clientId } = environment.keycloak;
const adapter = customKeycloakAdapter(() => keycloak.getKeycloakInstance(), {});
if (window.location.search.length > 0) {
sessionStorage.setItem('SEARCH', window.location.search);
}
const { host, hostname, href, origin, pathname, port, protocol, search } = window.location;
await keycloak.init({
config: { url, realm, clientId },
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.hostname === 'localhost' ? `${window.location.origin}/assets/silent-check-sso.html` : `${window.location.origin}/dealerweb/assets/silent-check-sso.html`,
adapter,
redirectUri: `${origin}${pathname}`,
},
});
};
}
function initializeKeycloak(keycloak: KeycloakService) {
return async () => {
logger.info(`###>calling keycloakService init ...`);
const authenticated = await keycloak.init({
config: {
url: environment.keycloak.url,
realm: environment.keycloak.realm,
clientId: environment.keycloak.clientId,
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: (<any>window).location.origin + '/assets/silent-check-sso.html',
},
bearerExcludedUrls: ['/assets'],
shouldUpdateToken(request) {
return !request.headers.get('token-update') === false;
},
});
logger.info(`+++>${authenticated}`);
};
}
// export function initializeKeycloak1(keycloak: KeycloakService): () => Promise<void> {
// return async () => {
// const { url, realm, clientId } = environment.keycloak;
// const adapter = customKeycloakAdapter(() => keycloak.getKeycloakInstance(), {});
// if (window.location.search.length > 0) {
// sessionStorage.setItem('SEARCH', window.location.search);
// }
// const { host, hostname, href, origin, pathname, port, protocol, search } = window.location;
// await keycloak.init({
// config: { url, realm, clientId },
// initOptions: {
// onLoad: 'check-sso',
// silentCheckSsoRedirectUri: window.location.hostname === 'localhost' ? `${window.location.origin}/assets/silent-check-sso.html` : `${window.location.origin}/dealerweb/assets/silent-check-sso.html`,
// adapter,
// redirectUri: `${origin}${pathname}`,
// },
// });
// };
// }
// function initializeKeycloak2(keycloak: KeycloakService) {
// return async () => {
// logger.info(`###>calling keycloakService init ...`);
// const authenticated = await keycloak.init({
// config: {
// url: environment.keycloak.url,
// realm: environment.keycloak.realm,
// clientId: environment.keycloak.clientId,
// },
// initOptions: {
// onLoad: 'check-sso',
// silentCheckSsoRedirectUri: (<any>window).location.origin + '/assets/silent-check-sso.html',
// },
// bearerExcludedUrls: ['/assets'],
// shouldUpdateToken(request) {
// return !request.headers.get('token-update') === false;
// },
// });
// logger.info(`+++>${authenticated}`);
// };
// }

View File

@@ -4,6 +4,7 @@ import { NotFoundComponent } from './components/not-found/not-found.component';
import { AuthGuard } from './guards/auth.guard';
import { ListingCategoryGuard } from './guards/listing-category.guard';
import { UserListComponent } from './pages/admin/user-list/user-list.component';
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
@@ -11,6 +12,7 @@ import { HomeComponent } from './pages/home/home.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
import { LoginComponent } from './pages/login/login.component';
import { PricingComponent } from './pages/pricing/pricing.component';
import { AccountComponent } from './pages/subscription/account/account.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component';
@@ -18,6 +20,7 @@ import { EditCommercialPropertyListingComponent } from './pages/subscription/edi
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
import { SuccessComponent } from './pages/success/success.component';
export const routes: Routes = [
{
@@ -54,6 +57,14 @@ export const routes: Routes = [
canActivate: [ListingCategoryGuard],
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{
path: 'login/:page',
component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{
path: 'notfound',
component: NotFoundComponent,
},
// #########
// User Details
{
@@ -113,7 +124,7 @@ export const routes: Routes = [
{
path: 'emailUs',
component: EmailUsComponent,
canActivate: [AuthGuard],
// canActivate: [AuthGuard],
},
// #########
// Logout
@@ -128,5 +139,25 @@ export const routes: Routes = [
path: 'pricing',
component: PricingComponent,
},
{
path: 'pricingOverview',
component: PricingComponent,
data: {
pricingOverview: true,
},
},
{
path: 'pricing/:id',
component: PricingComponent,
},
{
path: 'success',
component: SuccessComponent,
},
{
path: 'admin/users',
component: UserListComponent,
canActivate: [AuthGuard],
},
{ path: '**', redirectTo: 'home' },
];

View File

@@ -19,6 +19,7 @@ export abstract class BaseInputComponent implements ControlValueAccessor {
@Input() label: string = '';
// @Input() id: string = '';
@Input() name: string = '';
isTooltipVisible = false;
constructor(protected validationMessagesService: ValidationMessagesService) {}
ngOnInit() {
this.subscription = this.validationMessagesService.messages$.subscribe(() => {
@@ -51,4 +52,9 @@ export abstract class BaseInputComponent implements ControlValueAccessor {
this.validationMessage = this.validationMessagesService.getMessage(this.name);
}
setDisabledState?(isDisabled: boolean): void {}
toggleTooltip(event: Event) {
event.preventDefault();
event.stopPropagation();
this.isTooltipVisible = !this.isTooltipVisible;
}
}

View File

@@ -25,8 +25,8 @@ import { ConfirmationService } from './confirmation.service';
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
@let confirmation = (confirmationService.confirmation$ | async);
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{{ confirmation.message }}</h3>
@if(confirmation.buttons==='both'){
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{{ confirmation?.message }}</h3>
@if(confirmation?.buttons==='both'){
<button
(click)="confirmationService.accept()"
type="button"

View File

@@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
interface KeyValue {
name: string;
value: string;
}
@Component({
selector: 'app-customer-sub-type',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<ng-container [ngSwitch]="customerSubType">
<span *ngSwitchCase="'broker'" class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-blue-400 border border-blue-400">Broker</span>
<span *ngSwitchCase="'cpa'" class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-gray-400 border border-gray-500">CPA</span>
<span *ngSwitchCase="'attorney'" class="bg-red-100 text-red-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-red-400 border border-red-400">Attorney</span>
<span *ngSwitchCase="'titleCompany'" class="bg-green-100 text-green-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-green-400 border border-green-400">Title Company</span>
<span *ngSwitchCase="'surveyor'" class="bg-yellow-100 text-yellow-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-yellow-300 border border-yellow-300">Surveyor</span>
<span *ngSwitchCase="'appraiser'" class="bg-pink-100 text-pink-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-pink-400 border border-pink-400">Appraiser</span>
<span *ngSwitchDefault class="text-gray-500">Unknown</span>
</ng-container>
`,
styles: [],
})
export class CustomerSubTypeComponent {
@Input() customerSubType: 'broker' | 'cpa' | 'attorney' | 'surveyor' | 'appraiser' | 'titleCompany';
}

View File

@@ -0,0 +1,43 @@
<!-- Main modal -->
<div *ngIf="eMailService.modalVisible$ | async" id="authentication-modal" tabindex="-1" class="z-40 fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Email listing to a friend</h3>
<button
(click)="eMailService.reject()"
type="button"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5">
<form class="space-y-4" action="#">
<div>
<app-validated-input label="Your Email" name="yourEmail" [(ngModel)]="shareByEMail.yourEmail"></app-validated-input>
</div>
<div>
<app-validated-input label="Your Name" name="yourName" [(ngModel)]="shareByEMail.yourName"></app-validated-input>
</div>
<div>
<app-validated-input label="Your Friend's EMail" name="recipientEmail" [(ngModel)]="shareByEMail.recipientEmail"></app-validated-input>
</div>
<button
(click)="sendMail()"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Send EMail
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
import { MailService } from '../../services/mail.service';
import { ValidatedInputComponent } from '../validated-input/validated-input.component';
import { ValidationMessagesService } from '../validation-messages.service';
import { EMailService } from './email.service';
@UntilDestroy()
@Component({
selector: 'app-email',
standalone: true,
imports: [CommonModule, FormsModule, ValidatedInputComponent],
templateUrl: './email.component.html',
template: ``,
})
export class EMailComponent {
shareByEMail: ShareByEMail = {};
constructor(public eMailService: EMailService, private mailService: MailService, private validationMessagesService: ValidationMessagesService) {}
ngOnInit() {
this.eMailService.shareByEMail$.pipe(untilDestroyed(this)).subscribe(val => {
this.shareByEMail = val;
});
}
async sendMail() {
try {
const result = await this.mailService.mailToFriend(this.shareByEMail);
this.eMailService.accept(this.shareByEMail);
} catch (error) {
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
}
}
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
@Injectable({
providedIn: 'root',
})
export class EMailService {
private modalVisibleSubject = new Subject<boolean>();
private shareByEMailSubject = new Subject<ShareByEMail>();
private resolvePromise!: (value: boolean | ShareByEMail) => void;
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
shareByEMail$: Observable<ShareByEMail> = this.shareByEMailSubject.asObservable();
showShareByEMail(shareByEMail: ShareByEMail): Promise<boolean | ShareByEMail> {
this.shareByEMailSubject.next(shareByEMail);
this.modalVisibleSubject.next(true);
return new Promise<boolean | ShareByEMail>(resolve => {
this.resolvePromise = resolve;
});
}
accept(value: ShareByEMail): void {
this.modalVisibleSubject.next(false);
this.resolvePromise(value);
}
reject(): void {
this.modalVisibleSubject.next(false);
this.resolvePromise(false);
}
}

View File

@@ -1,41 +1,17 @@
<!-- <div class="surface-0 px-4 py-4 md:px-6 lg:px-8">
<div class="surface-0">
<div class="grid">
<div class="col-12 md:col-3 md:mb-0 mb-3 cursor-pointer" routerLink="/home">
<img src="assets/images/header-logo.png" alt="footer sections" height="30" class="mr-3" />
<div class="text-500">© 2024 Bizmatch All rights reserved.</div>
</div>
<div class="col-12 md:col-3">
<div class="text-black mb-4 flex flex-wrap" style="max-width: 290px">BizMatch, Inc., 1001 Blucher Street, Corpus Christi, Texas 78401</div>
<div class="text-black mb-3"><i class="text-white pi pi-phone surface-800 border-round p-1 mr-2"></i>1-800-840-6025</div>
<div class="text-black mb-3"><i class="text-white pi pi-inbox surface-800 border-round p-1 mr-2"></i>info&#64;bizmatch.net</div>
</div>
<div class="col-12 md:col-3 text-500">
<div class="text-black font-bold line-height-3 mb-3">Legal</div>
<a class="line-height-3 block cursor-pointer mb-2" (click)="termsVisible = true">Terms of use</a>
<a class="line-height-3 block cursor-pointer mb-2" (click)="privacyVisible = true">Privacy statement</a>
</div>
<div class="col-12 md:col-3 text-500">
<div class="text-black font-bold line-height-3 mb-3">Actions</div>
<a *ngIf="!keycloakService.isLoggedIn()" (click)="login()" class="text-500 line-height-3 block cursor-pointer mb-2 no-underline">Login</a>
<a *ngIf="!keycloakService.isLoggedIn()" (click)="register()" class="text-500 line-height-3 block cursor-pointer mb-2 no-underline">Register</a>
<a *ngIf="keycloakService.isLoggedIn()" [routerLink]="['/account']" class="text-500 line-height-3 block cursor-pointer mb-2 no-underline">Account</a>
<a *ngIf="keycloakService.isLoggedIn()" class="text-500 line-height-3 block cursor-pointer mb-2 no-underline" (click)="keycloakService.logout()">Log Out</a>
</div>
</div>
</div>
</div> -->
<footer class="bg-white px-4 py-2 md:px-6 mt-auto w-full">
<footer class="bg-white px-4 py-2 md:px-6 mt-auto w-full print:hidden">
<div class="container mx-auto flex flex-col lg:flex-row justify-between items-center">
<div class="flex flex-col lg:flex-row items-center mb-4 lg:mb-0">
<img src="assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-4" />
<!-- <img src="assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-4" /> -->
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="assets/images/header-logo.png" class="h-8" class="h-8 mb-2 lg:mb-0 lg:mr-4" />
</a>
<p class="text-sm text-gray-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
</div>
<div class="flex flex-col lg:flex-row items-center order-3 lg:order-2">
<a class="text-sm text-blue-600 hover:underline hover:cursor-pointer mx-2" data-drawer-target="terms-of-use" data-drawer-show="terms-of-use" aria-controls="terms-of-use">Terms of use</a>
<a class="text-sm text-blue-600 hover:underline hover:cursor-pointer mx-2" data-drawer-target="privacy" data-drawer-show="privacy" aria-controls="privacy">Privacy statement</a>
<a class="text-sm text-blue-600 hover:underline hover:cursor-pointer mx-2" routerLink="/pricingOverview">Pricing</a>
</div>
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
@@ -58,6 +34,17 @@
</svg>
Privacy Statement
</h5>
<button
type="button"
data-drawer-hide="privacy"
aria-controls="privacy"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close menu</span>
</button>
<section id="content" role="main">
<article id="post-2" class="post-2 page type-page status-publish hentry pmpro-has-access">
<section class="entry-content">
@@ -262,6 +249,17 @@
</svg>
Terms of use
</h5>
<button
type="button"
data-drawer-hide="terms-of-use"
aria-controls="terms-of-use"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close menu</span>
</button>
<section id="content" role="main">
<article id="post-1" class="post-1 page type-page status-publish hentry pmpro-has-access">
<section class="entry-content">

View File

@@ -1,143 +1,57 @@
<!-- <nav class="bg-white border-gray-200 dark:bg-gray-900">
<nav class="bg-white border-gray-200 dark:bg-gray-900 print:hidden">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a>
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<button
type="button"
class="flex text-sm bg-gray-200 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
aria-expanded="false"
[attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
data-dropdown-placement="bottom"
>
<span class="sr-only">Open user menu</span>
@if(user){
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
} @else {
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
}
</button>
@if(user){
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-login">
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">Welcome, {{ user.firstname }} </span>
<span class="block text-sm text-gray-500 truncate dark:text-gray-400">{{ user.email }}</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a>
</li>
<li>
<a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Create Listing</a
>
</li>
<li>
<a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a>
</li>
<li>
<a routerLink="/emailUs" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">EMail Us</a>
</li>
<li>
<a routerLink="/logout" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Logout</a>
</li>
</ul>
</div>
} @else {
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-unknown">
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a (click)="login()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a>
</li>
<li>
<a (click)="register()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Register</a>
</li>
</ul>
</div>
}
<button
data-collapse-toggle="navbar-user"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-user"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
</div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul
class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"
>
<li>
<a
routerLinkActive="active-link"
routerLink="/businessListings"
[ngClass]="{ 'bg-blue-700 text-white md:text-blue-700 md:bg-transparent md:dark:text-blue-500': isActive('/businessListings') }"
class="block py-2 px-3 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
aria-current="page"
(click)="closeMenus()"
>Businesses</a
>
</li>
<li>
<a
routerLinkActive="active-link"
routerLink="/commercialPropertyListings"
[ngClass]="{ 'bg-blue-700 text-white md:text-blue-700 md:bg-transparent md:dark:text-blue-500': isActive('/commercialPropertyListings') }"
class="block py-2 px-3 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
(click)="closeMenus()"
>Properties</a
>
</li>
<li>
<a
routerLinkActive="active-link"
routerLink="/brokerListings"
[ngClass]="{ 'bg-blue-700 text-white md:text-blue-700 md:bg-transparent md:dark:text-blue-500': isActive('/brokerListings') }"
class="block py-2 px-3 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
(click)="closeMenus()"
>Professionals</a
>
</li>
</ul>
</div>
</div>
</nav> -->
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a>
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
<!-- Filter button -->
@if(isListingUrl()){
@if(isFilterUrl()){
<button
type="button"
#triggerButton
(click)="openModal()"
id="filterDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 md:me-2"
class="max-sm:hidden px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button>
<!-- Sort button -->
<div class="relative">
<button
type="button"
id="sortDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
(click)="toggleSortDropdown()"
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
>
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
</button>
<!-- Sort options dropdown -->
<div *ngIf="sortDropdownVisible" class="absolute right-0 z-50 w-48 md:mt-2 max-md:mt-20 max-md:mr-[-2.5rem] bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-600">
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200">
@for(item of sortByOptions; track item){
<li (click)="sortBy(item.value)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">{{ item.selectName ? item.selectName : item.name }}</li>
}
<!-- <li (click)="sortBy('priceAsc')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Price Ascending</li>
<li (click)="sortBy('priceDesc')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Price Descending</li>
<li (click)="sortBy('creationDateFirst')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Creation Date First</li>
<li (click)="sortBy('creationDateLast')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Creation Date Last</li>
<li (click)="sortBy(null)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Default Sorting</li> -->
</ul>
</div>
</div>
}
<button
type="button"
class="flex text-sm bg-gray-200 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
class="flex text-sm bg-gray-400 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
aria-expanded="false"
[attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
data-dropdown-placement="bottom"
>
<span class="sr-only">Open user menu</span>
@if(user){
@if(user?.hasProfile){
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
} @else {
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
@@ -154,14 +68,25 @@
<li>
<a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a>
</li>
@if(user.customerType==='professional' || user.customerType==='seller' || isAdmin()){
<li>
@if(user.customerSubType==='broker'){
<a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Create Listing</a
>
}@else {
<a routerLink="/createCommercialPropertyListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Create Listing</a
>
}
</li>
<li>
<a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a>
</li>
<li>
<a routerLink="/myFavorites" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Favorites</a>
</li>
}
<li>
<a routerLink="/emailUs" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">EMail Us</a>
</li>
@@ -169,6 +94,42 @@
<a routerLink="/logout" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Logout</a>
</li>
</ul>
@if(isAdmin()){
<ul class="py-2">
<li>
<a routerLink="admin/users" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Users (Admin)</a>
</li>
</ul>
}
<ul class="py-2 md:hidden">
<li>
<a
routerLink="/businessListings"
[ngClass]="{ 'text-blue-700': isActive('/businessListings'), 'text-gray-700': !isActive('/businessListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('businessListings')"
>Businesses</a
>
</li>
<li>
<a
routerLink="/commercialPropertyListings"
[ngClass]="{ 'text-blue-700': isActive('/commercialPropertyListings'), 'text-gray-700': !isActive('/commercialPropertyListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')"
>Properties</a
>
</li>
<li>
<a
routerLink="/brokerListings"
[ngClass]="{ 'text-blue-700': isActive('/brokerListings'), 'text-gray-700': !isActive('/brokerListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('brokerListings')"
>Professionals</a
>
</li>
</ul>
</div>
} @else {
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-unknown">
@@ -177,12 +138,41 @@
<a (click)="login()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a>
</li>
<li>
<a (click)="register()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Register</a>
<a routerLink="/pricing" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Register</a>
</li>
</ul>
<ul class="py-2 md:hidden">
<li>
<a
routerLink="/businessListings"
[ngClass]="{ 'text-blue-700': isActive('/businessListings'), 'text-gray-700': !isActive('/businessListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('businessListings')"
>Businesses</a
>
</li>
<li>
<a
routerLink="/commercialPropertyListings"
[ngClass]="{ 'text-blue-700': isActive('/commercialPropertyListings'), 'text-gray-700': !isActive('/commercialPropertyListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')"
>Properties</a
>
</li>
<li>
<a
routerLink="/brokerListings"
[ngClass]="{ 'text-blue-700': isActive('/brokerListings'), 'text-gray-700': !isActive('/brokerListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('brokerListings')"
>Professionals</a
>
</li>
</ul>
</div>
}
<button
<!-- <button
data-collapse-toggle="navbar-user"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
@@ -193,7 +183,7 @@
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
</button> -->
</div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul
@@ -234,7 +224,7 @@
</div>
</div>
<!-- Mobile filter button -->
@if(isListingUrl()){
@if(isFilterUrl()){
<div class="md:hidden flex justify-center pb-4">
<button
(click)="openModal()"
@@ -244,6 +234,16 @@
>
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button>
<!-- Sorting -->
<button
(click)="toggleSortDropdown()"
type="button"
id="sortDropdownMobileButton"
class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
>
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
</button>
</div>
}
</nav>

View File

@@ -1,22 +1,25 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { Component, HostListener } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { KeycloakService } from 'keycloak-angular';
import { filter, Observable, Subject, Subscription } from 'rxjs';
import { User } from '../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { CriteriaChangeService } from '../../services/criteria-change.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service';
import { compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils';
import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils';
import { DropdownComponent } from '../dropdown/dropdown.component';
import { ModalService } from '../search-modal/modal.service';
@UntilDestroy()
@Component({
selector: 'header',
standalone: true,
@@ -41,6 +44,8 @@ export class HeaderComponent {
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
private routerSubscription: Subscription | undefined;
baseRoute: string;
sortDropdownVisible: boolean;
sortByOptions: KeyValueAsSortBy[] = [];
constructor(
public keycloakService: KeycloakService,
private router: Router,
@@ -50,8 +55,15 @@ export class HeaderComponent {
private modalService: ModalService,
private searchService: SearchService,
private criteriaChangeService: CriteriaChangeService,
public selectOptions: SelectOptionsService,
) {}
@HostListener('document:click', ['$event'])
handleGlobalClick(event: Event) {
const target = event.target as HTMLElement;
if (target.id !== 'sortDropdownButton' && target.id !== 'sortDropdownMobileButton') {
this.sortDropdownVisible = false;
}
}
async ngOnInit() {
const token = await this.keycloakService.getToken();
this.keycloakUser = map2User(token);
@@ -65,64 +77,48 @@ export class HeaderComponent {
}, 10);
this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
if (photoUrl) {
this.profileUrl = photoUrl;
}
this.profileUrl = photoUrl;
});
this.checkCurrentRoute(this.router.url);
this.setupSortByOptions();
this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => {
this.checkCurrentRoute(event.urlAfterRedirects);
this.setupSortByOptions();
});
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
this.user = u;
});
}
private checkCurrentRoute(url: string): void {
this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
const specialRoutes = [, '', ''];
this.criteria = getCriteriaProxy(this.baseRoute, this);
this.searchService.search(this.criteria);
// this.searchService.search(this.criteria);
}
setupSortByOptions() {
this.sortByOptions = [];
if (this.isProfessionalListing()) {
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
}
if (this.isBusinessListing()) {
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
}
if (this.isCommercialPropertyListing()) {
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
}
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
}
// getCriteriaProxy(path:string):BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria{
// if ('businessListings' === path) {
// return this.createEnhancedProxy(getCriteriaStateObject('business'));
// } else if ('commercialPropertyListings' === path) {
// return this.createEnhancedProxy(getCriteriaStateObject('commercialProperty'));
// } else if ('brokerListings' === path) {
// return this.createEnhancedProxy(getCriteriaStateObject('broker'));
// } else {
// return undefined;
// }
// }
// private createEnhancedProxy(obj: any) {
// const component = this;
// const sessionStorageHandler = function (path, value, previous, applyData) {
// let criteriaType = '';
// if ('/businessListings' === window.location.pathname) {
// criteriaType = 'business';
// } else if ('/commercialPropertyListings' === window.location.pathname) {
// criteriaType = 'commercialProperty';
// } else if ('/brokerListings' === window.location.pathname) {
// criteriaType = 'broker';
// }
// sessionStorage.setItem(`${criteriaType}_criteria`, JSON.stringify(this));
// };
// return onChange(obj, function (path, value, previous, applyData) {
// // Call the original sessionStorageHandler
// sessionStorageHandler.call(this, path, value, previous, applyData);
// // Notify about the criteria change using the component's context
// component.criteriaChangeService.notifyCriteriaChange();
// });
// }
ngAfterViewInit() {}
async openModal() {
const accepted = await this.modalService.showModal(this.criteria);
if (accepted) {
const modalResult = await this.modalService.showModal(this.criteria);
if (modalResult.accepted) {
this.searchService.search(this.criteria);
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
}
}
navigateWithState(dest: string, state: any) {
@@ -130,18 +126,28 @@ export class HeaderComponent {
}
login() {
this.keycloakService.login({
redirectUri: window.location.href,
redirectUri: `${window.location.origin}/login${this.router.routerState.snapshot.url}`,
});
}
register() {
this.keycloakService.register({ redirectUri: `${window.location.origin}/account` });
}
isActive(route: string): boolean {
return this.router.url === route;
}
isListingUrl(): boolean {
isFilterUrl(): boolean {
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
}
isBusinessListing(): boolean {
return ['/businessListings'].includes(this.router.url);
}
isCommercialPropertyListing(): boolean {
return ['/commercialPropertyListings'].includes(this.router.url);
}
isProfessionalListing(): boolean {
return ['/brokerListings'].includes(this.router.url);
}
// isSortingUrl(): boolean {
// return ['/businessListings', '/commercialPropertyListings'].includes(this.router.url);
// }
closeDropdown() {
const dropdownButton = document.getElementById('user-menu-button');
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
@@ -174,13 +180,24 @@ export class HeaderComponent {
}
getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'brokerListings') {
return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else if (this.criteria?.criteriaType === 'businessListings') {
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else if (this.criteria?.criteriaType === 'commercialPropertyListings') {
return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else {
return 0;
}
}
isAdmin() {
return this.keycloakService.getUserRoles(true).includes('ADMIN');
}
sortBy(sortBy: SortByOptions) {
this.criteria.sortBy = sortBy;
this.sortDropdownVisible = false;
this.searchService.search(this.criteria);
}
toggleSortDropdown() {
this.sortDropdownVisible = !this.sortDropdownVisible;
}
}

View File

@@ -1 +1,35 @@
<p>not-found works!</p>
<!-- <section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-primary-600 dark:text-primary-500">404</h1>
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">Something's missing.</p>
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page.</p>
<a
routerLink="/home"
class="inline-flex text-white bg-primary-600 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900 my-4"
>Back to Homepage</a
>
</div>
</div>
</section> -->
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-blue-700 dark:text-blue-500">404</h1>
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">Something's missing.</p>
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page</p>
<!-- <a
href="#"
class="inline-flex text-white bg-primary-600 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900 my-4"
>Back to Homepage</a
> -->
<button
type="button"
[routerLink]="['/home']"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
Back to Homepage
</button>
</div>
</div>
</section>

View File

@@ -1,8 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-not-found',
standalone: true,
template: '<h2>Page not found</h2>',
imports: [CommonModule, RouterModule],
templateUrl: './not-found.component.html',
})
export class NotFoundComponent {}

View File

@@ -1,7 +1,7 @@
// 1. Shared Service (modal.service.ts)
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, ModalResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
@Injectable({
providedIn: 'root',
@@ -9,26 +9,26 @@ import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListing
export class ModalService {
private modalVisibleSubject = new BehaviorSubject<boolean>(false);
private messageSubject = new BehaviorSubject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>(null);
private resolvePromise!: (value: boolean) => void;
private resolvePromise!: (value: ModalResult) => void;
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
message$: Observable<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria> = this.messageSubject.asObservable();
showModal(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<boolean> {
showModal(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<ModalResult> {
this.messageSubject.next(message);
this.modalVisibleSubject.next(true);
return new Promise<boolean>(resolve => {
return new Promise<ModalResult>(resolve => {
this.resolvePromise = resolve;
});
}
accept(): void {
this.modalVisibleSubject.next(false);
this.resolvePromise(true);
this.resolvePromise({ accepted: true });
}
reject(): void {
reject(backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void {
this.modalVisibleSubject.next(false);
this.resolvePromise(false);
this.resolvePromise({ accepted: false, criteria: backupCriteria });
}
}

View File

@@ -2,8 +2,14 @@
<div class="relative w-full max-w-4xl max-h-full">
<div class="relative bg-white rounded-lg shadow">
<div class="flex items-start justify-between p-4 border-b rounded-t">
@if(criteria.criteriaType==='businessListings'){
<h3 class="text-xl font-semibold text-gray-900">Business Listing Search</h3>
<button (click)="modalService.reject()" type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center">
} @else if (criteria.criteriaType==='commercialPropertyListings'){
<h3 class="text-xl font-semibold text-gray-900">Property Listing Search</h3>
} @else {
<h3 class="text-xl font-semibold text-gray-900">Professional Listing Search</h3>
}
<button (click)="close()" type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
@@ -13,17 +19,24 @@
<div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4">
<button class="text-blue-600 font-medium border-b-2 border-blue-600 pb-2">Classic Search</button>
<button class="text-gray-500">AI Search <span class="bg-gray-200 text-xs font-semibold px-2 py-1 rounded">BETA</span></button>
<!-- <button class="text-gray-500">AI Search <span class="bg-gray-200 text-xs font-semibold px-2 py-1 rounded">BETA</span></button> -->
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500" (click)="clearFilter()"></i>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
@if(criteria.criteriaType==='businessListings'){
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"> </ng-select>
</div>
<div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<!-- <div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select
@@ -42,7 +55,7 @@
<ng-option [value]="city">{{ city.city }} - {{ city.state }}</ng-option>
}
</ng-select>
</div>
</div> -->
<!-- New section for city search type -->
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
@@ -77,61 +90,67 @@
<div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2">
<input
<app-validated-price name="price-from" [(ngModel)]="criteria.minPrice" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<!-- <input
type="number"
id="price-from"
[(ngModel)]="criteria.minPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/>
/> -->
<span>-</span>
<input
<!-- <input
type="number"
id="price-to"
[(ngModel)]="criteria.maxPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/>
/> -->
<app-validated-price name="price-to" [(ngModel)]="criteria.maxPrice" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
</div>
</div>
<div>
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-gray-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<input
<!-- <input
type="number"
id="salesRevenue-from"
[(ngModel)]="criteria.minRevenue"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/>
/> -->
<app-validated-price name="salesRevenue-from" [(ngModel)]="criteria.minRevenue" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<span>-</span>
<input
<!-- <input
type="number"
id="salesRevenue-to"
[(ngModel)]="criteria.maxRevenue"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/>
/> -->
<app-validated-price name="salesRevenue-to" [(ngModel)]="criteria.maxRevenue" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
</div>
</div>
<div>
<label for="cashflow" class="block mb-2 text-sm font-medium text-gray-900">Cashflow</label>
<div class="flex items-center space-x-2">
<input
<!-- <input
type="number"
id="cashflow-from"
[(ngModel)]="criteria.minCashFlow"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/>
/> -->
<app-validated-price name="cashflow-from" [(ngModel)]="criteria.minCashFlow" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<span>-</span>
<input
<app-validated-price name="cashflow-to" [(ngModel)]="criteria.maxCashFlow" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<!-- <input
type="number"
id="cashflow-to"
[(ngModel)]="criteria.maxCashFlow"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/>
/> -->
</div>
</div>
<div>
@@ -168,15 +187,33 @@
<label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</label>
<div class="space-y-2">
<div class="flex items-center">
<input [(ngModel)]="criteria.realEstateChecked" type="radio" id="realEstateChecked" name="wbs" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500" checked />
<input
[(ngModel)]="criteria.realEstateChecked"
(ngModelChange)="onCheckboxChange('realEstateChecked', $event)"
type="checkbox"
name="realEstateChecked"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="realEstateChecked" class="ml-2 text-sm font-medium text-gray-900">Real Estate</label>
</div>
<div class="flex items-center">
<input [(ngModel)]="criteria.leasedLocation" type="radio" id="leasedLocation" name="wbs" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500" />
<input
[(ngModel)]="criteria.leasedLocation"
(ngModelChange)="onCheckboxChange('leasedLocation', $event)"
type="checkbox"
name="leasedLocation"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="leasedLocation" class="ml-2 text-sm font-medium text-gray-900">Leased Location</label>
</div>
<div class="flex items-center">
<input [(ngModel)]="criteria.franchiseResale" type="radio" id="franchiseResale" name="wbs" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500" />
<input
[(ngModel)]="criteria.franchiseResale"
(ngModelChange)="onCheckboxChange('franchiseResale', $event)"
type="checkbox"
name="franchiseResale"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="franchiseResale" class="ml-2 text-sm font-medium text-gray-900">Franchise</label>
</div>
</div>
@@ -241,7 +278,7 @@
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"> </ng-select>
</div>
<div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<!-- <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select
class="custom"
[multiple]="false"
@@ -257,7 +294,8 @@
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
}
</ng-select>
</ng-select> -->
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<!-- New section for city search type -->
<div *ngIf="criteria.city">
@@ -292,21 +330,23 @@
<div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2">
<input
<!-- <input
type="number"
id="price-from"
[(ngModel)]="criteria.minPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/>
/> -->
<app-validated-price name="price-from" [(ngModel)]="criteria.minPrice" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<span>-</span>
<input
<app-validated-price name="price-to" [(ngModel)]="criteria.maxPrice" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<!-- <input
type="number"
id="price-to"
[(ngModel)]="criteria.maxPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/>
/> -->
</div>
</div>
<div>
@@ -363,13 +403,10 @@
[typeahead]="countyInput$"
[(ngModel)]="criteria.counties"
>
<!-- @for (county of counties$ | async; track county.id) {
<ng-option [value]="city.city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
} -->
</ng-select>
</div>
<div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<!-- <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select
class="custom"
[multiple]="false"
@@ -383,9 +420,17 @@
(ngModelChange)="setCity($event)"
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
<ng-option [value]="city">{{ city.name }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
}
</ng-select>
</ng-select> -->
<app-validated-city
label="Company Location - City"
name="city"
[ngModel]="criteria.city"
(ngModelChange)="setCity($event)"
labelClasses="text-gray-900 font-medium"
[state]="criteria.state"
></app-validated-city>
</div>
<!-- New section for city search type -->
<div *ngIf="criteria.city">
@@ -401,6 +446,15 @@
</label>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-gray-900">Name of Professional</label>
<input
type="text"
id="brokername"
[(ngModel)]="criteria.brokerName"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
/>
</div>
<!-- New section for radius selection -->
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
@@ -447,7 +501,7 @@
</button>
<button
type="button"
(click)="modalService.reject()"
(click)="close()"
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10"
>
Cancel

View File

@@ -10,25 +10,30 @@ import { ListingsService } from '../../services/listings.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { SharedModule } from '../../shared/shared/shared.module';
import { resetBusinessListingCriteria, resetCommercialPropertyListingCriteria, resetUserListingCriteria } from '../../utils/utils';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal',
standalone: true,
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule],
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal.component.html',
styleUrl: './search-modal.component.scss',
})
export class SearchModalComponent {
cities$: Observable<GeoResult[]>;
// cities$: Observable<GeoResult[]>;
counties$: Observable<CountyResult[]>;
cityLoading = false;
// cityLoading = false;
countyLoading = false;
cityInput$ = new Subject<string>();
// cityInput$ = new Subject<string>();
countyInput$ = new Subject<string>();
private criteriaChangeSubscription: Subscription;
public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
@@ -41,6 +46,7 @@ export class SearchModalComponent {
this.setupCriteriaChangeListener();
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => {
this.criteria = msg;
this.backupCriteria = JSON.parse(JSON.stringify(msg));
this.setTotalNumberOfResults();
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
@@ -49,7 +55,7 @@ export class SearchModalComponent {
this.criteria.start = 0;
}
});
this.loadCities();
// this.loadCities();
this.loadCounties();
}
@@ -64,22 +70,6 @@ export class SearchModalComponent {
}
}
}
private loadCities() {
this.cities$ = concat(
of([]), // default items
this.cityInput$.pipe(
distinctUntilChanged(),
tap(() => (this.cityLoading = true)),
switchMap(term =>
this.geoService.findCitiesStartingWith(term).pipe(
catchError(() => of([])), // empty list on error
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
tap(() => (this.cityLoading = false)),
),
),
),
);
}
private loadCounties() {
this.counties$ = concat(
of([]), // default items
@@ -98,7 +88,7 @@ export class SearchModalComponent {
}
setCity(city) {
if (city) {
this.criteria.city = city.city;
this.criteria.city = city;
this.criteria.state = city.state;
} else {
this.criteria.city = null;
@@ -115,7 +105,10 @@ export class SearchModalComponent {
}
}
private setupCriteriaChangeListener() {
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => this.setTotalNumberOfResults());
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
this.setTotalNumberOfResults();
this.cancelDisable = true;
});
}
trackByFn(item: GeoResult) {
return item.id;
@@ -147,4 +140,25 @@ export class SearchModalComponent {
}
}
}
clearFilter() {
if (this.criteria.criteriaType === 'businessListings') {
resetBusinessListingCriteria(this.criteria);
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
resetCommercialPropertyListingCriteria(this.criteria);
} else {
resetUserListingCriteria(this.criteria);
}
}
close() {
this.modalService.reject(this.backupCriteria);
}
onCheckboxChange(checkbox: string, value: boolean) {
// Deaktivieren Sie alle Checkboxes
(<BusinessListingCriteria>this.criteria).realEstateChecked = false;
(<BusinessListingCriteria>this.criteria).leasedLocation = false;
(<BusinessListingCriteria>this.criteria).franchiseResale = false;
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
this.criteria[checkbox] = value;
}
}

View File

@@ -1,7 +1,7 @@
<div
[id]="id"
role="tooltip"
class="max-w-72 w-max absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
class="max-w-72 w-max absolute z-50 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
>
{{ text }}
<div class="tooltip-arrow" data-popper-arrow></div>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { Component, Input, SimpleChanges } from '@angular/core';
import { initFlowbite } from 'flowbite';
@Component({
@@ -9,11 +9,36 @@ import { initFlowbite } from 'flowbite';
templateUrl: './tooltip.component.html',
})
export class TooltipComponent {
@Input() id;
@Input() text;
@Input() id: string;
@Input() text: string;
@Input() isVisible: boolean = false;
ngOnInit() {
this.initializeTooltip();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['isVisible']) {
this.updateTooltipVisibility();
}
}
private initializeTooltip() {
setTimeout(() => {
initFlowbite();
}, 10);
}
private updateTooltipVisibility() {
const tooltipElement = document.getElementById(this.id);
if (tooltipElement) {
if (this.isVisible) {
tooltipElement.classList.remove('invisible', 'opacity-0');
tooltipElement.classList.add('visible', 'opacity-100');
} else {
tooltipElement.classList.remove('visible', 'opacity-100');
tooltipElement.classList.add('invisible', 'opacity-0');
}
}
}
}

View File

@@ -1,13 +1,15 @@
<div>
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit"
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
>{{ label }} @if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage"></app-tooltip>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<ng-select
@@ -19,11 +21,11 @@
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
ngModel="{{ value?.city }} {{ value ? '-' : '' }} {{ value?.state }}"
ngModel="{{ value?.name }} {{ value ? '-' : '' }} {{ value?.state }}"
(ngModelChange)="onInputChange($event)"
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.city }} - {{ city.state }}</ng-option>
<ng-option [value]="city">{{ city.name }} - {{ city.state }}</ng-option>
}
</ng-select>
</div>

View File

@@ -1,7 +1,7 @@
:host ::ng-deep .ng-select.custom .ng-select-container {
// --tw-bg-opacity: 1;
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
height: 42px;
// height: 42px;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;

View File

@@ -27,6 +27,8 @@ import { ValidationMessagesService } from '../validation-messages.service';
})
export class ValidatedCityComponent extends BaseInputComponent {
@Input() items;
@Input() labelClasses: string;
@Input() state: string;
cities$: Observable<GeoResult[]>;
cityInput$ = new Subject<string>();
countyInput$ = new Subject<string>();
@@ -50,7 +52,7 @@ export class ValidatedCityComponent extends BaseInputComponent {
distinctUntilChanged(),
tap(() => (this.cityLoading = true)),
switchMap(term =>
this.geoService.findCitiesStartingWith(term).pipe(
this.geoService.findCitiesStartingWith(term, this.state).pipe(
catchError(() => of([])), // empty list on error
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
tap(() => (this.cityLoading = false)),

View File

@@ -0,0 +1,34 @@
<div>
@if(label){
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
>{{ label }} @if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
}
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="countyLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="countyInput$"
ngModel="{{ value }}"
(ngModelChange)="onInputChange($event)"
[readonly]="readonly"
>
@for (county of counties$ | async; track county.id) {
<ng-option [value]="county">{{ county }}</ng-option>
}
</ng-select>
</div>

Some files were not shown because too many files have changed in this diff Show More