Fix business filtering logic and add docker sync guide

This commit is contained in:
knuthtimo-lab 2026-01-12 13:58:45 +01:00
parent 4f8fd77f7d
commit adeefb199c
21 changed files with 538 additions and 69 deletions

View File

@ -13,7 +13,20 @@
"Bash(sudo chown:*)",
"Bash(chmod:*)",
"Bash(npm audit:*)",
"Bash(npm view:*)"
"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:*)"
]
}
}

27
DOCKER_SYNC_GUIDE.md Normal file
View File

@ -0,0 +1,27 @@
# 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.
.

0
bizmatch-server/prod.dump Executable file → Normal file
View File

View File

@ -5,7 +5,7 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { ZodError } from 'zod';
import * as schema from '../drizzle/schema';
import { businesses_json, PG_CONNECTION, users_json } from '../drizzle/schema';
import { businesses_json, PG_CONNECTION } from '../drizzle/schema';
import { GeoService } from '../geo/geo.service';
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
@ -18,27 +18,30 @@ export class BusinessListingService {
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private geoService?: GeoService,
) {}
) { }
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = [];
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
}
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
this.logger.debug('Adding radius search filter', { city: criteria.city.name, radius: criteria.radius });
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
whereConditions.push(sql`(${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius})`);
}
if (criteria.types && Array.isArray(criteria.types) && criteria.types.length > 0) {
const validTypes = criteria.types.filter(t => t !== null && t !== undefined && t !== '');
if (validTypes.length > 0) {
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, validTypes));
}
if (criteria.types && criteria.types.length > 0) {
this.logger.warn('Adding business category filter', { types: criteria.types });
// Use explicit SQL with IN for robust JSONB comparison
const typeValues = criteria.types.map(t => sql`${t}`);
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
}
if (criteria.state) {
this.logger.debug('Adding state filter', { state: criteria.state });
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
}
@ -105,27 +108,30 @@ export class BusinessListingService {
if (criteria.title && criteria.title.trim() !== '') {
const searchTerm = `%${criteria.title.trim()}%`;
whereConditions.push(
or(
sql`(${businesses_json.data}->>'title') ILIKE ${searchTerm}`,
sql`(${businesses_json.data}->>'description') ILIKE ${searchTerm}`
)
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
);
}
if (criteria.brokerName) {
const { firstname, lastname } = splitName(criteria.brokerName);
if (firstname === lastname) {
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
} else {
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
}
}
if (criteria.email) {
whereConditions.push(eq(users_json.email, criteria.email));
whereConditions.push(eq(schema.users_json.email, criteria.email));
}
if (user?.role !== 'admin') {
whereConditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
whereConditions.push(
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
);
}
whereConditions.push(and(sql`(${users_json.data}->>'customerType') = 'professional'`, sql`(${users_json.data}->>'customerSubType') = 'broker'`));
this.logger.warn('whereConditions count', { count: whereConditions.length });
return whereConditions;
}
@ -135,24 +141,21 @@ export class BusinessListingService {
const query = this.conn
.select({
business: businesses_json,
brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'),
brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'),
brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
})
.from(businesses_json)
.leftJoin(users_json, eq(businesses_json.email, users_json.email));
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
const whereConditions = this.getWhereConditions(criteria, user);
// Uncomment for debugging filter issues:
// this.logger.info('Filter Criteria:', { criteria });
// this.logger.info('Where Conditions Count:', { count: whereConditions.length });
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
query.where(whereClause);
const whereClause = sql.join(whereConditions, sql` AND `);
query.where(sql`(${whereClause})`);
// Uncomment for debugging SQL queries:
// this.logger.info('Generated SQL:', { sql: query.toSQL() });
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
}
// Sortierung
@ -228,13 +231,13 @@ export class BusinessListingService {
}
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(users_json, eq(businesses_json.email, users_json.email));
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
countQuery.where(whereClause);
const whereClause = sql.join(whereConditions, sql` AND `);
countQuery.where(sql`(${whereClause})`);
}
const [{ value: totalCount }] = await countQuery;

View File

