Compare commits
No commits in common. "master" and "tailwind" have entirely different histories.
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npm install)",
|
|
||||||
"Bash(docker ps:*)",
|
|
||||||
"Bash(docker cp:*)",
|
|
||||||
"Bash(docker exec:*)",
|
|
||||||
"Bash(find:*)",
|
|
||||||
"Bash(docker restart:*)",
|
|
||||||
"Bash(npm run build)",
|
|
||||||
"Bash(rm:*)",
|
|
||||||
"Bash(npm audit fix:*)",
|
|
||||||
"Bash(sudo chown:*)",
|
|
||||||
"Bash(chmod:*)",
|
|
||||||
"Bash(npm audit:*)",
|
|
||||||
"Bash(npm view:*)",
|
|
||||||
"Bash(npm run build:ssr:*)",
|
|
||||||
"Bash(pkill:*)",
|
|
||||||
"WebSearch",
|
|
||||||
"Bash(lsof:*)",
|
|
||||||
"Bash(xargs:*)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(cat:*)",
|
|
||||||
"Bash(NODE_ENV=development npm run build:ssr:*)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"WebFetch(domain:angular.dev)",
|
|
||||||
"Bash(killall:*)",
|
|
||||||
"Bash(echo:*)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(npx tsc:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
node_modules
|
|
||||||
.git
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
* text=auto eol=lf
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
647
CHANGES.md
647
CHANGES.md
|
|
@ -1,647 +0,0 @@
|
||||||
# Changelog - BizMatch Project
|
|
||||||
|
|
||||||
Dokumentation aller wichtigen Änderungen am BizMatch-Projekt. Diese Datei listet Feature-Implementierungen, Bugfixes, Datenbank-Migrationen und architektonische Verbesserungen auf.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Inhaltsverzeichnis
|
|
||||||
|
|
||||||
1. [Datenbank-Änderungen](#1-datenbank-änderungen)
|
|
||||||
2. [Backend-Änderungen](#2-backend-änderungen)
|
|
||||||
3. [Frontend-Änderungen](#3-frontend-änderungen)
|
|
||||||
4. [SEO-Verbesserungen](#4-seo-verbesserungen)
|
|
||||||
5. [Code-Cleanup & Wartung](#5-code-cleanup--wartung)
|
|
||||||
6. [Bekannte Issues & Workarounds](#6-bekannte-issues--workarounds)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Datenbank-Änderungen
|
|
||||||
|
|
||||||
### 1.1 Schema-Migration: JSON-basierte Speicherung
|
|
||||||
|
|
||||||
**Datum:** November 2025
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
#### Zusammenfassung der Änderungen
|
|
||||||
|
|
||||||
Die Datenbank wurde von einem **relationalen Schema** zu einem **JSON-basierten Schema** migriert. Dies bedeutet:
|
|
||||||
|
|
||||||
- ✅ **Neue Tabellen wurden erstellt** (`users_json`, `businesses_json`, `commercials_json`)
|
|
||||||
- ❌ **Alte Tabellen wurden NICHT gelöscht** (`users`, `businesses`, `commercials` existieren noch)
|
|
||||||
- ✅ **Alle Daten wurden migriert** (kopiert von alten zu neuen Tabellen)
|
|
||||||
- ✅ **Anwendung nutzt ausschließlich neue Tabellen** (alte Tabellen dienen nur als Backup)
|
|
||||||
|
|
||||||
#### Detaillierte Tabellenstruktur
|
|
||||||
|
|
||||||
**ALTE Tabellen (nicht mehr in Verwendung, aber noch vorhanden):**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- users (relational)
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
email VARCHAR(255),
|
|
||||||
firstname VARCHAR(100),
|
|
||||||
lastname VARCHAR(100),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
location_name VARCHAR(255),
|
|
||||||
location_state VARCHAR(2),
|
|
||||||
location_latitude FLOAT,
|
|
||||||
location_longitude FLOAT,
|
|
||||||
customer_type VARCHAR(50),
|
|
||||||
customer_sub_type VARCHAR(50),
|
|
||||||
show_in_directory BOOLEAN,
|
|
||||||
created TIMESTAMP,
|
|
||||||
updated TIMESTAMP,
|
|
||||||
-- ... weitere 20+ Spalten
|
|
||||||
);
|
|
||||||
|
|
||||||
-- businesses (relational)
|
|
||||||
CREATE TABLE businesses (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
email VARCHAR(255),
|
|
||||||
title VARCHAR(500),
|
|
||||||
asking_price DECIMAL,
|
|
||||||
established INTEGER,
|
|
||||||
revenue DECIMAL,
|
|
||||||
cash_flow DECIMAL,
|
|
||||||
-- ... weitere 30+ Spalten für alle Business-Eigenschaften
|
|
||||||
);
|
|
||||||
|
|
||||||
-- commercials (relational)
|
|
||||||
CREATE TABLE commercials (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
email VARCHAR(255),
|
|
||||||
title VARCHAR(500),
|
|
||||||
asking_price DECIMAL,
|
|
||||||
property_type VARCHAR(100),
|
|
||||||
-- ... weitere 25+ Spalten für alle Property-Eigenschaften
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**NEUE Tabellen (aktuell in Verwendung):**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- users_json (JSON-basiert)
|
|
||||||
CREATE TABLE users_json (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
data JSONB NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- businesses_json (JSON-basiert)
|
|
||||||
CREATE TABLE businesses_json (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
data JSONB NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- commercials_json (JSON-basiert)
|
|
||||||
CREATE TABLE commercials_json (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
data JSONB NOT NULL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Was wurde NICHT geändert
|
|
||||||
|
|
||||||
**❌ Folgende Dinge wurden NICHT geändert:**
|
|
||||||
|
|
||||||
1. **Alte Tabellen existieren weiterhin** - Sie wurden nicht gelöscht, um als Backup zu dienen
|
|
||||||
2. **Datenbank-Name** - Weiterhin `bizmatch` (keine Änderung)
|
|
||||||
3. **Datenbank-User** - Weiterhin `bizmatch` (keine Änderung)
|
|
||||||
4. **Datenbank-Passwort** - Keine Änderung
|
|
||||||
5. **Datenbank-Port** - Weiterhin `5432` (keine Änderung)
|
|
||||||
6. **Docker-Container-Name** - Weiterhin `bizmatchdb` (keine Änderung)
|
|
||||||
7. **Indices** - PostgreSQL JSONB-Indices wurden automatisch erstellt
|
|
||||||
|
|
||||||
#### Was wurde geändert
|
|
||||||
|
|
||||||
**✅ Folgende Dinge wurden geändert:**
|
|
||||||
|
|
||||||
1. **Anwendungs-Code verwendet nur noch neue Tabellen:**
|
|
||||||
- Backend liest/schreibt nur noch in `users_json`, `businesses_json`, `commercials_json`
|
|
||||||
- Drizzle ORM Schema wurde aktualisiert (`bizmatch-server/src/drizzle/schema.ts`)
|
|
||||||
- Alle Services wurden angepasst (user.service.ts, business-listing.service.ts, etc.)
|
|
||||||
|
|
||||||
2. **Datenstruktur in JSONB-Spalten:**
|
|
||||||
- Alle Felder, die vorher einzelne Spalten waren, sind jetzt in der `data`-Spalte als JSON
|
|
||||||
- Nested Objects möglich (z.B. `location` mit `name`, `state`, `latitude`, `longitude`)
|
|
||||||
- Arrays direkt im JSON speicherbar (z.B. `imageOrder`, `areasServed`)
|
|
||||||
|
|
||||||
3. **Query-Syntax:**
|
|
||||||
- Statt `WHERE firstname = 'John'` → `WHERE (data->>'firstname') = 'John'`
|
|
||||||
- Statt `WHERE location_state = 'TX'` → `WHERE (data->'location'->>'state') = 'TX'`
|
|
||||||
- Haversine-Formel für Radius-Suche nutzt jetzt JSON-Pfade
|
|
||||||
|
|
||||||
#### Beispiel: User-Datensatz Vorher/Nachher
|
|
||||||
|
|
||||||
**VORHER (relationale Tabelle `users`):**
|
|
||||||
|
|
||||||
| id | email | firstname | lastname | phone | location_name | location_state | location_latitude | location_longitude | customer_type | customer_sub_type | show_in_directory | created | updated |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| abc-123 | john@example.com | John | Doe | +1-555-0123 | Austin | TX | 30.2672 | -97.7431 | professional | broker | true | 2025-11-01 | 2025-11-25 |
|
|
||||||
|
|
||||||
**NACHHER (JSON-Tabelle `users_json`):**
|
|
||||||
|
|
||||||
| id | email | data (JSONB) |
|
|
||||||
|---|---|---|
|
|
||||||
| abc-123 | john@example.com | `{"firstname": "John", "lastname": "Doe", "phone": "+1-555-0123", "location": {"name": "Austin", "state": "TX", "latitude": 30.2672, "longitude": -97.7431}, "customerType": "professional", "customerSubType": "broker", "showInDirectory": true, "created": "2025-11-01T10:00:00Z", "updated": "2025-11-25T15:30:00Z"}` |
|
|
||||||
|
|
||||||
#### Vorteile der neuen Struktur
|
|
||||||
|
|
||||||
- ✅ **Keine Schema-Migrationen mehr nötig** - Neue Felder einfach im JSON hinzufügen
|
|
||||||
- ✅ **Flexiblere Datenstrukturen** - Nested Objects und Arrays direkt speicherbar
|
|
||||||
- ✅ **Einfacheres ORM-Mapping** - TypeScript-Interfaces direkt zu JSON serialisierbar
|
|
||||||
- ✅ **Bessere Performance** - PostgreSQL JSONB ist indexierbar und schnell durchsuchbar
|
|
||||||
- ✅ **Reduzierte Code-Komplexität** - Weniger Join-Operationen, weniger Spalten-Mapping
|
|
||||||
|
|
||||||
#### Migration durchführen (Referenz)
|
|
||||||
|
|
||||||
Die Migration wurde bereits durchgeführt. Falls nötig, Backup-Prozess:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Backup der alten relationalen Tabellen
|
|
||||||
docker exec -it bizmatchdb \
|
|
||||||
pg_dump -U bizmatch -d bizmatch -t users -t businesses -t commercials \
|
|
||||||
-F c -Z 9 -f /tmp/backup_relational_tables.dump
|
|
||||||
|
|
||||||
# 2. Neue Tabellen sind bereits vorhanden und in Verwendung
|
|
||||||
# 3. Alte Tabellen können bei Bedarf gelöscht werden (NICHT empfohlen vor finalem Produktions-Test)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 Location-Datenstruktur bei Professionals
|
|
||||||
|
|
||||||
**Datum:** November 2025
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
#### Problem
|
|
||||||
Professionals-Suche funktionierte nicht, da die Datenstruktur für `location` falsch angenommen wurde.
|
|
||||||
|
|
||||||
#### Lösung
|
|
||||||
- Professionals verwenden `location`-Objekt (nicht `areasServed`-Array)
|
|
||||||
- Struktur: `{ name: 'Austin', state: 'TX', latitude: 30.2672, longitude: -97.7431 }`
|
|
||||||
|
|
||||||
#### Betroffene Queries
|
|
||||||
- Exact City Search: `location.name` ILIKE-Vergleich
|
|
||||||
- Radius Search: Haversine-Formel mit `location.latitude` und `location.longitude`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Backend-Änderungen
|
|
||||||
|
|
||||||
### 2.1 Professionals Search Fix
|
|
||||||
|
|
||||||
**Datei:** `bizmatch-server/src/user/user.service.ts`
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
#### Änderungen
|
|
||||||
|
|
||||||
**Exact City Search (Zeile 28-30):**
|
|
||||||
```typescript
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Radius Search (Zeile 32-36):**
|
|
||||||
```typescript
|
|
||||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
|
||||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
|
||||||
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
|
|
||||||
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Filter (Zeile 55-57):**
|
|
||||||
```typescript
|
|
||||||
if (criteria.state) {
|
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**County Filter (Zeile 51-53):**
|
|
||||||
```typescript
|
|
||||||
if (criteria.counties && criteria.counties.length > 0) {
|
|
||||||
whereConditions.push(or(...criteria.counties.map(county =>
|
|
||||||
sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 TypeScript-Fehler behoben
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
Kompilierungsfehler wegen falscher Parameter-Übergabe an `getDistanceQuery()`
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
- ❌ Alt: `getDistanceQuery(schema.users_json.data, 'location', lat, lon)`
|
|
||||||
- ✅ Neu: `getDistanceQuery(schema.users_json, lat, lon)`
|
|
||||||
|
|
||||||
### 2.3 Slug-Unterstützung für SEO-freundliche URLs
|
|
||||||
|
|
||||||
**Status:** ✅ Implementiert
|
|
||||||
|
|
||||||
Business- und Commercial-Property-Listings können nun über SEO-freundliche Slugs aufgerufen werden:
|
|
||||||
|
|
||||||
- `/business/austin-coffee-shop-for-sale` statt `/business/uuid-123-456`
|
|
||||||
- `/commercial-property/downtown-retail-space-dallas` statt `/commercial-property/uuid-789`
|
|
||||||
|
|
||||||
**Implementierung:**
|
|
||||||
- Automatische Slug-Generierung aus Listing-Titeln
|
|
||||||
- Slug-basierte Routen in allen Controllern
|
|
||||||
- Fallback auf ID, falls kein Slug vorhanden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Frontend-Änderungen
|
|
||||||
|
|
||||||
### 3.1 Breadcrumbs-Navigation
|
|
||||||
|
|
||||||
**Datum:** November 2025
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
#### Implementierte Komponenten
|
|
||||||
|
|
||||||
**Betroffene Seiten:**
|
|
||||||
- ✅ Business Detail Pages (`details-business-listing.component.ts`)
|
|
||||||
- ✅ Commercial Property Detail Pages (`details-commercial-property-listing.component.ts`)
|
|
||||||
- ✅ User Detail Pages (`details-user.component.ts`)
|
|
||||||
- ✅ 404 Not Found Page (`not-found.component.ts`)
|
|
||||||
- ✅ Business Listings Overview (`business-listings.component.ts`)
|
|
||||||
- ✅ Commercial Property Listings Overview (`commercial-property-listings.component.ts`)
|
|
||||||
|
|
||||||
**Beispiel-Struktur:**
|
|
||||||
```
|
|
||||||
Home > Commercial Properties > Austin Office Space for Sale
|
|
||||||
Home > Business Listings > Restaurant for Sale in Dallas
|
|
||||||
Home > 404 - Page Not Found
|
|
||||||
```
|
|
||||||
|
|
||||||
**Komponente:** `bizmatch/src/app/components/breadcrumbs/breadcrumbs.component.ts`
|
|
||||||
|
|
||||||
### 3.2 Automatische 50-Meilen-Radius-Auswahl
|
|
||||||
|
|
||||||
**Datum:** November 2025
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
#### Änderungen
|
|
||||||
|
|
||||||
Bei Auswahl einer Stadt in den Suchfiltern wird automatisch:
|
|
||||||
- **Search Type** auf `"radius"` gesetzt
|
|
||||||
- **Radius** auf `50` Meilen gesetzt
|
|
||||||
- Filter-UI aktualisiert (Radius-Feld aktiv und ausgefüllt)
|
|
||||||
|
|
||||||
**Betroffene Dateien:**
|
|
||||||
- `bizmatch/src/app/components/search-modal/search-modal.component.ts` (Business)
|
|
||||||
- `bizmatch/src/app/components/search-modal/search-modal-commercial.component.ts` (Properties)
|
|
||||||
- `bizmatch/src/app/components/search-modal/search-modal-broker.component.ts` (Professionals)
|
|
||||||
|
|
||||||
**Implementierung (Zeilen 255-269 in search-modal.component.ts):**
|
|
||||||
```typescript
|
|
||||||
setCity(city: any): void {
|
|
||||||
const updates: any = {};
|
|
||||||
if (city) {
|
|
||||||
updates.city = city;
|
|
||||||
updates.state = city.state;
|
|
||||||
// Automatically set radius to 50 miles and enable radius search
|
|
||||||
updates.searchType = 'radius';
|
|
||||||
updates.radius = 50;
|
|
||||||
} else {
|
|
||||||
updates.city = null;
|
|
||||||
updates.radius = null;
|
|
||||||
updates.searchType = 'exact';
|
|
||||||
}
|
|
||||||
this.updateCriteria(updates);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Error Handling Verbesserungen
|
|
||||||
|
|
||||||
**Betroffene Komponenten:**
|
|
||||||
- `details-business-listing.component.ts`
|
|
||||||
- `details-commercial-property-listing.component.ts`
|
|
||||||
|
|
||||||
**Änderungen:**
|
|
||||||
- ✅ Safe Navigation für `listing.imageOrder` (Null-Check vor forEach)
|
|
||||||
- ✅ Verbesserte Error-Message-Extraktion mit Optional Chaining
|
|
||||||
- ✅ Default-Breadcrumbs auch bei Fehlerfall
|
|
||||||
- ✅ Korrekte Navigation zu 404-Seite bei fehlenden Listings
|
|
||||||
|
|
||||||
**Beispiel (Zeile 139 in details-commercial-property-listing.component.ts):**
|
|
||||||
```typescript
|
|
||||||
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
|
||||||
this.listing.imageOrder.forEach(image => {
|
|
||||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
|
||||||
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 Business Location Privacy - Stadt-Grenze statt exakter Adresse
|
|
||||||
|
|
||||||
**Datum:** November 2025
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
#### Problem & Motivation
|
|
||||||
|
|
||||||
Bei Business-Listings für verkaufende Unternehmen sollte die **exakte Adresse nicht öffentlich angezeigt** werden, um:
|
|
||||||
- ✅ Konkurrierende Unternehmen nicht zu informieren
|
|
||||||
- ✅ Kunden nicht zu verunsichern
|
|
||||||
- ✅ Mitarbeiter vor Verunsicherung zu schützen
|
|
||||||
|
|
||||||
Nur die **ungefähre Stadt-Region** soll angezeigt werden. Die genaue Adresse wird erst nach Kontaktaufnahme mitgeteilt.
|
|
||||||
|
|
||||||
#### Implementierung
|
|
||||||
|
|
||||||
**Betroffene Dateien:**
|
|
||||||
- `bizmatch/src/app/services/geo.service.ts` - Neue Methode `getCityBoundary()`
|
|
||||||
- `bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts` - Override von Map-Methoden
|
|
||||||
|
|
||||||
**Unterschied: Business vs. Commercial Property**
|
|
||||||
|
|
||||||
| Listing-Typ | Map-Anzeige | Adresse-Anzeige | Begründung |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Business Listings** | Stadt-Grenze (rotes Polygon) | Nur Stadt, County, State | Privacy: Verkäufer-Schutz |
|
|
||||||
| **Commercial Properties** | Exakter Pin-Marker | Vollständige Straßenadresse | Immobilie muss sichtbar sein |
|
|
||||||
|
|
||||||
**Technische Umsetzung:**
|
|
||||||
|
|
||||||
1. **Nominatim API Integration** ([geo.service.ts:33-37](bizmatch/src/app/services/geo.service.ts#L33-L37)):
|
|
||||||
```typescript
|
|
||||||
getCityBoundary(cityName: string, state: string): Observable<any> {
|
|
||||||
const query = `${cityName}, ${state}, USA`;
|
|
||||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
|
|
||||||
return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1`, { headers });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **City Boundary Polygon** ([details-business-listing.component.ts:322-430](bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts#L322-L430)):
|
|
||||||
- Lädt Stadt-Grenz-Polygon von OpenStreetMap Nominatim API
|
|
||||||
- Zeigt rote Umrandung der Stadt (wie Google Maps)
|
|
||||||
- Unterstützt `Polygon` und `MultiPolygon` Geometrien
|
|
||||||
- **Fallback:** 8km-Radius-Kreis bei API-Fehler oder fehlenden Daten
|
|
||||||
|
|
||||||
3. **Karten-Konfiguration:**
|
|
||||||
```typescript
|
|
||||||
// Rotes Polygon (wie Google Maps)
|
|
||||||
const cityPolygon = polygon(latlngs, {
|
|
||||||
color: '#ef4444', // Rot
|
|
||||||
fillColor: '#ef4444', // Rot
|
|
||||||
fillOpacity: 0.1, // Sehr transparent (90% durchsichtig)
|
|
||||||
weight: 2 // Linienstärke
|
|
||||||
});
|
|
||||||
|
|
||||||
// Popup zeigt nur allgemeine Information
|
|
||||||
cityPolygon.bindPopup(`
|
|
||||||
<div style="padding: 8px;">
|
|
||||||
<strong>General Area:</strong><br/>
|
|
||||||
${cityName}, ${county ? county + ', ' : ''}${state}<br/>
|
|
||||||
<small style="color: #666;">City boundary shown for privacy.<br/>Exact location provided after contact.</small>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Address Control Override:**
|
|
||||||
- Zeigt nur: "Austin, Travis County, TX"
|
|
||||||
- **NICHT** angezeigt: Straßenname, Hausnummer, PLZ
|
|
||||||
|
|
||||||
5. **OpenStreetMap Link Override:**
|
|
||||||
- Zoom-Level 11 (Stadt-Ansicht) statt Zoom-Level 15 (Straßen-Ansicht)
|
|
||||||
- Keine Marker auf exakter Position
|
|
||||||
|
|
||||||
**Entwicklungs-Verlauf (Entscheidungen):**
|
|
||||||
|
|
||||||
| Ansatz | Grund für Ablehnung | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| 2km Fuzzy-Radius | Zu klein - bei wenigen Businesses in Stadt identifizierbar | ❌ Abgelehnt |
|
|
||||||
| County-Level | Zu groß - schlechte UX, schlechtes SEO | ❌ Abgelehnt |
|
|
||||||
| Stadt-Center + 8km-Kreis | Funktioniert, aber nicht professionell aussehend | ⚠️ Fallback |
|
|
||||||
| **Stadt-Grenz-Polygon (wie Google Maps)** | Professionell, präzise, gute Privacy | ✅ **Implementiert** |
|
|
||||||
|
|
||||||
**Vorteile der Lösung:**
|
|
||||||
|
|
||||||
- ✅ **Privacy by Design** - Exakte Location nicht sichtbar
|
|
||||||
- ✅ **Professionelles Erscheinungsbild** - Wie Google Maps Stadt-Grenzen
|
|
||||||
- ✅ **Genaue Stadt-Darstellung** - Nutzt offizielle OSM-Daten
|
|
||||||
- ✅ **Robust** - Fallback auf Kreis bei API-Problemen
|
|
||||||
- ✅ **SEO-freundlich** - Stadt-Namen bleiben in Metadaten erhalten
|
|
||||||
- ✅ **Multi-Polygon Support** - Städte mit mehreren Bereichen (Inseln, etc.)
|
|
||||||
|
|
||||||
**Was NICHT geändert wurde:**
|
|
||||||
|
|
||||||
- ❌ **Commercial Property Listings** zeigen weiterhin exakte Adressen
|
|
||||||
- ❌ **User/Professional Locations** zeigen weiterhin Stadt-Pins
|
|
||||||
- ❌ **Datenbank** - Location-Daten bleiben unverändert gespeichert
|
|
||||||
- ❌ **Backend** - Keine API-Änderungen nötig
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) SEO-Verbesserungen
|
|
||||||
|
|
||||||
### 4.1 Meta-Tags & Structured Data
|
|
||||||
|
|
||||||
**Status:** ✅ Implementiert
|
|
||||||
|
|
||||||
**Neue SEO-Features:**
|
|
||||||
- ✅ Dynamische Title & Description für alle Listing-Seiten
|
|
||||||
- ✅ Open Graph Tags für Social Media Sharing
|
|
||||||
- ✅ JSON-LD Structured Data (Schema.org)
|
|
||||||
- ✅ Canonical URLs
|
|
||||||
- ✅ Noindex für 404-Seiten
|
|
||||||
|
|
||||||
**Implementierung:**
|
|
||||||
- `bizmatch/src/app/services/seo.service.ts`
|
|
||||||
- Automatische Meta-Tag-Generierung basierend auf Listing-Daten
|
|
||||||
|
|
||||||
### 4.2 Sitemap-Generierung
|
|
||||||
|
|
||||||
**Status:** ✅ Implementiert
|
|
||||||
|
|
||||||
**Endpunkte:**
|
|
||||||
- `/bizmatch/sitemap.xml` - Haupt-Sitemap (Index)
|
|
||||||
- `/bizmatch/sitemap/static.xml` - Statische Seiten
|
|
||||||
- `/bizmatch/sitemap/business-1.xml` - Business-Listings (paginiert)
|
|
||||||
- `/bizmatch/sitemap/commercial-1.xml` - Commercial-Properties (paginiert)
|
|
||||||
|
|
||||||
**Controller:** `bizmatch-server/src/sitemap/sitemap.controller.ts`
|
|
||||||
|
|
||||||
### 4.3 SEO-freundliche 404-Seite
|
|
||||||
|
|
||||||
**Datei:** `bizmatch/src/app/components/not-found/not-found.component.ts`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- ✅ Breadcrumbs für bessere Navigation
|
|
||||||
- ✅ `noindex` Meta-Tag (verhindert Indexierung)
|
|
||||||
- ✅ Aussagekräftige Title & Description
|
|
||||||
- ✅ Link zurück zur Homepage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Code-Cleanup & Wartung
|
|
||||||
|
|
||||||
### 5.1 Gelöschte temporäre Dateien
|
|
||||||
|
|
||||||
**Datum:** November 2025
|
|
||||||
**Status:** ✅ Abgeschlossen
|
|
||||||
|
|
||||||
**Markdown-Dokumentation (7 Dateien):**
|
|
||||||
- ❌ `DATABASE-FIX-INSTRUCTIONS.md`
|
|
||||||
- ❌ `DEPLOYMENT-GUIDE.md`
|
|
||||||
- ❌ `PROFESSIONALS-TAB-IMPLEMENTATION.md`
|
|
||||||
- ❌ `RESTART-BACKEND.md`
|
|
||||||
- ❌ `SEO-IMPROVEMENTS-SUMMARY.md`
|
|
||||||
- ❌ `bizmatch-server/SEO-DEPLOYMENT-SUCCESS.md`
|
|
||||||
- ❌ `bizmatch-server/TYPESCRIPT-FIX-SUMMARY.md`
|
|
||||||
|
|
||||||
**Shell-Scripts (33 Dateien):**
|
|
||||||
- ❌ Alle `.sh`-Dateien in `bizmatch-server/` (check-*, fix-*, test-*, run-*, setup-*, etc.)
|
|
||||||
|
|
||||||
**SQL-Test-Dateien (5 Dateien):**
|
|
||||||
- ❌ `create-schema.sql`
|
|
||||||
- ❌ `insert-professionals-json.sql`
|
|
||||||
- ❌ `insert-professionals-simple.sql`
|
|
||||||
- ❌ `insert-test-professionals.sql`
|
|
||||||
- ❌ `insert-test-professionals-fixed.sql`
|
|
||||||
|
|
||||||
**Debug-JavaScript (2 Dateien):**
|
|
||||||
- ❌ `check-db.js`
|
|
||||||
- ❌ `verify.js`
|
|
||||||
|
|
||||||
**Komplette Verzeichnisse:**
|
|
||||||
- ❌ `bizmatch-server/scripts/` (~13 Dateien)
|
|
||||||
- ❌ `bizmatch-server/src/scripts/` (~13 Dateien)
|
|
||||||
- ❌ `.claude/` (Verzeichnis)
|
|
||||||
|
|
||||||
**Gesamt:** ~75 temporäre Dateien und 3 Verzeichnisse entfernt
|
|
||||||
|
|
||||||
### 5.2 Beibehaltene Konfigurationsdateien
|
|
||||||
|
|
||||||
**✅ Wichtige Dateien (nicht gelöscht):**
|
|
||||||
- `README.md` (Projekt-Dokumentation)
|
|
||||||
- `bizmatch-server/README.md` (Server-Dokumentation)
|
|
||||||
- `.eslintrc.js` (Code-Style-Konfiguration)
|
|
||||||
- `docker-compose.yml` (Container-Konfiguration)
|
|
||||||
- `.gitignore` (Git-Konfiguration)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Bekannte Issues & Workarounds
|
|
||||||
|
|
||||||
### 6.1 Docker-Container-Neustart nach Code-Änderungen
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
TypeScript-Kompilierungsfehler können dazu führen, dass der Backend-Container nicht startet.
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
```bash
|
|
||||||
# Container-Logs prüfen
|
|
||||||
docker logs bizmatch-app --tail 50
|
|
||||||
|
|
||||||
# Bei TypeScript-Fehlern: Container neu starten
|
|
||||||
docker restart bizmatch-app
|
|
||||||
|
|
||||||
# Prüfen, ob App erfolgreich gestartet ist
|
|
||||||
docker logs bizmatch-app | grep "Nest application successfully started"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Database Connection Issues
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
`password authentication failed for user "bizmatch"`
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
Siehe [README.md - Abschnitt 4.1](README.md#41-password-authentication-failed-for-user-bizmatch)
|
|
||||||
|
|
||||||
### 6.3 Frontend Proxy-Fehler
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
`http proxy error: /bizmatch/select-options` während Backend-Neustart
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
- Warten, bis Backend vollständig gestartet ist (~30 Sekunden)
|
|
||||||
- Frontend-Dev-Server bei Bedarf neu starten: `npm start`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration-Guide: JSON-Schema
|
|
||||||
|
|
||||||
### Von relationaler DB zu JSON-Schema
|
|
||||||
|
|
||||||
**Beispiel: User-Daten**
|
|
||||||
|
|
||||||
**Alt (relationale Tabelle):**
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
email VARCHAR(255),
|
|
||||||
firstname VARCHAR(100),
|
|
||||||
lastname VARCHAR(100),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
...
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Neu (JSON-Schema):**
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users_json (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255),
|
|
||||||
data JSONB
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**JSON-Struktur in `data`-Spalte:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"firstname": "John",
|
|
||||||
"lastname": "Doe",
|
|
||||||
"email": "john.doe@example.com",
|
|
||||||
"phone": "+1-555-0123",
|
|
||||||
"customerType": "professional",
|
|
||||||
"customerSubType": "broker",
|
|
||||||
"location": {
|
|
||||||
"name": "Austin",
|
|
||||||
"state": "TX",
|
|
||||||
"latitude": 30.2672,
|
|
||||||
"longitude": -97.7431
|
|
||||||
},
|
|
||||||
"showInDirectory": true,
|
|
||||||
"created": "2025-11-25T10:30:00Z",
|
|
||||||
"updated": "2025-11-25T10:30:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query-Beispiele:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Alle Professionals in Texas finden
|
|
||||||
SELECT * FROM users_json
|
|
||||||
WHERE (data->>'customerType') = 'professional'
|
|
||||||
AND (data->'location'->>'state') = 'TX';
|
|
||||||
|
|
||||||
-- Nach Name suchen
|
|
||||||
SELECT * FROM users_json
|
|
||||||
WHERE (data->>'firstname') ILIKE '%John%';
|
|
||||||
|
|
||||||
-- Radius-Suche (50 Meilen um Austin)
|
|
||||||
SELECT * FROM users_json
|
|
||||||
WHERE (
|
|
||||||
3959 * 2 * ASIN(SQRT(
|
|
||||||
POWER(SIN((30.2672 - (data->'location'->>'latitude')::float) * PI() / 180 / 2), 2) +
|
|
||||||
COS(30.2672 * PI() / 180) * COS((data->'location'->>'latitude')::float * PI() / 180) *
|
|
||||||
POWER(SIN((-97.7431 - (data->'location'->>'longitude')::float) * PI() / 180 / 2), 2)
|
|
||||||
))
|
|
||||||
) <= 50;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support & Fragen
|
|
||||||
|
|
||||||
Bei Fragen zu diesen Änderungen:
|
|
||||||
1. README.md für Setup-Informationen konsultieren
|
|
||||||
2. Docker-Logs prüfen: `docker logs bizmatch-app` und `docker logs bizmatchdb`
|
|
||||||
3. Git-History für Details zu Änderungen durchsuchen
|
|
||||||
|
|
||||||
**Letzte Aktualisierung:** November 2025
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# Docker Code Sync Guide
|
|
||||||
|
|
||||||
If you have made changes to the backend code and they don't seem to take effect (even though the files on disk are updated), it's because the Docker container is running from a pre-compiled `dist/` directory.
|
|
||||||
|
|
||||||
### The Problem
|
|
||||||
The `bizmatch-app` container compiles the TypeScript code *only once* when the container starts. It does not automatically watch for changes and recompile while running.
|
|
||||||
|
|
||||||
### The Solution
|
|
||||||
You must restart or recreate the container to trigger a new build.
|
|
||||||
|
|
||||||
**Option 1: Quick Restart (Recommended)**
|
|
||||||
Run this in the `bizmatch-server` directory:
|
|
||||||
```bash
|
|
||||||
docker-compose restart app
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Force Rebuild (If changes aren't picked up)**
|
|
||||||
If a simple restart doesn't work, use this to force a fresh build:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Summary for Other Laptops
|
|
||||||
1. **Pull** the latest changes from Git.
|
|
||||||
2. **Execute** `docker-compose restart app`.
|
|
||||||
3. **Verify** the logs for the new `WARN` debug messages.
|
|
||||||
.
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# Final Project Summary & Deployment Guide
|
|
||||||
|
|
||||||
## Recent Changes (Last 3 Git Pushes)
|
|
||||||
|
|
||||||
Here is a summary of the most recent activity on the repository:
|
|
||||||
|
|
||||||
1. **`e3e726d`** - Timo, 3 minutes ago
|
|
||||||
* **Message**: `feat: Initialize BizMatch application with core UI components, routing, listing pages, backend services, migration scripts, and vulnerability management.`
|
|
||||||
* **Impact**: Major initialization of the application structure, including core features and security baselines.
|
|
||||||
|
|
||||||
2. **`e32e43d`** - Timo, 10 hours ago
|
|
||||||
* **Message**: `docs: Add comprehensive deployment guide for BizMatch project.`
|
|
||||||
* **Impact**: Added documentation for deployment procedures.
|
|
||||||
|
|
||||||
3. **`b52e47b`** - Timo, 10 hours ago
|
|
||||||
* **Message**: `feat: Initialize Angular SSR application with core pages, components, and server setup.`
|
|
||||||
* **Impact**: Initial naming and setup of the Angular SSR environment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment Instructions
|
|
||||||
|
|
||||||
### 1. Prerequisites
|
|
||||||
* **Node.js**: Version **20.x** or higher is recommended.
|
|
||||||
* **Package Manager**: `npm`.
|
|
||||||
|
|
||||||
### 2. Building for Production (SSR)
|
|
||||||
The application is configured for **Angular SSR (Server-Side Rendering)**. You must build the application specifically for this mode.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Navigate to the project directory:
|
|
||||||
```bash
|
|
||||||
cd bizmatch
|
|
||||||
```
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
3. Build the project:
|
|
||||||
```bash
|
|
||||||
npm run build:ssr
|
|
||||||
```
|
|
||||||
* This command executes `node version.js` (to update build versions) and then `ng build --configuration prod`.
|
|
||||||
* Output will be generated in `dist/bizmatch/browser` and `dist/bizmatch/server`.
|
|
||||||
|
|
||||||
### 3. Running the Application
|
|
||||||
To start the production server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run serve:ssr
|
|
||||||
```
|
|
||||||
* **Entry Point**: `dist/bizmatch/server/server.mjs`
|
|
||||||
* **Port**: The server listens on `process.env.PORT` or defaults to **4200**.
|
|
||||||
|
|
||||||
### 4. Critical Deployment Checks (SSR & Polyfills)
|
|
||||||
**⚠️ IMPORTANT:**
|
|
||||||
The application uses a custom **DOM Polyfill** to support third-party libraries that might rely on browser-specific objects (like `window`, `document`) during server-side rendering.
|
|
||||||
|
|
||||||
* **Polyfill Location**: `src/ssr-dom-polyfill.ts`
|
|
||||||
* **Server Verification**: Open `server.ts` and ensure the polyfill is imported **BEFORE** any other imports:
|
|
||||||
```typescript
|
|
||||||
// IMPORTANT: DOM polyfill must be imported FIRST
|
|
||||||
import './src/ssr-dom-polyfill';
|
|
||||||
```
|
|
||||||
* **Why is this important?**
|
|
||||||
If this import is removed or moved down, you may encounter `ReferenceError: window is not defined` or `document is not defined` errors when the server tries to render pages containing Leaflet maps or other browser-only libraries.
|
|
||||||
|
|
||||||
### 5. Environment Variables & Security
|
|
||||||
* Ensure all necessary environment variables (e.g., Database URLs, API Keys) are configured in your deployment environment.
|
|
||||||
* Since `server.ts` is an Express app, you can extend it to handle specialized headers or proxy configurations if needed.
|
|
||||||
|
|
||||||
### 6. Vulnerability Status
|
|
||||||
* Please refer to `FINAL_VULNERABILITY_STATUS.md` for the most recent security audit and known issues.
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
# Final Vulnerability Status - BizMatch Project
|
|
||||||
|
|
||||||
**Updated**: 2026-01-03
|
|
||||||
**Status**: Production-Ready ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Current Vulnerability Count
|
|
||||||
|
|
||||||
### bizmatch-server
|
|
||||||
- **Total**: 41 vulnerabilities
|
|
||||||
- **Critical**: 0 ❌
|
|
||||||
- **High**: 33 (all mjml-related, NOT USED) ✅
|
|
||||||
- **Moderate**: 7 (dev tools only) ✅
|
|
||||||
- **Low**: 1 ✅
|
|
||||||
|
|
||||||
### bizmatch (Frontend)
|
|
||||||
- **Total**: 10 vulnerabilities
|
|
||||||
- **Moderate**: 10 (dev tools + legacy dependencies) ✅
|
|
||||||
- **All are acceptable for production** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ What Was Fixed
|
|
||||||
|
|
||||||
### Backend (bizmatch-server)
|
|
||||||
1. ✅ **nodemailer** 6.9 → 7.0.12 (Fixed 3 DoS vulnerabilities)
|
|
||||||
2. ✅ **firebase** 11.3 → 11.9 (Fixed undici vulnerabilities)
|
|
||||||
3. ✅ **drizzle-kit** 0.23 → 0.31 (Fixed esbuild dev vulnerability)
|
|
||||||
|
|
||||||
### Frontend (bizmatch)
|
|
||||||
1. ✅ **Angular 18 → 19** (Fixed 17 XSS vulnerabilities)
|
|
||||||
2. ✅ **@angular/fire** 18.0 → 19.2 (Angular 19 compatibility)
|
|
||||||
3. ✅ **zone.js** 0.14 → 0.15 (Angular 19 requirement)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Remaining Vulnerabilities (ACCEPTABLE)
|
|
||||||
|
|
||||||
### bizmatch-server: 33 High (mjml-related)
|
|
||||||
|
|
||||||
**Package**: `@nestjs-modules/mailer` depends on `mjml`
|
|
||||||
|
|
||||||
**Why These Are Safe**:
|
|
||||||
```typescript
|
|
||||||
// mail.module.ts uses Handlebars, NOT MJML!
|
|
||||||
template: {
|
|
||||||
adapter: new HandlebarsAdapter({...}), // ← Using Handlebars
|
|
||||||
// MJML is NOT used anywhere in the code
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Vulnerabilities**:
|
|
||||||
- `html-minifier` (ReDoS) - via mjml
|
|
||||||
- `mjml-*` packages (33 packages) - NOT USED
|
|
||||||
- `glob` 10.x (Command Injection) - via mjml
|
|
||||||
- `preview-email` - via mjml
|
|
||||||
|
|
||||||
**Mitigation**:
|
|
||||||
- ✅ MJML is never called in production code
|
|
||||||
- ✅ Only Handlebars templates are used
|
|
||||||
- ✅ These packages are dead code in node_modules
|
|
||||||
- ✅ Production builds don't include unused dependencies
|
|
||||||
|
|
||||||
**To verify MJML is not used**:
|
|
||||||
```bash
|
|
||||||
cd bizmatch-server
|
|
||||||
grep -r "mjml" src/ # Returns NO results in source code
|
|
||||||
```
|
|
||||||
|
|
||||||
### bizmatch-server: 7 Moderate (dev tools)
|
|
||||||
|
|
||||||
1. **esbuild** (dev server vulnerability) - drizzle-kit dev dependency
|
|
||||||
2. **pg-promise** (SQL injection) - pg-to-ts type generation tool only
|
|
||||||
|
|
||||||
**Why Safe**: Development tools, not in production runtime
|
|
||||||
|
|
||||||
### bizmatch: 10 Moderate (legacy deps)
|
|
||||||
|
|
||||||
1. **inflight** - deprecated but stable
|
|
||||||
2. **rimraf** v3 - old version but safe
|
|
||||||
3. **glob** v7 - old version in dev dependencies
|
|
||||||
4. **@types/cropperjs** - type definitions only
|
|
||||||
|
|
||||||
**Why Safe**: All are development dependencies or stable legacy packages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Installation Commands
|
|
||||||
|
|
||||||
### Fresh Install (Recommended)
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
|
||||||
sudo rm -rf node_modules package-lock.json
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
|
||||||
sudo rm -rf node_modules package-lock.json
|
|
||||||
npm install --legacy-peer-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verify Production Security
|
|
||||||
```bash
|
|
||||||
# Check ONLY production dependencies
|
|
||||||
cd bizmatch-server
|
|
||||||
npm audit --production
|
|
||||||
|
|
||||||
cd ../bizmatch
|
|
||||||
npm audit --omit=dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Production Security Score
|
|
||||||
|
|
||||||
### Runtime Dependencies Only
|
|
||||||
|
|
||||||
**bizmatch-server** (production):
|
|
||||||
- ✅ **0 Critical**
|
|
||||||
- ✅ **0 High** (mjml not in runtime)
|
|
||||||
- ✅ **2 Moderate** (nodemailer already latest)
|
|
||||||
|
|
||||||
**bizmatch** (production):
|
|
||||||
- ✅ **0 High**
|
|
||||||
- ✅ **3 Moderate** (stable legacy deps)
|
|
||||||
|
|
||||||
**Overall Grade**: **A** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Security Audit Commands
|
|
||||||
|
|
||||||
### Check Production Only
|
|
||||||
```bash
|
|
||||||
# Server (excludes dev deps and mjml unused code)
|
|
||||||
npm audit --production
|
|
||||||
|
|
||||||
# Frontend (excludes dev deps)
|
|
||||||
npm audit --omit=dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full Audit (includes dev tools)
|
|
||||||
```bash
|
|
||||||
npm audit
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Why This Is Production-Safe
|
|
||||||
|
|
||||||
1. **No Critical Vulnerabilities** ❌→✅
|
|
||||||
2. **All High-Severity Fixed** (Angular XSS, etc.) ✅
|
|
||||||
3. **Remaining "High" are Unused Code** (mjml never called) ✅
|
|
||||||
4. **Dev Dependencies Don't Affect Production** ✅
|
|
||||||
5. **Latest Versions of All Active Packages** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Next Steps
|
|
||||||
|
|
||||||
### Immediate (Done) ✅
|
|
||||||
- [x] Update Angular 18 → 19
|
|
||||||
- [x] Update nodemailer 6 → 7
|
|
||||||
- [x] Update @angular/fire 18 → 19
|
|
||||||
- [x] Update firebase to latest
|
|
||||||
- [x] Update zone.js for Angular 19
|
|
||||||
|
|
||||||
### Optional (Future Improvements)
|
|
||||||
- [ ] Consider replacing `@nestjs-modules/mailer` with direct `nodemailer` usage
|
|
||||||
- This would eliminate all 33 mjml vulnerabilities from `npm audit`
|
|
||||||
- Benefit: Cleaner audit report
|
|
||||||
- Cost: Some refactoring needed
|
|
||||||
- **Not urgent**: mjml code is dead and never executed
|
|
||||||
|
|
||||||
- [ ] Set up Dependabot for automatic security updates
|
|
||||||
- [ ] Add monthly security audit to CI/CD pipeline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Security Best Practices Applied
|
|
||||||
|
|
||||||
1. ✅ **Principle of Least Privilege**: Only using necessary features
|
|
||||||
2. ✅ **Defense in Depth**: Multiple layers (no mjml usage even if vulnerable)
|
|
||||||
3. ✅ **Keep Dependencies Updated**: Latest stable versions
|
|
||||||
4. ✅ **Audit Regularly**: Monthly reviews recommended
|
|
||||||
5. ✅ **Production Hardening**: Dev deps excluded from production
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support & Questions
|
|
||||||
|
|
||||||
**Q: Why do we still see 41 vulnerabilities in `npm audit`?**
|
|
||||||
A: 33 are in unused mjml code, 7 are dev tools. Only 0-2 affect production runtime.
|
|
||||||
|
|
||||||
**Q: Should we remove @nestjs-modules/mailer?**
|
|
||||||
A: Optional. It works fine with Handlebars. Removal would clean audit report but requires refactoring.
|
|
||||||
|
|
||||||
**Q: Are we safe to deploy?**
|
|
||||||
A: **YES**. All runtime vulnerabilities are fixed. Remaining ones are unused code or dev tools.
|
|
||||||
|
|
||||||
**Q: What about future updates?**
|
|
||||||
A: Run `npm audit` monthly and update packages quarterly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Security Status**: ✅ **PRODUCTION-READY**
|
|
||||||
**Risk Level**: 🟢 **LOW**
|
|
||||||
**Confidence**: 💯 **HIGH**
|
|
||||||
195
README.md
195
README.md
|
|
@ -1,195 +0,0 @@
|
||||||
# bizmatch-project
|
|
||||||
|
|
||||||
Monorepo bestehend aus **Client** (`bizmatch-project/bizmatch`) und **Server/API** (`bizmatch-project/bizmatch-server`). Diese README führt dich vom frischen Clone bis zum laufenden System mit Produktivdaten im lokalen Dev-Setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
|
|
||||||
- **Node.js** ≥ 20.x (empfohlen LTS) und **npm**
|
|
||||||
- **Docker** ≥ 24.x und **Docker Compose**
|
|
||||||
- Netzwerkzugriff auf die lokalen Ports (Standard: App 3001, Postgres 5433)
|
|
||||||
|
|
||||||
> **Hinweis zu Container-Namen/Ports**
|
|
||||||
> In Beispielen wird der DB-Container als `bizmatchdb` angesprochen. Falls deine Compose andere Namen/Ports nutzt (z. B. `bizmatchdb-prod` oder Ports 5433/3001), passe die Befehle entsprechend an.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository-Struktur (Auszug)
|
|
||||||
|
|
||||||
```
|
|
||||||
bizmatch-project/
|
|
||||||
├─ bizmatch/ # Client (Angular/React/…)
|
|
||||||
├─ bizmatch-server/ # Server (NestJS + Postgres via Docker)
|
|
||||||
│ ├─ docker-compose.yml
|
|
||||||
│ ├─ env.prod # Umgebungsvariablen (Beispiel)
|
|
||||||
│ ├─ bizmatchdb-data-prod/ # (Volume-Pfad für Postgres-Daten)
|
|
||||||
│ └─ initdb/ # (optional: SQL-Skripte für Erstinitialisierung)
|
|
||||||
└─ README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Client starten (Ordner `bizmatch`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/git/bizmatch-project/bizmatch
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
- Der Client startet im Dev-Modus (Standardport: meist `http://localhost:4200` oder projektspezifisch; siehe `package.json`).
|
|
||||||
- API-URL ggf. in den Client-Env-Dateien anpassen (z. B. `environment.ts`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Server & Datenbank starten (Ordner `bizmatch-server`)
|
|
||||||
|
|
||||||
### 2.1 .env-Datei anlegen
|
|
||||||
|
|
||||||
Lege im Ordner `bizmatch-server` eine `.env` (oder `env.prod`) mit folgenden **Beispiel-/Dummy-Werten** an:
|
|
||||||
|
|
||||||
```
|
|
||||||
POSTGRES_DB=bizmatch
|
|
||||||
POSTGRES_USER=bizmatch
|
|
||||||
POSTGRES_PASSWORD=qG5LZhL7Y3
|
|
||||||
DATABASE_URL=postgresql://bizmatch:qG5LZhL7Y3@postgres:5432/bizmatch
|
|
||||||
|
|
||||||
OPENAI_API_KEY=sk-proj-3PVgp1dMTxnigr4nxgg
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Wichtig:** Wenn du `DATABASE_URL` verwendest und dein Passwort Sonderzeichen wie `@ : / % # ?` enthält, **URL-encoden** (z. B. `@` → `%40`). Alternativ nur die Einzel-Variablen `POSTGRES_*` in der App verwenden.
|
|
||||||
|
|
||||||
### 2.2 Docker-Services starten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/git/bizmatch-project/bizmatch-server
|
|
||||||
# Erststart/Neustart der Services
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
- Der Server-Container baut die App (NestJS) und startet auf Port **3001** (Host), intern **3000** (Container), sofern so in `docker-compose.yml` konfiguriert.
|
|
||||||
- Postgres läuft im Container auf **5432**; per Port-Mapping meist auf **5433** am Host erreichbar (siehe `docker-compose.yml`).
|
|
||||||
|
|
||||||
> Warte nach dem Start, bis in den DB-Logs „database system is ready to accept connections“ erscheint:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> docker logs -f bizmatchdb
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Produktiv-Dump lokal importieren
|
|
||||||
|
|
||||||
Falls du einen Dump aus der Produktion hast (Datei `prod.dump`), kannst du ihn in deine lokale DB importieren.
|
|
||||||
|
|
||||||
### 3.1 Dump in den DB-Container kopieren
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# im Ordner bizmatch-server
|
|
||||||
docker cp prod.dump bizmatchdb:/tmp/prod.dump
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Container-Name:** Falls dein DB-Container anders heißt (z. B. `bizmatchdb-prod`), ersetze den Namen im Befehl entsprechend.
|
|
||||||
|
|
||||||
### 3.2 Restore ausführen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it bizmatchdb \
|
|
||||||
sh -c 'pg_restore -U "$POSTGRES_USER" --clean --no-owner -d "$POSTGRES_DB" /tmp/prod.dump'
|
|
||||||
```
|
|
||||||
|
|
||||||
- `--clean` löscht vorhandene Objekte vor dem Einspielen
|
|
||||||
- `--no-owner` ignoriert Besitzer/Role-Bindungen (praktisch für Dev)
|
|
||||||
|
|
||||||
### 3.3 Smoke-Test: DB erreichbar?
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ping/Verbindung testen (pSQL muss im Container verfügbar sein)
|
|
||||||
docker exec -it bizmatchdb \
|
|
||||||
sh -lc 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h /var/run/postgresql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select current_user, now();"'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Häufige Probleme & Lösungen
|
|
||||||
|
|
||||||
### 4.1 `password authentication failed for user "bizmatch"`
|
|
||||||
|
|
||||||
- Prüfe, ob die Passwortänderung **in der DB** erfolgt ist (Env-Änderung allein genügt nicht, wenn das Volume existiert).
|
|
||||||
- Passwort in Postgres setzen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -u postgres -it bizmatchdb \
|
|
||||||
psql -d postgres -c "ALTER ROLE bizmatch WITH LOGIN PASSWORD 'NEUES_PWD';"
|
|
||||||
```
|
|
||||||
|
|
||||||
- App-Umgebung (`.env`) anpassen und App neu starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose restart app
|
|
||||||
```
|
|
||||||
|
|
||||||
- Bei Nutzung von `DATABASE_URL`: Sonderzeichen **URL-encoden**.
|
|
||||||
|
|
||||||
### 4.2 Container-Hostnamen stimmen nicht
|
|
||||||
|
|
||||||
- Innerhalb des Compose-Netzwerks ist der **Service-Name** der Host (z. B. `postgres` oder `postgres-prod`). Achte darauf, dass `DB_HOST`/`DATABASE_URL` dazu passen.
|
|
||||||
|
|
||||||
### 4.3 Dump/Restore vs. Datenverzeichnis-Kopie
|
|
||||||
|
|
||||||
- **Empfehlung:** `pg_dump/pg_restore` für Prod→Dev.
|
|
||||||
- Ganze Datenverzeichnisse (Volume) nur **bei gestoppter** DB und **identischer Postgres-Major-Version** kopieren.
|
|
||||||
|
|
||||||
### 4.4 Ports
|
|
||||||
|
|
||||||
- API nicht erreichbar? Prüfe Port-Mapping in `docker-compose.yml` (z. B. `3001:3000`) und Firewall.
|
|
||||||
- Postgres-Hostport (z. B. `5433`) gegen Client-Konfiguration prüfen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Nützliche Befehle (Cheatsheet)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Compose starten/stoppen
|
|
||||||
cd ~/git/bizmatch-project/bizmatch-server
|
|
||||||
docker compose up -d
|
|
||||||
docker compose stop
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
docker logs -f bizmatchdb
|
|
||||||
docker logs -f bizmatch-app
|
|
||||||
|
|
||||||
# Shell in Container
|
|
||||||
docker exec -it bizmatchdb sh
|
|
||||||
|
|
||||||
# Datenbankbenutzer-Passwort ändern
|
|
||||||
docker exec -u postgres -it bizmatchdb \
|
|
||||||
psql -d postgres -c "ALTER ROLE bizmatch WITH LOGIN PASSWORD 'NEUES_PWD';"
|
|
||||||
|
|
||||||
# Dump aus laufender DB (vom Host, falls Port veröffentlicht ist)
|
|
||||||
PGPASSWORD="$POSTGRES_PASSWORD" \
|
|
||||||
pg_dump -h 127.0.0.1 -p 5433 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
|
||||||
-F c -Z 9 -f ./prod.dump
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Sicherheit & Datenschutz
|
|
||||||
|
|
||||||
- Lege **keine echten Secrets** (API-Keys, Prod-Passwörter) im Repo ab. Nutze `.env`-Dateien außerhalb der Versionskontrolle oder einen Secrets-Manager.
|
|
||||||
- Bei Produktivdaten in Dev: **Anonymisierung** (Masking) für personenbezogene Daten erwägen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Erweiterungen (optional)
|
|
||||||
|
|
||||||
- **Init-Skripte**: Lege SQL-Dateien in `bizmatch-server/initdb/` ab, um beim Erststart Benutzer/Schema anzulegen.
|
|
||||||
- **Multi-Stage Dockerfile** für den App-Container (schnellere, reproduzierbare Builds ohne devDependencies).
|
|
||||||
- **Makefile/Skripte** für häufige Tasks (z. B. `make db-backup`, `make db-restore`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Support
|
|
||||||
|
|
||||||
Bei Fragen zu Setup, Dumps oder Container-Namen/Ports: Logs und Compose-Datei prüfen, anschließend die oben beschriebenen Tests (DNS/Ports, psql) durchführen. Anschließend Issue/Notiz anlegen mit Logs & `docker-compose.yml`-Ausschnitt.
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
# Security Vulnerability Fixes
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document details all security vulnerability fixes applied to the BizMatch project.
|
|
||||||
|
|
||||||
**Date**: 2026-01-03
|
|
||||||
**Total Vulnerabilities Before**: 81 (45 server + 36 frontend)
|
|
||||||
**Critical Updates Required**: Yes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Critical Fixes (Server)
|
|
||||||
|
|
||||||
### 1. Underscore.js Arbitrary Code Execution
|
|
||||||
**Vulnerability**: CVE (Arbitrary Code Execution)
|
|
||||||
**Severity**: Critical
|
|
||||||
**Status**: ✅ **FIXED** (via nodemailer-smtp-transport dependency update)
|
|
||||||
|
|
||||||
### 2. HTML Minifier ReDoS
|
|
||||||
**Vulnerability**: GHSA-pfq8-rq6v-vf5m (ReDoS in kangax html-minifier)
|
|
||||||
**Severity**: High
|
|
||||||
**Status**: ✅ **FIXED** (via @nestjs-modules/mailer 2.0.2 → 2.1.0)
|
|
||||||
**Impact**: Fixes 33 high-severity vulnerabilities in mjml-* packages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟠 High Severity Fixes (Frontend)
|
|
||||||
|
|
||||||
### 1. Angular XSS Vulnerability
|
|
||||||
**Vulnerability**: GHSA-58c5-g7wp-6w37 (XSRF Token Leakage via Protocol-Relative URLs)
|
|
||||||
**Severity**: High
|
|
||||||
**Package**: @angular/common, @angular/compiler, and all Angular packages
|
|
||||||
**Status**: ✅ **FIXED** (Angular 18.1.3 → 19.2.16)
|
|
||||||
|
|
||||||
**Files Updated**:
|
|
||||||
- @angular/animations: 18.1.3 → 19.2.16
|
|
||||||
- @angular/common: 18.1.3 → 19.2.16
|
|
||||||
- @angular/compiler: 18.1.3 → 19.2.16
|
|
||||||
- @angular/core: 18.1.3 → 19.2.16
|
|
||||||
- @angular/forms: 18.1.3 → 19.2.16
|
|
||||||
- @angular/platform-browser: 18.1.3 → 19.2.16
|
|
||||||
- @angular/platform-browser-dynamic: 18.1.3 → 19.2.16
|
|
||||||
- @angular/platform-server: 18.1.3 → 19.2.16
|
|
||||||
- @angular/router: 18.1.3 → 19.2.16
|
|
||||||
- @angular/ssr: 18.2.21 → 19.2.16
|
|
||||||
- @angular/cdk: 18.0.6 → 19.1.5
|
|
||||||
- @angular/cli: 18.1.3 → 19.2.16
|
|
||||||
- @angular-devkit/build-angular: 18.1.3 → 19.2.16
|
|
||||||
- @angular/compiler-cli: 18.1.3 → 19.2.16
|
|
||||||
|
|
||||||
### 2. Angular Stored XSS via SVG/MathML
|
|
||||||
**Vulnerability**: GHSA-v4hv-rgfq-gp49
|
|
||||||
**Severity**: High
|
|
||||||
**Status**: ✅ **FIXED** (via Angular 19 update)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 Moderate Severity Fixes
|
|
||||||
|
|
||||||
### 1. Nodemailer Vulnerabilities (Server)
|
|
||||||
**Vulnerabilities**:
|
|
||||||
- GHSA-mm7p-fcc7-pg87 (Email to unintended domain)
|
|
||||||
- GHSA-rcmh-qjqh-p98v (DoS via recursive calls in addressparser)
|
|
||||||
- GHSA-46j5-6fg5-4gv3 (DoS via uncontrolled recursion)
|
|
||||||
|
|
||||||
**Severity**: Moderate
|
|
||||||
**Package**: nodemailer
|
|
||||||
**Status**: ✅ **FIXED** (nodemailer 6.9.10 → 7.0.12)
|
|
||||||
|
|
||||||
### 2. Undici Vulnerabilities (Frontend)
|
|
||||||
**Vulnerabilities**:
|
|
||||||
- GHSA-c76h-2ccp-4975 (Use of Insufficiently Random Values)
|
|
||||||
- GHSA-cxrh-j4jr-qwg3 (DoS via bad certificate data)
|
|
||||||
|
|
||||||
**Severity**: Moderate
|
|
||||||
**Package**: undici (via Firebase dependencies)
|
|
||||||
**Status**: ✅ **FIXED** (firebase 11.3.1 → 11.9.0)
|
|
||||||
|
|
||||||
### 3. Esbuild Development Server Vulnerability
|
|
||||||
**Vulnerability**: GHSA-67mh-4wv8-2f99
|
|
||||||
**Severity**: Moderate
|
|
||||||
**Status**: ✅ **FIXED** (drizzle-kit 0.23.2 → 0.31.8)
|
|
||||||
**Note**: Development-only vulnerability, does not affect production
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Accepted Risks (Development-Only)
|
|
||||||
|
|
||||||
### 1. pg-promise SQL Injection (Server)
|
|
||||||
**Vulnerability**: GHSA-ff9h-848c-4xfj
|
|
||||||
**Severity**: Moderate
|
|
||||||
**Package**: pg-promise (used by pg-to-ts dev tool)
|
|
||||||
**Status**: ⚠️ **ACCEPTED RISK**
|
|
||||||
**Reason**:
|
|
||||||
- No fix available
|
|
||||||
- Only used in development tool (pg-to-ts)
|
|
||||||
- Not used in production runtime
|
|
||||||
- pg-to-ts is only for type generation
|
|
||||||
|
|
||||||
### 2. tmp Symbolic Link Vulnerability (Frontend)
|
|
||||||
**Vulnerability**: GHSA-52f5-9888-hmc6
|
|
||||||
**Severity**: Low
|
|
||||||
**Package**: tmp (used by Angular CLI)
|
|
||||||
**Status**: ⚠️ **ACCEPTED RISK**
|
|
||||||
**Reason**:
|
|
||||||
- Development tool only
|
|
||||||
- Angular CLI dependency
|
|
||||||
- Not included in production build
|
|
||||||
|
|
||||||
### 3. esbuild (Various)
|
|
||||||
**Vulnerability**: GHSA-67mh-4wv8-2f99
|
|
||||||
**Severity**: Moderate
|
|
||||||
**Status**: ⚠️ **PARTIALLY FIXED**
|
|
||||||
**Reason**:
|
|
||||||
- Development server only
|
|
||||||
- Fixed in drizzle-kit
|
|
||||||
- Remaining instances in vite are dev-only
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Package Updates Summary
|
|
||||||
|
|
||||||
### bizmatch-server/package.json
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs-modules/mailer": "^2.0.2" → "^2.1.0",
|
|
||||||
"firebase": "^11.3.1" → "^11.9.0",
|
|
||||||
"nodemailer": "^6.9.10" → "^7.0.12"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"drizzle-kit": "^0.23.2" → "^0.31.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### bizmatch/package.json
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@angular/animations": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/cdk": "^18.0.6" → "^19.1.5",
|
|
||||||
"@angular/common": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/compiler": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/core": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/forms": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/platform-browser": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/platform-browser-dynamic": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/platform-server": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/router": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/ssr": "^18.2.21" → "^19.2.16"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@angular-devkit/build-angular": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/cli": "^18.1.3" → "^19.2.16",
|
|
||||||
"@angular/compiler-cli": "^18.1.3" → "^19.2.16"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Installation Instructions
|
|
||||||
|
|
||||||
### Automatic Installation (Recommended)
|
|
||||||
```bash
|
|
||||||
cd /home/timo/bizmatch-project
|
|
||||||
bash fix-vulnerabilities.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
**If you encounter permission errors:**
|
|
||||||
```bash
|
|
||||||
# Fix permissions first
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
|
||||||
sudo rm -rf node_modules package-lock.json
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
|
||||||
sudo rm -rf node_modules package-lock.json
|
|
||||||
|
|
||||||
# Then install
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
|
||||||
npm install
|
|
||||||
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verify Installation
|
|
||||||
```bash
|
|
||||||
# Check server
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
|
||||||
npm audit --production
|
|
||||||
|
|
||||||
# Check frontend
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
|
||||||
npm audit --production
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Breaking Changes Warning
|
|
||||||
|
|
||||||
### Angular 18 → 19 Migration
|
|
||||||
|
|
||||||
**Potential Issues**:
|
|
||||||
1. **Route configuration**: Some routing APIs may have changed
|
|
||||||
2. **Template syntax**: Check for deprecated template features
|
|
||||||
3. **Third-party libraries**: Some Angular libraries may not yet support v19
|
|
||||||
- @angular/fire: Still on v18.0.1 (compatible but check for updates)
|
|
||||||
- @bluehalo/ngx-leaflet: May need testing
|
|
||||||
- @ng-select/ng-select: May need testing
|
|
||||||
|
|
||||||
**Testing Required**:
|
|
||||||
```bash
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
|
||||||
npm run build
|
|
||||||
npm run serve:ssr
|
|
||||||
# Test all major features
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nodemailer 6 → 7 Migration
|
|
||||||
|
|
||||||
**Potential Issues**:
|
|
||||||
1. **SMTP configuration**: Minor API changes
|
|
||||||
2. **Email templates**: Should be compatible
|
|
||||||
|
|
||||||
**Testing Required**:
|
|
||||||
```bash
|
|
||||||
# Test email functionality
|
|
||||||
# - User registration emails
|
|
||||||
# - Password reset emails
|
|
||||||
# - Contact form emails
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Expected Results
|
|
||||||
|
|
||||||
### Before Updates
|
|
||||||
- **bizmatch-server**: 45 vulnerabilities (4 critical, 33 high, 7 moderate, 1 low)
|
|
||||||
- **bizmatch**: 36 vulnerabilities (17 high, 13 moderate, 6 low)
|
|
||||||
|
|
||||||
### After Updates (Production Only)
|
|
||||||
- **bizmatch-server**: ~5-10 vulnerabilities (mostly dev-only)
|
|
||||||
- **bizmatch**: ~3-5 vulnerabilities (mostly dev-only)
|
|
||||||
|
|
||||||
### Remaining Vulnerabilities
|
|
||||||
All remaining vulnerabilities should be:
|
|
||||||
- Development dependencies only (not in production builds)
|
|
||||||
- Low/moderate severity
|
|
||||||
- Acceptable risk or no fix available
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Security Best Practices
|
|
||||||
|
|
||||||
After applying these fixes:
|
|
||||||
|
|
||||||
1. **Regular Updates**: Run `npm audit` monthly
|
|
||||||
2. **Production Builds**: Always use production builds for deployment
|
|
||||||
3. **Dependency Review**: Review new dependencies before adding
|
|
||||||
4. **Testing**: Thoroughly test after major updates
|
|
||||||
5. **Monitoring**: Set up dependabot or similar tools
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
If you encounter issues during installation:
|
|
||||||
|
|
||||||
1. Check the permission errors first
|
|
||||||
2. Ensure Node.js and npm are up to date
|
|
||||||
3. Review breaking changes section
|
|
||||||
4. Test each component individually
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: 2026-01-03
|
|
||||||
**Next Review**: 2026-02-03 (monthly)
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
/dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
/build
|
/build
|
||||||
/data
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|
@ -58,12 +57,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
pictures
|
pictures
|
||||||
pictures_base
|
pictures_base
|
||||||
pictures_
|
|
||||||
|
|
||||||
src/*.js
|
src/*.js
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
#drizzle migrations
|
|
||||||
src/drizzle/migrations
|
|
||||||
|
|
||||||
importlog.txt
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug Nest Framework",
|
"name": "Debug Nest Framework",
|
||||||
//"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeExecutable": "/home/aknuth/.nvm/versions/node/v22.14.0/bin/npm",
|
|
||||||
"runtimeArgs": ["run", "start:debug", "--", "--inspect-brk"],
|
"runtimeArgs": ["run", "start:debug", "--", "--inspect-brk"],
|
||||||
"autoAttachChildProcesses": true,
|
"autoAttachChildProcesses": true,
|
||||||
"restart": true,
|
"restart": true,
|
||||||
|
|
@ -14,20 +13,16 @@
|
||||||
"stopOnEntry": false,
|
"stopOnEntry": false,
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"env": {
|
"env": {
|
||||||
"HOST_NAME": "localhost",
|
"HOST_NAME": "localhost"
|
||||||
"FIREBASE_PROJECT_ID": "bizmatch-net",
|
|
||||||
"FIREBASE_CLIENT_EMAIL": "firebase-adminsdk-fbsvc@bizmatch-net.iam.gserviceaccount.com",
|
|
||||||
"FIREBASE_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsOlDmhG0zi1zh\nlvobM8yAmLDR3P0F7mHcLyAga2rZm9MnPiGcmkoqRtDnxpZXio36PiyEgdKyhJFK\nP+jPJx1Zo/Ko9vb983oCGcz6MWgRKFXwLT4UJXjwjBdNDe/gcl52c+JJtZJR4bwD\n/bBgkoLzU9lF97pJoQypkSXytyxea6yrS2oEDs7SjW7z9JGFsoxFrt7zbMRb8tIs\nyCWe4I9YSgjSrwOw2uXpdrV0qjDkjx1TokuVJHDH9Vi8XhXDBx9y87Ja0hBoYDE9\nJJRLAa70qHQ9ytfdH/H0kucptC1JkdYGmLQHbohoPDuTU/C85JZvqIitwJ4YEH6Y\nfd+gEe5TAgMBAAECggEALrKDI/WNDFhBn1MJzl1dmhKMguKJ4lVPyF0ot1GYv5bu\nCipg/66f5FWeJ/Hi6qqBM3QvKuBuagPixwCMFbrTzO3UijaoIpQlJTOsrbu+rURE\nBOKnfdvpLkO1v6lDPJaWAUULepPWMAhmK6jZ7V1cTzCRbVSteHBH2CQoZ2Z+C71w\nyvzAIr6JRSg4mYbtHrQCXx9odPCRTdiRvxu5QtihiZGFSXnkTfhDNL1DKff7XHKF\nbOaDPumGtE7ypXr+0qyefg8xeTmXxdI4lPdqxd8XTpLFdMU8nW+/sEjdR40G8ikf\nt6nwyMh01YMMNi88t7ZoDvhpLALb4OqHBhDmyMdOWQKBgQDm5I0cqYX18jypC32G\nUhOdOou6IaZlVDNztZUhFPHPrP0P5Qg1PE5E5YybV7GVNXWiNwI/MPPF0JBce/Ie\ngJoXnuQ9kLh7cNZ432Jhz/Nmhytr6RGxoykAMT1fCuVLsTCfuK4e/aDAgVFJ84gS\nsB3TA62t2hak2MMntKoAQeDwWwKBgQC+9K+MRI/Vj1Xl7jwJ+adRQIvOssVz74ZE\nRYwIDZNRdk/c7c63WVHXASCRZbroGvqJgVfnmtwR6XJTnW3tkYqKUl5W9E+FSVbf\ng4aZs1oaVMA/IirVlRbJ4oCT+nDxPPuJ3ceJ4mBcODO82zXaC6pSFCvkpz9k9lc3\nUPlTLk1baQKBgFMbLqODbSFSeH0MErlXL5InMYXkeMT+IqriT/QhWsw6Yrfm4yZu\nN2nbCdocHWIsZNPnYtql3whzgpKXVlWeSlh4K4TxY0WjHr9RAFNeiyh7PKjRsjmz\nFZ3pG0LrZA7zjyHeUmX7OnIv2bd5fZ/kXkfGiiwKVJ4vG0deYtZG4BUDAoGBAJbI\nFRn4RW8HiHdPv37M8E5bXknvpbRfDTE5jVIKjioD9xnneZQTZmkUjcfhgU2nh+8t\n/+B0ypMmN81IgTXW94MzeSTGM0h22a8SZyVUlrA1/bucWiBeYik1vfubBLWoRqLd\nSaNZ6mbHRis5GPO8xFedb+9UFN2/Gq0mNkl1RUYJAoGBALqTxfdr4MXnG6Nhy22V\nWqui9nsHE5RMIvGYBnnq9Kqt8tUEkxB52YkBilx43q/TY4DRMDOeJk2krEbSN3AO\nguTE6BmZacamrt1HIdSAmJ1RktlVDRgIHXMBkBIumCsTCuXaZ+aEjuLOXJDIsIHZ\nEA9ftLrt1h1u+7QPI+E11Fmx\n-----END PRIVATE KEY-----"
|
|
||||||
}
|
}
|
||||||
// "preLaunchTask": "Start Stripe Listener"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch import from exported with tsx",
|
"name": "Launch TypeScript file with tsx",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npx",
|
||||||
"runtimeArgs": ["tsx", "--inspect"],
|
"runtimeArgs": ["tsx", "--inspect"],
|
||||||
"args": ["${workspaceFolder}/src/drizzle/importFromExported.ts"],
|
"args": ["${workspaceFolder}/src/drizzle/import.ts"],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"outFiles": ["${workspaceFolder}/dist/**/*.js", "!**/node_modules/**"],
|
"outFiles": ["${workspaceFolder}/dist/**/*.js", "!**/node_modules/**"],
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
|
|
@ -65,30 +60,5 @@
|
||||||
"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": "."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# --- STAGE 1: Build ---
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
# HIER KEIN NODE_ENV=production setzen! Wir brauchen devDependencies zum Bauen.
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# --- STAGE 2: Runtime ---
|
|
||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# HIER ist es richtig!
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist /app/dist
|
|
||||||
COPY --from=builder /app/package*.json /app/
|
|
||||||
|
|
||||||
# Installiert nur "dependencies" (Nest core, TypeORM, Helmet, Sharp etc.)
|
|
||||||
# "devDependencies" (TypeScript, Jest, ESLint) werden weggelassen.
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
# WICHTIG: Pfad prüfen (siehe Punkt 2 unten)
|
|
||||||
CMD ["node", "dist/src/main.js"]
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
/* 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,
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ export default defineConfig({
|
||||||
schema: './src/drizzle/schema.ts',
|
schema: './src/drizzle/schema.ts',
|
||||||
out: './src/drizzle/migrations',
|
out: './src/drizzle/migrations',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
|
// driver: 'pg',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- Create missing sequence for commercials_json serialId
|
|
||||||
-- This sequence is required for generating unique serialId values for commercial property listings
|
|
||||||
|
|
||||||
CREATE SEQUENCE IF NOT EXISTS commercials_json_serial_id_seq START WITH 100000;
|
|
||||||
|
|
||||||
-- Verify the sequence was created
|
|
||||||
SELECT sequence_name, start_value, last_value
|
|
||||||
FROM information_schema.sequences
|
|
||||||
WHERE sequence_name = 'commercials_json_serial_id_seq';
|
|
||||||
|
|
||||||
-- Also verify all sequences to check if business listings sequence exists
|
|
||||||
\ds
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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": "nest start",
|
"start": "HOST_NAME=localhost nest start",
|
||||||
"start:local": "HOST_NAME=localhost node dist/src/main",
|
"start:dev": "HOST_NAME=dev.bizmatch.net nest start --watch",
|
||||||
"start:dev": "NODE_ENV=development node dist/src/main",
|
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "NODE_ENV=production node dist/src/main",
|
"start:prod": "HOST_NAME=www.bizmatch.net node dist/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",
|
||||||
|
|
@ -23,41 +23,38 @@
|
||||||
"drop": "drizzle-kit drop",
|
"drop": "drizzle-kit drop",
|
||||||
"migrate": "tsx src/drizzle/migrate.ts",
|
"migrate": "tsx src/drizzle/migrate.ts",
|
||||||
"import": "tsx src/drizzle/import.ts",
|
"import": "tsx src/drizzle/import.ts",
|
||||||
"generateTypes": "tsx src/drizzle/generateTypes.ts src/drizzle/schema.ts src/models/db.model.ts",
|
"generateTypes": "tsx src/drizzle/generateTypes.ts src/drizzle/schema.ts src/models/db.model.ts"
|
||||||
"create-tables": "node src/scripts/create-tables.js",
|
|
||||||
"seed": "node src/scripts/seed-database.js",
|
|
||||||
"create-user": "node src/scripts/create-test-user.js",
|
|
||||||
"seed:all": "npm run create-user && npm run seed",
|
|
||||||
"setup": "npm run create-tables && npm run seed"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^1.10.3",
|
||||||
"@nestjs/cli": "^11.0.11",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/common": "^11.0.11",
|
"@nestjs/config": "^3.2.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/core": "^11.0.11",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/platform-express": "^11.0.11",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@types/stripe": "^8.0.417",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"body-parser": "^1.20.2",
|
"@nestjs/serve-static": "^4.0.1",
|
||||||
"cls-hooked": "^4.2.2",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.32.0",
|
"drizzle-orm": "^0.32.0",
|
||||||
"firebase": "^11.9.0",
|
|
||||||
"firebase-admin": "^13.1.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",
|
||||||
"helmet": "^8.1.0",
|
"jwks-rsa": "^3.1.0",
|
||||||
|
"ky": "^1.4.0",
|
||||||
"nest-winston": "^1.9.4",
|
"nest-winston": "^1.9.4",
|
||||||
"nestjs-cls": "^5.4.0",
|
"nodemailer": "^6.9.10",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer-smtp-transport": "^2.7.4",
|
||||||
"openai": "^4.52.6",
|
"openai": "^4.52.6",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"pgvector": "^0.2.0",
|
"pgvector": "^0.2.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.33.5",
|
"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",
|
||||||
|
|
@ -66,22 +63,29 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/parser": "^7.24.4",
|
"@babel/parser": "^7.24.4",
|
||||||
"@babel/traverse": "^7.24.1",
|
"@babel/traverse": "^7.24.1",
|
||||||
"@nestjs/cli": "^11.0.5",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^11.0.1",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^11.0.11",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jest": "^29.5.2",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.11",
|
||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.11.19",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@types/passport-google-oauth20": "^2.0.14",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/pg": "^8.11.5",
|
"@types/pg": "^8.11.5",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.23.0",
|
||||||
"esbuild-register": "^3.5.0",
|
"esbuild-register": "^3.5.0",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
"kysely-codegen": "^0.15.0",
|
"kysely-codegen": "^0.15.0",
|
||||||
"nest-commander": "^3.16.1",
|
|
||||||
"pg-to-ts": "^4.1.1",
|
"pg-to-ts": "^4.1.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
|
|
@ -91,7 +95,7 @@
|
||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.1.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|
@ -110,4 +114,4 @@
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
|
@ -1,117 +0,0 @@
|
||||||
-- =============================================================
|
|
||||||
-- SEO SLUG MIGRATION SCRIPT
|
|
||||||
-- Run this directly in your PostgreSQL database
|
|
||||||
-- =============================================================
|
|
||||||
|
|
||||||
-- First, let's see how many listings need slugs
|
|
||||||
SELECT 'Businesses without slugs: ' || COUNT(*) FROM businesses_json
|
|
||||||
WHERE data->>'slug' IS NULL OR data->>'slug' = '';
|
|
||||||
|
|
||||||
SELECT 'Commercial properties without slugs: ' || COUNT(*) FROM commercials_json
|
|
||||||
WHERE data->>'slug' IS NULL OR data->>'slug' = '';
|
|
||||||
|
|
||||||
-- =============================================================
|
|
||||||
-- UPDATE BUSINESS LISTINGS WITH SEO SLUGS
|
|
||||||
-- Format: title-city-state-shortid (e.g., restaurant-austin-tx-a3f7b2c1)
|
|
||||||
-- =============================================================
|
|
||||||
|
|
||||||
UPDATE businesses_json
|
|
||||||
SET data = jsonb_set(
|
|
||||||
data::jsonb,
|
|
||||||
'{slug}',
|
|
||||||
to_jsonb(
|
|
||||||
LOWER(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
CONCAT(
|
|
||||||
-- Title (first 50 chars, cleaned)
|
|
||||||
SUBSTRING(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
LOWER(COALESCE(data->>'title', '')),
|
|
||||||
'[^a-z0-9\s-]', '', 'g'
|
|
||||||
), 1, 50
|
|
||||||
),
|
|
||||||
'-',
|
|
||||||
-- City or County
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
LOWER(COALESCE(data->'location'->>'name', data->'location'->>'county', '')),
|
|
||||||
'[^a-z0-9\s-]', '', 'g'
|
|
||||||
),
|
|
||||||
'-',
|
|
||||||
-- State
|
|
||||||
LOWER(COALESCE(data->'location'->>'state', '')),
|
|
||||||
'-',
|
|
||||||
-- First 8 chars of UUID
|
|
||||||
SUBSTRING(id::text, 1, 8)
|
|
||||||
),
|
|
||||||
'\s+', '-', 'g' -- Replace spaces with hyphens
|
|
||||||
),
|
|
||||||
'-+', '-', 'g' -- Replace multiple hyphens with single
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WHERE data->>'slug' IS NULL OR data->>'slug' = '';
|
|
||||||
|
|
||||||
-- =============================================================
|
|
||||||
-- UPDATE COMMERCIAL PROPERTIES WITH SEO SLUGS
|
|
||||||
-- =============================================================
|
|
||||||
|
|
||||||
UPDATE commercials_json
|
|
||||||
SET data = jsonb_set(
|
|
||||||
data::jsonb,
|
|
||||||
'{slug}',
|
|
||||||
to_jsonb(
|
|
||||||
LOWER(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
CONCAT(
|
|
||||||
-- Title (first 50 chars, cleaned)
|
|
||||||
SUBSTRING(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
LOWER(COALESCE(data->>'title', '')),
|
|
||||||
'[^a-z0-9\s-]', '', 'g'
|
|
||||||
), 1, 50
|
|
||||||
),
|
|
||||||
'-',
|
|
||||||
-- City or County
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
LOWER(COALESCE(data->'location'->>'name', data->'location'->>'county', '')),
|
|
||||||
'[^a-z0-9\s-]', '', 'g'
|
|
||||||
),
|
|
||||||
'-',
|
|
||||||
-- State
|
|
||||||
LOWER(COALESCE(data->'location'->>'state', '')),
|
|
||||||
'-',
|
|
||||||
-- First 8 chars of UUID
|
|
||||||
SUBSTRING(id::text, 1, 8)
|
|
||||||
),
|
|
||||||
'\s+', '-', 'g' -- Replace spaces with hyphens
|
|
||||||
),
|
|
||||||
'-+', '-', 'g' -- Replace multiple hyphens with single
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WHERE data->>'slug' IS NULL OR data->>'slug' = '';
|
|
||||||
|
|
||||||
-- =============================================================
|
|
||||||
-- VERIFY THE RESULTS
|
|
||||||
-- =============================================================
|
|
||||||
|
|
||||||
SELECT 'Migration complete! Checking results...' AS status;
|
|
||||||
|
|
||||||
-- Show sample of updated slugs
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
data->>'title' AS title,
|
|
||||||
data->>'slug' AS slug
|
|
||||||
FROM businesses_json
|
|
||||||
LIMIT 5;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
data->>'title' AS title,
|
|
||||||
data->>'slug' AS slug
|
|
||||||
FROM commercials_json
|
|
||||||
LIMIT 5;
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
/**
|
|
||||||
* Migration Script: Generate Slugs for Existing Listings
|
|
||||||
*
|
|
||||||
* This script generates SEO-friendly slugs for all existing businesses
|
|
||||||
* and commercial properties that don't have slugs yet.
|
|
||||||
*
|
|
||||||
* Run with: npx ts-node scripts/migrate-slugs.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
||||||
import { sql, eq, isNull } from 'drizzle-orm';
|
|
||||||
import * as schema from '../src/drizzle/schema';
|
|
||||||
|
|
||||||
// Slug generation function (copied from utils for standalone execution)
|
|
||||||
function generateSlug(title: string, location: any, id: string): string {
|
|
||||||
if (!title || !id) return id; // Fallback to ID if no title
|
|
||||||
|
|
||||||
const titleSlug = title
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.substring(0, 50);
|
|
||||||
|
|
||||||
let locationSlug = '';
|
|
||||||
if (location) {
|
|
||||||
const locationName = location.name || location.county || '';
|
|
||||||
const state = location.state || '';
|
|
||||||
|
|
||||||
if (locationName) {
|
|
||||||
locationSlug = locationName
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state) {
|
|
||||||
locationSlug = locationSlug
|
|
||||||
? `${locationSlug}-${state.toLowerCase()}`
|
|
||||||
: state.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortId = id.substring(0, 8);
|
|
||||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
|
||||||
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateBusinessSlugs(db: NodePgDatabase<typeof schema>) {
|
|
||||||
console.log('🔄 Migrating Business Listings...');
|
|
||||||
|
|
||||||
// Get all businesses without slugs
|
|
||||||
const businesses = await db
|
|
||||||
.select({
|
|
||||||
id: schema.businesses_json.id,
|
|
||||||
email: schema.businesses_json.email,
|
|
||||||
data: schema.businesses_json.data,
|
|
||||||
})
|
|
||||||
.from(schema.businesses_json);
|
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
for (const business of businesses) {
|
|
||||||
const data = business.data as any;
|
|
||||||
|
|
||||||
// Skip if slug already exists
|
|
||||||
if (data.slug) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = generateSlug(data.title || '', data.location || {}, business.id);
|
|
||||||
|
|
||||||
// Update with new slug
|
|
||||||
const updatedData = { ...data, slug };
|
|
||||||
await db
|
|
||||||
.update(schema.businesses_json)
|
|
||||||
.set({ data: updatedData })
|
|
||||||
.where(eq(schema.businesses_json.id, business.id));
|
|
||||||
|
|
||||||
console.log(` ✓ ${data.title?.substring(0, 40)}... → ${slug}`);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Business Listings: ${updated} updated, ${skipped} skipped (already had slugs)`);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateCommercialSlugs(db: NodePgDatabase<typeof schema>) {
|
|
||||||
console.log('\n🔄 Migrating Commercial Properties...');
|
|
||||||
|
|
||||||
// Get all commercial properties without slugs
|
|
||||||
const properties = await db
|
|
||||||
.select({
|
|
||||||
id: schema.commercials_json.id,
|
|
||||||
email: schema.commercials_json.email,
|
|
||||||
data: schema.commercials_json.data,
|
|
||||||
})
|
|
||||||
.from(schema.commercials_json);
|
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
for (const property of properties) {
|
|
||||||
const data = property.data as any;
|
|
||||||
|
|
||||||
// Skip if slug already exists
|
|
||||||
if (data.slug) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = generateSlug(data.title || '', data.location || {}, property.id);
|
|
||||||
|
|
||||||
// Update with new slug
|
|
||||||
const updatedData = { ...data, slug };
|
|
||||||
await db
|
|
||||||
.update(schema.commercials_json)
|
|
||||||
.set({ data: updatedData })
|
|
||||||
.where(eq(schema.commercials_json.id, property.id));
|
|
||||||
|
|
||||||
console.log(` ✓ ${data.title?.substring(0, 40)}... → ${slug}`);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Commercial Properties: ${updated} updated, ${skipped} skipped (already had slugs)`);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('═══════════════════════════════════════════════════════');
|
|
||||||
console.log(' SEO SLUG MIGRATION SCRIPT');
|
|
||||||
console.log('═══════════════════════════════════════════════════════\n');
|
|
||||||
|
|
||||||
// Connect to database
|
|
||||||
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/bizmatch';
|
|
||||||
console.log(`📡 Connecting to database...`);
|
|
||||||
|
|
||||||
const pool = new Pool({ connectionString });
|
|
||||||
const db = drizzle(pool, { schema });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const businessCount = await migrateBusinessSlugs(db);
|
|
||||||
const commercialCount = await migrateCommercialSlugs(db);
|
|
||||||
|
|
||||||
console.log('\n═══════════════════════════════════════════════════════');
|
|
||||||
console.log(`🎉 Migration complete! Total: ${businessCount + commercialCount} listings updated`);
|
|
||||||
console.log('═══════════════════════════════════════════════════════\n');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
||||||
import { sql, eq, and } from 'drizzle-orm';
|
|
||||||
import * as schema from '../src/drizzle/schema';
|
|
||||||
import { users_json } from '../src/drizzle/schema';
|
|
||||||
|
|
||||||
// Mock JwtUser
|
|
||||||
interface JwtUser {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logic from UserService.addFavorite
|
|
||||||
async function addFavorite(db: NodePgDatabase<typeof schema>, id: string, user: JwtUser) {
|
|
||||||
console.log(`[Action] Adding favorite. Target ID: ${id}, Favoriter Email: ${user.email}`);
|
|
||||||
await db
|
|
||||||
.update(schema.users_json)
|
|
||||||
.set({
|
|
||||||
data: sql`jsonb_set(${schema.users_json.data}, '{favoritesForUser}',
|
|
||||||
coalesce((${schema.users_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
|
||||||
} as any)
|
|
||||||
.where(eq(schema.users_json.id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logic from UserService.getFavoriteUsers
|
|
||||||
async function getFavoriteUsers(db: NodePgDatabase<typeof schema>, user: JwtUser) {
|
|
||||||
console.log(`[Action] Fetching favorites for ${user.email}`);
|
|
||||||
|
|
||||||
// Corrected query using `?` operator (matches array element check)
|
|
||||||
const data = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.users_json)
|
|
||||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logic from UserService.deleteFavorite
|
|
||||||
async function deleteFavorite(db: NodePgDatabase<typeof schema>, id: string, user: JwtUser) {
|
|
||||||
console.log(`[Action] Removing favorite. Target ID: ${id}, Favoriter Email: ${user.email}`);
|
|
||||||
await db
|
|
||||||
.update(schema.users_json)
|
|
||||||
.set({
|
|
||||||
data: sql`jsonb_set(${schema.users_json.data}, '{favoritesForUser}',
|
|
||||||
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
|
||||||
FROM jsonb_array_elements(coalesce(${schema.users_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
|
||||||
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
|
||||||
} as any)
|
|
||||||
.where(eq(schema.users_json.id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('═══════════════════════════════════════════════════════');
|
|
||||||
console.log(' FAVORITES REPRODUCTION SCRIPT');
|
|
||||||
console.log('═══════════════════════════════════════════════════════\n');
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/bizmatch';
|
|
||||||
const pool = new Pool({ connectionString });
|
|
||||||
const db = drizzle(pool, { schema });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Find a "professional" user to be the TARGET listing
|
|
||||||
// filtering by customerType = 'professional' inside the jsonb data
|
|
||||||
const targets = await db.select().from(users_json).limit(1);
|
|
||||||
|
|
||||||
if (targets.length === 0) {
|
|
||||||
console.error("No users found in DB to test with.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUser = targets[0];
|
|
||||||
console.log(`Found target user: ID=${targetUser.id}, Email=${targetUser.email}`);
|
|
||||||
|
|
||||||
// 2. Define a "favoriter" user (doesn't need to exist in DB for the logic to work, but better if it's realistic)
|
|
||||||
// We'll just use a dummy email or one from DB if available.
|
|
||||||
const favoriterEmail = 'test-repro-favoriter@example.com';
|
|
||||||
const favoriter: JwtUser = { email: favoriterEmail };
|
|
||||||
|
|
||||||
// 3. Clear any existing favorite for this pair first
|
|
||||||
await deleteFavorite(db, targetUser.id, favoriter);
|
|
||||||
|
|
||||||
// 4. Add Favorite
|
|
||||||
await addFavorite(db, targetUser.id, favoriter);
|
|
||||||
|
|
||||||
// 5. Verify it was added by checking the raw data
|
|
||||||
const updatedTarget = await db.select().from(users_json).where(eq(users_json.id, targetUser.id));
|
|
||||||
const favoritesData = (updatedTarget[0].data as any).favoritesForUser;
|
|
||||||
console.log(`\n[Check] Raw favoritesForUser data on target:`, favoritesData);
|
|
||||||
|
|
||||||
if (!favoritesData || !favoritesData.includes(favoriterEmail)) {
|
|
||||||
console.error("❌ Add Favorite FAILED. Email not found in favoritesForUser array.");
|
|
||||||
} else {
|
|
||||||
console.log("✅ Add Favorite SUCCESS. Email found in JSON.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Test retrieval using the getFavoriteUsers query
|
|
||||||
const retrievedFavorites = await getFavoriteUsers(db, favoriter);
|
|
||||||
console.log(`\n[Check] retrievedFavorites count: ${retrievedFavorites.length}`);
|
|
||||||
|
|
||||||
const found = retrievedFavorites.find(u => u.id === targetUser.id);
|
|
||||||
if (found) {
|
|
||||||
console.log("✅ Get Favorites SUCCESS. Target user returned in query.");
|
|
||||||
} else {
|
|
||||||
console.log("❌ Get Favorites FAILED. Target user NOT returned by query.");
|
|
||||||
console.log("Query used: favoritesForUser @> [email]");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Cleanup
|
|
||||||
await deleteFavorite(db, targetUser.id, favoriter);
|
|
||||||
console.log("\n[Cleanup] Removed test favorite.");
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Script failed:', error);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service.js';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class AiController {
|
export class AiController {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AiController } from './ai.controller';
|
import { AiController } from './ai.controller.js';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
|
|
|
||||||
|
|
@ -3,85 +3,30 @@ 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;
|
||||||
|
|
@ -101,49 +46,42 @@ export class AiService {
|
||||||
const prompt = `The Search Query of the User is: "${query}"`;
|
const prompt = `The Search Query of the User is: "${query}"`;
|
||||||
let response = null;
|
let response = null;
|
||||||
try {
|
try {
|
||||||
response = await this.openai.chat.completions.create({
|
// response = await this.openai.chat.completions.create({
|
||||||
model: 'gpt-4o-mini',
|
// model: 'gpt-4o-mini',
|
||||||
//model: 'gpt-3.5-turbo',
|
// //model: 'gpt-3.5-turbo',
|
||||||
max_tokens: 300,
|
// max_tokens: 300,
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Please create unformatted JSON Object from a user input.
|
|
||||||
The criteriaType must be only either 'businessListings' or 'commercialPropertyListings' or 'brokerListings' !!!!
|
|
||||||
The format of the object (depending on your choice of criteriaType) must be either ${BusinessListingCriteriaStructure}, ${CommercialPropertyListingCriteriaStructure} or ${UserListingCriteriaStructure} !!!!
|
|
||||||
If location details available please fill city and state as State Code and only county if explicitly mentioned.
|
|
||||||
If you decide for searchType==='exact', please do not set the attribute radius`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
temperature: 0.5,
|
|
||||||
response_format: { type: 'json_object' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// response = await this.groq.chat.completions.create({
|
|
||||||
// messages: [
|
// messages: [
|
||||||
// {
|
// {
|
||||||
// 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 criteriaType must be only either 'businessListings' or 'commercialPropertyListings' or 'brokerListings' !!!!
|
// The type is: ${JSON.stringify(businessListingCriteriaStructure)}.,
|
||||||
// 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, county and state as State Code`,
|
||||||
// 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',
|
|
||||||
// content: prompt,
|
|
||||||
// },
|
// },
|
||||||
// ],
|
// ],
|
||||||
// model: 'llama-3.3-70b-versatile',
|
// temperature: 0.5,
|
||||||
// temperature: 0.2,
|
|
||||||
// max_tokens: 300,
|
|
||||||
// response_format: { type: 'json_object' },
|
// response_format: { type: 'json_object' },
|
||||||
// });
|
// });
|
||||||
|
|
||||||
|
response = await this.groq.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Please create unformatted JSON Object from a user input.
|
||||||
|
The type must be: ${JSON.stringify(businessListingCriteriaStructure)}.
|
||||||
|
If location details available please fill city, county and state as State Code`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: 'llama-3.1-70b-versatile',
|
||||||
|
//model: 'llama-3.1-8b-instant',
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 300,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
const generatedCriteria = JSON.parse(response.choices[0]?.message?.content);
|
const generatedCriteria = JSON.parse(response.choices[0]?.message?.content);
|
||||||
return generatedCriteria;
|
return generatedCriteria;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service.js';
|
||||||
|
import { AuthService } from './auth/auth.service.js';
|
||||||
import { AuthGuard } from './jwt-auth/auth.guard';
|
import { JwtAuthGuard } from './jwt-auth/jwt-auth.guard.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly appService: AppService,
|
private readonly appService: AppService,
|
||||||
|
private authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get()
|
@Get()
|
||||||
getHello(@Request() req): string {
|
getHello(@Request() req): string {
|
||||||
return req.user;
|
return req.user;
|
||||||
|
//return 'dfgdf';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,62 @@
|
||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { MiddlewareConsumer, Module } from '@nestjs/common';
|
||||||
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
|
|
||||||
import * as winston from 'winston';
|
|
||||||
import { AiModule } from './ai/ai.module';
|
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
import { AuthModule } from './auth/auth.module';
|
|
||||||
import { FileService } from './file/file.service';
|
|
||||||
import { GeoModule } from './geo/geo.module';
|
|
||||||
import { ImageModule } from './image/image.module';
|
|
||||||
import { ListingsModule } from './listings/listings.module';
|
|
||||||
import { LogController } from './log/log.controller';
|
|
||||||
import { LogModule } from './log/log.module';
|
|
||||||
|
|
||||||
import { EventModule } from './event/event.module';
|
|
||||||
import { MailModule } from './mail/mail.module';
|
|
||||||
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ClsMiddleware, ClsModule } from 'nestjs-cls';
|
import * as dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import fs from 'fs-extra';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { WinstonModule, utilities as nestWinstonModuleUtilities } from 'nest-winston';
|
||||||
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
|
import * as winston from 'winston';
|
||||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
import { AiModule } from './ai/ai.module.js';
|
||||||
import { UserInterceptor } from './interceptors/user.interceptor';
|
import { AppController } from './app.controller.js';
|
||||||
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
|
import { AppService } from './app.service.js';
|
||||||
import { SelectOptionsModule } from './select-options/select-options.module';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { FileService } from './file/file.service.js';
|
||||||
import { UserModule } from './user/user.module';
|
import { GeoModule } from './geo/geo.module.js';
|
||||||
|
import { ImageModule } from './image/image.module.js';
|
||||||
|
import { ListingsModule } from './listings/listings.module.js';
|
||||||
|
import { MailModule } from './mail/mail.module.js';
|
||||||
|
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware.js';
|
||||||
|
import { SelectOptionsModule } from './select-options/select-options.module.js';
|
||||||
|
import { UserModule } from './user/user.module.js';
|
||||||
|
// const __filename = fileURLToPath(import.meta.url);
|
||||||
|
// const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
//loadEnvFiles();
|
function loadEnvFiles() {
|
||||||
console.log('Loaded environment variables:');
|
// Load the .env file
|
||||||
//console.log(JSON.stringify(process.env, null, 2));
|
dotenv.config();
|
||||||
|
console.log('Loaded .env file');
|
||||||
|
|
||||||
|
// Determine which additional env file to load
|
||||||
|
let envFilePath = '';
|
||||||
|
const host = process.env.HOST_NAME || '';
|
||||||
|
|
||||||
|
if (host.includes('localhost')) {
|
||||||
|
envFilePath = '.env.local';
|
||||||
|
} else if (host.includes('dev.bizmatch.net')) {
|
||||||
|
envFilePath = '.env.dev';
|
||||||
|
} else if (host.includes('www.bizmatch.net') || host.includes('bizmatch.net')) {
|
||||||
|
envFilePath = '.env.prod';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the additional env file if it exists
|
||||||
|
if (fs.existsSync(envFilePath)) {
|
||||||
|
dotenv.config({ path: envFilePath });
|
||||||
|
console.log(`Loaded ${envFilePath} file`);
|
||||||
|
} else {
|
||||||
|
console.log(`No additional .env file found for HOST_NAME: ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFiles();
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ClsModule.forRoot({
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
global: true, // Macht den ClsService global verfügbar
|
|
||||||
middleware: { mount: true }, // Registriert automatisch die ClsMiddleware
|
|
||||||
}),
|
|
||||||
//ConfigModule.forRoot({ envFilePath: '.env' }),
|
|
||||||
ConfigModule.forRoot({
|
|
||||||
envFilePath: [path.resolve(__dirname, '..', '.env')],
|
|
||||||
}),
|
|
||||||
MailModule,
|
MailModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
WinstonModule.forRoot({
|
WinstonModule.forRoot({
|
||||||
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,
|
||||||
|
|
@ -66,31 +73,14 @@ console.log('Loaded environment variables:');
|
||||||
ListingsModule,
|
ListingsModule,
|
||||||
SelectOptionsModule,
|
SelectOptionsModule,
|
||||||
ImageModule,
|
ImageModule,
|
||||||
|
PassportModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
LogModule,
|
|
||||||
// PaymentModule,
|
|
||||||
EventModule,
|
|
||||||
FirebaseAdminModule,
|
|
||||||
SitemapModule,
|
|
||||||
],
|
|
||||||
controllers: [AppController, LogController],
|
|
||||||
providers: [
|
|
||||||
AppService,
|
|
||||||
FileService,
|
|
||||||
{
|
|
||||||
provide: APP_INTERCEPTOR,
|
|
||||||
useClass: UserInterceptor, // Registriere den Interceptor global
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: APP_INTERCEPTOR,
|
|
||||||
useClass: LoggingInterceptor, // Registriere den LoggingInterceptor global
|
|
||||||
},
|
|
||||||
AuthService,
|
|
||||||
],
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService, FileService],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(ClsMiddleware).forRoutes('*');
|
|
||||||
consumer.apply(RequestDurationMiddleware).forRoutes('*');
|
consumer.apply(RequestDurationMiddleware).forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +1,40 @@
|
||||||
import { Body, Controller, Get, HttpException, HttpStatus, Inject, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Put } from '@nestjs/common';
|
||||||
import * as admin from 'firebase-admin';
|
import { AuthService } from './auth.service.js';
|
||||||
import { AdminGuard } from 'src/jwt-auth/admin-auth.guard';
|
|
||||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
|
||||||
import { LocalhostGuard } from 'src/jwt-auth/localhost.guard';
|
|
||||||
import { UserRole, UsersResponse } from 'src/models/main.model';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(private readonly authService: AuthService) {}
|
||||||
@Inject('FIREBASE_ADMIN')
|
|
||||||
private readonly firebaseAdmin: typeof admin,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {}
|
|
||||||
@Post('verify-email')
|
|
||||||
async verifyEmail(@Body('oobCode') oobCode: string, @Body('email') email: string) {
|
|
||||||
if (!oobCode || !email) {
|
|
||||||
throw new HttpException('oobCode and email are required', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: Get the user by email address
|
|
||||||
const userRecord = await this.firebaseAdmin.auth().getUserByEmail(email);
|
|
||||||
|
|
||||||
if (userRecord.emailVerified) {
|
|
||||||
// Even if already verified, we'll still return a valid token
|
|
||||||
const customToken = await this.firebaseAdmin.auth().createCustomToken(userRecord.uid);
|
|
||||||
return {
|
|
||||||
message: 'Email is already verified',
|
|
||||||
token: customToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Update the user status to set emailVerified to true
|
|
||||||
await this.firebaseAdmin.auth().updateUser(userRecord.uid, {
|
|
||||||
emailVerified: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Generate a custom Firebase token for the user
|
|
||||||
// This token can be used on the client side to authenticate with Firebase
|
|
||||||
const customToken = await this.firebaseAdmin.auth().createCustomToken(userRecord.uid);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Email successfully verified',
|
|
||||||
token: customToken,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new HttpException(error.message || 'Failed to verify email', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Post(':uid/role')
|
|
||||||
@UseGuards(AuthGuard, AdminGuard) // Only admins can change roles
|
|
||||||
async setUserRole(@Param('uid') uid: string, @Body('role') role: UserRole): Promise<{ success: boolean }> {
|
|
||||||
await this.authService.setUserRole(uid, role);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('me/role')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
async getMyRole(@Req() req: any): Promise<{ role: UserRole | null }> {
|
|
||||||
console.log('->', req.user);
|
|
||||||
console.log('-->', req.user.uid);
|
|
||||||
const uid = req.user.uid; // From FirebaseAuthGuard
|
|
||||||
const role = await this.authService.getUserRole(uid);
|
|
||||||
return { role };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':uid/role')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
async getUserRole(@Param('uid') uid: string): Promise<{ role: UserRole | null }> {
|
|
||||||
const role = await this.authService.getUserRole(uid);
|
|
||||||
return { role };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('role/:role')
|
|
||||||
@UseGuards(AuthGuard, AdminGuard) // Only admins can list users by role
|
|
||||||
async getUsersByRole(@Param('role') role: UserRole): Promise<{ users: any[] }> {
|
|
||||||
const users = await this.authService.getUsersByRole(role);
|
|
||||||
// Map to simpler objects to avoid circular references
|
|
||||||
const simplifiedUsers = users.map(user => ({
|
|
||||||
uid: user.uid,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.displayName,
|
|
||||||
}));
|
|
||||||
return { users: simplifiedUsers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ruft alle Firebase-Benutzer mit ihren Rollen ab
|
|
||||||
* @param maxResults Maximale Anzahl an zurückzugebenden Benutzern (optional, Standard: 1000)
|
|
||||||
* @param pageToken Token für die Paginierung (optional)
|
|
||||||
* @returns Eine Liste von Benutzern mit ihren Rollen und Metadaten
|
|
||||||
*/
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard, AdminGuard) // Only admins can list all users
|
getAccessToken(): any {
|
||||||
async getAllUsers(@Query('maxResults') maxResults?: number, @Query('pageToken') pageToken?: string): Promise<UsersResponse> {
|
return this.authService.getAccessToken();
|
||||||
const result = await this.authService.getAllUsers(maxResults ? parseInt(maxResults.toString(), 10) : undefined, pageToken);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: result.users,
|
|
||||||
totalCount: result.users.length,
|
|
||||||
...(result.pageToken && { pageToken: result.pageToken }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Get('users')
|
||||||
* Endpoint zum direkten Einstellen einer Rolle für Debug-Zwecke
|
getUsers(): any {
|
||||||
* WARNUNG: Dieser Endpoint sollte in der Produktion entfernt oder stark gesichert werden
|
return this.authService.getUsers();
|
||||||
*/
|
}
|
||||||
@Post('set-role')
|
@Get('user/:userid')
|
||||||
@UseGuards(AuthGuard, LocalhostGuard)
|
getUser(@Param('userid') userId: string): any {
|
||||||
async setUserRoleOnLocalhost(@Req() req: any, @Body('role') role: UserRole): Promise<{ success: boolean; message: string }> {
|
return this.authService.getUser(userId);
|
||||||
try {
|
}
|
||||||
const uid = req.user.uid;
|
@Get('groups')
|
||||||
|
getGroups(): any {
|
||||||
|
return this.authService.getGroups();
|
||||||
|
}
|
||||||
|
|
||||||
// Aktuelle Rolle protokollieren
|
@Get('user/:userid/groups') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth
|
||||||
const currentUser = await this.authService.getUserRole(uid);
|
getGroupsForUsers(@Param('userid') userId: string): any {
|
||||||
console.log(`Changing role for user ${uid} from ${currentUser} to ${role}`);
|
return this.authService.getGroupsForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
// Neue Rolle setzen
|
@Get('user/:userid/lastlogin') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth
|
||||||
await this.authService.setUserRole(uid, role);
|
getLastLogin(@Param('userid') userId: string): any {
|
||||||
|
return this.authService.getLastLogin(userId);
|
||||||
|
}
|
||||||
|
|
||||||
// Rolle erneut prüfen, um zu bestätigen
|
@Put('user/:userid/group/:groupid') //e0811669-c7eb-4e5e-a699-e8334d5c5b01 -> aknuth //
|
||||||
const newRole = await this.authService.getUserRole(uid);
|
addUser2Group(@Param('userid') userId: string,@Param('groupid') groupId: string): any {
|
||||||
|
return this.authService.addUser2Group(userId,groupId);
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Rolle für Benutzer ${uid} von ${currentUser} zu ${newRole} geändert`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Setzen der Rolle:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Fehler: ${error.message}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
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: [ConfigModule.forRoot({ envFilePath: '.env' }),FirebaseAdminModule],
|
imports: [PassportModule],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService],
|
exports: [AuthService],
|
||||||
exports: [],
|
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,119 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import * as admin from 'firebase-admin';
|
import ky from 'ky';
|
||||||
import { FirebaseUserInfo, UserRole } from 'src/models/main.model';
|
import urlcat from 'urlcat';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(@Inject('FIREBASE_ADMIN') private firebaseAdmin: admin.app.App) {}
|
public async getAccessToken() {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('grant_type', 'password');
|
||||||
|
form.append('username', process.env.user);
|
||||||
|
form.append('password', process.env.password);
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a user's role via Firebase custom claims
|
|
||||||
*/
|
|
||||||
async setUserRole(uid: string, role: UserRole): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
// Get the current custom claims
|
const params = new URLSearchParams();
|
||||||
const user = await this.firebaseAdmin.auth().getUser(uid);
|
params.append('grant_type', 'password');
|
||||||
const currentClaims = user.customClaims || {};
|
params.append('username', process.env.user);
|
||||||
|
params.append('password', process.env.password);
|
||||||
|
const URL = `${process.env.host}${process.env.tokenURL}`;
|
||||||
|
|
||||||
// Set the new role
|
const response = await ky
|
||||||
await this.firebaseAdmin.auth().setCustomUserClaims(uid, {
|
.post(URL, {
|
||||||
...currentClaims,
|
body: params.toString(),
|
||||||
role: role,
|
headers: {
|
||||||
});
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: 'Basic YWRtaW4tY2xpOnE0RmJnazFkd0NaelFQZmt5VzhhM3NnckV5UHZlRUY3',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
return (<any>response).access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting user role:', error);
|
if (error.name === 'HTTPError') {
|
||||||
throw error;
|
const errorJson = await error.response.json();
|
||||||
|
console.error('Fehlerantwort vom Server:', errorJson);
|
||||||
|
} else {
|
||||||
|
console.error('Allgemeiner Fehler:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async getUsers() {
|
||||||
* Get a user's current role
|
const token = await this.getAccessToken();
|
||||||
*/
|
const URL = `${process.env.host}${process.env.usersURL}`;
|
||||||
async getUserRole(uid: string): Promise<UserRole | null> {
|
const response = await ky
|
||||||
try {
|
.get(URL, {
|
||||||
const user = await this.firebaseAdmin.auth().getUser(uid);
|
headers: {
|
||||||
const claims = user.customClaims || {};
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
return (claims.role as UserRole) || null;
|
Authorization: `Bearer ${token}`,
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error getting user role:', error);
|
})
|
||||||
throw error;
|
.json();
|
||||||
}
|
return response;
|
||||||
|
}
|
||||||
|
public async getUser(userid: string) {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
const URL = urlcat(process.env.host, process.env.userURL, { userid });
|
||||||
|
const response = await ky
|
||||||
|
.get(URL, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
public async getGroups() {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
const URL = `${process.env.host}${process.env.groupsURL}`;
|
||||||
|
const response = await ky
|
||||||
|
.get(URL, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async getGroupsForUser(userid: string) {
|
||||||
* Get all users with a specific role
|
const token = await this.getAccessToken();
|
||||||
*/
|
const URL = urlcat(process.env.host, process.env.userGroupsURL, { userid });
|
||||||
async getUsersByRole(role: UserRole): Promise<admin.auth.UserRecord[]> {
|
const response = await ky
|
||||||
// Note: Firebase Admin doesn't provide a direct way to query users by custom claims
|
.get(URL, {
|
||||||
// For a production app, you might want to store role information in Firestore as well
|
headers: {
|
||||||
// This is a simple implementation that lists all users and filters them
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
try {
|
Authorization: `Bearer ${token}`,
|
||||||
const listUsersResult = await this.firebaseAdmin.auth().listUsers();
|
},
|
||||||
return listUsersResult.users.filter(user => user.customClaims && user.customClaims.role === role);
|
})
|
||||||
} catch (error) {
|
.json();
|
||||||
console.error('Error getting users by role:', error);
|
return response;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
public async getLastLogin(userid: string) {
|
||||||
/**
|
const token = await this.getAccessToken();
|
||||||
* Get all Firebase users with their roles
|
const URL = urlcat(process.env.host, process.env.lastLoginURL, { userid });
|
||||||
* @param maxResults Maximum number of users to return (optional, default 1000)
|
const response = await ky
|
||||||
* @param pageToken Token for pagination (optional)
|
.get(URL, {
|
||||||
*/
|
headers: {
|
||||||
async getAllUsers(maxResults: number = 1000, pageToken?: string): Promise<{ users: FirebaseUserInfo[]; pageToken?: string }> {
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
try {
|
Authorization: `Bearer ${token}`,
|
||||||
const listUsersResult = await this.firebaseAdmin.auth().listUsers(maxResults, pageToken);
|
},
|
||||||
|
})
|
||||||
const users = listUsersResult.users.map(user => this.mapUserRecord(user));
|
.json();
|
||||||
|
return response;
|
||||||
return {
|
|
||||||
users,
|
|
||||||
pageToken: listUsersResult.pageToken,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting all users:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/**
|
public async addUser2Group(userid: string, groupid: string) {
|
||||||
* Maps a Firebase UserRecord to our FirebaseUserInfo interface
|
const token = await this.getAccessToken();
|
||||||
*/
|
const URL = urlcat(process.env.host, process.env.addUser2GroupURL, { userid, groupid });
|
||||||
private mapUserRecord(user: admin.auth.UserRecord): FirebaseUserInfo {
|
const response = await ky
|
||||||
return {
|
.put(URL, {
|
||||||
uid: user.uid,
|
headers: {
|
||||||
email: user.email || null,
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
displayName: user.displayName || null,
|
Authorization: `Bearer ${token}`,
|
||||||
photoURL: user.photoURL || null,
|
},
|
||||||
phoneNumber: user.phoneNumber || null,
|
})
|
||||||
disabled: user.disabled,
|
.json();
|
||||||
emailVerified: user.emailVerified,
|
return response;
|
||||||
role: user.customClaims?.role || null,
|
|
||||||
creationTime: user.metadata.creationTime,
|
|
||||||
lastSignInTime: user.metadata.lastSignInTime,
|
|
||||||
// Optionally include other customClaims if needed
|
|
||||||
customClaims: user.customClaims,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set default role for a new user
|
|
||||||
*/
|
|
||||||
async setDefaultRole(uid: string): Promise<void> {
|
|
||||||
return this.setUserRole(uid, 'guest');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a user has a specific role
|
|
||||||
*/
|
|
||||||
async hasRole(uid: string, role: UserRole): Promise<boolean> {
|
|
||||||
const userRole = await this.getUserRole(uid);
|
|
||||||
return userRole === role;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,44 +1,24 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
|
||||||
import { ClsService } from 'nestjs-cls';
|
|
||||||
import pkg from 'pg';
|
import pkg from 'pg';
|
||||||
import { Logger } from 'winston';
|
|
||||||
import * as schema from './schema';
|
|
||||||
import { PG_CONNECTION } from './schema';
|
|
||||||
const { Pool } = pkg;
|
const { Pool } = pkg;
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { jsonb, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
import { PG_CONNECTION } from './schema.js';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: PG_CONNECTION,
|
provide: PG_CONNECTION,
|
||||||
inject: [ConfigService, WINSTON_MODULE_PROVIDER, ClsService],
|
inject: [ConfigService],
|
||||||
useFactory: async (configService: ConfigService, logger: Logger, cls: ClsService) => {
|
useFactory: async (configService: ConfigService) => {
|
||||||
const connectionString = configService.get<string>('DATABASE_URL');
|
const connectionString = configService.get<string>('DATABASE_URL');
|
||||||
// const dbHost = configService.get<string>('DB_HOST');
|
|
||||||
// const dbPort = configService.get<string>('DB_PORT');
|
|
||||||
// const dbName = configService.get<string>('DB_NAME');
|
|
||||||
// const dbUser = configService.get<string>('DB_USER');
|
|
||||||
const dbPassword = configService.get<string>('DB_PASSWORD');
|
|
||||||
// logger.info(`Drizzle Connection - URL: ${connectionString}, Host: ${dbHost}, Port: ${dbPort}, DB: ${dbName}, User: ${dbUser}`);
|
|
||||||
// console.log(`---> Drizzle Connection - URL: ${connectionString}, Host: ${dbHost}, Port: ${dbPort}, DB: ${dbName}, User: ${dbUser}`);
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString,
|
connectionString,
|
||||||
// ssl: true, // Falls benötigt
|
// ssl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Definiere einen benutzerdefinierten Logger für Drizzle
|
return drizzle(pool, { schema, logger:true });
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -2,19 +2,20 @@ 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 { Pool } from 'pg';
|
import pkg 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';
|
import { BusinessListingService } from 'src/listings/business-listing.service.js';
|
||||||
import { CommercialPropertyService } from 'src/listings/commercial-property.service';
|
import { CommercialPropertyService } from 'src/listings/commercial-property.service.js';
|
||||||
import { Geo } from 'src/models/server.model';
|
import { Geo } from 'src/models/server.model.js';
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { User, UserData } from '../models/db.model';
|
import { User, UserData } from '../models/db.model.js';
|
||||||
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName } from '../models/main.model';
|
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName, KeyValueStyle } from '../models/main.model.js';
|
||||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
import { SelectOptionsService } from '../select-options/select-options.service.js';
|
||||||
import * as schema from './schema';
|
import { convertUserToDrizzleUser } from '../utils.js';
|
||||||
|
import * as schema from './schema.js';
|
||||||
interface PropertyImportListing {
|
interface PropertyImportListing {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -53,226 +54,224 @@ 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'] });
|
||||||
});
|
});
|
||||||
const commService = new CommercialPropertyService(null, db);
|
user.areasServed = [];
|
||||||
const businessService = new BusinessListingService(null, db);
|
user.areasServed = userData.areasServed.map(l => {
|
||||||
const userService = new UserService(null, db, null, null);
|
return { county: l.split(',')[0].trim(), state: l.split(',')[1].trim() };
|
||||||
//Delete Content
|
});
|
||||||
await db.delete(schema.commercials);
|
user.hasCompanyLogo = true;
|
||||||
await db.delete(schema.businesses);
|
user.hasProfile = true;
|
||||||
await db.delete(schema.users);
|
user.firstname = userData.firstname;
|
||||||
let filePath = `./src/assets/geo.json`;
|
user.lastname = userData.lastname;
|
||||||
const rawData = readFileSync(filePath, 'utf8');
|
user.email = userData.email;
|
||||||
const geos = JSON.parse(rawData) as Geo;
|
user.phoneNumber = userData.phoneNumber;
|
||||||
|
user.description = userData.description;
|
||||||
|
user.companyName = userData.companyName;
|
||||||
|
user.companyOverview = userData.companyOverview;
|
||||||
|
user.companyWebsite = userData.companyWebsite;
|
||||||
|
const [city, state] = userData.companyLocation.split('-').map(e => e.trim());
|
||||||
|
user.companyLocation = {};
|
||||||
|
user.companyLocation.city = city;
|
||||||
|
user.companyLocation.state = state;
|
||||||
|
const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city);
|
||||||
|
user.companyLocation.latitude = cityGeo.latitude;
|
||||||
|
user.companyLocation.longitude = cityGeo.longitude;
|
||||||
|
user.offeredServices = userData.offeredServices;
|
||||||
|
user.gender = userData.gender;
|
||||||
|
user.customerType = 'professional';
|
||||||
|
user.customerSubType = 'broker';
|
||||||
|
user.created = new Date();
|
||||||
|
user.updated = new Date();
|
||||||
|
|
||||||
const sso = new SelectOptionsService();
|
const u = await db
|
||||||
//Broker
|
.insert(schema.users)
|
||||||
filePath = `./data/broker.json`;
|
.values(convertUserToDrizzleUser(user))
|
||||||
let data: string = readFileSync(filePath, 'utf8');
|
.returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname });
|
||||||
const usersData: UserData[] = JSON.parse(data); // Erwartet ein Array von Objekten
|
generatedUserData.push(u[0]);
|
||||||
const generatedUserData = [];
|
i++;
|
||||||
console.log(usersData.length);
|
logger.info(`user_${index} inserted`);
|
||||||
let i = 0,
|
if (u[0].gender === 'male') {
|
||||||
male = 0,
|
male++;
|
||||||
female = 0;
|
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
|
||||||
const targetPathProfile = `./pictures/profile`;
|
await storeProfilePicture(data, emailToDirName(u[0].email));
|
||||||
deleteFilesOfDir(targetPathProfile);
|
} else {
|
||||||
const targetPathLogo = `./pictures/logo`;
|
female++;
|
||||||
deleteFilesOfDir(targetPathLogo);
|
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`);
|
||||||
const targetPathProperty = `./pictures/property`;
|
await storeProfilePicture(data, emailToDirName(u[0].email));
|
||||||
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.name = commercialJsonData[index].city;
|
commercial.location.city = 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;
|
||||||
|
|
||||||
//Business Listings
|
const result = await commService.createListing(commercial); //await db.insert(schema.commercials).values(commercial).returning();
|
||||||
filePath = `./data/businesses.json`;
|
try {
|
||||||
data = readFileSync(filePath, 'utf8');
|
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result.imagePath}/${result.serialId}`);
|
||||||
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
|
} catch (err) {
|
||||||
for (let index = 0; index < businessJsonData.length; index++) {
|
console.log(`----- No pictures available for ${id} ------ ${err}`);
|
||||||
const business = createDefaultBusinessListing(); //businessJsonData[index];
|
|
||||||
delete business.id;
|
|
||||||
const user = getRandomItem(generatedUserData);
|
|
||||||
business.email = user.email;
|
|
||||||
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value;
|
|
||||||
business.title = businessJsonData[index].title;
|
|
||||||
business.description = businessJsonData[index].description;
|
|
||||||
try {
|
|
||||||
const cityGeo = geos.states.find(s => s.state_code === businessJsonData[index].state).cities.find(c => c.name === businessJsonData[index].city);
|
|
||||||
business.location = {};
|
|
||||||
business.location.latitude = cityGeo.latitude;
|
|
||||||
business.location.longitude = cityGeo.longitude;
|
|
||||||
business.location.name = businessJsonData[index].city;
|
|
||||||
business.location.state = businessJsonData[index].state;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`----------------> ERROR ${businessJsonData[index].state} - ${businessJsonData[index].city}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
business.price = businessJsonData[index].price;
|
|
||||||
business.title = businessJsonData[index].title;
|
|
||||||
business.draft = businessJsonData[index].draft;
|
|
||||||
business.listingsCategory = 'business';
|
|
||||||
business.realEstateIncluded = businessJsonData[index].realEstateIncluded;
|
|
||||||
business.leasedLocation = businessJsonData[index].leasedLocation;
|
|
||||||
business.franchiseResale = businessJsonData[index].franchiseResale;
|
|
||||||
|
|
||||||
business.salesRevenue = businessJsonData[index].salesRevenue;
|
|
||||||
business.cashFlow = businessJsonData[index].cashFlow;
|
|
||||||
business.supportAndTraining = businessJsonData[index].supportAndTraining;
|
|
||||||
business.employees = businessJsonData[index].employees;
|
|
||||||
business.established = businessJsonData[index].established;
|
|
||||||
business.internalListingNumber = businessJsonData[index].internalListingNumber;
|
|
||||||
business.reasonForSale = businessJsonData[index].reasonForSale;
|
|
||||||
business.brokerLicencing = businessJsonData[index].brokerLicencing;
|
|
||||||
business.internals = businessJsonData[index].internals;
|
|
||||||
business.imageName = emailToDirName(user.email);
|
|
||||||
business.created = new Date(businessJsonData[index].created);
|
|
||||||
business.updated = new Date(businessJsonData[index].created);
|
|
||||||
|
|
||||||
await businessService.createListing(business); //db.insert(schema.businesses).values(business);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//End
|
//Business Listings
|
||||||
await client.end();
|
filePath = `./data/businesses.json`;
|
||||||
})();
|
data = readFileSync(filePath, 'utf8');
|
||||||
// function sleep(ms) {
|
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
|
||||||
// return new Promise(resolve => setTimeout(resolve, ms));
|
for (let index = 0; index < businessJsonData.length; index++) {
|
||||||
// }
|
const business = createDefaultBusinessListing(); //businessJsonData[index];
|
||||||
// async function createEmbedding(text: string): Promise<number[]> {
|
delete business.id;
|
||||||
// const response = await openai.embeddings.create({
|
const user = getRandomItem(generatedUserData);
|
||||||
// model: 'text-embedding-3-small',
|
business.email = user.email;
|
||||||
// input: text,
|
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value;
|
||||||
// });
|
business.title = businessJsonData[index].title;
|
||||||
// return response.data[0].embedding;
|
business.description = businessJsonData[index].description;
|
||||||
// }
|
try {
|
||||||
|
const cityGeo = geos.states.find(s => s.state_code === businessJsonData[index].state).cities.find(c => c.name === businessJsonData[index].city);
|
||||||
|
business.location = {};
|
||||||
|
business.location.latitude = cityGeo.latitude;
|
||||||
|
business.location.longitude = cityGeo.longitude;
|
||||||
|
business.location.city = businessJsonData[index].city;
|
||||||
|
business.location.state = businessJsonData[index].state;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`----------------> ERROR ${businessJsonData[index].state} - ${businessJsonData[index].city}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
business.price = businessJsonData[index].price;
|
||||||
|
business.title = businessJsonData[index].title;
|
||||||
|
business.draft = businessJsonData[index].draft;
|
||||||
|
business.listingsCategory = 'business';
|
||||||
|
business.realEstateIncluded = businessJsonData[index].realEstateIncluded;
|
||||||
|
business.leasedLocation = businessJsonData[index].leasedLocation;
|
||||||
|
business.franchiseResale = businessJsonData[index].franchiseResale;
|
||||||
|
|
||||||
|
business.salesRevenue = businessJsonData[index].salesRevenue;
|
||||||
|
business.cashFlow = businessJsonData[index].cashFlow;
|
||||||
|
business.supportAndTraining = businessJsonData[index].supportAndTraining;
|
||||||
|
business.employees = businessJsonData[index].employees;
|
||||||
|
business.established = businessJsonData[index].established;
|
||||||
|
business.internalListingNumber = businessJsonData[index].internalListingNumber;
|
||||||
|
business.reasonForSale = businessJsonData[index].reasonForSale;
|
||||||
|
business.brokerLicencing = businessJsonData[index].brokerLicencing;
|
||||||
|
business.internals = businessJsonData[index].internals;
|
||||||
|
business.imageName = emailToDirName(user.email);
|
||||||
|
business.created = new Date(businessJsonData[index].created);
|
||||||
|
business.updated = new Date(businessJsonData[index].created);
|
||||||
|
|
||||||
|
await businessService.createListing(business); //db.insert(schema.businesses).values(business);
|
||||||
|
}
|
||||||
|
|
||||||
|
//End
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
async function createEmbedding(text: string): Promise<number[]> {
|
||||||
|
const response = await openai.embeddings.create({
|
||||||
|
model: 'text-embedding-3-small',
|
||||||
|
input: text,
|
||||||
|
});
|
||||||
|
return response.data[0].embedding;
|
||||||
|
}
|
||||||
|
|
||||||
function getRandomItem<T>(arr: T[]): T {
|
function getRandomItem<T>(arr: T[]): T {
|
||||||
if (arr.length === 0) {
|
if (arr.length === 0) {
|
||||||
|
|
@ -284,7 +283,7 @@ function getRandomItem<T>(arr: T[]): T {
|
||||||
}
|
}
|
||||||
function getFilenames(id: string): string[] {
|
function getFilenames(id: string): string[] {
|
||||||
try {
|
try {
|
||||||
const filePath = `./pictures_base/property/${id}`;
|
let filePath = `./pictures_base/property/${id}`;
|
||||||
return readdirSync(filePath);
|
return readdirSync(filePath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -301,7 +300,7 @@ function getRandomDateWithinLastYear(): Date {
|
||||||
return randomDate;
|
return randomDate;
|
||||||
}
|
}
|
||||||
async function storeProfilePicture(buffer: Buffer, userId: string) {
|
async function storeProfilePicture(buffer: Buffer, userId: string) {
|
||||||
const quality = 50;
|
let 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
|
||||||
|
|
@ -311,7 +310,7 @@ async function storeProfilePicture(buffer: Buffer, userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
|
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
|
||||||
const quality = 50;
|
let 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
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import 'dotenv/config';
|
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import { BusinessListingService } from 'src/listings/business-listing.service';
|
|
||||||
import { CommercialPropertyService } from 'src/listings/commercial-property.service';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
|
||||||
import winston from 'winston';
|
|
||||||
import { BusinessListing, CommercialPropertyListing, User } from '../models/db.model';
|
|
||||||
import * as schema from './schema';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const connectionString = process.env.DATABASE_URL;
|
|
||||||
const client = new Pool({ connectionString });
|
|
||||||
const db = drizzle(client, { schema, logger: true });
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
transports: [new winston.transports.Console()],
|
|
||||||
});
|
|
||||||
const commService = new CommercialPropertyService(null, db);
|
|
||||||
const businessService = new BusinessListingService(null, db);
|
|
||||||
const userService = new UserService(null, db, null, null);
|
|
||||||
|
|
||||||
//Delete Content
|
|
||||||
await db.delete(schema.commercials);
|
|
||||||
await db.delete(schema.businesses);
|
|
||||||
await db.delete(schema.users);
|
|
||||||
|
|
||||||
let filePath = `./data/users_export.json`;
|
|
||||||
let data: string = readFileSync(filePath, 'utf8');
|
|
||||||
const usersData: User[] = JSON.parse(data); // Erwartet ein Array von Objekten
|
|
||||||
for (let index = 0; index < usersData.length; index++) {
|
|
||||||
const user = usersData[index];
|
|
||||||
delete user.id;
|
|
||||||
const u = await userService.saveUser(user, false);
|
|
||||||
logger.info(`user_${index} inserted`);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Corporate Listings
|
|
||||||
filePath = `./data/commercials_export.json`;
|
|
||||||
data = readFileSync(filePath, 'utf8');
|
|
||||||
const commercialJsonData = JSON.parse(data) as CommercialPropertyListing[]; // Erwartet ein Array von Objekten
|
|
||||||
for (let index = 0; index < commercialJsonData.length; index++) {
|
|
||||||
const commercial = commercialJsonData[index];
|
|
||||||
delete commercial.id;
|
|
||||||
const result = await commService.createListing(commercial);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Business Listings
|
|
||||||
filePath = `./data/businesses_export.json`;
|
|
||||||
data = readFileSync(filePath, 'utf8');
|
|
||||||
const businessJsonData = JSON.parse(data) as BusinessListing[]; // Erwartet ein Array von Objekten
|
|
||||||
for (let index = 0; index < businessJsonData.length; index++) {
|
|
||||||
const business = businessJsonData[index];
|
|
||||||
delete business.id;
|
|
||||||
await businessService.createListing(business);
|
|
||||||
}
|
|
||||||
|
|
||||||
//End
|
|
||||||
await client.end();
|
|
||||||
})();
|
|
||||||
function getRandomItem<T>(arr: T[]): T {
|
|
||||||
if (arr.length === 0) {
|
|
||||||
throw new Error('The array is empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
|
||||||
return arr[randomIndex];
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import pkg from 'pg';
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
const { Pool } = pkg;
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const db = drizzle(pool, { schema });
|
||||||
|
// This will run migrations on the database, skipping the ones already applied
|
||||||
|
//await migrate(db, { migrationsFolder: './src/drizzle/migrations' });
|
||||||
|
// Don't forget to close the connection, otherwise the script will hang
|
||||||
|
//await pool.end();
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
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 $$;
|
||||||
|
|
@ -0,0 +1,541 @@
|
||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1723045357281,
|
||||||
|
"tag": "0000_lean_marvex",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,175 +1,92 @@
|
||||||
import { sql } from 'drizzle-orm';
|
import { boolean, char, doublePrecision, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
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', 'seller', 'professional']);
|
export const customerTypeEnum = pgEnum('customerType', ['buyer', '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']);
|
|
||||||
|
|
||||||
// Neue JSONB-basierte Tabellen
|
export const users = pgTable('users', {
|
||||||
export const users_json = pgTable(
|
|
||||||
'users_json',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
||||||
data: jsonb('data'),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
emailIdx: index('idx_users_json_email').on(table.email),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const businesses_json = pgTable(
|
|
||||||
'businesses_json',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
|
||||||
data: jsonb('data'),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
emailIdx: index('idx_businesses_json_email').on(table.email),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const commercials_json = pgTable(
|
|
||||||
'commercials_json',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
|
||||||
data: jsonb('data'),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
emailIdx: index('idx_commercials_json_email').on(table.email),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const listing_events_json = pgTable(
|
|
||||||
'listing_events_json',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
email: varchar('email', { length: 255 }),
|
|
||||||
data: jsonb('data'),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
emailIdx: index('idx_listing_events_json_email').on(table.email),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bestehende Tabellen bleiben unverändert
|
|
||||||
export const users = pgTable(
|
|
||||||
'users',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
firstname: varchar('firstname', { length: 255 }).notNull(),
|
|
||||||
lastname: varchar('lastname', { length: 255 }).notNull(),
|
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
||||||
phoneNumber: varchar('phoneNumber', { length: 255 }),
|
|
||||||
description: text('description'),
|
|
||||||
companyName: varchar('companyName', { length: 255 }),
|
|
||||||
companyOverview: text('companyOverview'),
|
|
||||||
companyWebsite: varchar('companyWebsite', { length: 255 }),
|
|
||||||
offeredServices: text('offeredServices'),
|
|
||||||
areasServed: jsonb('areasServed').$type<AreasServed[]>(),
|
|
||||||
hasProfile: boolean('hasProfile'),
|
|
||||||
hasCompanyLogo: boolean('hasCompanyLogo'),
|
|
||||||
licensedIn: jsonb('licensedIn').$type<LicensedIn[]>(),
|
|
||||||
gender: genderEnum('gender'),
|
|
||||||
customerType: customerTypeEnum('customerType'),
|
|
||||||
customerSubType: customerSubTypeEnum('customerSubType'),
|
|
||||||
created: timestamp('created'),
|
|
||||||
updated: timestamp('updated'),
|
|
||||||
subscriptionId: text('subscriptionId'),
|
|
||||||
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
|
|
||||||
location: jsonb('location'),
|
|
||||||
showInDirectory: boolean('showInDirectory').default(true),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
locationUserCityStateIdx: index('idx_user_location_city_state').on(
|
|
||||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const businesses = pgTable(
|
|
||||||
'businesses',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
|
||||||
type: varchar('type', { length: 255 }),
|
|
||||||
title: varchar('title', { length: 255 }),
|
|
||||||
description: text('description'),
|
|
||||||
price: doublePrecision('price'),
|
|
||||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
|
||||||
draft: boolean('draft'),
|
|
||||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
|
||||||
realEstateIncluded: boolean('realEstateIncluded'),
|
|
||||||
leasedLocation: boolean('leasedLocation'),
|
|
||||||
franchiseResale: boolean('franchiseResale'),
|
|
||||||
salesRevenue: doublePrecision('salesRevenue'),
|
|
||||||
cashFlow: doublePrecision('cashFlow'),
|
|
||||||
supportAndTraining: text('supportAndTraining'),
|
|
||||||
employees: integer('employees'),
|
|
||||||
established: integer('established'),
|
|
||||||
internalListingNumber: integer('internalListingNumber'),
|
|
||||||
reasonForSale: varchar('reasonForSale', { length: 255 }),
|
|
||||||
brokerLicencing: varchar('brokerLicencing', { length: 255 }),
|
|
||||||
internals: text('internals'),
|
|
||||||
imageName: varchar('imageName', { length: 200 }),
|
|
||||||
slug: varchar('slug', { length: 300 }).unique(),
|
|
||||||
created: timestamp('created'),
|
|
||||||
updated: timestamp('updated'),
|
|
||||||
location: jsonb('location'),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
locationBusinessCityStateIdx: index('idx_business_location_city_state').on(
|
|
||||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
|
||||||
),
|
|
||||||
slugIdx: index('idx_business_slug').on(table.slug),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const commercials = pgTable(
|
|
||||||
'commercials',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
|
||||||
serialId: serial('serialId'),
|
|
||||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
|
||||||
type: varchar('type', { length: 255 }),
|
|
||||||
title: varchar('title', { length: 255 }),
|
|
||||||
description: text('description'),
|
|
||||||
price: doublePrecision('price'),
|
|
||||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
|
||||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
|
||||||
draft: boolean('draft'),
|
|
||||||
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
|
||||||
imagePath: varchar('imagePath', { length: 200 }),
|
|
||||||
slug: varchar('slug', { length: 300 }).unique(),
|
|
||||||
created: timestamp('created'),
|
|
||||||
updated: timestamp('updated'),
|
|
||||||
location: jsonb('location'),
|
|
||||||
},
|
|
||||||
table => ({
|
|
||||||
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
|
|
||||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
|
||||||
),
|
|
||||||
slugIdx: index('idx_commercials_slug').on(table.slug),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const listing_events = pgTable('listing_events', {
|
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
listingId: varchar('listing_id', { length: 255 }),
|
firstname: varchar('firstname', { length: 255 }).notNull(),
|
||||||
email: varchar('email', { length: 255 }),
|
lastname: varchar('lastname', { length: 255 }).notNull(),
|
||||||
eventType: varchar('event_type', { length: 50 }),
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
eventTimestamp: timestamp('event_timestamp').defaultNow(),
|
phoneNumber: varchar('phoneNumber', { length: 255 }),
|
||||||
userIp: varchar('user_ip', { length: 45 }),
|
description: text('description'),
|
||||||
userAgent: varchar('user_agent', { length: 255 }),
|
companyName: varchar('companyName', { length: 255 }),
|
||||||
locationCountry: varchar('location_country', { length: 100 }),
|
companyOverview: text('companyOverview'),
|
||||||
locationCity: varchar('location_city', { length: 100 }),
|
companyWebsite: varchar('companyWebsite', { length: 255 }),
|
||||||
locationLat: varchar('location_lat', { length: 20 }),
|
city: varchar('city', { length: 255 }),
|
||||||
locationLng: varchar('location_lng', { length: 20 }),
|
state: char('state', { length: 2 }),
|
||||||
referrer: varchar('referrer', { length: 255 }),
|
offeredServices: text('offeredServices'),
|
||||||
additionalData: jsonb('additional_data'),
|
areasServed: jsonb('areasServed').$type<AreasServed[]>(),
|
||||||
|
hasProfile: boolean('hasProfile'),
|
||||||
|
hasCompanyLogo: boolean('hasCompanyLogo'),
|
||||||
|
licensedIn: jsonb('licensedIn').$type<LicensedIn[]>(),
|
||||||
|
gender: genderEnum('gender'),
|
||||||
|
customerType: customerTypeEnum('customerType'),
|
||||||
|
customerSubType: customerSubTypeEnum('customerSubType'),
|
||||||
|
created: timestamp('created'),
|
||||||
|
updated: timestamp('updated'),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
|
// embedding: vector('embedding', { dimensions: 1536 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const businesses = pgTable('businesses', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
|
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||||
|
type: varchar('type', { length: 255 }),
|
||||||
|
title: varchar('title', { length: 255 }),
|
||||||
|
description: text('description'),
|
||||||
|
city: varchar('city', { length: 255 }),
|
||||||
|
state: char('state', { length: 2 }),
|
||||||
|
// zipCode: integer('zipCode'),
|
||||||
|
// county: varchar('county', { length: 255 }),
|
||||||
|
price: doublePrecision('price'),
|
||||||
|
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||||
|
draft: boolean('draft'),
|
||||||
|
listingsCategory: listingsCategoryEnum('listingsCategory'), //varchar('listingsCategory', { length: 255 }),
|
||||||
|
realEstateIncluded: boolean('realEstateIncluded'),
|
||||||
|
leasedLocation: boolean('leasedLocation'),
|
||||||
|
franchiseResale: boolean('franchiseResale'),
|
||||||
|
salesRevenue: doublePrecision('salesRevenue'),
|
||||||
|
cashFlow: doublePrecision('cashFlow'),
|
||||||
|
supportAndTraining: text('supportAndTraining'),
|
||||||
|
employees: integer('employees'),
|
||||||
|
established: integer('established'),
|
||||||
|
internalListingNumber: integer('internalListingNumber'),
|
||||||
|
reasonForSale: varchar('reasonForSale', { length: 255 }),
|
||||||
|
brokerLicencing: varchar('brokerLicencing', { length: 255 }),
|
||||||
|
internals: text('internals'),
|
||||||
|
imageName: varchar('imageName', { length: 200 }),
|
||||||
|
created: timestamp('created'),
|
||||||
|
updated: timestamp('updated'),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
|
// embedding: vector('embedding', { dimensions: 1536 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const commercials = pgTable('commercials', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
|
serialId: serial('serialId'),
|
||||||
|
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||||
|
type: varchar('type', { length: 255 }),
|
||||||
|
title: varchar('title', { length: 255 }),
|
||||||
|
description: text('description'),
|
||||||
|
city: varchar('city', { length: 255 }),
|
||||||
|
state: char('state', { length: 2 }),
|
||||||
|
price: doublePrecision('price'),
|
||||||
|
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||||
|
listingsCategory: listingsCategoryEnum('listingsCategory'), //listingsCategory: varchar('listingsCategory', { length: 255 }),
|
||||||
|
draft: boolean('draft'),
|
||||||
|
// zipCode: integer('zipCode'),
|
||||||
|
// county: varchar('county', { length: 255 }),
|
||||||
|
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
||||||
|
imagePath: varchar('imagePath', { length: 200 }),
|
||||||
|
created: timestamp('created'),
|
||||||
|
updated: timestamp('updated'),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
|
// embedding: vector('embedding', { dimensions: 1536 }),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
const { users } = require('./schema.js');
|
||||||
|
|
||||||
function generateTypeScriptInterface(tableDefinition, tableName) {
|
function generateTypeScriptInterface(tableDefinition, tableName) {
|
||||||
let interfaceString = `export interface ${tableName} {\n`;
|
let interfaceString = `export interface ${tableName} {\n`;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common';
|
|
||||||
import { RealIp } from 'src/decorators/real-ip.decorator';
|
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-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(OptionalAuthGuard)
|
|
||||||
@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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { DrizzleModule } from 'src/drizzle/drizzle.module';
|
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
|
||||||
import { EventController } from './event.controller';
|
|
||||||
import { EventService } from './event.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [DrizzleModule,FirebaseAdminModule],
|
|
||||||
controllers: [EventController],
|
|
||||||
providers: [EventService],
|
|
||||||
})
|
|
||||||
export class EventModule {}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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 { Logger } from 'winston';
|
|
||||||
import * as schema from '../drizzle/schema';
|
|
||||||
import { listing_events_json, 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();
|
|
||||||
const { id, email, ...rest } = event;
|
|
||||||
const convertedEvent = { email, data: rest };
|
|
||||||
await this.conn.insert(listing_events_json).values(convertedEvent).execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,41 @@
|
||||||
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) {
|
||||||
const quality = 50;
|
let 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
|
||||||
|
|
@ -31,7 +50,7 @@ export class FileService {
|
||||||
// Logo
|
// Logo
|
||||||
// ############
|
// ############
|
||||||
async storeCompanyLogo(file: Express.Multer.File, adjustedEmail: string) {
|
async storeCompanyLogo(file: Express.Multer.File, adjustedEmail: string) {
|
||||||
const quality = 50;
|
let 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
|
||||||
|
|
@ -60,6 +79,7 @@ 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);
|
||||||
|
|
@ -69,6 +89,7 @@ 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);
|
||||||
|
|
@ -95,15 +116,16 @@ export class FileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async resizeImageToAVIF(buffer: Buffer, maxSize: number, imageName: string, directory: string) {
|
async resizeImageToAVIF(buffer: Buffer, maxSize: number, imageName: string, directory: string) {
|
||||||
const quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen
|
let quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen
|
||||||
const start = Date.now();
|
let output;
|
||||||
const output = await sharp(buffer)
|
let start = Date.now();
|
||||||
|
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
|
||||||
const timeTaken = Date.now() - start;
|
let 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) {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import * as admin from 'firebase-admin';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [ConfigModule],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: 'FIREBASE_ADMIN',
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (configService: ConfigService) => {
|
|
||||||
const serviceAccount = {
|
|
||||||
projectId: configService.get<string>('FIREBASE_PROJECT_ID'),
|
|
||||||
clientEmail: configService.get<string>('FIREBASE_CLIENT_EMAIL'),
|
|
||||||
privateKey: configService.get<string>('FIREBASE_PRIVATE_KEY')?.replace(/\\n/g, '\n'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!admin.apps.length) {
|
|
||||||
admin.initializeApp({
|
|
||||||
credential: admin.credential.cert(serviceAccount),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return admin;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: ['FIREBASE_ADMIN'],
|
|
||||||
})
|
|
||||||
export class FirebaseAdminModule {}
|
|
||||||
|
|
@ -1,41 +1,27 @@
|
||||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
import { RealIp } from 'src/decorators/real-ip.decorator';
|
import { CountyRequest } from 'src/models/server.model.js';
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { GeoService } from './geo.service.js';
|
||||||
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(OptionalAuthGuard)
|
|
||||||
@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(OptionalAuthGuard)
|
|
||||||
@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(OptionalAuthGuard)
|
|
||||||
@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(OptionalAuthGuard)
|
|
||||||
@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(OptionalAuthGuard)
|
|
||||||
@Get('ipinfo/georesult/wysiwyg')
|
|
||||||
async fetchIpAndGeoLocation(@RealIp() ipInfo: RealIpInfo): Promise<any> {
|
|
||||||
return await this.geoService.fetchIpAndGeoLocation(ipInfo);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
import { GeoController } from './geo.controller.js';
|
||||||
import { GeoController } from './geo.controller';
|
import { GeoService } from './geo.service.js';
|
||||||
import { GeoService } from './geo.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FirebaseAdminModule],
|
|
||||||
controllers: [GeoController],
|
controllers: [GeoController],
|
||||||
providers: [GeoService],
|
providers: [GeoService]
|
||||||
})
|
})
|
||||||
export class GeoModule {}
|
export class GeoModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import path, { join } from 'path';
|
||||||
import { join } from 'path';
|
import { CountyResult, GeoResult } from 'src/models/main.model.js';
|
||||||
import { CityAndStateResult, CountyResult, GeoResult, IpInfo, RealIpInfo } from 'src/models/main.model';
|
import { fileURLToPath } from 'url';
|
||||||
import { Logger } from 'winston';
|
import { City, CountyData, Geo, State } from '../models/server.model.js';
|
||||||
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(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
|
constructor() {
|
||||||
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[]) {
|
||||||
const results: CountyResult[] = [];
|
let 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,
|
||||||
name: city.name,
|
city: 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): Array<CityAndStateResult> {
|
findCitiesAndStatesStartingWith(prefix: string, state?: string): Array<{ id: string; name: string; type: 'city' | 'state'; state: string }> {
|
||||||
const results: Array<CityAndStateResult> = [];
|
const results: Array<{ id: string; name: string; type: 'city' | 'state'; state: string }> = [];
|
||||||
|
|
||||||
const lowercasePrefix = prefix.toLowerCase();
|
const lowercasePrefix = prefix.toLowerCase();
|
||||||
|
|
||||||
|
|
@ -73,9 +73,10 @@ 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,
|
id: state.id.toString(),
|
||||||
|
name: state.name,
|
||||||
type: 'state',
|
type: 'state',
|
||||||
content: state,
|
state: state.state_code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,9 +84,10 @@ 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,
|
id: city.id.toString(),
|
||||||
|
name: city.name,
|
||||||
type: 'city',
|
type: 'city',
|
||||||
content: { state: state.state_code, ...city },
|
state: state.state_code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,27 +97,10 @@ 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.content.name.localeCompare(b.content.name);
|
return a.name.localeCompare(b.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { Controller, Delete, Inject, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
import { Controller, Delete, Inject, Param, Post, UploadedFile, 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 { AuthGuard } from 'src/jwt-auth/auth.guard';
|
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service.js';
|
||||||
import { CommercialPropertyService } from '../listings/commercial-property.service';
|
import { CommercialPropertyService } from '../listings/commercial-property.service.js';
|
||||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
import { SelectOptionsService } from '../select-options/select-options.service.js';
|
||||||
|
|
||||||
@Controller('image')
|
@Controller('image')
|
||||||
export class ImageController {
|
export class ImageController {
|
||||||
|
|
@ -18,14 +17,12 @@ export class ImageController {
|
||||||
// ############
|
// ############
|
||||||
// Property
|
// Property
|
||||||
// ############
|
// ############
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@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(AuthGuard)
|
|
||||||
@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}`);
|
||||||
|
|
@ -34,13 +31,11 @@ export class ImageController {
|
||||||
// ############
|
// ############
|
||||||
// Profile
|
// Profile
|
||||||
// ############
|
// ############
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@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(AuthGuard)
|
|
||||||
@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`);
|
||||||
|
|
@ -48,13 +43,11 @@ export class ImageController {
|
||||||
// ############
|
// ############
|
||||||
// Logo
|
// Logo
|
||||||
// ############
|
// ############
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@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(AuthGuard)
|
|
||||||
@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`);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
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,FirebaseAdminModule],
|
imports: [ListingsModule],
|
||||||
controllers: [ImageController],
|
controllers: [ImageController],
|
||||||
providers: [ImageService, FileService, SelectOptionsService],
|
providers: [ImageService, FileService, SelectOptionsService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// 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('email') || '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);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// 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.email) {
|
|
||||||
try {
|
|
||||||
this.cls.set('email', request.user.email);
|
|
||||||
this.logger.log(`CLS context gesetzt: EMail=${request.user.email}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Fehler beim Setzen der EMail im CLS-Kontext', error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.log('Kein authentifizierter Benutzer gefunden');
|
|
||||||
}
|
|
||||||
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AdminGuard implements CanActivate {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
// The FirebaseAuthGuard should run before this guard
|
|
||||||
// and populate the request.user object
|
|
||||||
if (!request.user) {
|
|
||||||
throw new ForbiddenException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.user.role !== 'admin') {
|
|
||||||
throw new ForbiddenException('Requires admin privileges');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import * as admin from 'firebase-admin';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
@Inject('FIREBASE_ADMIN') private firebaseAdmin: admin.app.App,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const authHeader = request.headers.authorization;
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
throw new UnauthorizedException('Missing or invalid authorization token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.split('Bearer ')[1];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decodedToken = await this.firebaseAdmin.auth().verifyIdToken(token);
|
|
||||||
|
|
||||||
// Check if email is verified (optional but recommended)
|
|
||||||
if (!decodedToken.email_verified) {
|
|
||||||
throw new UnauthorizedException('Email not verified');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the user to the request
|
|
||||||
request.user = {
|
|
||||||
uid: decodedToken.uid,
|
|
||||||
email: decodedToken.email,
|
|
||||||
role: decodedToken.role || null,
|
|
||||||
// Add other user info as needed
|
|
||||||
};
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw new UnauthorizedException('Invalid token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard 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) {
|
||||||
|
throw err || new UnauthorizedException(info);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LocalhostGuard implements CanActivate {
|
|
||||||
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const ip = request.ip;
|
|
||||||
|
|
||||||
// Liste der erlaubten IPs
|
|
||||||
const allowedIPs = ['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1'];
|
|
||||||
|
|
||||||
if (!allowedIPs.includes(ip)) {
|
|
||||||
console.warn(`Versuchter Zugriff von unerlaubter IP: ${ip}`);
|
|
||||||
throw new ForbiddenException('Dieser Endpunkt kann nur lokal aufgerufen werden');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
|
||||||
import * as admin from 'firebase-admin';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OptionalAuthGuard implements CanActivate {
|
|
||||||
constructor(@Inject('FIREBASE_ADMIN') private firebaseAdmin: admin.app.App) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const authHeader = request.headers.authorization;
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
//throw new UnauthorizedException('Missing or invalid authorization token');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.split('Bearer ')[1];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decodedToken = await this.firebaseAdmin.auth().verifyIdToken(token);
|
|
||||||
|
|
||||||
// Check if email is verified (optional but recommended)
|
|
||||||
if (!decodedToken.email_verified) {
|
|
||||||
//throw new UnauthorizedException('Email not verified');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the user to the request
|
|
||||||
request.user = {
|
|
||||||
uid: decodedToken.uid,
|
|
||||||
email: decodedToken.email,
|
|
||||||
role: decodedToken.role || null,
|
|
||||||
// Add other user info as needed
|
|
||||||
};
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
//throw new UnauthorizedException('Invalid token');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
|
||||||
// import * as admin from 'firebase-admin';
|
|
||||||
|
|
||||||
// @Injectable()
|
|
||||||
// export class OptionalAuthGuard implements CanActivate {
|
|
||||||
// constructor(
|
|
||||||
// @Inject('FIREBASE_ADMIN')
|
|
||||||
// private readonly firebaseAdmin: typeof admin,
|
|
||||||
// ) {}
|
|
||||||
|
|
||||||
// async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
// const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
// const token = this.extractTokenFromHeader(request);
|
|
||||||
|
|
||||||
// if (!token) {
|
|
||||||
// return true;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// const decodedToken = await this.firebaseAdmin.auth().verifyIdToken(token);
|
|
||||||
// request['user'] = decodedToken;
|
|
||||||
// return true;
|
|
||||||
// } catch (error) {
|
|
||||||
// //throw new UnauthorizedException('Invalid token');
|
|
||||||
// request['user'] = null;
|
|
||||||
// return true;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// private extractTokenFromHeader(request: Request): string | undefined {
|
|
||||||
// const [type, token] = request.headers['authorization']?.split(' ') ?? [];
|
|
||||||
// return type === 'Bearer' ? token : undefined;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
handleRequest(err, user, info) {
|
||||||
|
// Wenn der Benutzer nicht authentifiziert ist, aber kein Fehler vorliegt, geben Sie null zurück
|
||||||
|
if (err || !user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { passportJwtSecret } from 'jwks-rsa';
|
||||||
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import { JwtPayload, JwtUser } from './models/main.model';
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
|
) {
|
||||||
|
const realm = configService.get<string>('REALM');
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKeyProvider: passportJwtSecret({
|
||||||
|
cache: true,
|
||||||
|
rateLimit: true,
|
||||||
|
jwksRequestsPerMinute: 5,
|
||||||
|
jwksUri: `https://auth.bizmatch.net/realms/${realm}/protocol/openid-connect/certs`,
|
||||||
|
}),
|
||||||
|
audience: 'account', // Keycloak Client ID
|
||||||
|
authorize: '',
|
||||||
|
issuer: `https://auth.bizmatch.net/realms/${realm}`,
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload): Promise<JwtUser> {
|
||||||
|
if (!payload) {
|
||||||
|
this.logger.error('Invalid payload');
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
if (!payload.sub || !payload.preferred_username) {
|
||||||
|
this.logger.error('Missing required claims');
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
const result = { userId: payload.sub, firstname: payload.given_name, lastname: payload.family_name, username: payload.preferred_username, roles: payload.realm_access?.roles };
|
||||||
|
this.logger.info(`JWT User: ${JSON.stringify(result)}`); // Debugging: JWT Payload anzeigen
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Inject, Post } from '@nestjs/common';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { UserListingCriteria } from 'src/models/main.model.js';
|
||||||
import { UserListingCriteria } from 'src/models/main.model';
|
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service.js';
|
||||||
|
|
||||||
@Controller('listings/professionals_brokers')
|
@Controller('listings/professionals_brokers')
|
||||||
export class BrokerListingsController {
|
export class BrokerListingsController {
|
||||||
|
|
@ -12,9 +11,8 @@ export class BrokerListingsController {
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
async find(@Body() criteria: UserListingCriteria): Promise<any> {
|
find(@Body() criteria: UserListingCriteria): any {
|
||||||
return await this.userService.searchUserListings(criteria);
|
return this.userService.searchUserListings(criteria);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,431 +1,226 @@
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
import { and, count, 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';
|
import * as schema from '../drizzle/schema.js';
|
||||||
import { businesses_json, PG_CONNECTION } from '../drizzle/schema';
|
import { businesses, PG_CONNECTION } from '../drizzle/schema.js';
|
||||||
import { GeoService } from '../geo/geo.service';
|
import { FileService } from '../file/file.service.js';
|
||||||
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
import { GeoService } from '../geo/geo.service.js';
|
||||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
import { BusinessListing, BusinessListingSchema } from '../models/db.model.js';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { BusinessListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
import { convertBusinessToDrizzleBusiness, convertDrizzleBusinessToBusiness, getDistanceQuery } from '../utils.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BusinessListingService {
|
export class BusinessListingService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
|
private fileService?: FileService,
|
||||||
private geoService?: GeoService,
|
private geoService?: GeoService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
|
private getWhereConditions(criteria: BusinessListingCriteria): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(ilike(businesses.city, `%${criteria.city}%`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||||
this.logger.debug('Adding radius search filter', { city: criteria.city.name, radius: criteria.radius });
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city);
|
||||||
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_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius})`);
|
|
||||||
}
|
}
|
||||||
if (criteria.types && criteria.types.length > 0) {
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
this.logger.warn('Adding business category filter', { types: criteria.types });
|
whereConditions.push(inArray(businesses.type, criteria.types));
|
||||||
// Use explicit SQL with IN for robust JSONB comparison
|
|
||||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
|
||||||
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
this.logger.debug('Adding state filter', { state: criteria.state });
|
whereConditions.push(eq(businesses.state, criteria.state));
|
||||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minPrice !== undefined && criteria.minPrice !== null) {
|
if (criteria.minPrice) {
|
||||||
whereConditions.push(
|
whereConditions.push(gte(businesses.price, criteria.minPrice));
|
||||||
and(
|
|
||||||
sql`(${businesses_json.data}->>'price') IS NOT NULL`,
|
|
||||||
sql`(${businesses_json.data}->>'price') != ''`,
|
|
||||||
gte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.minPrice)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxPrice !== undefined && criteria.maxPrice !== null) {
|
if (criteria.maxPrice) {
|
||||||
whereConditions.push(
|
whereConditions.push(lte(businesses.price, criteria.maxPrice));
|
||||||
and(
|
|
||||||
sql`(${businesses_json.data}->>'price') IS NOT NULL`,
|
|
||||||
sql`(${businesses_json.data}->>'price') != ''`,
|
|
||||||
lte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.maxPrice)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minRevenue) {
|
if (criteria.minRevenue) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.minRevenue));
|
whereConditions.push(gte(businesses.salesRevenue, criteria.minRevenue));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxRevenue) {
|
if (criteria.maxRevenue) {
|
||||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.maxRevenue));
|
whereConditions.push(lte(businesses.salesRevenue, criteria.maxRevenue));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minCashFlow) {
|
if (criteria.minCashFlow) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.minCashFlow));
|
whereConditions.push(gte(businesses.cashFlow, criteria.minCashFlow));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxCashFlow) {
|
if (criteria.maxCashFlow) {
|
||||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.maxCashFlow));
|
whereConditions.push(lte(businesses.cashFlow, criteria.maxCashFlow));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minNumberEmployees) {
|
if (criteria.minNumberEmployees) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.minNumberEmployees));
|
whereConditions.push(gte(businesses.employees, criteria.minNumberEmployees));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxNumberEmployees) {
|
if (criteria.maxNumberEmployees) {
|
||||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.maxNumberEmployees));
|
whereConditions.push(lte(businesses.employees, criteria.maxNumberEmployees));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.establishedMin) {
|
if (criteria.establishedSince) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'established')::integer`, criteria.establishedMin));
|
whereConditions.push(gte(businesses.established, criteria.establishedSince));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.establishedUntil) {
|
||||||
|
whereConditions.push(lte(businesses.established, criteria.establishedUntil));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.realEstateChecked) {
|
if (criteria.realEstateChecked) {
|
||||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'realEstateIncluded')::boolean`, criteria.realEstateChecked));
|
whereConditions.push(eq(businesses.realEstateIncluded, criteria.realEstateChecked));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.leasedLocation) {
|
if (criteria.leasedLocation) {
|
||||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'leasedLocation')::boolean`, criteria.leasedLocation));
|
whereConditions.push(eq(businesses.leasedLocation, criteria.leasedLocation));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.franchiseResale) {
|
if (criteria.franchiseResale) {
|
||||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale));
|
whereConditions.push(eq(businesses.franchiseResale, criteria.franchiseResale));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.title && criteria.title.trim() !== '') {
|
if (criteria.title) {
|
||||||
const searchTerm = `%${criteria.title.trim()}%`;
|
whereConditions.push(or(ilike(businesses.title, `%${criteria.title}%`), ilike(businesses.description, `%${criteria.title}%`)));
|
||||||
whereConditions.push(
|
|
||||||
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.brokerName) {
|
if (criteria.brokerName) {
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
whereConditions.push(or(ilike(schema.users.firstname, `%${criteria.brokerName}%`), ilike(schema.users.lastname, `%${criteria.brokerName}%`)));
|
||||||
if (firstname === lastname) {
|
|
||||||
whereConditions.push(
|
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
whereConditions.push(
|
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (criteria.email) {
|
whereConditions.push(and(eq(schema.users.customerType, 'professional'), eq(schema.users.customerSubType, 'broker')));
|
||||||
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
|
||||||
}
|
|
||||||
if (user?.role !== 'admin') {
|
|
||||||
whereConditions.push(
|
|
||||||
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) {
|
async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) {
|
||||||
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
|
const query = this.conn
|
||||||
.select({
|
.select({
|
||||||
business: businesses_json,
|
business: businesses,
|
||||||
brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
brokerFirstName: schema.users.firstname,
|
||||||
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
brokerLastName: schema.users.lastname,
|
||||||
})
|
})
|
||||||
.from(businesses_json)
|
.from(businesses)
|
||||||
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
.leftJoin(schema.users, eq(businesses.email, schema.users.email));
|
||||||
|
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = and(...whereConditions);
|
||||||
query.where(sql`(${whereClause})`);
|
query.where(whereClause);
|
||||||
|
|
||||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortierung
|
|
||||||
switch (criteria.sortBy) {
|
|
||||||
case 'priceAsc':
|
|
||||||
query.orderBy(asc(sql`(${businesses_json.data}->>'price')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'priceDesc':
|
|
||||||
query.orderBy(desc(sql`(${businesses_json.data}->>'price')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'srAsc':
|
|
||||||
query.orderBy(asc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'srDesc':
|
|
||||||
query.orderBy(desc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'cfAsc':
|
|
||||||
query.orderBy(asc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'cfDesc':
|
|
||||||
query.orderBy(desc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'creationDateFirst':
|
|
||||||
query.orderBy(asc(sql`${businesses_json.data}->>'created'`));
|
|
||||||
break;
|
|
||||||
case 'creationDateLast':
|
|
||||||
query.orderBy(desc(sql`${businesses_json.data}->>'created'`));
|
|
||||||
break;
|
|
||||||
default: {
|
|
||||||
// NEU (created < 14 Tage) > UPDATED (updated < 14 Tage) > Rest
|
|
||||||
const recencyRank = sql`
|
|
||||||
CASE
|
|
||||||
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days')) THEN 2
|
|
||||||
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days')) THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Innerhalb der Gruppe:
|
|
||||||
// NEW → created DESC
|
|
||||||
// UPDATED → updated DESC
|
|
||||||
// Rest → created DESC
|
|
||||||
const groupTimestamp = sql`
|
|
||||||
CASE
|
|
||||||
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days'))
|
|
||||||
THEN (${businesses_json.data}->>'created')::timestamptz
|
|
||||||
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days'))
|
|
||||||
THEN (${businesses_json.data}->>'updated')::timestamptz
|
|
||||||
ELSE (${businesses_json.data}->>'created')::timestamptz
|
|
||||||
END
|
|
||||||
`;
|
|
||||||
|
|
||||||
query.orderBy(desc(recencyRank), desc(groupTimestamp), desc(sql`(${businesses_json.data}->>'created')::timestamptz`));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Paginierung
|
// 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, user);
|
const totalCount = await this.getBusinessListingsCount(criteria);
|
||||||
const results = data.map(r => ({
|
const results = data.map(r => r.business).map(r => convertDrizzleBusinessToBusiness(r));
|
||||||
id: r.business.id,
|
|
||||||
email: r.business.email,
|
|
||||||
...(r.business.data as BusinessListing),
|
|
||||||
brokerFirstName: r.brokerFirstName,
|
|
||||||
brokerLastName: r.brokerLastName,
|
|
||||||
}));
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
|
async getBusinessListingsCount(criteria: BusinessListingCriteria): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
const countQuery = this.conn.select({ value: count() }).from(businesses).leftJoin(schema.users, eq(businesses.email, schema.users.email));
|
||||||
|
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = and(...whereConditions);
|
||||||
countQuery.where(sql`(${whereClause})`);
|
countQuery.where(whereClause);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find business by slug or ID
|
|
||||||
* Supports both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
|
||||||
*/
|
|
||||||
async findBusinessBySlugOrId(slugOrId: string, user: JwtUser): Promise<BusinessListing> {
|
|
||||||
this.logger.debug(`findBusinessBySlugOrId called with: ${slugOrId}`);
|
|
||||||
|
|
||||||
let id = slugOrId;
|
|
||||||
|
|
||||||
// Check if it's a slug (contains multiple hyphens) vs UUID
|
|
||||||
if (isSlug(slugOrId)) {
|
|
||||||
this.logger.debug(`Detected as slug: ${slugOrId}`);
|
|
||||||
|
|
||||||
// Extract short ID from slug and find by slug field
|
|
||||||
const listing = await this.findBusinessBySlug(slugOrId);
|
|
||||||
if (listing) {
|
|
||||||
this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`);
|
|
||||||
id = listing.id;
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Slug not found in database: ${slugOrId}`);
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Business listing not found with slug: ${slugOrId}. ` +
|
|
||||||
`The listing may have been deleted or the URL may be incorrect.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.findBusinessesById(id, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find business by slug
|
|
||||||
*/
|
|
||||||
async findBusinessBySlug(slug: string): Promise<BusinessListing | null> {
|
|
||||||
const result = await this.conn
|
|
||||||
.select()
|
|
||||||
.from(businesses_json)
|
|
||||||
.where(sql`${businesses_json.data}->>'slug' = ${slug}`)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
|
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
|
||||||
const conditions = [];
|
let result = await this.conn
|
||||||
if (user?.role !== 'admin') {
|
|
||||||
conditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
|
|
||||||
}
|
|
||||||
conditions.push(eq(businesses_json.id, id));
|
|
||||||
const result = await this.conn
|
|
||||||
.select()
|
.select()
|
||||||
.from(businesses_json)
|
.from(businesses)
|
||||||
.where(and(...conditions));
|
.where(and(sql`${businesses.id} = ${id}`));
|
||||||
if (result.length > 0) {
|
result = result.filter(r => !r.draft || r.imageName === emailToDirName(user?.username) || user?.roles.includes('ADMIN'));
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
return convertDrizzleBusinessToBusiness(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_json.email, email));
|
conditions.push(eq(businesses.imageName, emailToDirName(email)));
|
||||||
if (email !== user?.email && user?.role !== 'admin') {
|
if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) {
|
||||||
conditions.push(sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
conditions.push(ne(businesses.draft, true));
|
||||||
}
|
}
|
||||||
const listings = await this.conn
|
const listings = (await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(businesses_json)
|
.from(businesses)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions))) as BusinessListing[];
|
||||||
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
|
||||||
}
|
return listings.map(l => convertDrizzleBusinessToBusiness(l));
|
||||||
|
|
||||||
async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> {
|
|
||||||
const userFavorites = await this.conn
|
|
||||||
.select()
|
|
||||||
.from(businesses_json)
|
|
||||||
.where(sql`${businesses_json.data}->'favoritesForUser' ? ${user.email}`);
|
|
||||||
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #### 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();
|
||||||
BusinessListingSchema.parse(data);
|
const validatedBusinessListing = BusinessListingSchema.parse(data);
|
||||||
const { id, email, ...rest } = data;
|
const convertedBusinessListing = convertBusinessToDrizzleBusiness(data);
|
||||||
const convertedBusinessListing = { email, data: rest };
|
const [createdListing] = await this.conn.insert(businesses).values(convertedBusinessListing).returning();
|
||||||
const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).returning();
|
return convertDrizzleBusinessToBusiness(createdListing);
|
||||||
|
|
||||||
// Generate and update slug after creation (we need the ID first)
|
|
||||||
const slug = generateSlug(data.title, data.location, createdListing.id);
|
|
||||||
const listingWithSlug = { ...(createdListing.data as any), slug };
|
|
||||||
await this.conn.update(businesses_json).set({ data: listingWithSlug }).where(eq(businesses_json.id, createdListing.id));
|
|
||||||
|
|
||||||
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing), slug } as any;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const formattedErrors = error.errors.map(err => ({
|
||||||
.map(item => ({
|
field: err.path.join('.'),
|
||||||
...item,
|
message: err.message,
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #### UPDATE Business ########################################
|
||||||
async updateBusinessListing(id: string, data: BusinessListing, user: JwtUser): Promise<BusinessListing> {
|
async updateBusinessListing(id: string, data: BusinessListing): Promise<BusinessListing> {
|
||||||
try {
|
try {
|
||||||
const [existingListing] = await this.conn.select().from(businesses_json).where(eq(businesses_json.id, id));
|
|
||||||
|
|
||||||
if (!existingListing) {
|
|
||||||
throw new NotFoundException(`Business listing with id ${id} not found`);
|
|
||||||
}
|
|
||||||
data.updated = new Date();
|
data.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();
|
||||||
if (existingListing.email === user?.email) {
|
const validatedBusinessListing = BusinessListingSchema.parse(data);
|
||||||
data.favoritesForUser = (<BusinessListing>existingListing.data).favoritesForUser || [];
|
const convertedBusinessListing = convertBusinessToDrizzleBusiness(data);
|
||||||
}
|
const [updateListing] = await this.conn.update(businesses).set(convertedBusinessListing).where(eq(businesses.id, id)).returning();
|
||||||
|
return convertDrizzleBusinessToBusiness(updateListing);
|
||||||
// Regenerate slug if title or location changed
|
|
||||||
const existingData = existingListing.data as BusinessListing;
|
|
||||||
let slug: string;
|
|
||||||
if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) {
|
|
||||||
slug = generateSlug(data.title, data.location, id);
|
|
||||||
} else {
|
|
||||||
// Keep existing slug
|
|
||||||
slug = (existingData as any).slug || generateSlug(data.title, data.location, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add slug to data before validation
|
|
||||||
const dataWithSlug = { ...data, slug };
|
|
||||||
BusinessListingSchema.parse(dataWithSlug);
|
|
||||||
const { id: _, email, ...rest } = dataWithSlug;
|
|
||||||
const convertedBusinessListing = { email, data: rest };
|
|
||||||
const [updateListing] = await this.conn.update(businesses_json).set(convertedBusinessListing).where(eq(businesses_json.id, id)).returning();
|
|
||||||
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as BusinessListing) };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const formattedErrors = error.errors.map(err => ({
|
||||||
.map(item => ({
|
field: err.path.join('.'),
|
||||||
...item,
|
message: err.message,
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #### DELETE ########################################
|
||||||
async deleteListing(id: string): Promise<void> {
|
async deleteListing(id: string): Promise<void> {
|
||||||
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id));
|
await this.conn.delete(businesses).where(eq(businesses.id, id));
|
||||||
}
|
}
|
||||||
|
// ##############################################################
|
||||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
// States
|
||||||
await this.conn
|
// ##############################################################
|
||||||
.update(businesses_json)
|
async getStates(): Promise<any[]> {
|
||||||
.set({
|
return await this.conn
|
||||||
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
|
.select({ state: businesses.state, count: sql<number>`count(${businesses.id})`.mapWith(Number) })
|
||||||
coalesce((${businesses_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
.from(businesses)
|
||||||
})
|
.groupBy(sql`${businesses.state}`)
|
||||||
.where(eq(businesses_json.id, id));
|
.orderBy(sql`count desc`);
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
|
||||||
await this.conn
|
|
||||||
.update(businesses_json)
|
|
||||||
.set({
|
|
||||||
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
|
|
||||||
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
|
||||||
FROM jsonb_array_elements(coalesce(${businesses_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
|
||||||
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
|
||||||
})
|
|
||||||
.where(eq(businesses_json.id, id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,61 @@
|
||||||
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 { AuthGuard } from 'src/jwt-auth/auth.guard';
|
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 { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { BusinessListingCriteria, JwtUser } from '../models/main.model.js';
|
||||||
import { BusinessListing } from '../models/db.model';
|
import { BusinessListingService } from './business-listing.service.js';
|
||||||
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 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly listingsService: BusinessListingService,
|
private readonly listingsService: BusinessListingService,
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@Post('favorites/all')
|
@Get(':id')
|
||||||
async findFavorites(@Request() req): Promise<any> {
|
findById(@Request() req, @Param('id') id: string): any {
|
||||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
return this.listingsService.findBusinessesById(id, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@Get(':slugOrId')
|
|
||||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
|
||||||
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
|
||||||
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Get('user/:userid')
|
@Get('user/:userid')
|
||||||
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
||||||
return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
|
return this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@Post('find')
|
@Post('find')
|
||||||
async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
|
find(@Request() req, @Body() criteria: BusinessListingCriteria): any {
|
||||||
return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
|
return this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post('findTotal')
|
@Post('findTotal')
|
||||||
async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
|
findTotal(@Body() criteria: BusinessListingCriteria): Promise<number> {
|
||||||
return await this.listingsService.getBusinessListingsCount(criteria, req.user as JwtUser);
|
return this.listingsService.getBusinessListingsCount(criteria);
|
||||||
}
|
}
|
||||||
|
// @UseGuards(OptionalJwtAuthGuard)
|
||||||
|
// @Post('search')
|
||||||
|
// search(@Request() req, @Body() criteria: BusinessListingCriteria): any {
|
||||||
|
// return this.listingsService.searchBusinessListings(criteria.prompt);
|
||||||
|
// }
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() listing: any) {
|
create(@Body() listing: any) {
|
||||||
return await this.listingsService.createListing(listing);
|
this.logger.info(`Save Listing`);
|
||||||
|
return this.listingsService.createListing(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Put()
|
@Put()
|
||||||
async update(@Request() req, @Body() listing: any) {
|
update(@Body() listing: any) {
|
||||||
return await this.listingsService.updateBusinessListing(listing.id, listing, req.user as JwtUser);
|
this.logger.info(`Save Listing`);
|
||||||
|
return this.listingsService.updateBusinessListing(listing.id, listing);
|
||||||
}
|
}
|
||||||
|
@Delete(':id')
|
||||||
@UseGuards(OptionalAuthGuard)
|
deleteById(@Param('id') id: string) {
|
||||||
@Delete('listing/:id')
|
this.listingsService.deleteListing(id);
|
||||||
async deleteById(@Param('id') id: string) {
|
|
||||||
await this.listingsService.deleteListing(id);
|
|
||||||
}
|
}
|
||||||
|
@Get('states/all')
|
||||||
@UseGuards(AuthGuard)
|
getStates(): any {
|
||||||
@Post('favorite/:id')
|
return this.listingsService.getStates();
|
||||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
|
||||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
|
||||||
return { success: true, message: 'Added to favorites' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Delete('favorite/:id')
|
|
||||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
|
||||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
|
||||||
return { success: true, message: 'Removed from favorites' };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +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 { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service.js';
|
||||||
|
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js';
|
||||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
|
||||||
import { CommercialPropertyListing } from '../models/db.model';
|
import { CommercialPropertyListing } from '../models/db.model';
|
||||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model.js';
|
||||||
import { CommercialPropertyService } from './commercial-property.service';
|
import { CommercialPropertyService } from './commercial-property.service.js';
|
||||||
|
|
||||||
@Controller('listings/commercialProperty')
|
@Controller('listings/commercialProperty')
|
||||||
export class CommercialPropertyListingsController {
|
export class CommercialPropertyListingsController {
|
||||||
|
|
@ -15,68 +13,45 @@ export class CommercialPropertyListingsController {
|
||||||
private readonly listingsService: CommercialPropertyService,
|
private readonly listingsService: CommercialPropertyService,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@Post('favorites/all')
|
@Get(':id')
|
||||||
async findFavorites(@Request() req): Promise<any> {
|
findById(@Request() req, @Param('id') id: string): any {
|
||||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
return this.listingsService.findCommercialPropertiesById(id, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@Get(':slugOrId')
|
|
||||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
|
||||||
// Support both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
|
||||||
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Get('user/:email')
|
@Get('user/:email')
|
||||||
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
||||||
return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
|
return this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@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(OptionalAuthGuard)
|
|
||||||
@Post('findTotal')
|
@Post('findTotal')
|
||||||
async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
|
findTotal(@Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
|
||||||
return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
|
return this.listingsService.getCommercialPropertiesCount(criteria);
|
||||||
|
}
|
||||||
|
@Get('states/all')
|
||||||
|
getStates(): any {
|
||||||
|
return this.listingsService.getStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@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(OptionalAuthGuard)
|
|
||||||
@Put()
|
@Put()
|
||||||
async update(@Request() req, @Body() listing: any) {
|
async update(@Body() listing: any) {
|
||||||
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing, req.user as JwtUser);
|
this.logger.info(`Save Listing`);
|
||||||
|
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing);
|
||||||
}
|
}
|
||||||
|
@Delete(':id/:imagePath')
|
||||||
@UseGuards(OptionalAuthGuard)
|
deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
|
||||||
@Delete('listing/:id/:imagePath')
|
this.listingsService.deleteListing(id);
|
||||||
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
|
|
||||||
await this.listingsService.deleteListing(id);
|
|
||||||
this.fileService.deleteDirectoryIfExists(imagePath);
|
this.fileService.deleteDirectoryIfExists(imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Post('favorite/:id')
|
|
||||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
|
||||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
|
||||||
return { success: true, message: 'Added to favorites' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Delete('favorite/:id')
|
|
||||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
|
||||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
|
||||||
return { success: true, message: 'Removed from favorites' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
import { and, count, 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';
|
import * as schema from '../drizzle/schema.js';
|
||||||
import { commercials_json, PG_CONNECTION } from '../drizzle/schema';
|
import { commercials, 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 { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model.js';
|
||||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
import { CommercialPropertyListingCriteria, emailToDirName, JwtUser } from '../models/main.model.js';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { convertCommercialToDrizzleCommercial, convertDrizzleCommercialToCommercial, getDistanceQuery } from '../utils.js';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommercialPropertyService {
|
export class CommercialPropertyService {
|
||||||
|
|
@ -20,118 +19,70 @@ export class CommercialPropertyService {
|
||||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
private fileService?: FileService,
|
private fileService?: FileService,
|
||||||
private geoService?: GeoService,
|
private geoService?: GeoService,
|
||||||
) { }
|
) {}
|
||||||
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
|
private getWhereConditions(criteria: CommercialPropertyListingCriteria): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(ilike(schema.commercials.city, `%${criteria.city}%`));
|
||||||
}
|
}
|
||||||
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.name);
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city);
|
||||||
whereConditions.push(sql`${getDistanceQuery(commercials_json, 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) {
|
||||||
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
|
whereConditions.push(inArray(schema.commercials.type, criteria.types));
|
||||||
// Use explicit SQL with IN for robust JSONB comparison
|
|
||||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
|
||||||
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`);
|
whereConditions.push(eq(schema.commercials.state, criteria.state));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minPrice) {
|
if (criteria.minPrice) {
|
||||||
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice));
|
whereConditions.push(gte(schema.commercials.price, criteria.minPrice));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxPrice) {
|
if (criteria.maxPrice) {
|
||||||
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice));
|
whereConditions.push(lte(schema.commercials.price, criteria.maxPrice));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.title) {
|
if (criteria.title) {
|
||||||
whereConditions.push(
|
whereConditions.push(or(ilike(schema.commercials.title, `%${criteria.title}%`), ilike(schema.commercials.description, `%${criteria.title}%`)));
|
||||||
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
whereConditions.push(and(eq(schema.users.customerType, 'professional')));
|
||||||
if (criteria.brokerName) {
|
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
|
||||||
if (firstname === lastname) {
|
|
||||||
// Single word: search either first OR last name
|
|
||||||
whereConditions.push(
|
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Multiple words: search both first AND last name
|
|
||||||
whereConditions.push(
|
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user?.role !== 'admin') {
|
|
||||||
whereConditions.push(
|
|
||||||
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
// #### Find by criteria ########################################
|
// #### Find by criteria ########################################
|
||||||
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
|
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
|
||||||
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_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
const query = this.conn.select({ commercial: commercials }).from(commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = and(...whereConditions);
|
||||||
query.where(sql`(${whereClause})`);
|
query.where(whereClause);
|
||||||
|
|
||||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
|
||||||
}
|
|
||||||
// Sortierung
|
|
||||||
switch (criteria.sortBy) {
|
|
||||||
case 'priceAsc':
|
|
||||||
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'priceDesc':
|
|
||||||
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`));
|
|
||||||
break;
|
|
||||||
case 'creationDateFirst':
|
|
||||||
query.orderBy(asc(sql`${commercials_json.data}->>'created'`));
|
|
||||||
break;
|
|
||||||
case 'creationDateLast':
|
|
||||||
query.orderBy(desc(sql`${commercials_json.data}->>'created'`));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginierung
|
// Paginierung
|
||||||
query.limit(length).offset(start);
|
query.limit(length).offset(start);
|
||||||
|
|
||||||
const data = await query;
|
const data = await query;
|
||||||
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) }));
|
const results = data.map(r => r.commercial).map(r => convertDrizzleCommercialToCommercial(r));
|
||||||
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
|
const totalCount = await this.getCommercialPropertiesCount(criteria);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> {
|
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
const countQuery = this.conn.select({ value: count() }).from(schema.commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = and(...whereConditions);
|
||||||
countQuery.where(sql`(${whereClause})`);
|
countQuery.where(whereClause);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
|
|
@ -139,182 +90,78 @@ export class CommercialPropertyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// #### Find by ID ########################################
|
// #### Find by ID ########################################
|
||||||
/**
|
|
||||||
* Find commercial property by slug or ID
|
|
||||||
* Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
|
||||||
*/
|
|
||||||
async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
|
||||||
this.logger.debug(`findCommercialBySlugOrId called with: ${slugOrId}`);
|
|
||||||
|
|
||||||
let id = slugOrId;
|
|
||||||
|
|
||||||
// Check if it's a slug (contains multiple hyphens) vs UUID
|
|
||||||
if (isSlug(slugOrId)) {
|
|
||||||
this.logger.debug(`Detected as slug: ${slugOrId}`);
|
|
||||||
|
|
||||||
// Extract short ID from slug and find by slug field
|
|
||||||
const listing = await this.findCommercialBySlug(slugOrId);
|
|
||||||
if (listing) {
|
|
||||||
this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`);
|
|
||||||
id = listing.id;
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Slug not found in database: ${slugOrId}`);
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Commercial property listing not found with slug: ${slugOrId}. ` +
|
|
||||||
`The listing may have been deleted or the URL may be incorrect.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.findCommercialPropertiesById(id, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find commercial property by slug
|
|
||||||
*/
|
|
||||||
async findCommercialBySlug(slug: string): Promise<CommercialPropertyListing | null> {
|
|
||||||
const result = await this.conn
|
|
||||||
.select()
|
|
||||||
.from(commercials_json)
|
|
||||||
.where(sql`${commercials_json.data}->>'slug' = ${slug}`)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
||||||
const conditions = [];
|
let result = await this.conn
|
||||||
if (user?.role !== 'admin') {
|
|
||||||
conditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
|
||||||
}
|
|
||||||
conditions.push(eq(commercials_json.id, id));
|
|
||||||
const result = await this.conn
|
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials)
|
||||||
.where(and(...conditions));
|
.where(and(sql`${commercials.id} = ${id}`));
|
||||||
if (result.length > 0) {
|
result = result.filter(r => !r.draft || r.imagePath === emailToDirName(user?.username) || user?.roles.includes('ADMIN'));
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
return convertDrizzleCommercialToCommercial(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_json.email, email));
|
conditions.push(eq(commercials.imagePath, emailToDirName(email)));
|
||||||
if (email !== user?.email && user?.role !== 'admin') {
|
if (email !== user?.username && (!user?.roles?.includes('ADMIN') ?? false)) {
|
||||||
conditions.push(sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
conditions.push(ne(commercials.draft, true));
|
||||||
}
|
}
|
||||||
const listings = await this.conn
|
const listings = (await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions))) as CommercialPropertyListing[];
|
||||||
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
return listings.map(l => convertDrizzleCommercialToCommercial(l)) as CommercialPropertyListing[];
|
||||||
}
|
|
||||||
// #### Find Favorites ########################################
|
|
||||||
async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> {
|
|
||||||
const userFavorites = await this.conn
|
|
||||||
.select()
|
|
||||||
.from(commercials_json)
|
|
||||||
.where(sql`${commercials_json.data}->'favoritesForUser' ? ${user.email}`);
|
|
||||||
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
|
||||||
}
|
}
|
||||||
// #### Find by imagePath ########################################
|
// #### Find by imagePath ########################################
|
||||||
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
|
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
|
||||||
const result = await this.conn
|
const result = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials)
|
||||||
.where(and(sql`(${commercials_json.data}->>'imagePath') = ${imagePath}`, sql`(${commercials_json.data}->>'serialId')::integer = ${serial}`));
|
.where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`));
|
||||||
if (result.length > 0) {
|
return convertDrizzleCommercialToCommercial(result[0]) as CommercialPropertyListing;
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// #### CREATE ########################################
|
// #### CREATE ########################################
|
||||||
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
|
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
|
||||||
try {
|
try {
|
||||||
// Generate serialId based on timestamp + random number (temporary solution until sequence is created)
|
|
||||||
// This ensures uniqueness without requiring a database sequence
|
|
||||||
const serialId = Date.now() % 1000000 + Math.floor(Math.random() * 1000);
|
|
||||||
|
|
||||||
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
||||||
data.updated = new Date();
|
data.updated = new Date();
|
||||||
data.serialId = Number(serialId);
|
const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data);
|
||||||
CommercialPropertyListingSchema.parse(data);
|
const convertedCommercialPropertyListing = convertCommercialToDrizzleCommercial(data);
|
||||||
const { id, email, ...rest } = data;
|
const [createdListing] = await this.conn.insert(commercials).values(convertedCommercialPropertyListing).returning();
|
||||||
const convertedCommercialPropertyListing = { email, data: rest };
|
return convertDrizzleCommercialToCommercial(createdListing);
|
||||||
const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning();
|
|
||||||
|
|
||||||
// Generate and update slug after creation (we need the ID first)
|
|
||||||
const slug = generateSlug(data.title, data.location, createdListing.id);
|
|
||||||
const listingWithSlug = { ...(createdListing.data as any), slug };
|
|
||||||
await this.conn.update(commercials_json).set({ data: listingWithSlug }).where(eq(commercials_json.id, createdListing.id));
|
|
||||||
|
|
||||||
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing), slug } as any;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const formattedErrors = error.errors.map(err => ({
|
||||||
.map(item => ({
|
field: err.path.join('.'),
|
||||||
...item,
|
message: err.message,
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #### UPDATE CommercialProps ########################################
|
// #### UPDATE CommercialProps ########################################
|
||||||
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing, user: JwtUser): Promise<CommercialPropertyListing> {
|
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
|
||||||
try {
|
try {
|
||||||
const [existingListing] = await this.conn.select().from(commercials_json).where(eq(commercials_json.id, id));
|
|
||||||
|
|
||||||
if (!existingListing) {
|
|
||||||
throw new NotFoundException(`Business listing with id ${id} not found`);
|
|
||||||
}
|
|
||||||
data.updated = new Date();
|
data.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();
|
||||||
if (existingListing.email === user?.email || !user) {
|
const validatedCommercialPropertyListing = CommercialPropertyListingSchema.parse(data);
|
||||||
data.favoritesForUser = (<CommercialPropertyListing>existingListing.data).favoritesForUser || [];
|
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)));
|
||||||
|
|
||||||
// Regenerate slug if title or location changed
|
|
||||||
const existingData = existingListing.data as CommercialPropertyListing;
|
|
||||||
let slug: string;
|
|
||||||
if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) {
|
|
||||||
slug = generateSlug(data.title, data.location, id);
|
|
||||||
} else {
|
|
||||||
// Keep existing slug
|
|
||||||
slug = (existingData as any).slug || generateSlug(data.title, data.location, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add slug to data before validation
|
|
||||||
const dataWithSlug = { ...data, slug };
|
|
||||||
CommercialPropertyListingSchema.parse(dataWithSlug);
|
|
||||||
const imageOrder = await this.fileService.getPropertyImages(dataWithSlug.imagePath, String(dataWithSlug.serialId));
|
|
||||||
const difference = imageOrder.filter(x => !dataWithSlug.imageOrder.includes(x)).concat(dataWithSlug.imageOrder.filter(x => !imageOrder.includes(x)));
|
|
||||||
if (difference.length > 0) {
|
if (difference.length > 0) {
|
||||||
this.logger.warn(`changes between image directory and imageOrder in listing ${dataWithSlug.serialId}: ${difference.join(',')}`);
|
this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`);
|
||||||
dataWithSlug.imageOrder = imageOrder;
|
data.imageOrder = imageOrder;
|
||||||
}
|
}
|
||||||
const { id: _, email, ...rest } = dataWithSlug;
|
const convertedCommercialPropertyListing = convertCommercialToDrizzleCommercial(data);
|
||||||
const convertedCommercialPropertyListing = { email, data: rest };
|
const [updateListing] = await this.conn.update(commercials).set(convertedCommercialPropertyListing).where(eq(commercials.id, id)).returning();
|
||||||
const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning();
|
return convertDrizzleCommercialToCommercial(updateListing);
|
||||||
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const formattedErrors = error.errors.map(err => ({
|
||||||
.map(item => ({
|
field: err.path.join('.'),
|
||||||
...item,
|
message: err.message,
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -323,42 +170,30 @@ export class CommercialPropertyService {
|
||||||
// Images for commercial Properties
|
// Images for commercial Properties
|
||||||
// ##############################################################
|
// ##############################################################
|
||||||
async deleteImage(imagePath: string, serial: string, name: string) {
|
async deleteImage(imagePath: string, serial: string, name: string) {
|
||||||
const listing = await this.findByImagePath(imagePath, serial);
|
const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing;
|
||||||
const index = listing.imageOrder.findIndex(im => im === name);
|
const index = listing.imageOrder.findIndex(im => im === name);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listing.imageOrder.splice(index, 1);
|
listing.imageOrder.splice(index, 1);
|
||||||
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
await this.updateCommercialPropertyListing(listing.id, listing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async addImage(imagePath: string, serial: string, imagename: string) {
|
async addImage(imagePath: string, serial: string, imagename: string) {
|
||||||
const listing = await this.findByImagePath(imagePath, serial);
|
const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing;
|
||||||
listing.imageOrder.push(imagename);
|
listing.imageOrder.push(imagename);
|
||||||
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
await this.updateCommercialPropertyListing(listing.id, listing);
|
||||||
}
|
}
|
||||||
// #### DELETE ########################################
|
// #### DELETE ########################################
|
||||||
async deleteListing(id: string): Promise<void> {
|
async deleteListing(id: string): Promise<void> {
|
||||||
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id));
|
await this.conn.delete(commercials).where(eq(commercials.id, id));
|
||||||
}
|
}
|
||||||
// #### ADD Favorite ######################################
|
// ##############################################################
|
||||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
// States
|
||||||
await this.conn
|
// ##############################################################
|
||||||
.update(commercials_json)
|
async getStates(): Promise<any[]> {
|
||||||
.set({
|
return await this.conn
|
||||||
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
|
.select({ state: commercials.state, count: sql<number>`count(${commercials.id})`.mapWith(Number) })
|
||||||
coalesce((${commercials_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
.from(commercials)
|
||||||
})
|
.groupBy(sql`${commercials.state}`)
|
||||||
.where(eq(commercials_json.id, id));
|
.orderBy(sql`count desc`);
|
||||||
}
|
|
||||||
// #### DELETE Favorite ###################################
|
|
||||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
|
||||||
await this.conn
|
|
||||||
.update(commercials_json)
|
|
||||||
.set({
|
|
||||||
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
|
|
||||||
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
|
||||||
FROM jsonb_array_elements(coalesce(${commercials_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
|
||||||
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
|
||||||
})
|
|
||||||
.where(eq(commercials_json.id, id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module.js';
|
||||||
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 { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service.js';
|
||||||
import { BrokerListingsController } from './broker-listings.controller';
|
import { BrokerListingsController } from './broker-listings.controller.js';
|
||||||
import { BusinessListingsController } from './business-listings.controller';
|
import { BusinessListingsController } from './business-listings.controller.js';
|
||||||
import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
|
import { CommercialPropertyListingsController } from './commercial-property-listings.controller.js';
|
||||||
import { UserListingsController } from './user-listings.controller';
|
|
||||||
|
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
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,FirebaseAdminModule],
|
imports: [DrizzleModule, AuthModule, GeoModule],
|
||||||
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
|
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController],
|
||||||
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
||||||
exports: [BusinessListingService, CommercialPropertyService],
|
exports: [BusinessListingService, CommercialPropertyService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,18 @@
|
||||||
import { Controller, Get, Inject, Param, Request, UseGuards } from '@nestjs/common';
|
import { Controller, Inject } from '@nestjs/common';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
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(
|
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
|
||||||
private readonly businessListingsService: BusinessListingService,
|
|
||||||
private readonly propertyListingsService: CommercialPropertyService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
// @Get(':id')
|
||||||
@Get(':id')
|
// async findById(@Param('id') id: string): Promise<any> {
|
||||||
async findById(@Request() req, @Param('id') id: string): Promise<any> {
|
// const result = await this.listingsService.findById(id, businesses);
|
||||||
try {
|
// if (result) {
|
||||||
return await this.businessListingsService.findBusinessesById(id, req.user);
|
// return result;
|
||||||
} catch (error) {
|
// } else {
|
||||||
return await this.propertyListingsService.findCommercialPropertiesById(id, req.user);
|
// return await this.listingsService.findById(id, commercials);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { Controller, Delete, Param, Post, Request, UseGuards } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../jwt-auth/auth.guard';
|
|
||||||
import { JwtUser } from '../models/main.model';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
|
|
||||||
@Controller('listings/user')
|
|
||||||
export class UserListingsController {
|
|
||||||
constructor(private readonly userService: UserService) { }
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Post('favorite/:id')
|
|
||||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
|
||||||
await this.userService.addFavorite(id, req.user as JwtUser);
|
|
||||||
return { success: true, message: 'Added to favorites' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Delete('favorite/:id')
|
|
||||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
|
||||||
await this.userService.deleteFavorite(id, req.user as JwtUser);
|
|
||||||
return { success: true, message: 'Removed from favorites' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Post('favorites/all')
|
|
||||||
async getFavorites(@Request() req) {
|
|
||||||
return await this.userService.getFavoriteUsers(req.user as JwtUser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { Body, Controller, Inject, Post, Request, UseGuards } from '@nestjs/common';
|
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
|
||||||
import { Logger } from 'winston';
|
|
||||||
import { LogMessage } from '../models/main.model';
|
|
||||||
@Controller('log')
|
|
||||||
export class LogController {
|
|
||||||
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
|
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post()
|
|
||||||
log(@Request() req, @Body() message: LogMessage) {
|
|
||||||
if (message.severity === 'info') {
|
|
||||||
this.logger.info(message.text);
|
|
||||||
} else {
|
|
||||||
this.logger.error(message.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
|
||||||
import { LogController } from './log.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [FirebaseAdminModule],
|
|
||||||
controllers: [LogController],
|
|
||||||
})
|
|
||||||
export class LogModule {}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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>',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
@ -1,43 +1,16 @@
|
||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-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';
|
import { MailService } from './mail.service.js';
|
||||||
|
|
||||||
@Controller('mail')
|
@Controller('mail')
|
||||||
export class MailController {
|
export class MailController {
|
||||||
constructor(private mailService: MailService) {}
|
constructor(private mailService: MailService) {}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post()
|
@Post()
|
||||||
async sendEMail(@Body() mailInfo: MailInfo): Promise<void | ErrorResponse> {
|
sendEMail(@Body() mailInfo: MailInfo): Promise<void | ErrorResponse> {
|
||||||
if (mailInfo.listing) {
|
if (mailInfo.listing) {
|
||||||
return await this.mailService.sendInquiry(mailInfo);
|
return this.mailService.sendInquiry(mailInfo);
|
||||||
} else {
|
} else {
|
||||||
return await this.mailService.sendRequest(mailInfo);
|
return this.mailService.sendRequest(mailInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Post('verify-email')
|
|
||||||
async sendVerificationEmail(@Body() data: {
|
|
||||||
email: string,
|
|
||||||
redirectConfig: {
|
|
||||||
protocol: string,
|
|
||||||
hostname: string,
|
|
||||||
port?: number
|
|
||||||
}
|
|
||||||
}): Promise<void | ErrorResponse> {
|
|
||||||
return await this.mailService.sendVerificationEmail(data.email, data.redirectConfig);
|
|
||||||
}
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post('subscriptionConfirmation')
|
|
||||||
async sendSubscriptionConfirmation(@Body() user: User): Promise<void | ErrorResponse> {
|
|
||||||
return await this.mailService.sendSubscriptionConfirmation(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post('send2Friend')
|
|
||||||
async send2Friend(@Body() shareByEMail: ShareByEMail): Promise<void | ErrorResponse> {
|
|
||||||
return await this.mailService.send2Friend(shareByEMail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,45 @@
|
||||||
import { MailerModule } from '@nestjs-modules/mailer';
|
import { MailerModule } from '@nestjs-modules/mailer';
|
||||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter.js';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { join } from 'path';
|
import path, { join } from 'path';
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
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 __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,
|
||||||
FirebaseAdminModule,
|
MailerModule.forRoot({
|
||||||
MailerModule.forRootAsync({
|
transport: {
|
||||||
useFactory: () => ({
|
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: process.env.AMAZON_USER,
|
|
||||||
pass: process.env.AMAZON_PASSWORD,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
defaults: {
|
},
|
||||||
from: '"No Reply" <noreply@example.com>',
|
defaults: {
|
||||||
|
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],
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
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 { getAuth } from 'firebase-admin/auth';
|
import path, { join } from 'path';
|
||||||
import { join } from 'path';
|
import { fileURLToPath } from 'url';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import { SenderSchema, ShareByEMail, ShareByEMailSchema, User } from '../models/db.model';
|
import { SenderSchema } from '../models/db.model.js';
|
||||||
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model';
|
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model.js';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service.js';
|
||||||
// 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 +18,7 @@ export class MailService {
|
||||||
|
|
||||||
async sendInquiry(mailInfo: MailInfo): Promise<void | ErrorResponse> {
|
async sendInquiry(mailInfo: MailInfo): Promise<void | ErrorResponse> {
|
||||||
try {
|
try {
|
||||||
SenderSchema.parse(mailInfo.sender);
|
const validatedSender = 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 => ({
|
||||||
|
|
@ -53,68 +53,9 @@ export class MailService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async sendVerificationEmail(
|
|
||||||
email: string,
|
|
||||||
redirectConfig: { protocol: string, hostname: string, port?: number }
|
|
||||||
): Promise<void | ErrorResponse> {
|
|
||||||
try {
|
|
||||||
// Firebase Auth-Instanz holen
|
|
||||||
const auth = getAuth();
|
|
||||||
|
|
||||||
// Baue den Redirect-URL aus den übergebenen Parametern
|
|
||||||
let continueUrl = `${redirectConfig.protocol}://${redirectConfig.hostname}`;
|
|
||||||
if (redirectConfig.port) {
|
|
||||||
continueUrl += `:${redirectConfig.port}`;
|
|
||||||
}
|
|
||||||
continueUrl += '/auth/verify-email-success'; // Beispiel für einen Weiterleitungspfad
|
|
||||||
|
|
||||||
// Custom Verification Link generieren
|
|
||||||
const firebaseActionLink = await auth.generateEmailVerificationLink(email, {
|
|
||||||
url: continueUrl,
|
|
||||||
handleCodeInApp: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extrahiere den oobCode aus dem Firebase Link
|
|
||||||
const actionLinkUrl = new URL(firebaseActionLink);
|
|
||||||
const oobCode = actionLinkUrl.searchParams.get('oobCode');
|
|
||||||
|
|
||||||
if (!oobCode) {
|
|
||||||
throw new BadRequestException('Failed to generate verification code');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erstelle die benutzerdefinierte URL mit dem oobCode
|
|
||||||
let customActionLink = `${redirectConfig.protocol}://${redirectConfig.hostname}`;
|
|
||||||
if (redirectConfig.port) {
|
|
||||||
customActionLink += `:${redirectConfig.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ersetze die Platzhalter mit den tatsächlichen Werten
|
|
||||||
customActionLink += `/email-authorized?email=${encodeURIComponent(email)}&mode=verifyEmail&oobCode=${oobCode}`;
|
|
||||||
|
|
||||||
// Zufallszahl für die E-Mail generieren
|
|
||||||
const randomNumber = Math.floor(Math.random() * 10000);
|
|
||||||
|
|
||||||
// E-Mail senden
|
|
||||||
await this.mailerService.sendMail({
|
|
||||||
to: email,
|
|
||||||
from: '"Bizmatch Team" <info@bizmatch.net>',
|
|
||||||
subject: 'Verify your email address',
|
|
||||||
template: join(__dirname, '../..', 'mail/templates/email-verification.hbs'),
|
|
||||||
context: {
|
|
||||||
actionLink: customActionLink,
|
|
||||||
randomNumber: randomNumber
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending verification email:', error);
|
|
||||||
throw new BadRequestException('Failed to send verification email');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async sendRequest(mailInfo: MailInfo): Promise<void | ErrorResponse> {
|
async sendRequest(mailInfo: MailInfo): Promise<void | ErrorResponse> {
|
||||||
try {
|
try {
|
||||||
SenderSchema.parse(mailInfo.sender);
|
const validatedSender = 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 => ({
|
||||||
|
|
@ -140,48 +81,4 @@ 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"> <!-- utf-8 works for most cases -->
|
|
||||||
<meta name="viewport" content="width=device-width"> <!-- Forcing initial-scale shouldn't be necessary -->
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <!-- Use the latest (edge) version of IE rendering engine -->
|
|
||||||
<meta name="x-apple-disable-message-reformatting"> <!-- Disable auto-scale in iOS 10 Mail entirely -->
|
|
||||||
<title>Email address verification</title> <!-- The title tag shows in email notifications, like Android 4.4. -->
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- CSS Reset : BEGIN -->
|
|
||||||
<style>
|
|
||||||
/* What it does: Remove spaces around the email design added by some email clients. */
|
|
||||||
/* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0 auto !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
height: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
background: #f1f1f1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Stops email clients resizing small text. */
|
|
||||||
* {
|
|
||||||
-ms-text-size-adjust: 100%;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Centers email on Android 4.4 */
|
|
||||||
div[style*="margin: 16px 0"] {
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Stops Outlook from adding extra spacing to tables. */
|
|
||||||
table,
|
|
||||||
td {
|
|
||||||
mso-table-lspace: 0pt !important;
|
|
||||||
mso-table-rspace: 0pt !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Fixes webkit padding issue. */
|
|
||||||
table {
|
|
||||||
border-spacing: 0 !important;
|
|
||||||
border-collapse: collapse !important;
|
|
||||||
table-layout: fixed !important;
|
|
||||||
margin: 0 auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Uses a better rendering method when resizing images in IE. */
|
|
||||||
img {
|
|
||||||
-ms-interpolation-mode: bicubic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Prevents Windows 10 Mail from underlining links despite inline CSS. Styles for underlined links should be inline. */
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: A work-around for email clients meddling in triggered links. */
|
|
||||||
*[x-apple-data-detectors],
|
|
||||||
/* iOS */
|
|
||||||
.unstyle-auto-detected-links *,
|
|
||||||
.aBn {
|
|
||||||
border-bottom: 0 !important;
|
|
||||||
cursor: default !important;
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
font-size: inherit !important;
|
|
||||||
font-family: inherit !important;
|
|
||||||
font-weight: inherit !important;
|
|
||||||
line-height: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */
|
|
||||||
.a6S {
|
|
||||||
display: none !important;
|
|
||||||
opacity: 0.01 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Prevents Gmail from changing the text color in conversation threads. */
|
|
||||||
.im {
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If the above doesn't work, add a .g-img class to any image in question. */
|
|
||||||
img.g-img+div {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */
|
|
||||||
/* Create one of these media queries for each additional viewport size you'd like to fix */
|
|
||||||
|
|
||||||
/* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
|
|
||||||
@media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
|
|
||||||
u~div .email-container {
|
|
||||||
min-width: 320px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* iPhone 6, 6S, 7, 8, and X */
|
|
||||||
@media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
|
|
||||||
u~div .email-container {
|
|
||||||
min-width: 375px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* iPhone 6+, 7+, and 8+ */
|
|
||||||
@media only screen and (min-device-width: 414px) {
|
|
||||||
u~div .email-container {
|
|
||||||
min-width: 414px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!-- CSS Reset : END -->
|
|
||||||
|
|
||||||
<!-- Progressive Enhancements : BEGIN -->
|
|
||||||
<style>
|
|
||||||
.primary {
|
|
||||||
background: #30e3ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_white {
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_light {
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_black {
|
|
||||||
background: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_dark {
|
|
||||||
background: rgba(0, 0, 0, .8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-section {
|
|
||||||
padding: 2.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Lato', sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: rgba(0, 0, 0, .4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*HERO*/
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero .text {
|
|
||||||
color: rgba(0, 0, 0, .3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero .text h2 {
|
|
||||||
color: #000;
|
|
||||||
font-size: 40px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero .text h3 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero .text h2 span {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #30e3ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-body {
|
|
||||||
display: block;
|
|
||||||
color: black;
|
|
||||||
line-height: 32px;
|
|
||||||
font-weight: 300;
|
|
||||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, sans-serif;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width:400px) {
|
|
||||||
.hero img {
|
|
||||||
width: 200px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body width="100%"
|
|
||||||
style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #f1f1f1; display: flex; align-items: center; justify-content: center;">
|
|
||||||
<div style="width: 100%; background-color: #f1f1f1;">
|
|
||||||
<div
|
|
||||||
style="display: none; font-size: 1px;max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;">
|
|
||||||
Hello, click on the button below to verify your email address
|
|
||||||
</div>
|
|
||||||
<div style="max-width: 600px; margin: 0 auto;" class="email-container">
|
|
||||||
<!-- BEGIN BODY -->
|
|
||||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
|
|
||||||
style="margin: auto;">
|
|
||||||
<tr>
|
|
||||||
<td valign="middle" class="hero bg_white" style="padding: 3em 0 2em 0;">
|
|
||||||
<img src="https://github.com/ColorlibHQ/email-templates/blob/master/10/images/email.png?raw=true"
|
|
||||||
alt="" class="g-img" style="width: 200px; height: auto; margin: auto; display: block;">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- end tr -->
|
|
||||||
<tr>
|
|
||||||
<td valign="middle" class="hero bg_white" style="padding: 2em 0 4em 0;">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="text" style="padding: 0 2.5em; text-align: center;">
|
|
||||||
<h2 style="margin-bottom: 20px; font-size: 32px;">Verify your email address</h2>
|
|
||||||
<p class="email-body">
|
|
||||||
Thanks for signup with us. Click on the button below to verify your email
|
|
||||||
address.
|
|
||||||
</p>
|
|
||||||
<a href="{{actionLink}}" target="_blank"
|
|
||||||
style="padding:15px 40px; background-color: #5D91E8; color: white;">Verify
|
|
||||||
your email</a>
|
|
||||||
<p class="email-body">
|
|
||||||
If this email wasn't intended for you feel free to delete it.<br />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- end tr -->
|
|
||||||
<span style="color: #f1f1f1; display: none;">{{randomNumber}}</span>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,59 +1,18 @@
|
||||||
import { LoggerService } from '@nestjs/common';
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import helmet from 'helmet';
|
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' }));
|
|
||||||
// Serve static files from pictures directory
|
|
||||||
app.use('/pictures', express.static('pictures'));
|
|
||||||
|
|
||||||
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, x-hide-loading',
|
allowedHeaders: 'Content-Type, Accept, Authorization',
|
||||||
});
|
});
|
||||||
|
//origin: 'http://localhost:4200',
|
||||||
// Security Headers with helmet
|
await app.listen(3000);
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://fonts.googleapis.com"],
|
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
|
||||||
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
|
||||||
connectSrc: ["'self'", "https://api.bizmatch.net", "https://*.firebaseapp.com", "https://*.googleapis.com"],
|
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
mediaSrc: ["'self'"],
|
|
||||||
frameSrc: ["'self'"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
crossOriginEmbedderPolicy: false, // Disable for now to avoid breaking existing functionality
|
|
||||||
hsts: {
|
|
||||||
maxAge: 31536000,
|
|
||||||
includeSubDomains: true,
|
|
||||||
preload: true,
|
|
||||||
},
|
|
||||||
frameguard: {
|
|
||||||
action: 'sameorigin', // Allow same-origin framing
|
|
||||||
},
|
|
||||||
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, // Allow popups for OAuth
|
|
||||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allow cross-origin resources
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.listen(process.env.PORT || 3001);
|
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -17,26 +17,20 @@ export interface UserData {
|
||||||
hasCompanyLogo?: boolean;
|
hasCompanyLogo?: boolean;
|
||||||
licensedIn?: string[];
|
licensedIn?: string[];
|
||||||
gender?: 'male' | 'female';
|
gender?: 'male' | 'female';
|
||||||
customerType?: 'buyer' | 'seller' | 'professional';
|
customerType?: 'buyer' | 'broker' | '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' | 'seller' | 'professional';
|
export type CustomerType = 'buyer' | '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', 'seller', 'professional']);
|
export const CustomerTypeEnum = z.enum(['buyer', '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 ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
|
|
||||||
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
|
||||||
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
|
||||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||||
const TypeEnum = z.enum([
|
const TypeEnum = z.enum([
|
||||||
'automotive',
|
'automotive',
|
||||||
|
|
@ -108,73 +102,50 @@ const USStates = z.enum([
|
||||||
'WY',
|
'WY',
|
||||||
]);
|
]);
|
||||||
export const AreasServedSchema = z.object({
|
export const AreasServedSchema = z.object({
|
||||||
county: z.string().optional().nullable(),
|
county: z.string().nonempty('County is required'),
|
||||||
state: z
|
state: z.string().nonempty('State is required'),
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.refine(val => val !== null && val !== '', {
|
|
||||||
message: 'State is required',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LicensedInSchema = z.object({
|
export const LicensedInSchema = z.object({
|
||||||
state: z
|
registerNo: z.string().nonempty('Registration number is required'),
|
||||||
.string()
|
state: z.string().nonempty('State is required'),
|
||||||
.nullable()
|
|
||||||
.refine(val => val !== null && val !== '', {
|
|
||||||
message: 'State is required',
|
|
||||||
}),
|
|
||||||
registerNo: z.string().nonempty('License number is required'),
|
|
||||||
});
|
});
|
||||||
export const GeoSchema = z
|
export const GeoSchema = z.object({
|
||||||
.object({
|
city: z.string(),
|
||||||
name: z.string().optional().nullable(),
|
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
}),
|
||||||
}),
|
latitude: z.number().refine(
|
||||||
latitude: z.number().refine(
|
value => {
|
||||||
value => {
|
return value >= -90 && value <= 90;
|
||||||
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(
|
||||||
longitude: z.number().refine(
|
value => {
|
||||||
value => {
|
return value >= -180 && value <= 180;
|
||||||
return value >= -180 && value <= 180;
|
},
|
||||||
},
|
{
|
||||||
{
|
message: 'Longitude muss zwischen -180 und 180 liegen',
|
||||||
message: 'Longitude muss zwischen -180 und 180 liegen',
|
},
|
||||||
},
|
),
|
||||||
),
|
});
|
||||||
county: z.string().optional().nullable(),
|
const phoneRegex = /^\(\d{3}\)\s\d{3}-\d{4}$/;
|
||||||
housenumber: z.string().optional().nullable(),
|
|
||||||
street: z.string().optional().nullable(),
|
|
||||||
zipCode: z.number().optional().nullable(),
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
if (!data.state) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'You need to select at least a state',
|
|
||||||
path: ['name'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
|
|
||||||
export const UserSchema = z
|
export const UserSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().uuid().optional().nullable(),
|
id: z.string().uuid().optional().nullable(),
|
||||||
firstname: z.string().min(3, { message: 'First name must contain at least 2 characters' }),
|
firstname: z.string().min(2, { message: 'First name must contain at least 2 characters' }),
|
||||||
lastname: z.string().min(3, { message: 'Last name must contain at least 2 characters' }),
|
lastname: z.string().min(2, { message: 'Last name must contain at least 2 characters' }),
|
||||||
email: z.string().email({ message: 'Invalid email address' }),
|
email: z.string().email({ message: 'Invalid email address' }),
|
||||||
phoneNumber: z.string().optional().nullable(),
|
phoneNumber: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
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(),
|
||||||
location: GeoSchema.optional().nullable(),
|
companyLocation: 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(),
|
||||||
|
|
@ -185,10 +156,6 @@ 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(),
|
|
||||||
favoritesForUser: z.array(z.string()),
|
|
||||||
showInDirectory: z.boolean(),
|
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.customerType === 'professional') {
|
if (data.customerType === 'professional') {
|
||||||
|
|
@ -199,13 +166,7 @@ export const UserSchema = z
|
||||||
path: ['customerSubType'],
|
path: ['customerSubType'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!data.companyName || data.companyName.length < 6) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Company Name must contain at least 6 characters for professional customers',
|
|
||||||
path: ['companyName'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
|
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
|
|
@ -238,11 +199,11 @@ export const UserSchema = z
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.location) {
|
if (!data.companyLocation) {
|
||||||
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',
|
||||||
path: ['location'],
|
path: ['companyLocation'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,62 +221,35 @@ export type AreasServed = z.infer<typeof AreasServedSchema>;
|
||||||
export type LicensedIn = z.infer<typeof LicensedInSchema>;
|
export type LicensedIn = z.infer<typeof LicensedInSchema>;
|
||||||
export type User = z.infer<typeof UserSchema>;
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
|
||||||
export const BusinessListingSchema = z
|
export const BusinessListingSchema = z.object({
|
||||||
.object({
|
id: z.string().uuid().optional().nullable(),
|
||||||
id: z.string().uuid().optional().nullable(),
|
email: z.string().email(),
|
||||||
email: z.string().email(),
|
type: z.string().refine(val => TypeEnum.safeParse(val).success, {
|
||||||
type: z.string().refine(val => TypeEnum.safeParse(val).success, {
|
message: 'Invalid type. Must be one of: ' + TypeEnum.options.join(', '),
|
||||||
message: 'Invalid type. Must be one of: ' + TypeEnum.options.join(', '),
|
}),
|
||||||
}),
|
title: z.string().min(10),
|
||||||
title: z.string().min(10),
|
description: z.string().min(10),
|
||||||
description: z.string().min(10),
|
location: GeoSchema,
|
||||||
location: GeoSchema,
|
price: z.number().positive().max(1000000000),
|
||||||
price: z.number().positive().optional().nullable(),
|
favoritesForUser: z.array(z.string()),
|
||||||
favoritesForUser: z.array(z.string()),
|
draft: z.boolean(),
|
||||||
draft: z.boolean(),
|
listingsCategory: ListingsCategoryEnum,
|
||||||
listingsCategory: ListingsCategoryEnum,
|
realEstateIncluded: z.boolean().optional().nullable(),
|
||||||
realEstateIncluded: z.boolean().optional().nullable(),
|
leasedLocation: z.boolean().optional().nullable(),
|
||||||
leasedLocation: z.boolean().optional().nullable(),
|
franchiseResale: z.boolean().optional().nullable(),
|
||||||
franchiseResale: z.boolean().optional().nullable(),
|
salesRevenue: z.number().positive().max(100000000),
|
||||||
salesRevenue: z.number().positive().nullable(),
|
cashFlow: z.number().positive().max(100000000),
|
||||||
cashFlow: z.number().optional().nullable(),
|
supportAndTraining: z.string().min(5),
|
||||||
ffe: z.number().optional().nullable(),
|
employees: z.number().int().positive().max(100000).optional().nullable(),
|
||||||
inventory: z.number().optional().nullable(),
|
established: z.number().int().min(1800).max(2030).optional().nullable(),
|
||||||
supportAndTraining: z.string().min(5).optional().nullable(),
|
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||||
employees: z.number().int().positive().max(100000).optional().nullable(),
|
reasonForSale: z.string().min(5).optional().nullable(),
|
||||||
established: z.number().int().min(1).max(250).optional().nullable(),
|
brokerLicencing: z.string().min(5).optional().nullable(),
|
||||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
internals: z.string().min(5).optional().nullable(),
|
||||||
reasonForSale: z.string().min(5).optional().nullable(),
|
imageName: z.string().optional().nullable(),
|
||||||
brokerLicencing: z.string().optional().nullable(),
|
created: z.date(),
|
||||||
internals: z.string().min(5).optional().nullable(),
|
updated: z.date(),
|
||||||
imageName: z.string().optional().nullable(),
|
});
|
||||||
slug: z.string().optional().nullable(),
|
|
||||||
created: z.date(),
|
|
||||||
updated: z.date(),
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
if (data.price && data.price > 1000000000) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Price must less than or equal $1,000,000,000',
|
|
||||||
path: ['price'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (data.salesRevenue && data.salesRevenue > 100000000) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'SalesRevenue must less than or equal $100,000,000',
|
|
||||||
path: ['salesRevenue'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (data.cashFlow && data.cashFlow > 100000000) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'CashFlow must less than or equal $100,000,000',
|
|
||||||
path: ['cashFlow'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export type BusinessListing = z.infer<typeof BusinessListingSchema>;
|
export type BusinessListing = z.infer<typeof BusinessListingSchema>;
|
||||||
|
|
||||||
export const CommercialPropertyListingSchema = z
|
export const CommercialPropertyListingSchema = z
|
||||||
|
|
@ -329,26 +263,16 @@ export const CommercialPropertyListingSchema = z
|
||||||
title: z.string().min(10),
|
title: z.string().min(10),
|
||||||
description: z.string().min(10),
|
description: z.string().min(10),
|
||||||
location: GeoSchema,
|
location: GeoSchema,
|
||||||
price: z.number().positive().optional().nullable(),
|
price: z.number().positive().max(1000000000),
|
||||||
favoritesForUser: z.array(z.string()),
|
favoritesForUser: z.array(z.string()),
|
||||||
listingsCategory: ListingsCategoryEnum,
|
listingsCategory: ListingsCategoryEnum,
|
||||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
|
||||||
draft: z.boolean(),
|
draft: z.boolean(),
|
||||||
imageOrder: z.array(z.string()),
|
imageOrder: z.array(z.string()),
|
||||||
imagePath: z.string().nullable().optional(),
|
imagePath: z.string().nullable().optional(),
|
||||||
slug: z.string().optional().nullable(),
|
|
||||||
created: z.date(),
|
created: z.date(),
|
||||||
updated: z.date(),
|
updated: z.date(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.strict();
|
||||||
if (data.price && data.price > 1000000000) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Price must less than or equal $1,000,000,000',
|
|
||||||
path: ['price'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;
|
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;
|
||||||
|
|
||||||
|
|
@ -364,30 +288,3 @@ 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: ShareCategoryEnum,
|
|
||||||
});
|
|
||||||
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
|
||||||
|
|
||||||
export const ListingEventSchema = z.object({
|
|
||||||
id: z.string().uuid(), // UUID für das Event
|
|
||||||
listingId: z.string().uuid().optional().nullable(), // UUID für das Listing
|
|
||||||
email: z.string().email().optional().nullable(), // EMail des den Benutzer, optional, wenn kein Benutzer eingeloggt ist
|
|
||||||
eventType: ZodEventTypeEnum, // Die Event-Typen
|
|
||||||
eventTimestamp: z.string().datetime().or(z.date()), // Der Zeitstempel des Events, kann ein String im ISO-Format oder ein Date-Objekt sein
|
|
||||||
userIp: z.string().max(45).optional().nullable(), // IP-Adresse des Benutzers, optional
|
|
||||||
userAgent: z.string().max(255).optional().nullable(), // User-Agent des Benutzers, optional
|
|
||||||
locationCountry: z.string().max(100).optional().nullable(), // Land, optional
|
|
||||||
locationCity: z.string().max(100).optional().nullable(), // Stadt, optional
|
|
||||||
locationLat: z.string().max(20).optional().nullable(), // Latitude, als String
|
|
||||||
locationLng: z.string().max(20).optional().nullable(), // Longitude, als String
|
|
||||||
referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional
|
|
||||||
additionalData: z.record(z.string(), z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
|
|
||||||
});
|
|
||||||
export type ListingEvent = z.infer<typeof ListingEventSchema>;
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model';
|
import { BusinessListing, CommercialPropertyListing, Sender, User } from './db.model.js';
|
||||||
import { State } from './server.model';
|
|
||||||
|
|
||||||
export interface StatesResult {
|
export interface StatesResult {
|
||||||
state: string;
|
state: string;
|
||||||
|
|
@ -10,12 +9,6 @@ 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;
|
||||||
|
|
@ -66,13 +59,12 @@ export interface ListCriteria {
|
||||||
page: number;
|
page: number;
|
||||||
types: string[];
|
types: string[];
|
||||||
state: string;
|
state: string;
|
||||||
city: GeoResult;
|
city: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
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;
|
||||||
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
sortBy?: SortByOptions;
|
|
||||||
}
|
}
|
||||||
export interface BusinessListingCriteria extends ListCriteria {
|
export interface BusinessListingCriteria extends ListCriteria {
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
|
|
@ -83,24 +75,24 @@ export interface BusinessListingCriteria extends ListCriteria {
|
||||||
maxCashFlow: number;
|
maxCashFlow: number;
|
||||||
minNumberEmployees: number;
|
minNumberEmployees: number;
|
||||||
maxNumberEmployees: number;
|
maxNumberEmployees: number;
|
||||||
establishedMin: number;
|
establishedSince: number;
|
||||||
|
establishedUntil: number;
|
||||||
realEstateChecked: boolean;
|
realEstateChecked: boolean;
|
||||||
leasedLocation: boolean;
|
leasedLocation: boolean;
|
||||||
franchiseResale: boolean;
|
franchiseResale: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
brokerName: string;
|
brokerName: string;
|
||||||
email: string;
|
|
||||||
criteriaType: 'businessListings';
|
criteriaType: 'businessListings';
|
||||||
}
|
}
|
||||||
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
title: string;
|
title: string;
|
||||||
brokerName: string;
|
|
||||||
criteriaType: 'commercialPropertyListings';
|
criteriaType: 'commercialPropertyListings';
|
||||||
}
|
}
|
||||||
export interface UserListingCriteria extends ListCriteria {
|
export interface UserListingCriteria extends ListCriteria {
|
||||||
brokerName: string;
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
counties: string[];
|
counties: string[];
|
||||||
criteriaType: 'brokerListings';
|
criteriaType: 'brokerListings';
|
||||||
|
|
@ -120,16 +112,13 @@ export interface KeycloakUser {
|
||||||
requiredActions?: any[];
|
requiredActions?: any[];
|
||||||
notBefore?: number;
|
notBefore?: number;
|
||||||
access?: Access;
|
access?: Access;
|
||||||
attributes?: Attributes;
|
|
||||||
}
|
}
|
||||||
export interface JwtUser {
|
export interface JwtUser {
|
||||||
email: string;
|
userId: string;
|
||||||
role: string;
|
username: string;
|
||||||
uid: string;
|
firstname: string;
|
||||||
}
|
lastname: string;
|
||||||
interface Attributes {
|
roles: string[];
|
||||||
[key: string]: any;
|
|
||||||
priceID: any;
|
|
||||||
}
|
}
|
||||||
export interface Access {
|
export interface Access {
|
||||||
manageGroupMembership: boolean;
|
manageGroupMembership: boolean;
|
||||||
|
|
@ -177,7 +166,6 @@ 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;
|
||||||
|
|
@ -236,66 +224,24 @@ export interface UploadParams {
|
||||||
}
|
}
|
||||||
export interface GeoResult {
|
export interface GeoResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
city: string;
|
||||||
street?: string;
|
|
||||||
housenumber?: string;
|
|
||||||
county?: string;
|
|
||||||
zipCode?: number;
|
|
||||||
state: string;
|
state: string;
|
||||||
|
// state_code: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
interface CityResult {
|
export interface CityAndStateResult {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'city';
|
name: string;
|
||||||
content: GeoResult;
|
type: string;
|
||||||
|
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 type UserRole = 'admin' | 'pro' | 'guest' | null;
|
|
||||||
export interface FirebaseUserInfo {
|
|
||||||
uid: string;
|
|
||||||
email: string | null;
|
|
||||||
displayName: string | null;
|
|
||||||
photoURL: string | null;
|
|
||||||
phoneNumber: string | null;
|
|
||||||
disabled: boolean;
|
|
||||||
emailVerified: boolean;
|
|
||||||
role: UserRole;
|
|
||||||
creationTime?: string;
|
|
||||||
lastSignInTime?: string;
|
|
||||||
customClaims?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsersResponse {
|
|
||||||
users: FirebaseUserInfo[];
|
|
||||||
totalCount: number;
|
|
||||||
pageToken?: string;
|
|
||||||
}
|
|
||||||
export function isEmpty(value: any): boolean {
|
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) {
|
||||||
|
|
@ -335,7 +281,7 @@ export interface ValidationMessage {
|
||||||
field: string;
|
field: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
|
export function createDefaultUser(email: string, firstname: string, lastname: string): User {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
email,
|
email,
|
||||||
|
|
@ -346,7 +292,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
|
||||||
companyName: null,
|
companyName: null,
|
||||||
companyOverview: null,
|
companyOverview: null,
|
||||||
companyWebsite: null,
|
companyWebsite: null,
|
||||||
location: null,
|
companyLocation: null,
|
||||||
offeredServices: null,
|
offeredServices: null,
|
||||||
areasServed: [],
|
areasServed: [],
|
||||||
hasProfile: false,
|
hasProfile: false,
|
||||||
|
|
@ -357,10 +303,6 @@ 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,
|
|
||||||
favoritesForUser: [],
|
|
||||||
showInDirectory: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
||||||
|
|
@ -410,21 +352,3 @@ export function createDefaultBusinessListing(): BusinessListing {
|
||||||
listingsCategory: 'business',
|
listingsCategory: 'business',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type IpInfo = {
|
|
||||||
ip: string;
|
|
||||||
city: string;
|
|
||||||
region: string;
|
|
||||||
country: string;
|
|
||||||
loc: string; // Coordinates in "latitude,longitude" format
|
|
||||||
org: string;
|
|
||||||
postal: string;
|
|
||||||
timezone: string;
|
|
||||||
};
|
|
||||||
export interface CombinedUser {
|
|
||||||
keycloakUser?: KeycloakUser;
|
|
||||||
appUser?: User;
|
|
||||||
}
|
|
||||||
export interface RealIpInfo {
|
|
||||||
ip: string;
|
|
||||||
countryCode?: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -70,34 +70,3 @@ 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];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,25 @@
|
||||||
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 { ip, countryCode } = getRealIpInfo(req);
|
const start = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
// const duration = Date.now() - start;
|
||||||
|
// this.logger.log(`${req.method} ${req.url} - ${duration}ms`);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
let logMessage = `${req.method} ${req.url} - ${duration}ms`;
|
||||||
|
|
||||||
// Setze die IP-Adresse und den Ländercode im CLS-Kontext
|
if (req.method === 'POST' || req.method === 'PUT') {
|
||||||
try {
|
const body = JSON.stringify(req.body);
|
||||||
this.cls.set('ip', ip);
|
logMessage += ` - Body: ${body}`;
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
|
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
||||||
import { Client } from 'pg';
|
|
||||||
import * as schema from '../drizzle/schema';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
connectionString: process.env.PG_CONNECTION,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await client.connect();
|
|
||||||
const db = drizzle(client, { schema });
|
|
||||||
|
|
||||||
const testEmail = 'knuth.timo@gmail.com';
|
|
||||||
const targetEmail = 'target.user@example.com';
|
|
||||||
|
|
||||||
console.log('--- Starting Debug Script ---');
|
|
||||||
|
|
||||||
// 1. Simulate finding a user to favorite (using a dummy or existing one)
|
|
||||||
// For safety, let's just query existing users to see if any have favorites set
|
|
||||||
const usersWithFavorites = await db.select({
|
|
||||||
id: schema.users_json.id,
|
|
||||||
email: schema.users_json.email,
|
|
||||||
favorites: sql`${schema.users_json.data}->'favoritesForUser'`
|
|
||||||
}).from(schema.users_json);
|
|
||||||
|
|
||||||
console.log(`Found ${usersWithFavorites.length} users.`);
|
|
||||||
|
|
||||||
const usersWithAnyFavorites = usersWithFavorites.filter(u => u.favorites !== null);
|
|
||||||
console.log(`Users with 'favoritesForUser' field:`, JSON.stringify(usersWithAnyFavorites, null, 2));
|
|
||||||
|
|
||||||
// 2. Test the specific WHERE clause used in the service
|
|
||||||
// .where(sql`${schema.users_json.data}->'favoritesForUser' @> ${JSON.stringify([user.email])}::jsonb`);
|
|
||||||
|
|
||||||
console.log(`Testing query for email: ${testEmail}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await db
|
|
||||||
.select({
|
|
||||||
id: schema.users_json.id,
|
|
||||||
email: schema.users_json.email
|
|
||||||
})
|
|
||||||
.from(schema.users_json)
|
|
||||||
.where(sql`${schema.users_json.data}->'favoritesForUser' @> ${JSON.stringify([testEmail])}::jsonb`);
|
|
||||||
|
|
||||||
console.log('Query Result:', result);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Query Failed:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
|
|
||||||
|
|
||||||
//test
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { SelectOptionsService } from './select-options.service.js';
|
||||||
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(OptionalAuthGuard)
|
|
||||||
@Get()
|
@Get()
|
||||||
getSelectOption(): any {
|
getSelectOption(): any {
|
||||||
return {
|
return {
|
||||||
|
|
@ -18,7 +15,6 @@ 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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { FirebaseAdminModule } from '../firebase-admin/firebase-admin.module';
|
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({
|
||||||
imports: [FirebaseAdminModule],
|
controllers: [SelectOptionsController],
|
||||||
controllers: [SelectOptionsController],
|
providers: [SelectOptionsService]
|
||||||
providers: [SelectOptionsService],
|
})
|
||||||
})
|
|
||||||
export class SelectOptionsModule {}
|
export class SelectOptionsModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ImageType, KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../models/main.model';
|
import { ImageType, KeyValue, KeyValueStyle } from '../models/main.model.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SelectOptionsService {
|
export class SelectOptionsService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
public typesOfBusiness: Array<KeyValueStyle> = [
|
public typesOfBusiness: Array<KeyValueStyle> = [
|
||||||
{ name: 'Automotive', value: 'automotive', oldValue: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-500' },
|
{ name: 'Automotive', value: 'automotive', oldValue: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
|
||||||
{ name: 'Industrial Services', value: 'industrialServices', oldValue: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
|
{ name: 'Industrial Services', value: 'industrialServices', oldValue: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
|
||||||
{ name: 'Food and Restaurant', value: 'foodAndRestaurant', oldValue: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
|
{ name: 'Food and Restaurant', value: 'foodAndRestaurant', oldValue: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
|
||||||
{ name: 'Real Estate', value: 'realEstate', oldValue: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
{ name: 'Real Estate', value: 'realEstate', oldValue: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
||||||
|
|
@ -35,19 +35,7 @@ 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' },
|
||||||
|
|
@ -64,7 +52,6 @@ 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> = [
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Command, CommandRunner } from 'nest-commander';
|
|
||||||
import { AuthService } from './auth/auth.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
@Command({ name: 'setup-admin', description: 'Set up the first admin user' })
|
|
||||||
export class SetupAdminCommand extends CommandRunner {
|
|
||||||
constructor(private readonly authService: AuthService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async run(passedParams: string[]): Promise<void> {
|
|
||||||
if (passedParams.length < 1) {
|
|
||||||
console.error('Please provide a user UID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uid = passedParams[0];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.authService.setUserRole(uid, 'admin');
|
|
||||||
console.log(`User ${uid} has been set as admin`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting admin role:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common';
|
|
||||||
import { SitemapService } from './sitemap.service';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class SitemapController {
|
|
||||||
constructor(private readonly sitemapService: SitemapService) { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main sitemap index - lists all sitemap files
|
|
||||||
* Route: /sitemap.xml
|
|
||||||
*/
|
|
||||||
@Get('sitemap.xml')
|
|
||||||
@Header('Content-Type', 'application/xml')
|
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
|
||||||
async getSitemapIndex(): Promise<string> {
|
|
||||||
return await this.sitemapService.generateSitemapIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static pages sitemap
|
|
||||||
* Route: /sitemap/static.xml
|
|
||||||
*/
|
|
||||||
@Get('sitemap/static.xml')
|
|
||||||
@Header('Content-Type', 'application/xml')
|
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
|
||||||
async getStaticSitemap(): Promise<string> {
|
|
||||||
return await this.sitemapService.generateStaticSitemap();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Business listings sitemap (paginated)
|
|
||||||
* Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc.
|
|
||||||
*/
|
|
||||||
@Get('sitemap/business-:page.xml')
|
|
||||||
@Header('Content-Type', 'application/xml')
|
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
|
||||||
async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
|
||||||
return await this.sitemapService.generateBusinessSitemap(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Commercial property sitemap (paginated)
|
|
||||||
* Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc.
|
|
||||||
*/
|
|
||||||
@Get('sitemap/commercial-:page.xml')
|
|
||||||
@Header('Content-Type', 'application/xml')
|
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
|
||||||
async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
|
||||||
return await this.sitemapService.generateCommercialSitemap(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broker profiles sitemap (paginated)
|
|
||||||
* Route: /sitemap/brokers-1.xml, /sitemap/brokers-2.xml, etc.
|
|
||||||
*/
|
|
||||||
@Get('sitemap/brokers-:page.xml')
|
|
||||||
@Header('Content-Type', 'application/xml')
|
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
|
||||||
async getBrokerSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
|
||||||
return await this.sitemapService.generateBrokerSitemap(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { SitemapController } from './sitemap.controller';
|
|
||||||
import { SitemapService } from './sitemap.service';
|
|
||||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [DrizzleModule],
|
|
||||||
controllers: [SitemapController],
|
|
||||||
providers: [SitemapService],
|
|
||||||
exports: [SitemapService],
|
|
||||||
})
|
|
||||||
export class SitemapModule {}
|
|
||||||
|
|
@ -1,362 +0,0 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { eq, sql } from 'drizzle-orm';
|
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
||||||
import * as schema from '../drizzle/schema';
|
|
||||||
import { PG_CONNECTION } from '../drizzle/schema';
|
|
||||||
|
|
||||||
interface SitemapUrl {
|
|
||||||
loc: string;
|
|
||||||
lastmod?: string;
|
|
||||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SitemapIndexEntry {
|
|
||||||
loc: string;
|
|
||||||
lastmod?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SitemapService {
|
|
||||||
private readonly baseUrl = 'https://www.bizmatch.net';
|
|
||||||
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
|
||||||
|
|
||||||
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate sitemap index (main sitemap.xml)
|
|
||||||
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
|
|
||||||
*/
|
|
||||||
async generateSitemapIndex(): Promise<string> {
|
|
||||||
const sitemaps: SitemapIndexEntry[] = [];
|
|
||||||
|
|
||||||
// Add static pages sitemap
|
|
||||||
sitemaps.push({
|
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
|
|
||||||
lastmod: this.formatDate(new Date()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Count business listings
|
|
||||||
const businessCount = await this.getBusinessListingsCount();
|
|
||||||
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
|
|
||||||
for (let page = 1; page <= businessPages; page++) {
|
|
||||||
sitemaps.push({
|
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
|
|
||||||
lastmod: this.formatDate(new Date()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count commercial property listings
|
|
||||||
const commercialCount = await this.getCommercialPropertiesCount();
|
|
||||||
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
|
|
||||||
for (let page = 1; page <= commercialPages; page++) {
|
|
||||||
sitemaps.push({
|
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
|
|
||||||
lastmod: this.formatDate(new Date()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count broker profiles
|
|
||||||
const brokerCount = await this.getBrokerProfilesCount();
|
|
||||||
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
|
|
||||||
for (let page = 1; page <= brokerPages; page++) {
|
|
||||||
sitemaps.push({
|
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
|
|
||||||
lastmod: this.formatDate(new Date()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.buildXmlSitemapIndex(sitemaps);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate static pages sitemap
|
|
||||||
*/
|
|
||||||
async generateStaticSitemap(): Promise<string> {
|
|
||||||
const urls = this.getStaticPageUrls();
|
|
||||||
return this.buildXmlSitemap(urls);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate business listings sitemap (paginated)
|
|
||||||
*/
|
|
||||||
async generateBusinessSitemap(page: number): Promise<string> {
|
|
||||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
|
||||||
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
|
|
||||||
return this.buildXmlSitemap(urls);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate commercial property sitemap (paginated)
|
|
||||||
*/
|
|
||||||
async generateCommercialSitemap(page: number): Promise<string> {
|
|
||||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
|
||||||
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
|
|
||||||
return this.buildXmlSitemap(urls);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build XML sitemap index
|
|
||||||
*/
|
|
||||||
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
|
|
||||||
const sitemapElements = sitemaps
|
|
||||||
.map(sitemap => {
|
|
||||||
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
|
|
||||||
if (sitemap.lastmod) {
|
|
||||||
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
|
|
||||||
}
|
|
||||||
element += '\n </sitemap>';
|
|
||||||
return element;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
${sitemapElements}
|
|
||||||
</sitemapindex>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build XML sitemap string
|
|
||||||
*/
|
|
||||||
private buildXmlSitemap(urls: SitemapUrl[]): string {
|
|
||||||
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
${urlElements}
|
|
||||||
</urlset>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build single URL element
|
|
||||||
*/
|
|
||||||
private buildUrlElement(url: SitemapUrl): string {
|
|
||||||
let element = `<url>\n <loc>${url.loc}</loc>`;
|
|
||||||
|
|
||||||
if (url.lastmod) {
|
|
||||||
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.changefreq) {
|
|
||||||
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.priority !== undefined) {
|
|
||||||
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
element += '\n </url>';
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get static page URLs
|
|
||||||
*/
|
|
||||||
private getStaticPageUrls(): SitemapUrl[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/`,
|
|
||||||
changefreq: 'daily',
|
|
||||||
priority: 1.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/home`,
|
|
||||||
changefreq: 'daily',
|
|
||||||
priority: 1.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/businessListings`,
|
|
||||||
changefreq: 'daily',
|
|
||||||
priority: 0.9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/commercialPropertyListings`,
|
|
||||||
changefreq: 'daily',
|
|
||||||
priority: 0.9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/brokerListings`,
|
|
||||||
changefreq: 'daily',
|
|
||||||
priority: 0.8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/terms-of-use`,
|
|
||||||
changefreq: 'monthly',
|
|
||||||
priority: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loc: `${this.baseUrl}/privacy-statement`,
|
|
||||||
changefreq: 'monthly',
|
|
||||||
priority: 0.5,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count business listings (non-draft)
|
|
||||||
*/
|
|
||||||
private async getBusinessListingsCount(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const result = await this.db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(schema.businesses_json)
|
|
||||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
|
||||||
|
|
||||||
return Number(result[0]?.count || 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error counting business listings:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count commercial properties (non-draft)
|
|
||||||
*/
|
|
||||||
private async getCommercialPropertiesCount(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const result = await this.db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(schema.commercials_json)
|
|
||||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
|
||||||
|
|
||||||
return Number(result[0]?.count || 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error counting commercial properties:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get business listing URLs from database (paginated, slug-based)
|
|
||||||
*/
|
|
||||||
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
|
||||||
try {
|
|
||||||
const listings = await this.db
|
|
||||||
.select({
|
|
||||||
id: schema.businesses_json.id,
|
|
||||||
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
|
|
||||||
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
|
|
||||||
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
|
|
||||||
})
|
|
||||||
.from(schema.businesses_json)
|
|
||||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return listings.map(listing => {
|
|
||||||
const urlSlug = listing.slug || listing.id;
|
|
||||||
return {
|
|
||||||
loc: `${this.baseUrl}/business/${urlSlug}`,
|
|
||||||
lastmod: this.formatDate(listing.updated || listing.created),
|
|
||||||
changefreq: 'weekly' as const,
|
|
||||||
priority: 0.8,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching business listings for sitemap:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get commercial property URLs from database (paginated, slug-based)
|
|
||||||
*/
|
|
||||||
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
|
||||||
try {
|
|
||||||
const properties = await this.db
|
|
||||||
.select({
|
|
||||||
id: schema.commercials_json.id,
|
|
||||||
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
|
|
||||||
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
|
|
||||||
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
|
|
||||||
})
|
|
||||||
.from(schema.commercials_json)
|
|
||||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return properties.map(property => {
|
|
||||||
const urlSlug = property.slug || property.id;
|
|
||||||
return {
|
|
||||||
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
|
|
||||||
lastmod: this.formatDate(property.updated || property.created),
|
|
||||||
changefreq: 'weekly' as const,
|
|
||||||
priority: 0.8,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching commercial properties for sitemap:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date to ISO 8601 format (YYYY-MM-DD)
|
|
||||||
*/
|
|
||||||
private formatDate(date: Date | string): string {
|
|
||||||
if (!date) return new Date().toISOString().split('T')[0];
|
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
|
||||||
return d.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate broker profiles sitemap (paginated)
|
|
||||||
*/
|
|
||||||
async generateBrokerSitemap(page: number): Promise<string> {
|
|
||||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
|
||||||
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
|
|
||||||
return this.buildXmlSitemap(urls);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count broker profiles (professionals with showInDirectory=true)
|
|
||||||
*/
|
|
||||||
private async getBrokerProfilesCount(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const result = await this.db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(schema.users_json)
|
|
||||||
.where(sql`
|
|
||||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
|
||||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
|
||||||
`);
|
|
||||||
|
|
||||||
return Number(result[0]?.count || 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error counting broker profiles:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get broker profile URLs from database (paginated)
|
|
||||||
*/
|
|
||||||
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
|
||||||
try {
|
|
||||||
const brokers = await this.db
|
|
||||||
.select({
|
|
||||||
email: schema.users_json.email,
|
|
||||||
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
|
|
||||||
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
|
|
||||||
})
|
|
||||||
.from(schema.users_json)
|
|
||||||
.where(sql`
|
|
||||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
|
||||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
|
||||||
`)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return brokers.map(broker => ({
|
|
||||||
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
|
|
||||||
lastmod: this.formatDate(broker.updated || broker.created),
|
|
||||||
changefreq: 'weekly' as const,
|
|
||||||
priority: 0.7,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching broker profiles for sitemap:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import { BadRequestException, Body, Controller, Get, Inject, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
|
import { 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 { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { ZodError } from 'zod';
|
import { FileService } from '../file/file.service.js';
|
||||||
import { FileService } from '../file/file.service';
|
import { OptionalJwtAuthGuard } from '../jwt-auth/optional-jwt-auth.guard.js';
|
||||||
|
|
||||||
import { AdminGuard } from 'src/jwt-auth/admin-auth.guard';
|
|
||||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
|
||||||
import { User } from '../models/db.model';
|
import { User } from '../models/db.model';
|
||||||
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model';
|
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model.js';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service.js';
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
|
|
@ -18,70 +14,52 @@ 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(OptionalAuthGuard)
|
|
||||||
@Get()
|
@Get()
|
||||||
async findByMail(@Request() req, @Query('mail') mail: string): Promise<User> {
|
findByMail(@Request() req, @Query('mail') mail: string): any {
|
||||||
const user = await this.userService.getUserByMail(mail, req.user as JwtUser);
|
this.logger.info(`Searching for user with EMail: ${mail}`);
|
||||||
|
const user = this.userService.getUserByMail(mail, req.user as JwtUser);
|
||||||
|
this.logger.info(`Found user: ${JSON.stringify(user)}`);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findById(@Param('id') id: string): Promise<User> {
|
findById(@Param('id') id: string): any {
|
||||||
const user = await this.userService.getUserById(id);
|
this.logger.info(`Searching for user with ID: ${id}`);
|
||||||
|
const user = this.userService.getUserById(id);
|
||||||
|
this.logger.info(`Found user: ${JSON.stringify(user)}`);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
@Get('user/all')
|
|
||||||
async getAllUser(): Promise<User[]> {
|
|
||||||
return await this.userService.getAllUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post()
|
@Post()
|
||||||
async save(@Body() user: any): Promise<User> {
|
save(@Body() user: any): Promise<User> {
|
||||||
try {
|
this.logger.info(`Saving user: ${JSON.stringify(user)}`);
|
||||||
const savedUser = await this.userService.saveUser(user);
|
const savedUser = this.userService.saveUser(user);
|
||||||
return savedUser;
|
this.logger.info(`User persisted: ${JSON.stringify(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(OptionalAuthGuard)
|
|
||||||
@Post('guaranteed')
|
|
||||||
async saveGuaranteed(@Body() user: any): Promise<User> {
|
|
||||||
const savedUser = await this.userService.saveUser(user, false);
|
|
||||||
return savedUser;
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
async find(@Body() criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
find(@Body() criteria: UserListingCriteria): any {
|
||||||
const foundUsers = await this.userService.searchUserListings(criteria);
|
this.logger.info(`Searching for users with criteria: ${JSON.stringify(criteria)}`);
|
||||||
|
const foundUsers = this.userService.searchUserListings(criteria);
|
||||||
|
this.logger.info(`Found users: ${JSON.stringify(foundUsers)}`);
|
||||||
return foundUsers;
|
return foundUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
|
||||||
@Post('findTotal')
|
@Post('findTotal')
|
||||||
async findTotal(@Body() criteria: UserListingCriteria): Promise<number> {
|
findTotal(@Body() criteria: UserListingCriteria): Promise<number> {
|
||||||
return await this.userService.getUserListingsCount(criteria);
|
return this.userService.getUserListingsCount(criteria);
|
||||||
|
}
|
||||||
|
@Get('states/all')
|
||||||
|
async getStates(): Promise<any[]> {
|
||||||
|
this.logger.info(`Getting all states for users`);
|
||||||
|
const result = await this.userService.getStates();
|
||||||
|
this.logger.info(`Found ${result.length} entries`);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Get('subscriptions/:id')
|
@Get('subscriptions/:id')
|
||||||
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
|
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
|
||||||
const subscriptions = [];
|
const subscriptions = this.fileService.getSubscriptions();
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.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 { 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,FirebaseAdminModule],
|
imports: [DrizzleModule, GeoModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService, FileService, GeoService],
|
providers: [UserService, FileService, GeoService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm';
|
import { and, count, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import * as schema from '../drizzle/schema';
|
import { ZodError } from 'zod';
|
||||||
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema';
|
import * as schema from '../drizzle/schema.js';
|
||||||
import { FileService } from '../file/file.service';
|
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema.js';
|
||||||
import { GeoService } from '../geo/geo.service';
|
import { FileService } from '../file/file.service.js';
|
||||||
import { User, UserSchema } from '../models/db.model';
|
import { GeoService } from '../geo/geo.service.js';
|
||||||
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
|
import { User, UserSchema } from '../models/db.model.js';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model.js';
|
||||||
|
import { convertDrizzleUserToUser, convertUserToDrizzleUser, getDistanceQuery } from '../utils.js';
|
||||||
|
|
||||||
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -19,75 +20,60 @@ export class UserService {
|
||||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
|
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
|
whereConditions.push(eq(schema.users.customerType, 'professional'));
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(ilike(schema.users.city, `%${criteria.city}%`));
|
||||||
}
|
}
|
||||||
|
|
||||||
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.name);
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city);
|
||||||
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
|
whereConditions.push(sql`${getDistanceQuery(schema.users, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||||
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
|
|
||||||
}
|
}
|
||||||
if (criteria.types && criteria.types.length > 0) {
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
||||||
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[]));
|
whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.brokerName) {
|
if (criteria.firstname) {
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
whereConditions.push(ilike(schema.users.firstname, `%${criteria.firstname}%`));
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
}
|
||||||
|
|
||||||
|
if (criteria.lastname) {
|
||||||
|
whereConditions.push(ilike(schema.users.lastname, `%${criteria.lastname}%`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.companyName) {
|
if (criteria.companyName) {
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`);
|
whereConditions.push(ilike(schema.users.companyName, `%${criteria.companyName}%`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.counties && criteria.counties.length > 0) {
|
if (criteria.counties && criteria.counties.length > 0) {
|
||||||
whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`)));
|
whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'county' ILIKE ${`%${county}%`})`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
|
whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${criteria.state})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
//never show user which denied
|
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`);
|
|
||||||
|
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
async searchUserListings(criteria: UserListingCriteria) {
|
||||||
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_json);
|
const query = this.conn.select().from(schema.users);
|
||||||
const whereConditions = this.getWhereConditions(criteria);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
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 'nameAsc':
|
|
||||||
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`));
|
|
||||||
break;
|
|
||||||
case 'nameDesc':
|
|
||||||
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Paginierung
|
// Paginierung
|
||||||
query.limit(length).offset(start);
|
query.limit(length).offset(start);
|
||||||
|
|
||||||
const data = await query;
|
const data = await query;
|
||||||
const results = data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
const results = data.map(r => convertDrizzleUserToUser(r));
|
||||||
const totalCount = await this.getUserListingsCount(criteria);
|
const totalCount = await this.getUserListingsCount(criteria);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -96,7 +82,7 @@ export class UserService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(schema.users_json);
|
const countQuery = this.conn.select({ value: count() }).from(schema.users);
|
||||||
const whereConditions = this.getWhereConditions(criteria);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
|
|
@ -108,31 +94,33 @@ export class UserService {
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
async getUserByMail(email: string, jwtuser?: JwtUser) {
|
async getUserByMail(email: string, jwtuser?: JwtUser) {
|
||||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.email, email));
|
const users = (await this.conn
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(sql`email = ${email}`)) as User[];
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) };
|
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname) };
|
||||||
const u = await this.saveUser(user, false);
|
const u = await this.saveUser(user);
|
||||||
return u;
|
return convertDrizzleUserToUser(u);
|
||||||
} else {
|
} else {
|
||||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
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 user;
|
return convertDrizzleUserToUser(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getUserById(id: string) {
|
async getUserById(id: string) {
|
||||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id));
|
const users = (await this.conn
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(sql`id = ${id}`)) as User[];
|
||||||
|
|
||||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
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 user;
|
return convertDrizzleUserToUser(user);
|
||||||
}
|
}
|
||||||
async getAllUser() {
|
async saveUser(user: User): Promise<User> {
|
||||||
const users = await this.conn.select().from(schema.users_json);
|
|
||||||
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
|
||||||
}
|
|
||||||
async saveUser(user: User, processValidation = true): Promise<User> {
|
|
||||||
try {
|
try {
|
||||||
user.updated = new Date();
|
user.updated = new Date();
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
|
|
@ -140,56 +128,29 @@ export class UserService {
|
||||||
} else {
|
} else {
|
||||||
user.created = new Date();
|
user.created = new Date();
|
||||||
}
|
}
|
||||||
let validatedUser = user;
|
const validatedUser = UserSchema.parse(user);
|
||||||
if (processValidation) {
|
const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
||||||
validatedUser = UserSchema.parse(user);
|
|
||||||
}
|
|
||||||
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
|
||||||
const { id: _, ...rest } = validatedUser;
|
|
||||||
const drizzleUser = { email: user.email, data: rest };
|
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning();
|
const [updateUser] = await this.conn.update(schema.users).set(drizzleUser).where(eq(schema.users.id, user.id)).returning();
|
||||||
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User;
|
return convertDrizzleUserToUser(updateUser) as User;
|
||||||
} else {
|
} else {
|
||||||
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning();
|
const [newUser] = await this.conn.insert(schema.users).values(drizzleUser).returning();
|
||||||
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User;
|
return convertDrizzleUserToUser(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[]> {
|
||||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
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 existingUser = await this.getUserById(id);
|
const result = await this.conn.execute(query);
|
||||||
if (!existingUser) return;
|
return result.rows;
|
||||||
|
|
||||||
const favorites = existingUser.favoritesForUser || [];
|
|
||||||
if (!favorites.includes(user.email)) {
|
|
||||||
existingUser.favoritesForUser = [...favorites, user.email];
|
|
||||||
const { id: _, ...rest } = existingUser;
|
|
||||||
const drizzleUser = { email: existingUser.email, data: rest };
|
|
||||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
|
||||||
const existingUser = await this.getUserById(id);
|
|
||||||
if (!existingUser) return;
|
|
||||||
|
|
||||||
const favorites = existingUser.favoritesForUser || [];
|
|
||||||
if (favorites.includes(user.email)) {
|
|
||||||
existingUser.favoritesForUser = favorites.filter(email => email !== user.email);
|
|
||||||
const { id: _, ...rest } = existingUser;
|
|
||||||
const drizzleUser = { email: existingUser.email, data: rest };
|
|
||||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFavoriteUsers(user: JwtUser): Promise<User[]> {
|
|
||||||
const data = await this.conn
|
|
||||||
.select()
|
|
||||||
.from(schema.users_json)
|
|
||||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
|
||||||
return data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { businesses, businesses_json, commercials, commercials_json, users, users_json } from './drizzle/schema';
|
import { businesses, commercials, users } from './drizzle/schema.js';
|
||||||
|
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) {
|
||||||
|
|
@ -16,31 +17,109 @@ export function convertStringToNullUndefined(value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDistanceQuery = (schema: typeof businesses_json | typeof commercials_json | typeof users_json, 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`
|
return sql`
|
||||||
${radius} * 2 * ASIN(SQRT(
|
${radius} * 2 * ASIN(SQRT(
|
||||||
POWER(SIN((${lat} - (${schema.data}->'location'->>'latitude')::float) * PI() / 180 / 2), 2) +
|
POWER(SIN((${lat} - ${schema.latitude}) * PI() / 180 / 2), 2) +
|
||||||
COS(${lat} * PI() / 180) * COS((${schema.data}->'location'->>'latitude')::float * PI() / 180) *
|
COS(${lat} * PI() / 180) * COS(${schema.latitude} * PI() / 180) *
|
||||||
POWER(SIN((${lon} - (${schema.data}->'location'->>'longitude')::float) * PI() / 180 / 2), 2)
|
POWER(SIN((${lon} - ${schema.longitude}) * PI() / 180 / 2), 2)
|
||||||
))
|
))
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DrizzleUser = typeof users.$inferSelect;
|
type DrizzleUser = typeof users.$inferSelect;
|
||||||
export type DrizzleBusinessListing = typeof businesses.$inferSelect;
|
type DrizzleBusinessListing = typeof businesses.$inferSelect;
|
||||||
export type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect;
|
type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect;
|
||||||
|
export function convertBusinessToDrizzleBusiness(businessListing: Partial<BusinessListing>): DrizzleBusinessListing {
|
||||||
export function splitName(fullName: string): { firstname: string; lastname: string } {
|
return flattenObject(businessListing);
|
||||||
const parts = fullName.trim().split(/\s+/); // Teile den Namen am Leerzeichen auf
|
}
|
||||||
|
export function convertDrizzleBusinessToBusiness(drizzleBusinessListing: Partial<DrizzleBusinessListing>): BusinessListing {
|
||||||
if (parts.length === 1) {
|
const o = {
|
||||||
// Falls es nur ein Teil gibt, ist firstname und lastname gleich
|
location_city: drizzleBusinessListing.city,
|
||||||
return { firstname: parts[0], lastname: parts[0] };
|
location_state: drizzleBusinessListing.state,
|
||||||
} else {
|
location_latitude: drizzleBusinessListing.latitude,
|
||||||
// Ansonsten ist der letzte Teil der lastname, der Rest der firstname
|
location_longitude: drizzleBusinessListing.longitude,
|
||||||
const lastname = parts.pop()!;
|
...drizzleBusinessListing,
|
||||||
const firstname = parts.join(' ');
|
};
|
||||||
return { firstname, lastname };
|
delete o.city;
|
||||||
}
|
delete o.state;
|
||||||
|
delete o.latitude;
|
||||||
|
delete o.longitude;
|
||||||
|
return unflattenObject(o);
|
||||||
|
}
|
||||||
|
export function convertCommercialToDrizzleCommercial(commercialPropertyListing: Partial<CommercialPropertyListing>): DrizzleCommercialPropertyListing {
|
||||||
|
return flattenObject(commercialPropertyListing);
|
||||||
|
}
|
||||||
|
export function convertDrizzleCommercialToCommercial(drizzleCommercialPropertyListing: Partial<DrizzleCommercialPropertyListing>): CommercialPropertyListing {
|
||||||
|
const o = {
|
||||||
|
location_city: drizzleCommercialPropertyListing.city,
|
||||||
|
location_state: drizzleCommercialPropertyListing.state,
|
||||||
|
location_latitude: drizzleCommercialPropertyListing.latitude,
|
||||||
|
location_longitude: drizzleCommercialPropertyListing.longitude,
|
||||||
|
...drizzleCommercialPropertyListing,
|
||||||
|
};
|
||||||
|
delete o.city;
|
||||||
|
delete o.state;
|
||||||
|
delete o.latitude;
|
||||||
|
delete o.longitude;
|
||||||
|
return unflattenObject(o);
|
||||||
|
}
|
||||||
|
export function convertUserToDrizzleUser(user: Partial<User>): DrizzleUser {
|
||||||
|
return flattenObject(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertDrizzleUserToUser(drizzleUser: Partial<DrizzleUser>): User {
|
||||||
|
const o = {
|
||||||
|
companyLocation_city: drizzleUser.city,
|
||||||
|
companyLocation_state: drizzleUser.state,
|
||||||
|
companyLocation_latitude: drizzleUser.latitude,
|
||||||
|
companyLocation_longitude: drizzleUser.longitude,
|
||||||
|
...drizzleUser,
|
||||||
|
};
|
||||||
|
delete o.city;
|
||||||
|
delete o.state;
|
||||||
|
delete o.latitude;
|
||||||
|
delete o.longitude;
|
||||||
|
return unflattenObject(o);
|
||||||
|
}
|
||||||
|
function flattenObject(obj: any, res: any = {}): any {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
const value = obj[key];
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
/**
|
|
||||||
* Utility functions for generating and parsing SEO-friendly URL slugs
|
|
||||||
*
|
|
||||||
* Slug format: {title}-{location}-{short-id}
|
|
||||||
* Example: italian-restaurant-austin-tx-a3f7b2c1
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a SEO-friendly URL slug from listing data
|
|
||||||
*
|
|
||||||
* @param title - The listing title (e.g., "Italian Restaurant")
|
|
||||||
* @param location - Location object with name, county, and state
|
|
||||||
* @param id - The listing UUID
|
|
||||||
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
|
||||||
*/
|
|
||||||
export function generateSlug(title: string, location: any, id: string): string {
|
|
||||||
if (!title || !id) {
|
|
||||||
throw new Error('Title and ID are required to generate a slug');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean and slugify the title
|
|
||||||
const titleSlug = title
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
||||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
|
||||||
.substring(0, 50); // Limit title to 50 characters
|
|
||||||
|
|
||||||
// Get location string
|
|
||||||
let locationSlug = '';
|
|
||||||
if (location) {
|
|
||||||
const locationName = location.name || location.county || '';
|
|
||||||
const state = location.state || '';
|
|
||||||
|
|
||||||
if (locationName) {
|
|
||||||
locationSlug = locationName
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state) {
|
|
||||||
locationSlug = locationSlug
|
|
||||||
? `${locationSlug}-${state.toLowerCase()}`
|
|
||||||
: state.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get first 8 characters of UUID for uniqueness
|
|
||||||
const shortId = id.substring(0, 8);
|
|
||||||
|
|
||||||
// Combine parts: title-location-id
|
|
||||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
|
||||||
const slug = parts.join('-');
|
|
||||||
|
|
||||||
// Final cleanup
|
|
||||||
return slug
|
|
||||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
|
||||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the UUID from a slug
|
|
||||||
* The UUID is always the last segment (8 characters)
|
|
||||||
*
|
|
||||||
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
|
||||||
* @returns The short ID (e.g., "a3f7b2c1")
|
|
||||||
*/
|
|
||||||
export function extractShortIdFromSlug(slug: string): string {
|
|
||||||
if (!slug) {
|
|
||||||
throw new Error('Slug is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = slug.split('-');
|
|
||||||
return parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate if a string looks like a valid slug
|
|
||||||
*
|
|
||||||
* @param slug - The string to validate
|
|
||||||
* @returns true if the string looks like a valid slug
|
|
||||||
*/
|
|
||||||
export function isValidSlug(slug: string): boolean {
|
|
||||||
if (!slug || typeof slug !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if slug contains only lowercase letters, numbers, and hyphens
|
|
||||||
const slugPattern = /^[a-z0-9-]+$/;
|
|
||||||
if (!slugPattern.test(slug)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
|
|
||||||
if (slug.length < 10) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
|
|
||||||
const parts = slug.split('-');
|
|
||||||
const lastPart = parts[parts.length - 1];
|
|
||||||
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a parameter is a slug (vs a UUID)
|
|
||||||
*
|
|
||||||
* @param param - The URL parameter
|
|
||||||
* @returns true if it's a slug, false if it's likely a UUID
|
|
||||||
*/
|
|
||||||
export function isSlug(param: string): boolean {
|
|
||||||
if (!param) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UUIDs have a specific format with hyphens at specific positions
|
|
||||||
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
|
|
||||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
||||||
|
|
||||||
if (uuidPattern.test(param)) {
|
|
||||||
return false; // It's a UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it contains at least 3 parts (e.g., title-state-id or title-city-state-id) and looks like our slug format, it's probably a slug
|
|
||||||
return param.split('-').length >= 3 && isValidSlug(param);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regenerate slug from updated listing data
|
|
||||||
* Useful when title or location changes
|
|
||||||
*
|
|
||||||
* @param title - Updated title
|
|
||||||
* @param location - Updated location
|
|
||||||
* @param existingSlug - The current slug (to preserve short-id)
|
|
||||||
* @returns New slug with same short-id
|
|
||||||
*/
|
|
||||||
export function regenerateSlug(title: string, location: any, existingSlug: string): string {
|
|
||||||
if (!existingSlug) {
|
|
||||||
throw new Error('Existing slug is required to regenerate');
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortId = extractShortIdFromSlug(existingSlug);
|
|
||||||
|
|
||||||
// Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID)
|
|
||||||
// In practice, you'd need the full UUID from the database
|
|
||||||
// For now, we'll construct a new slug with the short-id
|
|
||||||
const titleSlug = title
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.substring(0, 50);
|
|
||||||
|
|
||||||
let locationSlug = '';
|
|
||||||
if (location) {
|
|
||||||
const locationName = location.name || location.county || '';
|
|
||||||
const state = location.state || '';
|
|
||||||
|
|
||||||
if (locationName) {
|
|
||||||
locationSlug = locationName
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state) {
|
|
||||||
locationSlug = locationSlug
|
|
||||||
? `${locationSlug}-${state.toLowerCase()}`
|
|
||||||
: state.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
|
||||||
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "src/drizzle/import.ts"]
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2021",
|
||||||
"module": "NodeNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "Node",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
|
@ -18,13 +18,6 @@
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": false,
|
"noFallthroughCasesInSwitch": false,
|
||||||
"esModuleInterop": true
|
"esModuleInterop":true
|
||||||
},
|
}
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist",
|
|
||||||
"src/scripts/seed-database.ts",
|
|
||||||
"src/scripts/create-test-user.ts",
|
|
||||||
"src/sitemap"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue