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 /dist
/node_modules /node_modules
/build /build
/data
# Logs # Logs
logs logs
@@ -60,3 +61,8 @@ pictures_base
src/*.js src/*.js
bun.lockb bun.lockb
#drizzle migrations
src/drizzle/migrations
importlog.txt

View File

@@ -14,7 +14,8 @@
"console": "integratedTerminal", "console": "integratedTerminal",
"env": { "env": {
"HOST_NAME": "localhost" "HOST_NAME": "localhost"
} },
"preLaunchTask": "Start Stripe Listener"
}, },
{ {
"type": "node", "type": "node",
@@ -60,5 +61,30 @@
"sourceMaps": true, "sourceMaps": true,
"smartStep": 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": "", "author": "",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"type": "module",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "HOST_NAME=localhost nest start", "start": "nest start",
"start:dev": "HOST_NAME=dev.bizmatch.net nest start --watch", "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: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", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@@ -34,15 +34,22 @@
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/serve-static": "^4.0.1", "@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", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-flow": "^4.1.0",
"drizzle-orm": "^0.32.0", "drizzle-orm": "^0.32.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"groq-sdk": "^0.5.0", "groq-sdk": "^0.5.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwk-to-pem": "^2.0.6",
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"ky": "^1.4.0", "ky": "^1.4.0",
"nest-winston": "^1.9.4", "nest-winston": "^1.9.4",
"nestjs-cls": "^4.4.1",
"nodemailer": "^6.9.10", "nodemailer": "^6.9.10",
"nodemailer-smtp-transport": "^2.7.4", "nodemailer-smtp-transport": "^2.7.4",
"openai": "^4.52.6", "openai": "^4.52.6",
@@ -55,6 +62,7 @@
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"stripe": "^16.8.0",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"urlcat": "^3.1.0", "urlcat": "^3.1.0",
"winston": "^3.11.0", "winston": "^3.11.0",
@@ -68,6 +76,8 @@
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/jwk-to-pem": "^2.0.3",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^20.11.19", "@types/node": "^20.11.19",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",

View File

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

View File

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

View File

@@ -3,30 +3,85 @@ import Groq from 'groq-sdk';
import OpenAI from 'openai'; import OpenAI from 'openai';
import { BusinessListingCriteria } from '../models/main.model'; import { BusinessListingCriteria } from '../models/main.model';
const businessListingCriteriaStructure = { // const businessListingCriteriaStructure = {
criteriaType: 'business | commercialProperty | broker', // criteriaType: 'business | commercialProperty | broker',
types: "'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'", // types: "'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'",
city: 'string', // city: 'string',
state: 'string', // state: 'string',
county: 'string', // county: 'string',
minPrice: 'number', // minPrice: 'number',
maxPrice: 'number', // maxPrice: 'number',
minRevenue: 'number', // minRevenue: 'number',
maxRevenue: 'number', // maxRevenue: 'number',
minCashFlow: 'number', // minCashFlow: 'number',
maxCashFlow: 'number', // maxCashFlow: 'number',
minNumberEmployees: 'number', // minNumberEmployees: 'number',
maxNumberEmployees: 'number', // maxNumberEmployees: 'number',
establishedSince: 'number', // establishedSince: 'number',
establishedUntil: 'number', // establishedUntil: 'number',
realEstateChecked: 'boolean', // realEstateChecked: 'boolean',
leasedLocation: 'boolean', // leasedLocation: 'boolean',
franchiseResale: 'boolean', // franchiseResale: 'boolean',
title: 'string', // title: 'string',
brokerName: 'string', // brokerName: 'string',
searchType: "'exact' | 'radius'", // searchType: "'exact' | 'radius'",
radius: "'0' | '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'", // 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() @Injectable()
export class AiService { export class AiService {
private readonly openai: OpenAI; private readonly openai: OpenAI;
@@ -67,8 +122,10 @@ export class AiService {
{ {
role: 'system', role: 'system',
content: `Please create unformatted JSON Object from a user input. content: `Please create unformatted JSON Object from a user input.
The type must be: ${JSON.stringify(businessListingCriteriaStructure)}. The criteriaType must be only either 'businessListings' or 'commercialPropertyListings' or 'brokerListings' !!!!
If location details available please fill city, county and state as State Code`, 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', role: 'user',
@@ -77,6 +134,8 @@ export class AiService {
], ],
model: 'llama-3.1-70b-versatile', model: 'llama-3.1-70b-versatile',
//model: 'llama-3.1-8b-instant', //model: 'llama-3.1-8b-instant',
// model: 'mixtral-8x7b-32768',
//model: 'gemma2-9b-it',
temperature: 0.2, temperature: 0.2,
max_tokens: 300, max_tokens: 300,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Request, UseGuards } from '@nestjs/common'; import { Controller, Get, Request, UseGuards } from '@nestjs/common';
import { AppService } from './app.service.js'; import { AppService } from './app.service';
import { AuthService } from './auth/auth.service.js'; import { AuthService } from './auth/auth.service';
import { JwtAuthGuard } from './jwt-auth/jwt-auth.guard.js'; import { JwtAuthGuard } from './jwt-auth/jwt-auth.guard';
@Controller() @Controller()
export class AppController { export class AppController {
@@ -14,6 +14,5 @@ export class AppController {
@Get() @Get()
getHello(@Request() req): string { getHello(@Request() req): string {
return req.user; 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 { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import * as dotenv from 'dotenv'; import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
import fs from 'fs-extra';
import { WinstonModule, utilities as nestWinstonModuleUtilities } from 'nest-winston';
import * as winston from 'winston'; import * as winston from 'winston';
import { AiModule } from './ai/ai.module.js'; import { AiModule } from './ai/ai.module';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller';
import { AppService } from './app.service.js'; import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module.js'; import { AuthModule } from './auth/auth.module';
import { FileService } from './file/file.service.js'; import { FileService } from './file/file.service';
import { GeoModule } from './geo/geo.module.js'; import { GeoModule } from './geo/geo.module';
import { ImageModule } from './image/image.module.js'; import { ImageModule } from './image/image.module';
import { ListingsModule } from './listings/listings.module.js'; import { ListingsModule } from './listings/listings.module';
import { MailModule } from './mail/mail.module.js'; import { LogController } from './log/log.controller';
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware.js'; import { LogModule } from './log/log.module';
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);
function loadEnvFiles() { import dotenvFlow from 'dotenv-flow';
// Load the .env file import { EventModule } from './event/event.module';
dotenv.config(); import { JwtStrategy } from './jwt.strategy';
console.log('Loaded .env file'); import { MailModule } from './mail/mail.module';
// Determine which additional env file to load import { APP_INTERCEPTOR } from '@nestjs/core';
let envFilePath = ''; import { ClsMiddleware, ClsModule } from 'nestjs-cls';
const host = process.env.HOST_NAME || ''; 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')) { //loadEnvFiles();
envFilePath = '.env.local'; dotenvFlow.config();
} else if (host.includes('dev.bizmatch.net')) { console.log('Loaded environment variables:');
envFilePath = '.env.dev'; console.log(JSON.stringify(process.env, null, 2));
} 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();
@Module({ @Module({
imports: [ 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 }), ConfigModule.forRoot({ isGlobal: true }),
MailModule, MailModule,
AuthModule, AuthModule,
@@ -56,7 +48,9 @@ loadEnvFiles();
transports: [ transports: [
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp(), winston.format.timestamp({
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
}),
winston.format.ms(), winston.format.ms(),
nestWinstonModuleUtilities.format.nestLike('Bizmatch', { nestWinstonModuleUtilities.format.nestLike('Bizmatch', {
colors: true, colors: true,
@@ -75,12 +69,28 @@ loadEnvFiles();
ImageModule, ImageModule,
PassportModule, PassportModule,
AiModule, 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) { configure(consumer: MiddlewareConsumer) {
consumer.apply(ClsMiddleware).forRoutes('*');
consumer.apply(RequestDurationMiddleware).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 { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service.js'; 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') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@UseGuards(AdminAuthGuard)
@Get() @Get()
getAccessToken(): any { async getAccessToken(): Promise<any> {
return this.authService.getAccessToken(); return await this.authService.getAccessToken();
} }
@Get('users') @UseGuards(AdminAuthGuard)
getUsers(): any { @Get('user/all')
return this.authService.getUsers(); async getUsers(): Promise<any> {
} return await 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();
} }
@Get('user/:userid/groups') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth @UseGuards(JwtAuthGuard)
getGroupsForUsers(@Param('userid') userId: string): any { @Get('users/:userid')
return this.authService.getGroupsForUser(userId); async getUser(@Param('userid') userId: string): Promise<any> {
return await this.authService.getUser(userId);
} }
@UseGuards(JwtAuthGuard)
@Get('user/:userid/lastlogin') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth @Put('users/:userid')
getLastLogin(@Param('userid') userId: string): any { async updateKeycloakUser(@Body() keycloakUser: KeycloakUser): Promise<any> {
return this.authService.getLastLogin(userId); return await this.authService.updateKeycloakUser(keycloakUser);
}
@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(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 { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from '../jwt.strategy.js'; import { AuthController } from './auth.controller';
import { AuthController } from './auth.controller.js'; import { AuthService } from './auth.service';
import { AuthService } from './auth.service.js';
@Module({ @Module({
imports: [PassportModule], imports: [PassportModule],
providers: [AuthService, JwtStrategy], providers: [AuthService],
controllers: [AuthController], controllers: [AuthController],
exports: [AuthService], exports: [AuthService],
}) })

View File

@@ -1,32 +1,29 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import ky from 'ky'; import { KeycloakUser } from 'src/models/main.model';
import urlcat from 'urlcat'; import urlcat from 'urlcat';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
public async getAccessToken() { 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 { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('grant_type', 'password'); params.append('grant_type', 'password');
params.append('username', process.env.user); params.append('username', process.env.KEYCLOAK_ADMIN_USER);
params.append('password', process.env.password); params.append('password', process.env.KEYCLOAK_ADMIN_PASSWORD);
const URL = `${process.env.host}${process.env.tokenURL}`; const URL = `${process.env.KEYCLOAK_HOST}${process.env.KEYCLOAK_TOKEN_URL}`;
const response = await ky const response = await fetch(URL, {
.post(URL, { method: 'POST',
body: params.toString(), body: params.toString(),
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic YWRtaW4tY2xpOnE0RmJnazFkd0NaelFQZmt5VzhhM3NnckV5UHZlRUY3', Authorization: process.env.KEYCLOAK_ADMIN_TOKEN,
}, },
}) });
.json(); if (!response.ok) {
return (<any>response).access_token; throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return (<any>data).access_token;
} catch (error) { } catch (error) {
if (error.name === 'HTTPError') { if (error.name === 'HTTPError') {
const errorJson = await error.response.json(); 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 token = await this.getAccessToken();
const URL = `${process.env.host}${process.env.usersURL}`; const URL = `${process.env.KEYCLOAK_HOST}${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_USERS_URL}`;
const response = await ky const response = await fetch(URL, {
.get(URL, { method: 'GET',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}) });
.json(); if (!response.ok) {
return response; 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 token = await this.getAccessToken();
const URL = urlcat(process.env.host, process.env.userURL, { userid }); const URLPATH = `${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_USER_URL}`;
const response = await ky const URL = urlcat(process.env.KEYCLOAK_HOST, URLPATH, { userid });
.get(URL, { const response = await fetch(URL, {
headers: { method: 'GET',
'Content-Type': 'application/x-www-form-urlencoded', headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded',
}, Authorization: `Bearer ${token}`,
}) },
.json(); });
return response; 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 token = await this.getAccessToken();
const URL = `${process.env.host}${process.env.groupsURL}`; const userid = keycloakUser.id;
const response = await ky const URLPATH = `${process.env.KEYCLOAK_ADMIN_REALM}${process.env.REALM}${process.env.KEYCLOAK_USER_URL}`;
.get(URL, { const URL = urlcat(process.env.KEYCLOAK_HOST, URLPATH, { userid });
headers: { const response = await fetch(URL, {
'Content-Type': 'application/x-www-form-urlencoded', method: 'PUT',
Authorization: `Bearer ${token}`, headers: {
}, 'Content-Type': 'application/json',
}) Authorization: `Bearer ${token}`,
.json(); },
return response; body: JSON.stringify(keycloakUser),
} });
if (!response.ok) {
public async getGroupsForUser(userid: string) { throw new Error(`HTTP error! status: ${response.status}`);
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;
} }
// 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 { 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 { ConfigService } from '@nestjs/config';
import { jsonb, varchar } from 'drizzle-orm/pg-core'; import { drizzle } from 'drizzle-orm/node-postgres';
import { PG_CONNECTION } from './schema.js'; 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({ @Module({
providers: [ providers: [
{ {
provide: PG_CONNECTION, provide: PG_CONNECTION,
inject: [ConfigService], inject: [ConfigService, WINSTON_MODULE_PROVIDER, ClsService],
useFactory: async (configService: ConfigService) => { useFactory: async (configService: ConfigService, logger: Logger, cls: ClsService) => {
const connectionString = configService.get<string>('DATABASE_URL'); const connectionString = configService.get<string>('DATABASE_URL');
const pool = new Pool({ const pool = new Pool({
connectionString, 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 { drizzle } from 'drizzle-orm/node-postgres';
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs'; import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
import fs from 'fs-extra'; import fs from 'fs-extra';
import OpenAI from 'openai';
import { join } from 'path'; import { join } from 'path';
import pkg from 'pg'; import { Pool } from 'pg';
import { rimraf } from 'rimraf'; import { rimraf } from 'rimraf';
import sharp from 'sharp'; import sharp from 'sharp';
import { BusinessListingService } from 'src/listings/business-listing.service.js'; import { BusinessListingService } from 'src/listings/business-listing.service';
import { CommercialPropertyService } from 'src/listings/commercial-property.service.js'; import { CommercialPropertyService } from 'src/listings/commercial-property.service';
import { Geo } from 'src/models/server.model.js'; import { Geo } from 'src/models/server.model';
import { UserService } from 'src/user/user.service';
import winston from 'winston'; import winston from 'winston';
import { User, UserData } from '../models/db.model.js'; import { User, UserData } from '../models/db.model';
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName, KeyValueStyle } from '../models/main.model.js'; import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName } from '../models/main.model';
import { SelectOptionsService } from '../select-options/select-options.service.js'; import { SelectOptionsService } from '../select-options/select-options.service';
import { convertUserToDrizzleUser } from '../utils.js'; import * as schema from './schema';
import * as schema from './schema.js';
interface PropertyImportListing { interface PropertyImportListing {
id: string; id: string;
userId: string; userId: string;
@@ -54,224 +53,226 @@ interface BusinessImportListing {
internals: string; internals: string;
created: string; created: string;
} }
const typesOfBusiness: Array<KeyValueStyle> = [ // const typesOfBusiness: Array<KeyValueStyle> = [
{ name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' }, // { 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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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' }, // { name: 'Food and Restaurant', value: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
]; // ];
const { Pool } = pkg; // const { Pool } = pkg;
const openai = new OpenAI({ // const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen // 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 connectionString = process.env.DATABASE_URL;
// const pool = new Pool({connectionString}) // const pool = new Pool({connectionString})
const client = new Pool({ connectionString }); const client = new Pool({ connectionString });
const db = drizzle(client, { schema, logger: true }); const db = drizzle(client, { schema, logger: true });
const logger = winston.createLogger({ const logger = winston.createLogger({
transports: [new winston.transports.Console()], 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'] });
}); });
user.areasServed = []; const commService = new CommercialPropertyService(null, db);
user.areasServed = userData.areasServed.map(l => { const businessService = new BusinessListingService(null, db);
return { county: l.split(',')[0].trim(), state: l.split(',')[1].trim() }; const userService = new UserService(null, db, null, null);
}); //Delete Content
user.hasCompanyLogo = true; await db.delete(schema.commercials);
user.hasProfile = true; await db.delete(schema.businesses);
user.firstname = userData.firstname; await db.delete(schema.users);
user.lastname = userData.lastname; let filePath = `./src/assets/geo.json`;
user.email = userData.email; const rawData = readFileSync(filePath, 'utf8');
user.phoneNumber = userData.phoneNumber; const geos = JSON.parse(rawData) as Geo;
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 u = await db const sso = new SelectOptionsService();
.insert(schema.users) //Broker
.values(convertUserToDrizzleUser(user)) filePath = `./data/broker.json`;
.returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname }); let data: string = readFileSync(filePath, 'utf8');
generatedUserData.push(u[0]); const usersData: UserData[] = JSON.parse(data); // Erwartet ein Array von Objekten
i++; const generatedUserData = [];
logger.info(`user_${index} inserted`); console.log(usersData.length);
if (u[0].gender === 'male') { let i = 0,
male++; male = 0,
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`); female = 0;
await storeProfilePicture(data, emailToDirName(u[0].email)); const targetPathProfile = `./pictures/profile`;
} else { deleteFilesOfDir(targetPathProfile);
female++; const targetPathLogo = `./pictures/logo`;
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`); deleteFilesOfDir(targetPathLogo);
await storeProfilePicture(data, emailToDirName(u[0].email)); 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 //Corporate Listings
filePath = `./data/commercials.json`; filePath = `./data/commercials.json`;
data = readFileSync(filePath, 'utf8'); data = readFileSync(filePath, 'utf8');
const commercialJsonData = JSON.parse(data) as PropertyImportListing[]; // Erwartet ein Array von Objekten const commercialJsonData = JSON.parse(data) as PropertyImportListing[]; // Erwartet ein Array von Objekten
for (let index = 0; index < commercialJsonData.length; index++) { for (let index = 0; index < commercialJsonData.length; index++) {
const user = getRandomItem(generatedUserData); const user = getRandomItem(generatedUserData);
const commercial = createDefaultCommercialPropertyListing(); const commercial = createDefaultCommercialPropertyListing();
const id = commercialJsonData[index].id; const id = commercialJsonData[index].id;
delete commercial.id; delete commercial.id;
commercial.email = user.email; commercial.email = user.email;
commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercialJsonData[index].type)).value; commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercialJsonData[index].type)).value;
commercial.title = commercialJsonData[index].title; commercial.title = commercialJsonData[index].title;
commercial.description = commercialJsonData[index].description; commercial.description = commercialJsonData[index].description;
try { try {
const cityGeo = geos.states.find(s => s.state_code === commercialJsonData[index].state).cities.find(c => c.name === commercialJsonData[index].city); const cityGeo = geos.states.find(s => s.state_code === commercialJsonData[index].state).cities.find(c => c.name === commercialJsonData[index].city);
commercial.location = {}; commercial.location = {};
commercial.location.latitude = cityGeo.latitude; commercial.location.latitude = cityGeo.latitude;
commercial.location.longitude = cityGeo.longitude; commercial.location.longitude = cityGeo.longitude;
commercial.location.city = commercialJsonData[index].city; commercial.location.name = commercialJsonData[index].city;
commercial.location.state = commercialJsonData[index].state; commercial.location.state = commercialJsonData[index].state;
// console.log(JSON.stringify(commercial.location)); // console.log(JSON.stringify(commercial.location));
} catch (e) { } catch (e) {
console.log(`----------------> ERROR ${commercialJsonData[index].state} - ${commercialJsonData[index].city}`); console.log(`----------------> ERROR ${commercialJsonData[index].state} - ${commercialJsonData[index].city}`);
continue; 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(); //Business Listings
try { filePath = `./data/businesses.json`;
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result.imagePath}/${result.serialId}`); data = readFileSync(filePath, 'utf8');
} catch (err) { const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
console.log(`----- No pictures available for ${id} ------ ${err}`); 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 //End
filePath = `./data/businesses.json`; await client.end();
data = readFileSync(filePath, 'utf8'); })();
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten // function sleep(ms) {
for (let index = 0; index < businessJsonData.length; index++) { // return new Promise(resolve => setTimeout(resolve, ms));
const business = createDefaultBusinessListing(); //businessJsonData[index]; // }
delete business.id; // async function createEmbedding(text: string): Promise<number[]> {
const user = getRandomItem(generatedUserData); // const response = await openai.embeddings.create({
business.email = user.email; // model: 'text-embedding-3-small',
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value; // input: text,
business.title = businessJsonData[index].title; // });
business.description = businessJsonData[index].description; // return response.data[0].embedding;
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;
}
function getRandomItem<T>(arr: T[]): T { function getRandomItem<T>(arr: T[]): T {
if (arr.length === 0) { if (arr.length === 0) {
@@ -283,7 +284,7 @@ function getRandomItem<T>(arr: T[]): T {
} }
function getFilenames(id: string): string[] { function getFilenames(id: string): string[] {
try { try {
let filePath = `./pictures_base/property/${id}`; const filePath = `./pictures_base/property/${id}`;
return readdirSync(filePath); return readdirSync(filePath);
} catch (e) { } catch (e) {
return []; return [];
@@ -300,7 +301,7 @@ function getRandomDateWithinLastYear(): Date {
return randomDate; return randomDate;
} }
async function storeProfilePicture(buffer: Buffer, userId: string) { async function storeProfilePicture(buffer: Buffer, userId: string) {
let quality = 50; const quality = 50;
const output = await sharp(buffer) const output = await sharp(buffer)
.resize({ width: 300 }) .resize({ width: 300 })
.avif({ quality }) // Verwende AVIF .avif({ quality }) // Verwende AVIF
@@ -310,7 +311,7 @@ async function storeProfilePicture(buffer: Buffer, userId: string) {
} }
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) { async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
let quality = 50; const quality = 50;
const output = await sharp(buffer) const output = await sharp(buffer)
.resize({ width: 300 }) .resize({ width: 300 })
.avif({ quality }) // Verwende AVIF .avif({ quality }) // Verwende AVIF

View File

@@ -1,7 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres'; import { drizzle } from 'drizzle-orm/node-postgres';
import pkg from 'pg'; import pkg from 'pg';
import * as schema from './schema.js'; import * as schema from './schema';
const { Pool } = pkg; const { Pool } = pkg;
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString }); 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'; import { AreasServed, LicensedIn } from '../models/db.model';
export const PG_CONNECTION = 'PG_CONNECTION'; export const PG_CONNECTION = 'PG_CONNECTION';
export const genderEnum = pgEnum('gender', ['male', 'female']); 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 customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']); 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(), id: uuid('id').primaryKey().defaultRandom().notNull(),
firstname: varchar('firstname', { length: 255 }).notNull(), listingId: varchar('listing_id', { length: 255 }), // Assuming listings are referenced by UUID, adjust as necessary
lastname: varchar('lastname', { length: 255 }).notNull(), email: varchar('email', { length: 255 }),
email: varchar('email', { length: 255 }).notNull().unique(), eventType: varchar('event_type', { length: 50 }), // 'view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact'
phoneNumber: varchar('phoneNumber', { length: 255 }), eventTimestamp: timestamp('event_timestamp').defaultNow(),
description: text('description'), userIp: varchar('user_ip', { length: 45 }), // Optional if you choose to track IP in frontend or backend
companyName: varchar('companyName', { length: 255 }), userAgent: varchar('user_agent', { length: 255 }), // Store User-Agent as string
companyOverview: text('companyOverview'), locationCountry: varchar('location_country', { length: 100 }), // Country from IP
companyWebsite: varchar('companyWebsite', { length: 255 }), locationCity: varchar('location_city', { length: 100 }), // City from IP
city: varchar('city', { length: 255 }), locationLat: varchar('location_lat', { length: 20 }), // Latitude from IP, stored as varchar
state: char('state', { length: 2 }), locationLng: varchar('location_lng', { length: 20 }), // Longitude from IP, stored as varchar
offeredServices: text('offeredServices'), referrer: varchar('referrer', { length: 255 }), // Referrer URL if applicable
areasServed: jsonb('areasServed').$type<AreasServed[]>(), additionalData: jsonb('additional_data'), // JSON for any other optional data (like email, social shares etc.)
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 }),
}); });

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
// Angenommen, du hast eine Datei `databaseModels.js` mit deinen pgTable-Definitionen // Angenommen, du hast eine Datei `databaseModels.js` mit deinen pgTable-Definitionen
const { users } = require('./schema.js'); const { users } = require('./schema');
function generateTypeScriptInterface(tableDefinition, tableName) { function generateTypeScriptInterface(tableDefinition, tableName) {
let interfaceString = `export interface ${tableName} {\n`; 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 { Inject, Injectable } from '@nestjs/common';
import { readFileSync } from 'fs';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import path, { join } from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { fileURLToPath } from 'url';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { ImageProperty, Subscription } from '../models/main.model.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@Injectable() @Injectable()
export class FileService { export class FileService {
private subscriptions: any;
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) { constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
this.loadSubscriptions();
fs.ensureDirSync(`./pictures`); fs.ensureDirSync(`./pictures`);
fs.ensureDirSync(`./pictures/profile`); fs.ensureDirSync(`./pictures/profile`);
fs.ensureDirSync(`./pictures/logo`); fs.ensureDirSync(`./pictures/logo`);
fs.ensureDirSync(`./pictures/property`); 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 // Profile
// ############ // ############
async storeProfilePicture(file: Express.Multer.File, adjustedEmail: string) { async storeProfilePicture(file: Express.Multer.File, adjustedEmail: string) {
let quality = 50; const quality = 50;
const output = await sharp(file.buffer) const output = await sharp(file.buffer)
.resize({ width: 300 }) .resize({ width: 300 })
.avif({ quality }) // Verwende AVIF .avif({ quality }) // Verwende AVIF
@@ -50,7 +31,7 @@ export class FileService {
// Logo // Logo
// ############ // ############
async storeCompanyLogo(file: Express.Multer.File, adjustedEmail: string) { async storeCompanyLogo(file: Express.Multer.File, adjustedEmail: string) {
let quality = 50; const quality = 50;
const output = await sharp(file.buffer) const output = await sharp(file.buffer)
.resize({ width: 300 }) .resize({ width: 300 })
.avif({ quality }) // Verwende AVIF .avif({ quality }) // Verwende AVIF
@@ -79,7 +60,6 @@ export class FileService {
} }
} }
async hasPropertyImages(imagePath: string, serial: string): Promise<boolean> { async hasPropertyImages(imagePath: string, serial: string): Promise<boolean> {
const result: ImageProperty[] = [];
const directory = `./pictures/property/${imagePath}/${serial}`; const directory = `./pictures/property/${imagePath}/${serial}`;
if (fs.existsSync(directory)) { if (fs.existsSync(directory)) {
const files = await fs.readdir(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> { 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}`; const directory = `./pictures/property/${imagePath}/${serial}`;
fs.ensureDirSync(`${directory}`); fs.ensureDirSync(`${directory}`);
const imageName = await this.getNextImageName(directory); const imageName = await this.getNextImageName(directory);
@@ -116,16 +95,15 @@ export class FileService {
} }
} }
async resizeImageToAVIF(buffer: Buffer, maxSize: number, imageName: string, directory: string) { async resizeImageToAVIF(buffer: Buffer, maxSize: number, imageName: string, directory: string) {
let quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen const quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen
let output; const start = Date.now();
let start = Date.now(); const output = await sharp(buffer)
output = await sharp(buffer)
.resize({ width: 1500 }) .resize({ width: 1500 })
.avif({ quality }) // Verwende AVIF .avif({ quality }) // Verwende AVIF
//.webp({ quality }) // Verwende Webp //.webp({ quality }) // Verwende Webp
.toBuffer(); .toBuffer();
await sharp(output).toFile(`${directory}/${imageName}.avif`); // Ersetze Dateierweiterung 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`); this.logger.info(`Quality: ${quality} - Time: ${timeTaken} milliseconds`);
} }
deleteImage(path: string) { deleteImage(path: string) {

View File

@@ -1,27 +1,41 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { CountyRequest } from 'src/models/server.model.js'; import { RealIp } from 'src/decorators/real-ip.decorator';
import { GeoService } from './geo.service.js'; 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') @Controller('geo')
export class GeoController { export class GeoController {
constructor(private geoService: GeoService) {} constructor(private geoService: GeoService) {}
@UseGuards(OptionalJwtAuthGuard)
@Get(':prefix') @Get(':prefix')
findByPrefix(@Param('prefix') prefix: string): any { findByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesStartingWith(prefix); return this.geoService.findCitiesStartingWith(prefix);
} }
@UseGuards(OptionalJwtAuthGuard)
@Get('citiesandstates/:prefix') @Get('citiesandstates/:prefix')
findByCitiesAndStatesByPrefix(@Param('prefix') prefix: string): any { findByCitiesAndStatesByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesAndStatesStartingWith(prefix); return this.geoService.findCitiesAndStatesStartingWith(prefix);
} }
@UseGuards(OptionalJwtAuthGuard)
@Get(':prefix/:state') @Get(':prefix/:state')
findByPrefixAndState(@Param('prefix') prefix: string, @Param('state') state: string): any { findByPrefixAndState(@Param('prefix') prefix: string, @Param('state') state: string): any {
return this.geoService.findCitiesStartingWith(prefix, state); return this.geoService.findCitiesStartingWith(prefix, state);
} }
@UseGuards(OptionalJwtAuthGuard)
@Post('counties') @Post('counties')
findByPrefixAndStates(@Body() countyRequest: CountyRequest): any { findByPrefixAndStates(@Body() countyRequest: CountyRequest): any {
return this.geoService.findCountiesStartingWith(countyRequest.prefix, countyRequest.states); 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 { Module } from '@nestjs/common';
import { GeoController } from './geo.controller.js'; import { GeoController } from './geo.controller';
import { GeoService } from './geo.service.js'; import { GeoService } from './geo.service';
@Module({ @Module({
controllers: [GeoController], controllers: [GeoController],
providers: [GeoService] providers: [GeoService],
}) })
export class GeoModule {} 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 { readFileSync } from 'fs';
import path, { join } from 'path'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { CountyResult, GeoResult } from 'src/models/main.model.js'; import { join } from 'path';
import { fileURLToPath } from 'url'; import { CityAndStateResult, CountyResult, GeoResult, IpInfo, RealIpInfo } from 'src/models/main.model';
import { City, CountyData, Geo, State } from '../models/server.model.js'; import { Logger } from 'winston';
import { City, CountyData, Geo, State } from '../models/server.model';
const __filename = fileURLToPath(import.meta.url); // const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // const __dirname = path.dirname(__filename);
@Injectable() @Injectable()
export class GeoService { export class GeoService {
geo: Geo; geo: Geo;
counties: CountyData[]; counties: CountyData[];
constructor() { constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
this.loadGeo(); this.loadGeo();
} }
private loadGeo(): void { private loadGeo(): void {
@@ -24,13 +24,13 @@ export class GeoService {
this.counties = JSON.parse(rawCountiesData); this.counties = JSON.parse(rawCountiesData);
} }
findCountiesStartingWith(prefix: string, states?: string[]) { findCountiesStartingWith(prefix: string, states?: string[]) {
let results: CountyResult[] = []; const results: CountyResult[] = [];
let idCounter = 1; let idCounter = 1;
this.counties.forEach(stateData => { this.counties.forEach(stateData => {
if (!states || states.includes(stateData.state)) { if (!states || states.includes(stateData.state)) {
stateData.counties.forEach(county => { stateData.counties.forEach(county => {
if (county.startsWith(prefix.toUpperCase())) { if (county.startsWith(prefix?.toUpperCase())) {
results.push({ results.push({
id: idCounter++, id: idCounter++,
name: county, name: county,
@@ -52,7 +52,7 @@ export class GeoService {
if (city.name.toLowerCase().startsWith(prefix.toLowerCase())) { if (city.name.toLowerCase().startsWith(prefix.toLowerCase())) {
result.push({ result.push({
id: city.id, id: city.id,
city: city.name, name: city.name,
state: state.state_code, state: state.state_code,
//state_code: state.state_code, //state_code: state.state_code,
latitude: city.latitude, latitude: city.latitude,
@@ -63,8 +63,8 @@ export class GeoService {
}); });
return state ? result.filter(e => e.state.toLowerCase() === state.toLowerCase()) : result; 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 }> { findCitiesAndStatesStartingWith(prefix: string): Array<CityAndStateResult> {
const results: Array<{ id: string; name: string; type: 'city' | 'state'; state: string }> = []; const results: Array<CityAndStateResult> = [];
const lowercasePrefix = prefix.toLowerCase(); const lowercasePrefix = prefix.toLowerCase();
@@ -73,10 +73,9 @@ export class GeoService {
for (const state of this.geo.states) { for (const state of this.geo.states) {
if (state.name.toLowerCase().startsWith(lowercasePrefix)) { if (state.name.toLowerCase().startsWith(lowercasePrefix)) {
results.push({ results.push({
id: state.id.toString(), id: state.id,
name: state.name,
type: 'state', type: 'state',
state: state.state_code, content: state,
}); });
} }
@@ -84,10 +83,9 @@ export class GeoService {
for (const city of state.cities) { for (const city of state.cities) {
if (city.name.toLowerCase().startsWith(lowercasePrefix)) { if (city.name.toLowerCase().startsWith(lowercasePrefix)) {
results.push({ results.push({
id: city.id.toString(), id: city.id,
name: city.name,
type: 'city', type: 'city',
state: state.state_code, content: { state: state.state_code, ...city },
}); });
} }
} }
@@ -97,10 +95,27 @@ export class GeoService {
return results.sort((a, b) => { return results.sort((a, b) => {
if (a.type === 'state' && b.type === 'city') return -1; if (a.type === 'state' && b.type === 'city') return -1;
if (a.type === 'city' && b.type === 'state') 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 { getCityWithCoords(state: string, city: string): City {
return this.geo.states.find(s => s.state_code === state).cities.find(c => c.name === 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 { FileInterceptor } from '@nestjs/platform-express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { JwtAuthGuard } from 'src/jwt-auth/jwt-auth.guard';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { FileService } from '../file/file.service.js'; import { FileService } from '../file/file.service';
import { CommercialPropertyService } from '../listings/commercial-property.service.js'; import { CommercialPropertyService } from '../listings/commercial-property.service';
import { SelectOptionsService } from '../select-options/select-options.service.js'; import { SelectOptionsService } from '../select-options/select-options.service';
@Controller('image') @Controller('image')
export class ImageController { export class ImageController {
@@ -17,12 +18,14 @@ export class ImageController {
// ############ // ############
// Property // Property
// ############ // ############
@UseGuards(JwtAuthGuard)
@Post('uploadPropertyPicture/:imagePath/:serial') @Post('uploadPropertyPicture/:imagePath/:serial')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadPropertyPicture(@UploadedFile() file: Express.Multer.File, @Param('imagePath') imagePath: string, @Param('serial') serial: string) { async uploadPropertyPicture(@UploadedFile() file: Express.Multer.File, @Param('imagePath') imagePath: string, @Param('serial') serial: string) {
const imagename = await this.fileService.storePropertyPicture(file, imagePath, serial); const imagename = await this.fileService.storePropertyPicture(file, imagePath, serial);
await this.listingService.addImage(imagePath, serial, imagename); await this.listingService.addImage(imagePath, serial, imagename);
} }
@UseGuards(JwtAuthGuard)
@Delete('propertyPicture/:imagePath/:serial/:imagename') @Delete('propertyPicture/:imagePath/:serial/:imagename')
async deletePropertyImagesById(@Param('imagePath') imagePath: string, @Param('serial') serial: string, @Param('imagename') imagename: string): Promise<any> { async deletePropertyImagesById(@Param('imagePath') imagePath: string, @Param('serial') serial: string, @Param('imagename') imagename: string): Promise<any> {
this.fileService.deleteImage(`pictures/property/${imagePath}/${serial}/${imagename}`); this.fileService.deleteImage(`pictures/property/${imagePath}/${serial}/${imagename}`);
@@ -31,11 +34,13 @@ export class ImageController {
// ############ // ############
// Profile // Profile
// ############ // ############
@UseGuards(JwtAuthGuard)
@Post('uploadProfile/:email') @Post('uploadProfile/:email')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadProfile(@UploadedFile() file: Express.Multer.File, @Param('email') adjustedEmail: string) { async uploadProfile(@UploadedFile() file: Express.Multer.File, @Param('email') adjustedEmail: string) {
await this.fileService.storeProfilePicture(file, adjustedEmail); await this.fileService.storeProfilePicture(file, adjustedEmail);
} }
@UseGuards(JwtAuthGuard)
@Delete('profile/:email/') @Delete('profile/:email/')
async deleteProfileImagesById(@Param('email') email: string): Promise<any> { async deleteProfileImagesById(@Param('email') email: string): Promise<any> {
this.fileService.deleteImage(`pictures/profile/${email}.avif`); this.fileService.deleteImage(`pictures/profile/${email}.avif`);
@@ -43,11 +48,13 @@ export class ImageController {
// ############ // ############
// Logo // Logo
// ############ // ############
@UseGuards(JwtAuthGuard)
@Post('uploadCompanyLogo/:email') @Post('uploadCompanyLogo/:email')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadCompanyLogo(@UploadedFile() file: Express.Multer.File, @Param('email') adjustedEmail: string) { async uploadCompanyLogo(@UploadedFile() file: Express.Multer.File, @Param('email') adjustedEmail: string) {
await this.fileService.storeCompanyLogo(file, adjustedEmail); await this.fileService.storeCompanyLogo(file, adjustedEmail);
} }
@UseGuards(JwtAuthGuard)
@Delete('logo/:email/') @Delete('logo/:email/')
async deleteLogoImagesById(@Param('email') adjustedEmail: string): Promise<any> { async deleteLogoImagesById(@Param('email') adjustedEmail: string): Promise<any> {
this.fileService.deleteImage(`pictures/logo/${adjustedEmail}.avif`); this.fileService.deleteImage(`pictures/logo/${adjustedEmail}.avif`);

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FileService } from '../file/file.service.js'; import { FileService } from '../file/file.service';
import { ListingsModule } from '../listings/listings.module.js'; import { ListingsModule } from '../listings/listings.module';
import { SelectOptionsService } from '../select-options/select-options.service.js'; import { SelectOptionsService } from '../select-options/select-options.service';
import { ImageController } from './image.controller.js'; import { ImageController } from './image.controller';
import { ImageService } from './image.service.js'; import { ImageService } from './image.service';
@Module({ @Module({
imports: [ListingsModule], 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 { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import fs from 'fs';
import { passportJwtSecret } from 'jwks-rsa'; import { passportJwtSecret } from 'jwks-rsa';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import path from 'path';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { JwtPayload, JwtUser } from './models/main.model'; import { JwtPayload, JwtUser } from './models/main.model';
// const logger = winston.createLogger({
// transports: [new winston.transports.Console()],
// });
// const pemCache = new Map();
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
@@ -13,13 +19,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) { ) {
const realm = configService.get<string>('REALM'); const realm = configService.get<string>('REALM');
// const staticCerts = loadStaticCerts();
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKeyProvider: passportJwtSecret({ secretOrKeyProvider: passportJwtSecret({
cache: true, cache: true,
rateLimit: true, rateLimit: false,
jwksRequestsPerMinute: 5, // jwksRequestsPerMinute: 5,
jwksUri: `https://auth.bizmatch.net/realms/${realm}/protocol/openid-connect/certs`, jwksUri: `https://auth.bizmatch.net/realms/${realm}/protocol/openid-connect/certs`,
}), }),
audience: 'account', // Keycloak Client ID audience: 'account', // Keycloak Client ID
@@ -28,7 +35,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
algorithms: ['RS256'], algorithms: ['RS256'],
}); });
} }
async validate(payload: JwtPayload): Promise<JwtUser> { async validate(payload: JwtPayload): Promise<JwtUser> {
if (!payload) { if (!payload) {
this.logger.error('Invalid payload'); this.logger.error('Invalid payload');
@@ -39,7 +45,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException(); 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 }; 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; 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 { 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 { Logger } from 'winston';
import { UserService } from '../user/user.service.js'; import { UserService } from '../user/user.service';
@Controller('listings/professionals_brokers') @Controller('listings/professionals_brokers')
export class BrokerListingsController { export class BrokerListingsController {
@@ -11,8 +12,9 @@ export class BrokerListingsController {
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {} ) {}
@UseGuards(OptionalJwtAuthGuard)
@Post('search') @Post('search')
find(@Body() criteria: UserListingCriteria): any { async find(@Body() criteria: UserListingCriteria): Promise<any> {
return this.userService.searchUserListings(criteria); return await this.userService.searchUserListings(criteria);
} }
} }

View File

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

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { BusinessListing } from 'src/models/db.model.js';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js'; import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
import { BusinessListingCriteria, JwtUser } from '../models/main.model.js'; import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard';
import { BusinessListingService } from './business-listing.service.js'; import { BusinessListing } from '../models/db.model';
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
import { BusinessListingService } from './business-listing.service';
@Controller('listings/business') @Controller('listings/business')
export class BusinessListingsController { export class BusinessListingsController {
@@ -15,47 +16,52 @@ export class BusinessListingsController {
@UseGuards(OptionalJwtAuthGuard) @UseGuards(OptionalJwtAuthGuard)
@Get(':id') @Get(':id')
findById(@Request() req, @Param('id') id: string): any { async findById(@Request() req, @Param('id') id: string): Promise<any> {
return this.listingsService.findBusinessesById(id, req.user as JwtUser); 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) @UseGuards(OptionalJwtAuthGuard)
@Get('user/:userid') @Get('user/:userid')
findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> { async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
return this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser); return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
} }
@UseGuards(OptionalJwtAuthGuard) @UseGuards(OptionalJwtAuthGuard)
@Post('find') @Post('find')
find(@Request() req, @Body() criteria: BusinessListingCriteria): any { async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
return this.listingsService.searchBusinessListings(criteria, req.user as JwtUser); return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
} }
@UseGuards(OptionalJwtAuthGuard)
@Post('findTotal') @Post('findTotal')
findTotal(@Body() criteria: BusinessListingCriteria): Promise<number> { async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
return this.listingsService.getBusinessListingsCount(criteria); 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() @Post()
create(@Body() listing: any) { async create(@Body() listing: any) {
this.logger.info(`Save Listing`); return await this.listingsService.createListing(listing);
return this.listingsService.createListing(listing);
} }
@UseGuards(OptionalJwtAuthGuard)
@Put() @Put()
update(@Body() listing: any) { async update(@Body() listing: any) {
this.logger.info(`Save Listing`); return await this.listingsService.updateBusinessListing(listing.id, listing);
return this.listingsService.updateBusinessListing(listing.id, listing);
} }
@Delete(':id')
deleteById(@Param('id') id: string) { @UseGuards(OptionalJwtAuthGuard)
this.listingsService.deleteListing(id); @Delete('listing/:id')
async deleteById(@Param('id') id: string) {
await this.listingsService.deleteListing(id);
} }
@Get('states/all')
getStates(): any { @UseGuards(JwtAuthGuard)
return this.listingsService.getStates(); @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 { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { FileService } from '../file/file.service.js'; import { FileService } from '../file/file.service';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js'; import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard';
import { CommercialPropertyListing } from '../models/db.model'; import { CommercialPropertyListing } from '../models/db.model';
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model.js'; import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
import { CommercialPropertyService } from './commercial-property.service.js'; import { CommercialPropertyService } from './commercial-property.service';
@Controller('listings/commercialProperty') @Controller('listings/commercialProperty')
export class CommercialPropertyListingsController { export class CommercialPropertyListingsController {
@@ -17,41 +18,54 @@ export class CommercialPropertyListingsController {
@UseGuards(OptionalJwtAuthGuard) @UseGuards(OptionalJwtAuthGuard)
@Get(':id') @Get(':id')
findById(@Request() req, @Param('id') id: string): any { async findById(@Request() req, @Param('id') id: string): Promise<any> {
return this.listingsService.findCommercialPropertiesById(id, req.user as JwtUser); 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) @UseGuards(OptionalJwtAuthGuard)
@Get('user/:email') @Get('user/:email')
findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> { async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
return this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser); return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
} }
@UseGuards(OptionalJwtAuthGuard) @UseGuards(OptionalJwtAuthGuard)
@Post('find') @Post('find')
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> { async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser); return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
} }
@UseGuards(OptionalJwtAuthGuard)
@Post('findTotal') @Post('findTotal')
findTotal(@Body() criteria: CommercialPropertyListingCriteria): Promise<number> { async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
return this.listingsService.getCommercialPropertiesCount(criteria); return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
}
@Get('states/all')
getStates(): any {
return this.listingsService.getStates();
} }
@UseGuards(OptionalJwtAuthGuard)
@Post() @Post()
async create(@Body() listing: any) { async create(@Body() listing: any) {
this.logger.info(`Save Listing`);
return await this.listingsService.createListing(listing); return await this.listingsService.createListing(listing);
} }
@UseGuards(OptionalJwtAuthGuard)
@Put() @Put()
async update(@Body() listing: any) { async update(@Body() listing: any) {
this.logger.info(`Save Listing`);
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing); return await this.listingsService.updateCommercialPropertyListing(listing.id, listing);
} }
@Delete(':id/:imagePath')
deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) { @UseGuards(OptionalJwtAuthGuard)
this.listingsService.deleteListing(id); @Delete('listing/:id/:imagePath')
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
await this.listingsService.deleteListing(id);
this.fileService.deleteDirectoryIfExists(imagePath); 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 { 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 { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import * as schema from '../drizzle/schema.js'; import * as schema from '../drizzle/schema';
import { commercials, PG_CONNECTION } from '../drizzle/schema.js'; import { commercials, PG_CONNECTION } from '../drizzle/schema';
import { FileService } from '../file/file.service.js'; import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service.js'; import { GeoService } from '../geo/geo.service';
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model.js'; import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
import { CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js'; import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
import { convertCommercialToDrizzleCommercial, convertDrizzleCommercialToCommercial, getDistanceQuery } from '../utils.js'; import { getDistanceQuery } from '../utils';
@Injectable() @Injectable()
export class CommercialPropertyService { export class CommercialPropertyService {
@@ -20,14 +20,14 @@ export class CommercialPropertyService {
private fileService?: FileService, private fileService?: FileService,
private geoService?: GeoService, private geoService?: GeoService,
) {} ) {}
private getWhereConditions(criteria: CommercialPropertyListingCriteria): SQL[] { private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
if (criteria.city && criteria.searchType === 'exact') { 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) { 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}`); whereConditions.push(sql`${getDistanceQuery(commercials, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
} }
if (criteria.types && criteria.types.length > 0) { if (criteria.types && criteria.types.length > 0) {
@@ -35,7 +35,7 @@ export class CommercialPropertyService {
} }
if (criteria.state) { if (criteria.state) {
whereConditions.push(eq(schema.commercials.state, criteria.state)); whereConditions.push(sql`${schema.commercials.location}->>'state' = ${criteria.state}`);
} }
if (criteria.minPrice) { if (criteria.minPrice) {
@@ -49,7 +49,10 @@ export class CommercialPropertyService {
if (criteria.title) { if (criteria.title) {
whereConditions.push(or(ilike(schema.commercials.title, `%${criteria.title}%`), ilike(schema.commercials.description, `%${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; return whereConditions;
} }
// #### Find by criteria ######################################## // #### Find by criteria ########################################
@@ -57,28 +60,46 @@ export class CommercialPropertyService {
const start = criteria.start ? criteria.start : 0; const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12; 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 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) { if (whereConditions.length > 0) {
const whereClause = and(...whereConditions); const whereClause = and(...whereConditions);
query.where(whereClause); 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 // Paginierung
query.limit(length).offset(start); query.limit(length).offset(start);
const data = await query; const data = await query;
const results = data.map(r => r.commercial).map(r => convertDrizzleCommercialToCommercial(r)); const results = data.map(r => r.commercial);
const totalCount = await this.getCommercialPropertiesCount(criteria); const totalCount = await this.getCommercialPropertiesCount(criteria, user);
return { return {
results, results,
totalCount, 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 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) { if (whereConditions.length > 0) {
const whereClause = and(...whereConditions); const whereClause = and(...whereConditions);
@@ -91,18 +112,26 @@ export class CommercialPropertyService {
// #### Find by ID ######################################## // #### Find by ID ########################################
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> { 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() .select()
.from(commercials) .from(commercials)
.where(and(sql`${commercials.id} = ${id}`)); .where(and(...conditions));
result = result.filter(r => !r.draft || r.imagePath === emailToDirName(user?.username) || user?.roles.includes('ADMIN')); if (result.length > 0) {
return convertDrizzleCommercialToCommercial(result[0]) as CommercialPropertyListing; return result[0] as CommercialPropertyListing;
} else {
throw new BadRequestException(`No entry available for ${id}`);
}
} }
// #### Find by User EMail ######################################## // #### Find by User EMail ########################################
async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> { async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> {
const conditions = []; const conditions = [];
conditions.push(eq(commercials.imagePath, emailToDirName(email))); conditions.push(eq(commercials.email, email));
if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) { if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) {
conditions.push(ne(commercials.draft, true)); conditions.push(ne(commercials.draft, true));
} }
@@ -110,7 +139,15 @@ export class CommercialPropertyService {
.select() .select()
.from(commercials) .from(commercials)
.where(and(...conditions))) as CommercialPropertyListing[]; .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 ######################################## // #### Find by imagePath ########################################
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> { async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
@@ -118,24 +155,27 @@ export class CommercialPropertyService {
.select() .select()
.from(commercials) .from(commercials)
.where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`)); .where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`));
return convertDrizzleCommercialToCommercial(result[0]) as CommercialPropertyListing; return result[0] as CommercialPropertyListing;
} }
// #### CREATE ######################################## // #### CREATE ########################################
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> { async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
try { try {
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
data.updated = new Date(); data.updated = new Date();
const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data); CommercialPropertyListingSchema.parse(data);
const convertedCommercialPropertyListing = convertCommercialToDrizzleCommercial(data); const convertedCommercialPropertyListing = data;
delete convertedCommercialPropertyListing.id;
const [createdListing] = await this.conn.insert(commercials).values(convertedCommercialPropertyListing).returning(); const [createdListing] = await this.conn.insert(commercials).values(convertedCommercialPropertyListing).returning();
return convertDrizzleCommercialToCommercial(createdListing); return createdListing;
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({ const filteredErrors = error.errors
field: err.path.join('.'), .map(item => ({
message: err.message, ...item,
})); field: item.path[0],
throw new BadRequestException(formattedErrors); }))
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
throw new BadRequestException(filteredErrors);
} }
throw error; throw error;
} }
@@ -145,23 +185,25 @@ export class CommercialPropertyService {
try { try {
data.updated = new Date(); data.updated = new Date();
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : 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)); 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) { if (difference.length > 0) {
this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`); this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`);
data.imageOrder = imageOrder; 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(); const [updateListing] = await this.conn.update(commercials).set(convertedCommercialPropertyListing).where(eq(commercials.id, id)).returning();
return convertDrizzleCommercialToCommercial(updateListing); return updateListing;
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({ const filteredErrors = error.errors
field: err.path.join('.'), .map(item => ({
message: err.message, ...item,
})); field: item.path[0],
throw new BadRequestException(formattedErrors); }))
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
throw new BadRequestException(filteredErrors);
} }
throw error; throw error;
} }
@@ -186,14 +228,23 @@ export class CommercialPropertyService {
async deleteListing(id: string): Promise<void> { async deleteListing(id: string): Promise<void> {
await this.conn.delete(commercials).where(eq(commercials.id, id)); 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 // States
// ############################################################## // ##############################################################
async getStates(): Promise<any[]> { // async getStates(): Promise<any[]> {
return await this.conn // return await this.conn
.select({ state: commercials.state, count: sql<number>`count(${commercials.id})`.mapWith(Number) }) // .select({ state: commercials.state, count: sql<number>`count(${commercials.id})`.mapWith(Number) })
.from(commercials) // .from(commercials)
.groupBy(sql`${commercials.state}`) // .groupBy(sql`${commercials.state}`)
.orderBy(sql`count desc`); // .orderBy(sql`count desc`);
} // }
} }

View File

@@ -1,17 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js'; import { AuthModule } from '../auth/auth.module';
import { DrizzleModule } from '../drizzle/drizzle.module.js'; import { DrizzleModule } from '../drizzle/drizzle.module';
import { FileService } from '../file/file.service.js'; import { FileService } from '../file/file.service';
import { UserService } from '../user/user.service.js'; import { UserService } from '../user/user.service';
import { BrokerListingsController } from './broker-listings.controller.js'; import { BrokerListingsController } from './broker-listings.controller';
import { BusinessListingsController } from './business-listings.controller.js'; import { BusinessListingsController } from './business-listings.controller';
import { CommercialPropertyListingsController } from './commercial-property-listings.controller.js'; import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
import { GeoModule } from '../geo/geo.module.js'; import { GeoModule } from '../geo/geo.module';
import { GeoService } from '../geo/geo.service.js'; import { GeoService } from '../geo/geo.service';
import { BusinessListingService } from './business-listing.service.js'; import { BusinessListingService } from './business-listing.service';
import { CommercialPropertyService } from './commercial-property.service.js'; import { CommercialPropertyService } from './commercial-property.service';
import { UnknownListingsController } from './unknown-listings.controller.js'; import { UnknownListingsController } from './unknown-listings.controller';
@Module({ @Module({
imports: [DrizzleModule, AuthModule, GeoModule], 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 { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from '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') @Controller('listings/undefined')
export class UnknownListingsController { 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') @UseGuards(OptionalJwtAuthGuard)
// async findById(@Param('id') id: string): Promise<any> { @Get(':id')
// const result = await this.listingsService.findById(id, businesses); async findById(@Request() req, @Param('id') id: string): Promise<any> {
// if (result) { try {
// return result; return await this.businessListingsService.findBusinessesById(id, req.user);
// } else { } catch (error) {
// return await this.listingsService.findById(id, commercials); 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 { ErrorResponse, MailInfo } from '../models/main.model';
import { MailService } from './mail.service.js'; import { MailService } from './mail.service';
@Controller('mail') @Controller('mail')
export class MailController { export class MailController {
constructor(private mailService: MailService) {} constructor(private mailService: MailService) {}
@UseGuards(OptionalJwtAuthGuard)
@Post() @Post()
sendEMail(@Body() mailInfo: MailInfo): Promise<void | ErrorResponse> { async sendEMail(@Body() mailInfo: MailInfo): Promise<void | ErrorResponse> {
if (mailInfo.listing) { if (mailInfo.listing) {
return this.mailService.sendInquiry(mailInfo); return await this.mailService.sendInquiry(mailInfo);
} else { } 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 { 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 { Module } from '@nestjs/common';
import path, { join } from 'path'; import { join } from 'path';
import { fileURLToPath } from 'url'; import { DrizzleModule } from '../drizzle/drizzle.module';
import { DrizzleModule } from '../drizzle/drizzle.module.js'; import { FileService } from '../file/file.service';
import { FileService } from '../file/file.service.js'; import { GeoModule } from '../geo/geo.module';
import { GeoModule } from '../geo/geo.module.js'; import { GeoService } from '../geo/geo.service';
import { GeoService } from '../geo/geo.service.js'; import { UserModule } from '../user/user.module';
import { UserModule } from '../user/user.module.js'; import { UserService } from '../user/user.service';
import { UserService } from '../user/user.service.js'; import { MailController } from './mail.controller';
import { MailController } from './mail.controller.js'; import { MailService } from './mail.service';
import { MailService } from './mail.service.js'; // const __filename = fileURLToPath(import.meta.url);
const __filename = fileURLToPath(import.meta.url); // const __dirname = path.dirname(__filename);
const __dirname = path.dirname(__filename);
const user = process.env.amazon_user;
const password = process.env.amazon_password;
@Module({ @Module({
imports: [ imports: [
DrizzleModule, DrizzleModule,
UserModule, UserModule,
GeoModule, GeoModule,
MailerModule.forRoot({ // ConfigModule.forFeature(mailConfig),
transport: { // MailerModule.forRoot({
host: 'email-smtp.us-east-2.amazonaws.com', // transport: {
secure: false, // host: 'email-smtp.us-east-2.amazonaws.com',
port: 587, // secure: false,
auth: { // port: 587,
user: 'AKIAU6GDWVAQ2QNFLNWN', // auth: {
pass: 'BDE9nZv/ARbpotim1mIOir52WgIbpSi9cv1oJoH8oEf7', // 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: {
defaults: { from: '"No Reply" <noreply@example.com>',
from: '"No Reply" <noreply@example.com>',
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(), // or new PugAdapter() or new EjsAdapter()
options: {
strict: true,
}, },
}, template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter({
eq: function (a, b) {
return a === b;
},
}),
options: {
strict: true,
},
},
}),
}), }),
], ],
providers: [MailService, UserService, FileService, GeoService], providers: [MailService, UserService, FileService, GeoService],

View File

@@ -1,13 +1,12 @@
import { MailerService } from '@nestjs-modules/mailer'; import { MailerService } from '@nestjs-modules/mailer';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import path, { join } from 'path'; import { join } from 'path';
import { fileURLToPath } from 'url';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { SenderSchema } from '../models/db.model.js'; import { SenderSchema, ShareByEMail, ShareByEMailSchema, User } from '../models/db.model';
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model.js'; import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model';
import { UserService } from '../user/user.service.js'; import { UserService } from '../user/user.service';
const __filename = fileURLToPath(import.meta.url); // const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // const __dirname = path.dirname(__filename);
@Injectable() @Injectable()
export class MailService { export class MailService {
@@ -18,7 +17,7 @@ export class MailService {
async sendInquiry(mailInfo: MailInfo): Promise<void | ErrorResponse> { async sendInquiry(mailInfo: MailInfo): Promise<void | ErrorResponse> {
try { try {
const validatedSender = SenderSchema.parse(mailInfo.sender); SenderSchema.parse(mailInfo.sender);
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({ const formattedErrors = error.errors.map(err => ({
@@ -55,7 +54,7 @@ export class MailService {
} }
async sendRequest(mailInfo: MailInfo): Promise<void | ErrorResponse> { async sendRequest(mailInfo: MailInfo): Promise<void | ErrorResponse> {
try { try {
const validatedSender = SenderSchema.parse(mailInfo.sender); SenderSchema.parse(mailInfo.sender);
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const formattedErrors = error.errors.map(err => ({ 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 { NestFactory } from '@nestjs/core';
import bodyParser from 'body-parser';
import express from 'express'; 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() { async function bootstrap() {
const server = express(); const server = express();
server.set('trust proxy', true);
const app = await NestFactory.create(AppModule); 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.setGlobalPrefix('bizmatch');
app.enableCors({ app.enableCors({
origin: '*', origin: '*',
//origin: 'http://localhost:4200', // Die URL Ihrer Angular-App
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', 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); await app.listen(3000);
} }
bootstrap(); bootstrap();

View File

@@ -17,20 +17,25 @@ export interface UserData {
hasCompanyLogo?: boolean; hasCompanyLogo?: boolean;
licensedIn?: string[]; licensedIn?: string[];
gender?: 'male' | 'female'; gender?: 'male' | 'female';
customerType?: 'buyer' | 'broker' | 'professional'; customerType?: 'buyer' | 'seller' | 'professional';
customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser'; customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
created?: Date; created?: Date;
updated?: 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 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 CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
export type ListingsCategory = 'commercialProperty' | 'business'; export type ListingsCategory = 'commercialProperty' | 'business';
export const GenderEnum = z.enum(['male', 'female']); 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 CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']); 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 PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
const TypeEnum = z.enum([ const TypeEnum = z.enum([
'automotive', 'automotive',
@@ -102,38 +107,61 @@ const USStates = z.enum([
'WY', 'WY',
]); ]);
export const AreasServedSchema = z.object({ export const AreasServedSchema = z.object({
county: z.string().nonempty('County is required'), county: z.string().optional().nullable(),
state: z.string().nonempty('State is required'), state: z
.string()
.nullable()
.refine(val => val !== null && val !== '', {
message: 'State is required',
}),
}); });
export const LicensedInSchema = z.object({ export const LicensedInSchema = z.object({
registerNo: z.string().nonempty('Registration number is required'), state: z
state: z.string().nonempty('State is required'), .string()
.nullable()
.refine(val => val !== null && val !== '', {
message: 'State is required',
}),
registerNo: z.string().nonempty('License number is required'),
}); });
export const GeoSchema = z.object({ export const GeoSchema = z
city: z.string(), .object({
state: z.string().refine(val => USStates.safeParse(val).success, { name: z.string().optional().nullable(),
message: 'Invalid state. Must be a valid 2-letter US state code.', 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 => { latitude: z.number().refine(
return value >= -90 && value <= 90; value => {
}, return value >= -90 && value <= 90;
{ },
message: 'Latitude muss zwischen -90 und 90 liegen', {
}, message: 'Latitude muss zwischen -90 und 90 liegen',
), },
longitude: z.number().refine( ),
value => { longitude: z.number().refine(
return value >= -180 && value <= 180; value => {
}, return value >= -180 && value <= 180;
{ },
message: 'Longitude muss zwischen -180 und 180 liegen', {
}, message: 'Longitude muss zwischen -180 und 180 liegen',
), },
}); ),
const phoneRegex = /^\(\d{3}\)\s\d{3}-\d{4}$/; 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 export const UserSchema = z
.object({ .object({
id: z.string().uuid().optional().nullable(), id: z.string().uuid().optional().nullable(),
@@ -145,7 +173,7 @@ export const UserSchema = z
companyName: z.string().optional().nullable(), companyName: z.string().optional().nullable(),
companyOverview: z.string().optional().nullable(), companyOverview: z.string().optional().nullable(),
companyWebsite: z.string().url({ message: 'Invalid URL format' }).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(), offeredServices: z.string().optional().nullable(),
areasServed: z.array(AreasServedSchema).optional().nullable(), areasServed: z.array(AreasServedSchema).optional().nullable(),
hasProfile: z.boolean().optional().nullable(), hasProfile: z.boolean().optional().nullable(),
@@ -156,6 +184,8 @@ export const UserSchema = z
customerSubType: CustomerSubTypeEnum.optional().nullable(), customerSubType: CustomerSubTypeEnum.optional().nullable(),
created: z.date().optional().nullable(), created: z.date().optional().nullable(),
updated: z.date().optional().nullable(), updated: z.date().optional().nullable(),
subscriptionId: z.string().optional().nullable(),
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.customerType === 'professional') { if (data.customerType === 'professional') {
@@ -199,7 +229,7 @@ export const UserSchema = z
}); });
} }
if (!data.companyLocation) { if (!data.location) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Company location is required for professional customers', 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(), established: z.number().int().min(1800).max(2030).optional().nullable(),
internalListingNumber: z.number().int().positive().optional().nullable(), internalListingNumber: z.number().int().positive().optional().nullable(),
reasonForSale: z.string().min(5).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(), internals: z.string().min(5).optional().nullable(),
imageName: z.string().optional().nullable(), imageName: z.string().optional().nullable(),
created: z.date(), 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' }), comments: z.string().min(10, { message: 'Comments must be at least 10 characters long' }),
}); });
export type Sender = z.infer<typeof SenderSchema>; 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 { export interface StatesResult {
state: string; state: string;
@@ -9,6 +11,12 @@ export interface KeyValue {
name: string; name: string;
value: string; value: string;
} }
export interface KeyValueAsSortBy {
name: string;
value: SortByOptions;
type?: SortByTypes;
selectName?: string;
}
export interface KeyValueRatio { export interface KeyValueRatio {
label: string; label: string;
value: number; value: number;
@@ -59,8 +67,9 @@ export interface ListCriteria {
page: number; page: number;
types: string[]; types: string[];
state: string; state: string;
city: string; city: GeoResult;
prompt: string; prompt: string;
sortBy: SortByOptions;
searchType: 'exact' | 'radius'; searchType: 'exact' | 'radius';
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'; // radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
radius: number; radius: number;
@@ -91,8 +100,7 @@ export interface CommercialPropertyListingCriteria extends ListCriteria {
criteriaType: 'commercialPropertyListings'; criteriaType: 'commercialPropertyListings';
} }
export interface UserListingCriteria extends ListCriteria { export interface UserListingCriteria extends ListCriteria {
firstname: string; brokerName: string;
lastname: string;
companyName: string; companyName: string;
counties: string[]; counties: string[];
criteriaType: 'brokerListings'; criteriaType: 'brokerListings';
@@ -112,6 +120,7 @@ export interface KeycloakUser {
requiredActions?: any[]; requiredActions?: any[];
notBefore?: number; notBefore?: number;
access?: Access; access?: Access;
attributes?: Attributes;
} }
export interface JwtUser { export interface JwtUser {
userId: string; userId: string;
@@ -120,6 +129,10 @@ export interface JwtUser {
lastname: string; lastname: string;
roles: string[]; roles: string[];
} }
interface Attributes {
[key: string]: any;
priceID: any;
}
export interface Access { export interface Access {
manageGroupMembership: boolean; manageGroupMembership: boolean;
view: boolean; view: boolean;
@@ -166,6 +179,7 @@ export interface JwtToken {
family_name: string; family_name: string;
email: string; email: string;
user_id: string; user_id: string;
price_id: string;
} }
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
@@ -224,24 +238,46 @@ export interface UploadParams {
} }
export interface GeoResult { export interface GeoResult {
id: number; id: number;
city: string; name: string;
street?: string;
housenumber?: string;
county?: string;
zipCode?: number;
state: string; state: string;
// state_code: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
} }
export interface CityAndStateResult { interface CityResult {
id: number; id: number;
name: string; type: 'city';
type: string; content: GeoResult;
state: string;
} }
interface StateResult {
id: number;
type: 'state';
content: State;
}
export type CityAndStateResult = CityResult | StateResult;
export interface CountyResult { export interface CountyResult {
id: number; id: number;
name: string; name: string;
state: string; state: string;
state_code: 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 { export function isEmpty(value: any): boolean {
// Check for undefined or null // Check for undefined or null
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -281,7 +317,7 @@ export interface ValidationMessage {
field: string; field: string;
message: 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 { return {
id: undefined, id: undefined,
email, email,
@@ -292,7 +328,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
companyName: null, companyName: null,
companyOverview: null, companyOverview: null,
companyWebsite: null, companyWebsite: null,
companyLocation: null, location: null,
offeredServices: null, offeredServices: null,
areasServed: [], areasServed: [],
hasProfile: false, hasProfile: false,
@@ -303,6 +339,8 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
customerSubType: null, customerSubType: null,
created: new Date(), created: new Date(),
updated: new Date(), updated: new Date(),
subscriptionId: null,
subscriptionPlan: subscriptionPlan,
}; };
} }
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing { export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
@@ -352,3 +390,25 @@ export function createDefaultBusinessListing(): BusinessListing {
listingsCategory: 'business', 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; prefix: string;
states: 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 { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { getRealIpInfo } from 'src/utils/ip.util';
@Injectable() @Injectable()
export class RequestDurationMiddleware implements NestMiddleware { export class RequestDurationMiddleware implements NestMiddleware {
private readonly logger = new Logger(RequestDurationMiddleware.name); private readonly logger = new Logger(RequestDurationMiddleware.name);
constructor(private readonly cls: ClsService) {}
use(req: Request, res: Response, next: NextFunction) { use(req: Request, res: Response, next: NextFunction) {
const start = Date.now(); const { ip, countryCode } = getRealIpInfo(req);
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`;
if (req.method === 'POST' || req.method === 'PUT') { // Setze die IP-Adresse und den Ländercode im CLS-Kontext
const body = JSON.stringify(req.body); try {
logMessage += ` - Body: ${body}`; 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(); next();
} }
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ImageType, KeyValue, KeyValueStyle } from '../models/main.model.js'; import { ImageType, KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../models/main.model';
@Injectable() @Injectable()
export class SelectOptionsService { export class SelectOptionsService {
@@ -35,7 +35,19 @@ export class SelectOptionsService {
{ name: '$1M', value: '1000000' }, { name: '$1M', value: '1000000' },
{ name: '$5M', value: '5000000' }, { 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> = [ public distances: Array<KeyValue> = [
{ name: '5 miles', value: '5' }, { name: '5 miles', value: '5' },
{ name: '20 miles', value: '20' }, { name: '20 miles', value: '20' },
@@ -52,6 +64,7 @@ export class SelectOptionsService {
]; ];
public customerTypes: Array<KeyValue> = [ public customerTypes: Array<KeyValue> = [
{ name: 'Buyer', value: 'buyer' }, { name: 'Buyer', value: 'buyer' },
{ name: 'Commercial Property Seller', value: 'seller' },
{ name: 'Professional', value: 'professional' }, { name: 'Professional', value: 'professional' },
]; ];
public customerSubTypes: Array<KeyValue> = [ 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 { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { AdminAuthGuard } from 'src/jwt-auth/admin-auth.guard';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { FileService } from '../file/file.service.js'; import { ZodError } from 'zod';
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 { User } from '../models/db.model'; import { User } from '../models/db.model';
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model.js'; import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model';
import { UserService } from './user.service.js'; import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
@@ -14,52 +17,70 @@ export class UserController {
private fileService: FileService, private fileService: FileService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {} ) {}
@UseGuards(OptionalJwtAuthGuard) @UseGuards(OptionalJwtAuthGuard)
@Get() @Get()
findByMail(@Request() req, @Query('mail') mail: string): any { async findByMail(@Request() req, @Query('mail') mail: string): Promise<User> {
this.logger.info(`Searching for user with EMail: ${mail}`); const user = await this.userService.getUserByMail(mail, req.user as JwtUser);
const user = this.userService.getUserByMail(mail, req.user as JwtUser);
this.logger.info(`Found user: ${JSON.stringify(user)}`);
return user; return user;
} }
@UseGuards(OptionalJwtAuthGuard)
@Get(':id') @Get(':id')
findById(@Param('id') id: string): any { async findById(@Param('id') id: string): Promise<User> {
this.logger.info(`Searching for user with ID: ${id}`); const user = await this.userService.getUserById(id);
const user = this.userService.getUserById(id);
this.logger.info(`Found user: ${JSON.stringify(user)}`);
return user; return user;
} }
@UseGuards(AdminAuthGuard)
@Get('user/all')
async getAllUser(): Promise<User[]> {
return await this.userService.getAllUser();
}
@UseGuards(OptionalJwtAuthGuard)
@Post() @Post()
save(@Body() user: any): Promise<User> { async save(@Body() user: any): Promise<User> {
this.logger.info(`Saving user: ${JSON.stringify(user)}`); try {
const savedUser = this.userService.saveUser(user); const savedUser = await this.userService.saveUser(user);
this.logger.info(`User persisted: ${JSON.stringify(savedUser)}`); 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; return savedUser;
} }
@UseGuards(OptionalJwtAuthGuard)
@Post('search') @Post('search')
find(@Body() criteria: UserListingCriteria): any { async find(@Body() criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
this.logger.info(`Searching for users with criteria: ${JSON.stringify(criteria)}`); const foundUsers = await this.userService.searchUserListings(criteria);
const foundUsers = this.userService.searchUserListings(criteria);
this.logger.info(`Found users: ${JSON.stringify(foundUsers)}`);
return foundUsers; return foundUsers;
} }
@UseGuards(OptionalJwtAuthGuard)
@Post('findTotal') @Post('findTotal')
findTotal(@Body() criteria: UserListingCriteria): Promise<number> { async findTotal(@Body() criteria: UserListingCriteria): Promise<number> {
return this.userService.getUserListingsCount(criteria); return await 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;
} }
@UseGuards(JwtAuthGuard)
@Get('subscriptions/:id') @Get('subscriptions/:id')
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> { async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
const subscriptions = this.fileService.getSubscriptions(); const subscriptions = [];
const user = await this.userService.getUserById(id); const user = await this.userService.getUserById(id);
subscriptions.forEach(s => { subscriptions.forEach(s => {
s.userId = user.id; s.userId = user.id;

View File

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

View File

@@ -1,16 +1,15 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { and, count, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm'; import { and, asc, count, desc, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js'; import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { ZodError } from 'zod'; import * as schema from '../drizzle/schema';
import * as schema from '../drizzle/schema.js'; import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema';
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema.js'; import { FileService } from '../file/file.service';
import { FileService } from '../file/file.service.js'; import { GeoService } from '../geo/geo.service';
import { GeoService } from '../geo/geo.service.js'; import { User, UserSchema } from '../models/db.model';
import { User, UserSchema } from '../models/db.model.js'; import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model.js'; import { DrizzleUser, getDistanceQuery, splitName } from '../utils';
import { convertDrizzleUserToUser, convertUserToDrizzleUser, getDistanceQuery } from '../utils.js';
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number]; type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
@Injectable() @Injectable()
@@ -26,10 +25,10 @@ export class UserService {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
whereConditions.push(eq(schema.users.customerType, 'professional')); whereConditions.push(eq(schema.users.customerType, 'professional'));
if (criteria.city && criteria.searchType === 'exact') { 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) { 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}`); whereConditions.push(sql`${getDistanceQuery(schema.users, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
} }
if (criteria.types && criteria.types.length > 0) { if (criteria.types && criteria.types.length > 0) {
@@ -37,12 +36,9 @@ export class UserService {
whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[])); whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[]));
} }
if (criteria.firstname) { if (criteria.brokerName) {
whereConditions.push(ilike(schema.users.firstname, `%${criteria.firstname}%`)); const { firstname, lastname } = splitName(criteria.brokerName);
} whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
if (criteria.lastname) {
whereConditions.push(ilike(schema.users.lastname, `%${criteria.lastname}%`));
} }
if (criteria.companyName) { if (criteria.companyName) {
@@ -58,7 +54,7 @@ export class UserService {
} }
return whereConditions; return whereConditions;
} }
async searchUserListings(criteria: UserListingCriteria) { async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
const start = criteria.start ? criteria.start : 0; const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12; const length = criteria.length ? criteria.length : 12;
const query = this.conn.select().from(schema.users); const query = this.conn.select().from(schema.users);
@@ -68,12 +64,23 @@ export class UserService {
const whereClause = and(...whereConditions); const whereClause = and(...whereConditions);
query.where(whereClause); 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 // Paginierung
query.limit(length).offset(start); query.limit(length).offset(start);
const data = await query; const data = await query;
const results = data.map(r => convertDrizzleUserToUser(r)); const results = data;
const totalCount = await this.getUserListingsCount(criteria); const totalCount = await this.getUserListingsCount(criteria);
return { return {
@@ -99,14 +106,14 @@ export class UserService {
.from(schema.users) .from(schema.users)
.where(sql`email = ${email}`)) as User[]; .where(sql`email = ${email}`)) as User[];
if (users.length === 0) { if (users.length === 0) {
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname) }; const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname, null) };
const u = await this.saveUser(user); const u = await this.saveUser(user, false);
return convertDrizzleUserToUser(u); return u;
} else { } else {
const user = users[0]; const user = users[0];
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email)); user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email)); user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
return convertDrizzleUserToUser(user); return user;
} }
} }
async getUserById(id: string) { async getUserById(id: string) {
@@ -118,9 +125,13 @@ export class UserService {
const user = users[0]; const user = users[0];
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email)); user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
user.hasProfile = this.fileService.hasProfile(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 { try {
user.updated = new Date(); user.updated = new Date();
if (user.id) { if (user.id) {
@@ -128,29 +139,21 @@ export class UserService {
} else { } else {
user.created = new Date(); user.created = new Date();
} }
const validatedUser = UserSchema.parse(user); let validatedUser = user;
const drizzleUser = convertUserToDrizzleUser(validatedUser); if (processValidation) {
validatedUser = UserSchema.parse(user);
}
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
const drizzleUser = validatedUser as DrizzleUser;
if (user.id) { if (user.id) {
const [updateUser] = await this.conn.update(schema.users).set(drizzleUser).where(eq(schema.users.id, user.id)).returning(); 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 { } else {
const [newUser] = await this.conn.insert(schema.users).values(drizzleUser).returning(); const [newUser] = await this.conn.insert(schema.users).values(drizzleUser).returning();
return convertDrizzleUserToUser(newUser) as User; return newUser as User;
} }
} catch (error) { } 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; 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 { sql } from 'drizzle-orm';
import { businesses, commercials, users } from './drizzle/schema.js'; import { businesses, commercials, users } from './drizzle/schema';
import { BusinessListing, CommercialPropertyListing, User } from './models/db.model.js';
export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern
export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen
export function convertStringToNullUndefined(value) { 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') => { 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; 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` return sql`
${radius} * 2 * ASIN(SQRT( ${radius} * 2 * ASIN(SQRT(
POWER(SIN((${lat} - ${schema.latitude}) * PI() / 180 / 2), 2) + POWER(SIN((${lat} - (${schema.location}->>'latitude')::float) * PI() / 180 / 2), 2) +
COS(${lat} * PI() / 180) * COS(${schema.latitude} * PI() / 180) * COS(${lat} * PI() / 180) * COS((${schema.location}->>'latitude')::float * PI() / 180) *
POWER(SIN((${lon} - ${schema.longitude}) * PI() / 180 / 2), 2) POWER(SIN((${lon} - (${schema.location}->>'longitude')::float) * PI() / 180 / 2), 2)
)) ))
`; `;
}; };
type DrizzleUser = typeof users.$inferSelect; export type DrizzleUser = typeof users.$inferSelect;
type DrizzleBusinessListing = typeof businesses.$inferSelect; export type DrizzleBusinessListing = typeof businesses.$inferSelect;
type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect; export type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect;
export function convertBusinessToDrizzleBusiness(businessListing: Partial<BusinessListing>): DrizzleBusinessListing { // export function convertBusinessToDrizzleBusiness(businessListing: Partial<BusinessListing>): DrizzleBusinessListing {
return flattenObject(businessListing); // const drizzleBusinessListing = flattenObject(businessListing);
} // drizzleBusinessListing.city = drizzleBusinessListing.name;
export function convertDrizzleBusinessToBusiness(drizzleBusinessListing: Partial<DrizzleBusinessListing>): BusinessListing { // delete drizzleBusinessListing.name;
const o = { // return drizzleBusinessListing;
location_city: drizzleBusinessListing.city, // }
location_state: drizzleBusinessListing.state, // export function convertDrizzleBusinessToBusiness(drizzleBusinessListing: Partial<DrizzleBusinessListing>): BusinessListing {
location_latitude: drizzleBusinessListing.latitude, // const o = {
location_longitude: drizzleBusinessListing.longitude, // location: drizzleBusinessListing.city ? undefined : null,
...drizzleBusinessListing, // location_name: drizzleBusinessListing.city ? drizzleBusinessListing.city : undefined,
}; // location_state: drizzleBusinessListing.state ? drizzleBusinessListing.state : undefined,
delete o.city; // location_latitude: drizzleBusinessListing.latitude ? drizzleBusinessListing.latitude : undefined,
delete o.state; // location_longitude: drizzleBusinessListing.longitude ? drizzleBusinessListing.longitude : undefined,
delete o.latitude; // ...drizzleBusinessListing,
delete o.longitude; // };
return unflattenObject(o); // Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
} // delete o.city;
export function convertCommercialToDrizzleCommercial(commercialPropertyListing: Partial<CommercialPropertyListing>): DrizzleCommercialPropertyListing { // delete o.state;
return flattenObject(commercialPropertyListing); // delete o.latitude;
} // delete o.longitude;
export function convertDrizzleCommercialToCommercial(drizzleCommercialPropertyListing: Partial<DrizzleCommercialPropertyListing>): CommercialPropertyListing { // return unflattenObject(o);
const o = { // }
location_city: drizzleCommercialPropertyListing.city, // export function convertCommercialToDrizzleCommercial(commercialPropertyListing: Partial<CommercialPropertyListing>): DrizzleCommercialPropertyListing {
location_state: drizzleCommercialPropertyListing.state, // const drizzleCommercialPropertyListing = flattenObject(commercialPropertyListing);
location_latitude: drizzleCommercialPropertyListing.latitude, // drizzleCommercialPropertyListing.city = drizzleCommercialPropertyListing.name;
location_longitude: drizzleCommercialPropertyListing.longitude, // delete drizzleCommercialPropertyListing.name;
...drizzleCommercialPropertyListing, // return drizzleCommercialPropertyListing;
}; // }
delete o.city; // export function convertDrizzleCommercialToCommercial(drizzleCommercialPropertyListing: Partial<DrizzleCommercialPropertyListing>): CommercialPropertyListing {
delete o.state; // const o = {
delete o.latitude; // location: drizzleCommercialPropertyListing.city ? undefined : null,
delete o.longitude; // location_name: drizzleCommercialPropertyListing.city ? drizzleCommercialPropertyListing.city : undefined,
return unflattenObject(o); // location_state: drizzleCommercialPropertyListing.state ? drizzleCommercialPropertyListing.state : undefined,
} // location_street: drizzleCommercialPropertyListing.street ? drizzleCommercialPropertyListing.street : undefined,
export function convertUserToDrizzleUser(user: Partial<User>): DrizzleUser { // location_housenumber: drizzleCommercialPropertyListing.housenumber ? drizzleCommercialPropertyListing.housenumber : undefined,
return flattenObject(user); // 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 { // export function convertDrizzleUserToUser(drizzleUser: Partial<DrizzleUser>): User {
const o = { // const o: any = {
companyLocation_city: drizzleUser.city, // companyLocation: drizzleUser.city ? undefined : null,
companyLocation_state: drizzleUser.state, // companyLocation_name: drizzleUser.city ? drizzleUser.city : undefined,
companyLocation_latitude: drizzleUser.latitude, // companyLocation_state: drizzleUser.state ? drizzleUser.state : undefined,
companyLocation_longitude: drizzleUser.longitude, // companyLocation_latitude: drizzleUser.latitude ? drizzleUser.latitude : undefined,
...drizzleUser, // companyLocation_longitude: drizzleUser.longitude ? drizzleUser.longitude : undefined,
}; // ...drizzleUser,
delete o.city; // };
delete o.state; // Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
delete o.latitude; // delete o.city;
delete o.longitude; // delete o.state;
return unflattenObject(o); // delete o.latitude;
} // delete o.longitude;
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)) { // return unflattenObject(o);
if (value instanceof Date) { // }
res[key] = value; // function flattenObject(obj: any, res: any = {}): any {
} else { // for (const key in obj) {
flattenObject(value, res); // if (obj.hasOwnProperty(key)) {
} // const value = obj[key];
} else {
res[key] = value; // 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", "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": { "compilerOptions": {
"target": "ES2021", "target": "ES2022",
"module": "ESNext", "module": "NodeNext",
"moduleResolution": "Node", "moduleResolution": "NodeNext",
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
@@ -18,6 +18,6 @@
"strictBindCallApply": false, "strictBindCallApply": false,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false, "noFallthroughCasesInSwitch": false,
"esModuleInterop":true "esModuleInterop": true
} }
} }

View File

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

View File

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

View File

@@ -1,10 +1,28 @@
{ {
"/api": { "/api": {
"target": "http://localhost:3000", "target": "http://localhost:3000",
"secure": false "secure": false,
"changeOrigin": true,
"logLevel": "debug"
}, },
"/pictures": { "/pictures": {
"target": "http://localhost:8080", "target": "http://localhost:8080",
"secure": false "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 class="container"> -->
<div [ngClass]="{ 'bg-slate-100': actualRoute !== 'home' }"> <div class="flex flex-col" [ngClass]="{ 'bg-slate-100 print:bg-white': actualRoute !== 'home' }">
@if (actualRoute !=='home' && actualRoute !=='pricing'){ @if (actualRoute !=='home'){
<header></header> <header></header>
} }
<router-outlet></router-outlet> <main class="flex-grow">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer> <app-footer></app-footer>
</div> </div>
@@ -30,26 +32,8 @@
</div> </div>
</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-message-container></app-message-container>
<app-search-modal></app-search-modal> <app-search-modal></app-search-modal>
<app-confirmation></app-confirmation> <app-confirmation></app-confirmation>
<app-email></app-email>

View File

@@ -1,23 +1,26 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core'; import { Component, HostListener } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; 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 { filter } from 'rxjs/operators';
import build from '../build'; import build from '../build';
import { ConfirmationComponent } from './components/confirmation/confirmation.component'; import { ConfirmationComponent } from './components/confirmation/confirmation.component';
import { ConfirmationService } from './components/confirmation/confirmation.service'; import { ConfirmationService } from './components/confirmation/confirmation.service';
import { EMailComponent } from './components/email/email.component';
import { FooterComponent } from './components/footer/footer.component'; import { FooterComponent } from './components/footer/footer.component';
import { HeaderComponent } from './components/header/header.component'; import { HeaderComponent } from './components/header/header.component';
import { MessageContainerComponent } from './components/message/message-container.component'; import { MessageContainerComponent } from './components/message/message-container.component';
import { SearchModalComponent } from './components/search-modal/search-modal.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 { LoadingService } from './services/loading.service';
import { UserService } from './services/user.service'; import { UserService } from './services/user.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent], imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent, EMailComponent],
providers: [], providers: [],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
@@ -34,6 +37,8 @@ export class AppComponent {
private keycloakService: KeycloakService, private keycloakService: KeycloakService,
private userService: UserService, private userService: UserService,
private confirmationService: ConfirmationService, private confirmationService: ConfirmationService,
private auditService: AuditService,
private geoService: GeoService,
) { ) {
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => { this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
let currentRoute = this.activatedRoute.root; let currentRoute = this.activatedRoute.root;
@@ -44,14 +49,38 @@ export class AppComponent {
this.actualRoute = currentRoute.snapshot.url[0].path; 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']) @HostListener('window:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) { handleKeyboardEvent(event: KeyboardEvent) {
// this.router.events.subscribe(event => {
// if (event instanceof NavigationEnd) {
// initFlowbite();
// }
// });
if (event.shiftKey && event.ctrlKey && event.key === 'V') { if (event.shiftKey && event.ctrlKey && event.key === 'V') {
this.showVersionDialog(); 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 { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
import { KeycloakBearerInterceptor, KeycloakService } from 'keycloak-angular'; import { KeycloakBearerInterceptor, KeycloakService } from 'keycloak-angular';
import { GALLERY_CONFIG, GalleryConfig } from 'ng-gallery';
import { provideQuillConfig } from 'ngx-quill'; import { provideQuillConfig } from 'ngx-quill';
import { environment } from '../environments/environment'; import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { customKeycloakAdapter } from '../keycloak'; import { shareIcons } from 'ngx-sharebuttons/icons';
import { provideNgxStripe } from 'ngx-stripe';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { LoadingInterceptor } from './interceptors/loading.interceptor'; 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 { KeycloakInitializerService } from './services/keycloak-initializer.service';
import { SelectOptionsService } from './services/select-options.service'; import { SelectOptionsService } from './services/select-options.service';
import { createLogger } from './utils/utils'; import { createLogger } from './utils/utils';
@@ -20,9 +24,9 @@ export const appConfig: ApplicationConfig = {
{ provide: KeycloakService }, { provide: KeycloakService },
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
// useFactory: initializeKeycloak, // useFactory: initializeKeycloak1,
//useFactory: initializeKeycloak, //useFactory: initializeKeycloak2,
useFactory: initializeKeycloak3, useFactory: initializeKeycloak,
multi: true, multi: true,
//deps: [KeycloakService], //deps: [KeycloakService],
deps: [KeycloakInitializerService], deps: [KeycloakInitializerService],
@@ -43,6 +47,30 @@ export const appConfig: ApplicationConfig = {
useClass: KeycloakBearerInterceptor, useClass: KeycloakBearerInterceptor,
multi: true, 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( provideRouter(
routes, routes,
withEnabledBlockingInitialNavigation(), withEnabledBlockingInitialNavigation(),
@@ -52,6 +80,7 @@ export const appConfig: ApplicationConfig = {
}), }),
), ),
provideAnimations(), provideAnimations(),
provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'),
provideQuillConfig({ provideQuillConfig({
modules: { modules: {
syntax: true, syntax: true,
@@ -71,47 +100,47 @@ function initServices(selectOptions: SelectOptionsService) {
await selectOptions.init(); await selectOptions.init();
}; };
} }
export function initializeKeycloak3(keycloak: KeycloakInitializerService) { export function initializeKeycloak(keycloak: KeycloakInitializerService) {
return () => keycloak.initialize(); return () => keycloak.initialize();
} }
export function initializeKeycloak2(keycloak: KeycloakService): () => Promise<void> { // export function initializeKeycloak1(keycloak: KeycloakService): () => Promise<void> {
return async () => { // return async () => {
const { url, realm, clientId } = environment.keycloak; // const { url, realm, clientId } = environment.keycloak;
const adapter = customKeycloakAdapter(() => keycloak.getKeycloakInstance(), {}); // const adapter = customKeycloakAdapter(() => keycloak.getKeycloakInstance(), {});
if (window.location.search.length > 0) { // if (window.location.search.length > 0) {
sessionStorage.setItem('SEARCH', window.location.search); // sessionStorage.setItem('SEARCH', window.location.search);
} // }
const { host, hostname, href, origin, pathname, port, protocol, search } = window.location; // const { host, hostname, href, origin, pathname, port, protocol, search } = window.location;
await keycloak.init({ // await keycloak.init({
config: { url, realm, clientId }, // config: { url, realm, clientId },
initOptions: { // initOptions: {
onLoad: 'check-sso', // 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`, // silentCheckSsoRedirectUri: window.location.hostname === 'localhost' ? `${window.location.origin}/assets/silent-check-sso.html` : `${window.location.origin}/dealerweb/assets/silent-check-sso.html`,
adapter, // adapter,
redirectUri: `${origin}${pathname}`, // redirectUri: `${origin}${pathname}`,
}, // },
}); // });
}; // };
} // }
function initializeKeycloak(keycloak: KeycloakService) { // function initializeKeycloak2(keycloak: KeycloakService) {
return async () => { // return async () => {
logger.info(`###>calling keycloakService init ...`); // logger.info(`###>calling keycloakService init ...`);
const authenticated = await keycloak.init({ // const authenticated = await keycloak.init({
config: { // config: {
url: environment.keycloak.url, // url: environment.keycloak.url,
realm: environment.keycloak.realm, // realm: environment.keycloak.realm,
clientId: environment.keycloak.clientId, // clientId: environment.keycloak.clientId,
}, // },
initOptions: { // initOptions: {
onLoad: 'check-sso', // onLoad: 'check-sso',
silentCheckSsoRedirectUri: (<any>window).location.origin + '/assets/silent-check-sso.html', // silentCheckSsoRedirectUri: (<any>window).location.origin + '/assets/silent-check-sso.html',
}, // },
bearerExcludedUrls: ['/assets'], // bearerExcludedUrls: ['/assets'],
shouldUpdateToken(request) { // shouldUpdateToken(request) {
return !request.headers.get('token-update') === false; // return !request.headers.get('token-update') === false;
}, // },
}); // });
logger.info(`+++>${authenticated}`); // 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 { AuthGuard } from './guards/auth.guard';
import { ListingCategoryGuard } from './guards/listing-category.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 { 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 { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { DetailsUserComponent } from './pages/details/details-user/details-user.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 { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-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 { 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 { PricingComponent } from './pages/pricing/pricing.component';
import { AccountComponent } from './pages/subscription/account/account.component'; import { AccountComponent } from './pages/subscription/account/account.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.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 { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component'; import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component'; import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
import { SuccessComponent } from './pages/success/success.component';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -54,6 +57,14 @@ export const routes: Routes = [
canActivate: [ListingCategoryGuard], canActivate: [ListingCategoryGuard],
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet 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 // User Details
{ {
@@ -113,7 +124,7 @@ export const routes: Routes = [
{ {
path: 'emailUs', path: 'emailUs',
component: EmailUsComponent, component: EmailUsComponent,
canActivate: [AuthGuard], // canActivate: [AuthGuard],
}, },
// ######### // #########
// Logout // Logout
@@ -128,5 +139,25 @@ export const routes: Routes = [
path: 'pricing', path: 'pricing',
component: PricingComponent, 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' }, { path: '**', redirectTo: 'home' },
]; ];

View File

@@ -19,6 +19,7 @@ export abstract class BaseInputComponent implements ControlValueAccessor {
@Input() label: string = ''; @Input() label: string = '';
// @Input() id: string = ''; // @Input() id: string = '';
@Input() name: string = ''; @Input() name: string = '';
isTooltipVisible = false;
constructor(protected validationMessagesService: ValidationMessagesService) {} constructor(protected validationMessagesService: ValidationMessagesService) {}
ngOnInit() { ngOnInit() {
this.subscription = this.validationMessagesService.messages$.subscribe(() => { this.subscription = this.validationMessagesService.messages$.subscribe(() => {
@@ -51,4 +52,9 @@ export abstract class BaseInputComponent implements ControlValueAccessor {
this.validationMessage = this.validationMessagesService.getMessage(this.name); this.validationMessage = this.validationMessagesService.getMessage(this.name);
} }
setDisabledState?(isDisabled: boolean): void {} 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" /> <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> </svg>
@let confirmation = (confirmationService.confirmation$ | async); @let confirmation = (confirmationService.confirmation$ | async);
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{{ confirmation.message }}</h3> <h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{{ confirmation?.message }}</h3>
@if(confirmation.buttons==='both'){ @if(confirmation?.buttons==='both'){
<button <button
(click)="confirmationService.accept()" (click)="confirmationService.accept()"
type="button" 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"> <footer class="bg-white px-4 py-2 md:px-6 mt-auto w-full print:hidden">
<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">
<div class="container mx-auto flex flex-col lg:flex-row justify-between items-center"> <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"> <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> <p class="text-sm text-gray-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
</div> </div>
<div class="flex flex-col lg:flex-row items-center order-3 lg:order-2"> <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="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" 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>
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3"> <div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
@@ -58,6 +34,17 @@
</svg> </svg>
Privacy Statement Privacy Statement
</h5> </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"> <section id="content" role="main">
<article id="post-2" class="post-2 page type-page status-publish hentry pmpro-has-access"> <article id="post-2" class="post-2 page type-page status-publish hentry pmpro-has-access">
<section class="entry-content"> <section class="entry-content">
@@ -262,6 +249,17 @@
</svg> </svg>
Terms of use Terms of use
</h5> </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"> <section id="content" role="main">
<article id="post-1" class="post-1 page type-page status-publish hentry pmpro-has-access"> <article id="post-1" class="post-1 page type-page status-publish hentry pmpro-has-access">
<section class="entry-content"> <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"> <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"> <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" /> <img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a> </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">
<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">
<!-- Filter button --> <!-- Filter button -->
@if(isListingUrl()){ @if(isFilterUrl()){
<button <button
type="button" type="button"
#triggerButton #triggerButton
(click)="openModal()" (click)="openModal()"
id="filterDropdownButton" 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() }}) <i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button> </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 <button
type="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" id="user-menu-button"
aria-expanded="false" aria-expanded="false"
[attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
data-dropdown-placement="bottom" data-dropdown-placement="bottom"
> >
<span class="sr-only">Open user menu</span> <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" /> <img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
} @else { } @else {
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i> <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> <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> <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>
@if(user.customerType==='professional' || user.customerType==='seller' || isAdmin()){
<li> <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" <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 >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>
<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> <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>
<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> <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> <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>
@@ -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> <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> </li>
</ul> </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> </div>
} @else { } @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"> <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> <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>
<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> </li>
</ul> </ul>
</div> </div>
} }
<button <!-- <button
data-collapse-toggle="navbar-user" data-collapse-toggle="navbar-user"
type="button" 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" 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"> <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" /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg> </svg>
</button> </button> -->
</div> </div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user"> <div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul <ul
@@ -234,7 +224,7 @@
</div> </div>
</div> </div>
<!-- Mobile filter button --> <!-- Mobile filter button -->
@if(isListingUrl()){ @if(isFilterUrl()){
<div class="md:hidden flex justify-center pb-4"> <div class="md:hidden flex justify-center pb-4">
<button <button
(click)="openModal()" (click)="openModal()"
@@ -244,6 +234,16 @@
> >
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }}) <i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button> </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> </div>
} }
</nav> </nav>

View File

@@ -1,22 +1,25 @@
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component, HostListener } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons'; import { faUserGear } from '@fortawesome/free-solid-svg-icons';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Collapse, Dropdown, initFlowbite } from 'flowbite'; import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { KeycloakService } from 'keycloak-angular'; import { KeycloakService } from 'keycloak-angular';
import { filter, Observable, Subject, Subscription } from 'rxjs'; import { filter, Observable, Subject, Subscription } from 'rxjs';
import { User } from '../../../../../bizmatch-server/src/models/db.model'; import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { CriteriaChangeService } from '../../services/criteria-change.service'; import { CriteriaChangeService } from '../../services/criteria-change.service';
import { SearchService } from '../../services/search.service'; import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SharedService } from '../../services/shared.service'; import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.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 { DropdownComponent } from '../dropdown/dropdown.component';
import { ModalService } from '../search-modal/modal.service'; import { ModalService } from '../search-modal/modal.service';
@UntilDestroy()
@Component({ @Component({
selector: 'header', selector: 'header',
standalone: true, standalone: true,
@@ -41,6 +44,8 @@ export class HeaderComponent {
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
private routerSubscription: Subscription | undefined; private routerSubscription: Subscription | undefined;
baseRoute: string; baseRoute: string;
sortDropdownVisible: boolean;
sortByOptions: KeyValueAsSortBy[] = [];
constructor( constructor(
public keycloakService: KeycloakService, public keycloakService: KeycloakService,
private router: Router, private router: Router,
@@ -50,8 +55,15 @@ export class HeaderComponent {
private modalService: ModalService, private modalService: ModalService,
private searchService: SearchService, private searchService: SearchService,
private criteriaChangeService: CriteriaChangeService, 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() { async ngOnInit() {
const token = await this.keycloakService.getToken(); const token = await this.keycloakService.getToken();
this.keycloakUser = map2User(token); this.keycloakUser = map2User(token);
@@ -65,64 +77,48 @@ export class HeaderComponent {
}, 10); }, 10);
this.sharedService.currentProfilePhoto.subscribe(photoUrl => { this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
if (photoUrl) { this.profileUrl = photoUrl;
this.profileUrl = photoUrl;
}
}); });
this.checkCurrentRoute(this.router.url); this.checkCurrentRoute(this.router.url);
this.setupSortByOptions();
this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => { this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => {
this.checkCurrentRoute(event.urlAfterRedirects); this.checkCurrentRoute(event.urlAfterRedirects);
this.setupSortByOptions();
});
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
this.user = u;
}); });
} }
private checkCurrentRoute(url: string): void { private checkCurrentRoute(url: string): void {
this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/' this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
const specialRoutes = [, '', '']; const specialRoutes = [, '', ''];
this.criteria = getCriteriaProxy(this.baseRoute, this); 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() {} ngAfterViewInit() {}
async openModal() { async openModal() {
const accepted = await this.modalService.showModal(this.criteria); const modalResult = await this.modalService.showModal(this.criteria);
if (accepted) { if (modalResult.accepted) {
this.searchService.search(this.criteria); this.searchService.search(this.criteria);
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
} }
} }
navigateWithState(dest: string, state: any) { navigateWithState(dest: string, state: any) {
@@ -130,18 +126,28 @@ export class HeaderComponent {
} }
login() { login() {
this.keycloakService.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 { isActive(route: string): boolean {
return this.router.url === route; return this.router.url === route;
} }
isListingUrl(): boolean { isFilterUrl(): boolean {
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url); 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() { closeDropdown() {
const dropdownButton = document.getElementById('user-menu-button'); const dropdownButton = document.getElementById('user-menu-button');
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown'); const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
@@ -174,13 +180,24 @@ export class HeaderComponent {
} }
getNumberOfFiltersSet() { getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'brokerListings') { 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') { } 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') { } 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 { } else {
return 0; 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 { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({ @Component({
selector: 'app-not-found', selector: 'app-not-found',
standalone: true, standalone: true,
template: '<h2>Page not found</h2>', imports: [CommonModule, RouterModule],
templateUrl: './not-found.component.html',
}) })
export class NotFoundComponent {} export class NotFoundComponent {}

View File

@@ -1,7 +1,7 @@
// 1. Shared Service (modal.service.ts) // 1. Shared Service (modal.service.ts)
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; 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({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -9,26 +9,26 @@ import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListing
export class ModalService { export class ModalService {
private modalVisibleSubject = new BehaviorSubject<boolean>(false); private modalVisibleSubject = new BehaviorSubject<boolean>(false);
private messageSubject = new BehaviorSubject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>(null); private messageSubject = new BehaviorSubject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>(null);
private resolvePromise!: (value: boolean) => void; private resolvePromise!: (value: ModalResult) => void;
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable(); modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
message$: Observable<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria> = this.messageSubject.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.messageSubject.next(message);
this.modalVisibleSubject.next(true); this.modalVisibleSubject.next(true);
return new Promise<boolean>(resolve => { return new Promise<ModalResult>(resolve => {
this.resolvePromise = resolve; this.resolvePromise = resolve;
}); });
} }
accept(): void { accept(): void {
this.modalVisibleSubject.next(false); this.modalVisibleSubject.next(false);
this.resolvePromise(true); this.resolvePromise({ accepted: true });
} }
reject(): void { reject(backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void {
this.modalVisibleSubject.next(false); 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 w-full max-w-4xl max-h-full">
<div class="relative bg-white rounded-lg shadow"> <div class="relative bg-white rounded-lg shadow">
<div class="flex items-start justify-between p-4 border-b rounded-t"> <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> <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"> <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" /> <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> </svg>
@@ -13,17 +19,24 @@
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4"> <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-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> </div>
@if(criteria.criteriaType==='businessListings'){ @if(criteria.criteriaType==='businessListings'){
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label> <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> <ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"> </ng-select>
</div> </div>
<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> <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select <ng-select
@@ -42,7 +55,7 @@
<ng-option [value]="city">{{ city.city }} - {{ city.state }}</ng-option> <ng-option [value]="city">{{ city.city }} - {{ city.state }}</ng-option>
} }
</ng-select> </ng-select>
</div> </div> -->
<!-- New section for city search type --> <!-- New section for city search type -->
<div *ngIf="criteria.city"> <div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label> <label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
@@ -77,61 +90,67 @@
<div> <div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label> <label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2"> <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" type="number"
id="price-from" id="price-from"
[(ngModel)]="criteria.minPrice" [(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" 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" placeholder="From"
/> /> -->
<span>-</span> <span>-</span>
<input <!-- <input
type="number" type="number"
id="price-to" id="price-to"
[(ngModel)]="criteria.maxPrice" [(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" 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" 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> </div>
<div> <div>
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-gray-900">Sales Revenue</label> <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"> <div class="flex items-center space-x-2">
<input <!-- <input
type="number" type="number"
id="salesRevenue-from" id="salesRevenue-from"
[(ngModel)]="criteria.minRevenue" [(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" 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" 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> <span>-</span>
<input <!-- <input
type="number" type="number"
id="salesRevenue-to" id="salesRevenue-to"
[(ngModel)]="criteria.maxRevenue" [(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" 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" 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> </div>
<div> <div>
<label for="cashflow" class="block mb-2 text-sm font-medium text-gray-900">Cashflow</label> <label for="cashflow" class="block mb-2 text-sm font-medium text-gray-900">Cashflow</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input <!-- <input
type="number" type="number"
id="cashflow-from" id="cashflow-from"
[(ngModel)]="criteria.minCashFlow" [(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" 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" 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> <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" type="number"
id="cashflow-to" id="cashflow-to"
[(ngModel)]="criteria.maxCashFlow" [(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" 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" placeholder="To"
/> /> -->
</div> </div>
</div> </div>
<div> <div>
@@ -168,15 +187,33 @@
<label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</label> <label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</label>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center"> <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> <label for="realEstateChecked" class="ml-2 text-sm font-medium text-gray-900">Real Estate</label>
</div> </div>
<div class="flex items-center"> <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> <label for="leasedLocation" class="ml-2 text-sm font-medium text-gray-900">Leased Location</label>
</div> </div>
<div class="flex items-center"> <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> <label for="franchiseResale" class="ml-2 text-sm font-medium text-gray-900">Franchise</label>
</div> </div>
</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> <ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"> </ng-select>
</div> </div>
<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 <ng-select
class="custom" class="custom"
[multiple]="false" [multiple]="false"
@@ -257,7 +294,8 @@
@for (city of cities$ | async; track city.id) { @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.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> </div>
<!-- New section for city search type --> <!-- New section for city search type -->
<div *ngIf="criteria.city"> <div *ngIf="criteria.city">
@@ -292,21 +330,23 @@
<div> <div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label> <label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input <!-- <input
type="number" type="number"
id="price-from" id="price-from"
[(ngModel)]="criteria.minPrice" [(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" 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" 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> <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" type="number"
id="price-to" id="price-to"
[(ngModel)]="criteria.maxPrice" [(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" 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" placeholder="To"
/> /> -->
</div> </div>
</div> </div>
<div> <div>
@@ -363,13 +403,10 @@
[typeahead]="countyInput$" [typeahead]="countyInput$"
[(ngModel)]="criteria.counties" [(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> </ng-select>
</div> </div>
<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 <ng-select
class="custom" class="custom"
[multiple]="false" [multiple]="false"
@@ -383,9 +420,17 @@
(ngModelChange)="setCity($event)" (ngModelChange)="setCity($event)"
> >
@for (city of cities$ | async; track city.id) { @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> </div>
<!-- New section for city search type --> <!-- New section for city search type -->
<div *ngIf="criteria.city"> <div *ngIf="criteria.city">
@@ -401,6 +446,15 @@
</label> </label>
</div> </div>
</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 --> <!-- New section for radius selection -->
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2"> <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> <label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
@@ -447,7 +501,7 @@
</button> </button>
<button <button
type="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" 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 Cancel

View File

@@ -10,25 +10,30 @@ import { ListingsService } from '../../services/listings.service';
import { SelectOptionsService } from '../../services/select-options.service'; import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service'; import { UserService } from '../../services/user.service';
import { SharedModule } from '../../shared/shared/shared.module'; 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'; import { ModalService } from './modal.service';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-search-modal', selector: 'app-search-modal',
standalone: true, standalone: true,
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule], imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal.component.html', templateUrl: './search-modal.component.html',
styleUrl: './search-modal.component.scss', styleUrl: './search-modal.component.scss',
}) })
export class SearchModalComponent { export class SearchModalComponent {
cities$: Observable<GeoResult[]>; // cities$: Observable<GeoResult[]>;
counties$: Observable<CountyResult[]>; counties$: Observable<CountyResult[]>;
cityLoading = false; // cityLoading = false;
countyLoading = false; countyLoading = false;
cityInput$ = new Subject<string>(); // cityInput$ = new Subject<string>();
countyInput$ = new Subject<string>(); countyInput$ = new Subject<string>();
private criteriaChangeSubscription: Subscription; private criteriaChangeSubscription: Subscription;
public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
numberOfResults$: Observable<number>; numberOfResults$: Observable<number>;
cancelDisable = false;
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
public modalService: ModalService, public modalService: ModalService,
@@ -41,6 +46,7 @@ export class SearchModalComponent {
this.setupCriteriaChangeListener(); this.setupCriteriaChangeListener();
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => { this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => {
this.criteria = msg; this.criteria = msg;
this.backupCriteria = JSON.parse(JSON.stringify(msg));
this.setTotalNumberOfResults(); this.setTotalNumberOfResults();
}); });
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => { this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
@@ -49,7 +55,7 @@ export class SearchModalComponent {
this.criteria.start = 0; this.criteria.start = 0;
} }
}); });
this.loadCities(); // this.loadCities();
this.loadCounties(); 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() { private loadCounties() {
this.counties$ = concat( this.counties$ = concat(
of([]), // default items of([]), // default items
@@ -98,7 +88,7 @@ export class SearchModalComponent {
} }
setCity(city) { setCity(city) {
if (city) { if (city) {
this.criteria.city = city.city; this.criteria.city = city;
this.criteria.state = city.state; this.criteria.state = city.state;
} else { } else {
this.criteria.city = null; this.criteria.city = null;
@@ -115,7 +105,10 @@ export class SearchModalComponent {
} }
} }
private setupCriteriaChangeListener() { 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) { trackByFn(item: GeoResult) {
return item.id; 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 <div
[id]="id" [id]="id"
role="tooltip" 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 }} {{ text }}
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core'; import { Component, Input, SimpleChanges } from '@angular/core';
import { initFlowbite } from 'flowbite'; import { initFlowbite } from 'flowbite';
@Component({ @Component({
@@ -9,11 +9,36 @@ import { initFlowbite } from 'flowbite';
templateUrl: './tooltip.component.html', templateUrl: './tooltip.component.html',
}) })
export class TooltipComponent { export class TooltipComponent {
@Input() id; @Input() id: string;
@Input() text; @Input() text: string;
@Input() isVisible: boolean = false;
ngOnInit() { ngOnInit() {
this.initializeTooltip();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['isVisible']) {
this.updateTooltipVisibility();
}
}
private initializeTooltip() {
setTimeout(() => { setTimeout(() => {
initFlowbite(); initFlowbite();
}, 10); }, 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> <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){ >{{ label }} @if(validationMessage){
<div <div
attr.data-tooltip-target="tooltip-{{ name }}" 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" 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> </div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage"></app-tooltip> <app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
} }
</label> </label>
<ng-select <ng-select
@@ -19,11 +21,11 @@
[loading]="cityLoading" [loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters" typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$" [typeahead]="cityInput$"
ngModel="{{ value?.city }} {{ value ? '-' : '' }} {{ value?.state }}" ngModel="{{ value?.name }} {{ value ? '-' : '' }} {{ value?.state }}"
(ngModelChange)="onInputChange($event)" (ngModelChange)="onInputChange($event)"
> >
@for (city of cities$ | async; track city.id) { @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> </ng-select>
</div> </div>

View File

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

View File

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