@ -10,7 +10,7 @@ import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service';
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
import { getDistanceQuery } from '../utils';
import { getDistanceQuery, splitName } from '../utils';
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
@Injectable()
@ -20,7 +20,7 @@ export class CommercialPropertyService {
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private fileService?: FileService,
private geoService?: GeoService,
) {}
) { }
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = [];
@ -32,7 +32,10 @@ export class CommercialPropertyService {
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
}
if (criteria.types && criteria.types.length > 0) {
whereConditions.push(inArray(sql`${commercials_json.data}->>'type'`, criteria.types));
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
// Use explicit SQL with IN for robust JSONB comparison
const typeValues = criteria.types.map(t => sql`${t}`);
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
}
if (criteria.state) {
@ -48,12 +51,32 @@ export class CommercialPropertyService {
}
if (criteria.title) {
whereConditions.push(sql`(${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`);
whereConditions.push(
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
);
}
if (criteria.brokerName) {
const { firstname, lastname } = splitName(criteria.brokerName);
if (firstname === lastname) {
// Single word: search either first OR last name
whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
} else {
// Multiple words: search both first AND last name
whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
}
}
if (user?.role !== 'admin') {
whereConditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
whereConditions.push(
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
);
}
// whereConditions.push(and(eq(schema.users.customerType, 'professional')));
this.logger.warn('whereConditions count', { count: whereConditions.length });
return whereConditions;
}
// #### Find by criteria ########################################
@ -63,9 +86,13 @@ export class CommercialPropertyService {
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
const whereConditions = this.getWhereConditions(criteria, user);
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
query.where(whereClause);
const whereClause = sql.join(whereConditions, sql` AND `);
query.where(sql`(${whereClause})`);
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
}
// Sortierung
switch (criteria.sortBy) {
@ -103,8 +130,8 @@ export class CommercialPropertyService {
const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) {
const whereClause = and(...whereConditions);
countQuery.where(whereClause);
const whereClause = sql.join(whereConditions, sql` AND `);
countQuery.where(sql`(${whereClause})`);
}
const [{ value: totalCount }] = await countQuery;

View File

@ -34,6 +34,7 @@ export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
@ -186,6 +187,7 @@ export const UserSchema = z
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) => {
@ -369,7 +371,7 @@ export const ShareByEMailSchema = z.object({
listingTitle: z.string().optional().nullable(),
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
id: z.string().optional().nullable(),
type: ListingsCategoryEnum,
type: ShareCategoryEnum,
});
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;

View File

@ -96,6 +96,7 @@ export interface CommercialPropertyListingCriteria extends ListCriteria {
minPrice: number;
maxPrice: number;
title: string;
brokerName: string;
criteriaType: 'commercialPropertyListings';
}
export interface UserListingCriteria extends ListCriteria {
@ -358,6 +359,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
updated: new Date(),
subscriptionId: null,
subscriptionPlan: subscriptionPlan,
favoritesForUser: [],
showInDirectory: false,
};
}

View File

@ -39,6 +39,9 @@
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="grid grid-cols-1 gap-6">
@ -113,6 +116,17 @@
placeholder="Select categories"
></ng-select>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
</div>
}
@ -146,6 +160,9 @@
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="space-y-4">
@ -217,6 +234,17 @@
placeholder="e.g. Office Space"
/>
</div>
<div>
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername-embedded"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
}
</div>

View File

@ -48,7 +48,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
private filterStateService: FilterStateService,
private listingService: ListingsService,
private searchService: SearchService,
) {}
) { }
ngOnInit(): void {
// Load counties
@ -143,6 +143,9 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
case 'title':
updates.title = null;
break;
case 'brokerName':
updates.brokerName = null;
break;
}
this.updateCriteria(updates);
@ -280,6 +283,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
minPrice: null,
maxPrice: null,
title: null,
brokerName: null,
prompt: null,
page: 1,
start: 0,
@ -290,7 +294,15 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
hasActiveFilters(): boolean {
if (!this.criteria) return false;
return !!(this.criteria.state || this.criteria.city || this.criteria.minPrice || this.criteria.maxPrice || this.criteria.types?.length || this.criteria.title);
return !!(
this.criteria.state ||
this.criteria.city ||
this.criteria.minPrice ||
this.criteria.maxPrice ||
this.criteria.types?.length ||
this.criteria.title ||
this.criteria.brokerName
);
}
trackByFn(item: GeoResult): any {

View File

@ -47,6 +47,68 @@
<!-- Description -->
<p class="p-4 text-neutral-700">{{ user.description }}</p>
<!-- Like and Share Action Buttons -->
<div class="py-4 px-4 print:hidden">
@if(user && keycloakUser && (user?.email===keycloakUser?.email || (authService.isAdmin() | async))){
<div class="inline">
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
[routerLink]="['/account', user.id]">
<i class="fa-regular fa-pen-to-square"></i>
<span class="ml-2">Edit</span>
</button>
</div>
}
@if(keycloakUser && keycloakUser.email !== user.email){
<div class="inline">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="save()" [disabled]="isAlreadyFavorite()">
<i class="fa-regular fa-heart"></i>
@if(isAlreadyFavorite()){
<span class="ml-2">Saved ...</span>
}@else {
<span class="ml-2">Save</span>
}
</button>
</div>
}
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
<div class="inline">
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="showShareByEMail()">
<i class="fa-solid fa-envelope"></i>
<span class="ml-2">Email</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Company Profile -->
<div class="p-4">
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
@ -142,8 +204,6 @@
</div>
}
</div>
} @if( user?.email===keycloakUser?.email || (authService.isAdmin() | async)){
<button class="mt-4 bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600" [routerLink]="['/account', user.id]">Edit</button>
}
</div>
</div>

View File

@ -3,11 +3,14 @@ import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { AuthService } from '../../../services/auth.service';
import { AuditService } from '../../../services/audit.service';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.service';
import { HistoryService } from '../../../services/history.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
@ -15,11 +18,12 @@ import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { formatPhoneNumber, map2User } from '../../../utils/utils';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-user',
standalone: true,
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage],
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
templateUrl: './details-user.component.html',
styleUrl: '../details.scss',
})
@ -47,12 +51,14 @@ export class DetailsUserComponent {
private router: Router,
private userService: UserService,
private listingsService: ListingsService,
public selectOptions: SelectOptionsService,
private sanitizer: DomSanitizer,
private imageService: ImageService,
public historyService: HistoryService,
public authService: AuthService,
private auditService: AuditService,
private emailService: EMailService,
private messageService: MessageService,
) {}
async ngOnInit() {
@ -66,4 +72,82 @@ export class DetailsUserComponent {
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
}
/**
* Add professional to favorites
*/
async save() {
await this.listingsService.addToFavorites(this.user.id, 'user');
if (!this.user.favoritesForUser) {
this.user.favoritesForUser = [];
}
this.user.favoritesForUser.push(this.keycloakUser.email);
this.auditService.createEvent(this.user.id, 'favorite', this.keycloakUser?.email);
}
/**
* Check if already in favorites
*/
isAlreadyFavorite(): boolean {
if (!this.keycloakUser?.email || !this.user?.favoritesForUser) return false;
return this.user.favoritesForUser.includes(this.keycloakUser.email);
}
/**
* Show email sharing modal
*/
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
yourEmail: this.keycloakUser ? this.keycloakUser.email : '',
yourName: this.keycloakUser ? `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` : '',
recipientEmail: '',
url: environment.mailinfoUrl,
listingTitle: `${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`,
id: this.user.id,
type: 'user',
});
if (result) {
this.auditService.createEvent(this.user.id, 'email', this.keycloakUser?.email, <ShareByEMail>result);
this.messageService.addMessage({
severity: 'success',
text: 'Your Email has been sent',
duration: 5000,
});
}
}
/**
* Create audit event
*/
createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.user.id, eventType, this.keycloakUser?.email);
}
/**
* Share to Facebook
*/
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
this.createEvent('facebook');
}
/**
* Share to Twitter/X
*/
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(`Check out ${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`);
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
this.createEvent('x');
}
/**
* Share to LinkedIn
*/
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
this.createEvent('linkedin');
}
}

View File

@ -34,7 +34,22 @@
<!-- Professional Cards -->
@for (user of users; track user) {
<div
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between hover:shadow-2xl transition-all duration-300 hover:scale-[1.02]">
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between hover:shadow-2xl transition-all duration-300 hover:scale-[1.02] group relative">
<!-- Quick Actions Overlay -->
<div class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
@if(currentUser) {
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(user)"
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, user)">
<i [class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share professional" (click)="shareProfessional($event, user)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
<div class="flex items-start space-x-4">
@if(user.hasProfile){
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"

View File

@ -5,7 +5,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs';
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName, KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
@ -20,7 +20,8 @@ import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { assignProperties, resetUserListingCriteria } from '../../../utils/utils';
import { AuthService } from '../../../services/auth.service';
import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-broker-listings',
@ -56,6 +57,7 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
page = 1;
pageCount = 1;
sortBy: SortByOptions = null; // Neu: Separate Property
currentUser: KeycloakUser | null = null; // Current logged-in user
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
@ -70,6 +72,7 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
private modalService: ModalService,
private criteriaChangeService: CriteriaChangeService,
private filterStateService: FilterStateService,
private authService: AuthService,
) {
this.loadSortBy();
}
@ -77,7 +80,11 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
}
ngOnInit(): void {
async ngOnInit(): Promise<void> {
// Get current logged-in user
const token = await this.authService.getToken();
this.currentUser = map2User(token);
// Subscribe to FilterStateService for criteria changes
this.filterStateService
.getState$('brokerListings')
@ -144,4 +151,86 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
}
}
/**
* Check if professional/user is already in current user's favorites
*/
isFavorite(professional: User): boolean {
if (!this.currentUser?.email || !professional.favoritesForUser) return false;
return professional.favoritesForUser.includes(this.currentUser.email);
}
/**
* Toggle favorite status for a professional
*/
async toggleFavorite(event: Event, professional: User): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.currentUser?.email) {
// User not logged in - redirect to login
this.router.navigate(['/login']);
return;
}
try {
if (this.isFavorite(professional)) {
// Remove from favorites
await this.listingsService.removeFavorite(professional.id, 'user');
professional.favoritesForUser = professional.favoritesForUser.filter(
email => email !== this.currentUser!.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(professional.id, 'user');
if (!professional.favoritesForUser) {
professional.favoritesForUser = [];
}
professional.favoritesForUser.push(this.currentUser.email);
}
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
/**
* Share professional profile
*/
async shareProfessional(event: Event, user: User): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/details-user/${user.id}`;
const title = `${user.firstname} ${user.lastname} - ${user.companyName}`;
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this professional: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}

View File

@ -23,17 +23,23 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (listing of listings; track listing.id) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden flex flex-col h-full group relative">
<!-- Favorites Button -->
@if(user) {
<button
class="absolute top-4 right-4 z-10 bg-white rounded-full p-2 shadow-lg transition-colors opacity-0 group-hover:opacity-100"
[class.bg-red-50]="isFavorite(listing)"
[class.opacity-100]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)">
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<!-- Quick Actions Overlay -->
<div class="absolute top-4 right-4 z-10 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
@if(user) {
<button
class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)"
[class.opacity-100]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)">
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share property" (click)="shareProperty($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
@if (listing.imageOrder?.length>0){
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
[alt]="altText.generatePropertyListingAlt(listing)"

View File

@ -258,4 +258,44 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
});
this.seoService.injectStructuredData(collectionSchema);
}
/**
* Share property listing
*/
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
const title = listing.title || 'Commercial Property Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this property: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}

View File

@ -233,7 +233,6 @@
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
</div>
</div>
}
<div class="flex items-center !my-8">
<label class="flex items-center cursor-pointer">
<div class="relative">
@ -243,6 +242,7 @@
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
</label>
</div>
}
<div class="flex justify-start">
<button type="submit"

View File

@ -226,6 +226,7 @@ export class FilterStateService {
minPrice: null,
maxPrice: null,
title: null,
brokerName: null,
prompt: null,
page: 1,
start: 0,

View File

@ -51,10 +51,10 @@ export class ListingsService {
async deleteCommercialPropertyListing(id: string, imagePath: string) {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
}
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty') {
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
await lastValueFrom(this.http.post<void>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`, {}));
}
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty') {
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`));
}

View File

@ -49,6 +49,7 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
minPrice: null,
maxPrice: null,
title: '',
brokerName: '',
searchType: 'exact',
radius: null,
};
@ -361,8 +362,8 @@ export function getCriteriaByListingCategory(listingsCategory: 'business' | 'pro
listingsCategory === 'business'
? sessionStorage.getItem('businessListings')
: listingsCategory === 'commercialProperty'
? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings');
? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings');
return storedState ? JSON.parse(storedState) : null;
}

59
debug-inarray.ts Normal file
View File

@ -0,0 +1,59 @@
import { and, inArray, sql, SQL } from 'drizzle-orm';
import { businesses_json, users_json } from './bizmatch-server/src/drizzle/schema';
// Mock criteria similar to what the user used
const criteria: any = {
types: ['retail'],
brokerName: 'page',
criteriaType: 'businessListings'
};
const user = { role: 'guest', email: 'timo@example.com' };
function getWhereConditions(criteria: any, user: any): SQL[] {
const whereConditions: SQL[] = [];
// Category filter
if (criteria.types && criteria.types.length > 0) {
// Suspected problematic line:
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, criteria.types));
}
// Broker filter
if (criteria.brokerName) {
const firstname = criteria.brokerName;
const lastname = criteria.brokerName;
whereConditions.push(
sql`((${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%` bubble})`
);
}
// Draft check
if (user?.role !== 'admin') {
whereConditions.push(
sql`((${ businesses_json.email } = ${ user?.email || null}) OR(${ businesses_json.data } ->> 'draft')::boolean IS NOT TRUE)`
);
}
return whereConditions;
}
const conditions = getWhereConditions(criteria, user);
const combined = and(...conditions);
console.log('--- Conditions Count ---');
console.log(conditions.length);
console.log('--- Generated SQL Fragment ---');
// We need a dummy query to see the full SQL
// Since we don't have a real DB connection here, we just inspect the SQL parts
// Drizzle conditions can be serialized to SQL strings
// This is a simplified test
try {
// In a real environment we would use a dummy pg adapter
console.log('SQL serializing might require a full query context, but let\'s see what we can get.');
} catch (e) {
console.error(e);
}

0
fix-vulnerabilities.sh Executable file → Normal file
View File