SEO/AEO, Farb schema, breadcrumbs

This commit is contained in:
2025-11-29 23:41:54 +01:00
parent 4fa24c8f3d
commit d2953fd0d9
87 changed files with 5672 additions and 579 deletions

View File

@@ -1,21 +1,19 @@
# ~/git/bizmatch-project/bizmatch-server/docker-compose.yml
services:
app:
image: node:22-alpine
container_name: bizmatch-app
working_dir: /app
volumes:
- ./:/app # Code liegt hier direkt im Ordner der Compose
- ./:/app
- node_modules:/app/node_modules
ports:
- '3001:3000' # Host 3001 -> Container 3000
- '3001:3001'
env_file:
- path: ./.env
required: true
- .env
environment:
- NODE_ENV=development # Prod-Modus (vorher stand fälschlich "development")
- NODE_ENV=development
- DATABASE_URL
# Hinweis: npm ci nutzt package-lock.json; falls nicht vorhanden, nimm "npm install"
command: sh -c "npm ci && npm run build && node dist/src/main.js"
command: sh -c "if [ ! -f node_modules/.installed ]; then npm ci && touch node_modules/.installed; fi && npm run build && node dist/src/main.js"
restart: unless-stopped
depends_on:
- postgres
@@ -24,22 +22,27 @@ services:
postgres:
container_name: bizmatchdb
image: postgres:17-alpine # Version pinnen ist stabiler als "latest"
image: postgres:17-alpine
restart: unless-stopped
volumes:
- ${PWD}/bizmatchdb-data:/var/lib/postgresql/data # Daten liegen im Server-Repo
- bizmatch-db-data:/var/lib/postgresql/data
env_file:
- path: ./.env
required: true
- .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- '5433:5432' # Host 5433 -> Container 5432
- '5434:5432'
networks:
- bizmatch
volumes:
bizmatch-db-data:
driver: local
node_modules:
driver: local
networks:
bizmatch:
external: true # einmalig anlegen: docker network create bizmatch-prod
external: true

View File

@@ -23,14 +23,19 @@
"drop": "drizzle-kit drop",
"migrate": "tsx src/drizzle/migrate.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": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/cli": "^11.0.11",
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.11",
"@nestjs/cli": "^11.0.11",
"@nestjs/platform-express": "^11.0.11",
"@types/stripe": "^8.0.417",
"body-parser": "^1.20.2",
@@ -51,7 +56,7 @@
"pgvector": "^0.2.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.2",
"sharp": "^0.33.5",
"stripe": "^16.8.0",
"tsx": "^4.16.2",
"urlcat": "^3.1.0",
@@ -66,11 +71,11 @@
"@nestjs/testing": "^11.0.11",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
"@types/node": "^20.11.19",
"@types/node": "^20.19.25",
"@types/nodemailer": "^6.4.14",
"@types/pg": "^8.11.5",
"commander": "^12.0.0",
"drizzle-kit": "^0.23.0",
"drizzle-kit": "^0.23.2",
"esbuild-register": "^3.5.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
@@ -86,7 +91,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
"typescript": "^5.9.3"
},
"jest": {
"moduleFileExtensions": [
@@ -105,4 +110,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}

View File

@@ -25,6 +25,7 @@ import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { UserInterceptor } from './interceptors/user.interceptor';
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
import { SelectOptionsModule } from './select-options/select-options.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { UserModule } from './user/user.module';
//loadEnvFiles();
@@ -70,6 +71,7 @@ console.log('Loaded environment variables:');
// PaymentModule,
EventModule,
FirebaseAdminModule,
SitemapModule,
],
controllers: [AppController, LogController],
providers: [

View File

@@ -117,6 +117,7 @@ export const businesses = pgTable(
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'),
@@ -125,6 +126,7 @@ export const businesses = pgTable(
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),
}),
);
@@ -143,6 +145,7 @@ export const commercials = pgTable(
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'),
@@ -151,6 +154,7 @@ export const commercials = pgTable(
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),
}),
);

View File

@@ -10,6 +10,7 @@ import { GeoService } from '../geo/geo.service';
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
import { getDistanceQuery, splitName } from '../utils';
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
@Injectable()
export class BusinessListingService {
@@ -212,6 +213,41 @@ export class BusinessListingService {
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> {
let id = slugOrId;
// Check if it's a slug (contains multiple hyphens) vs UUID
if (isSlug(slugOrId)) {
// Extract short ID from slug and find by slug field
const listing = await this.findBusinessBySlug(slugOrId);
if (listing) {
id = listing.id;
}
}
return this.findBusinessesById(id, user);
}
/**
* Find business by slug
*/
async findBusinessBySlug(slug: string): Promise<BusinessListing | null> {
const result = await this.conn
.select()
.from(businesses_json)
.where(sql`${businesses_json.data}->>'slug' = ${slug}`)
.limit(1);
if (result.length > 0) {
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
}
return null;
}
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
const conditions = [];
if (user?.role !== 'admin') {
@@ -246,7 +282,7 @@ export class BusinessListingService {
const userFavorites = await this.conn
.select()
.from(businesses_json)
.where(arrayContains(sql`${businesses_json.data}->>'favoritesForUser'`, [user.email]));
.where(sql`${businesses_json.data}->'favoritesForUser' ? ${user.email}`);
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
}
@@ -258,7 +294,13 @@ export class BusinessListingService {
const { id, email, ...rest } = data;
const convertedBusinessListing = { email, data: rest };
const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).returning();
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing) };
// Generate and update slug after creation (we need the ID first)
const slug = generateSlug(data.title, data.location, createdListing.id);
const listingWithSlug = { ...(createdListing.data as any), slug };
await this.conn.update(businesses_json).set({ data: listingWithSlug }).where(eq(businesses_json.id, createdListing.id));
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing), slug } as any;
} catch (error) {
if (error instanceof ZodError) {
const filteredErrors = error.errors
@@ -285,8 +327,21 @@ export class BusinessListingService {
if (existingListing.email === user?.email) {
data.favoritesForUser = (<BusinessListing>existingListing.data).favoritesForUser || [];
}
BusinessListingSchema.parse(data);
const { id: _, email, ...rest } = data;
// 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) };
@@ -308,11 +363,24 @@ export class BusinessListingService {
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id));
}
async addFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn
.update(businesses_json)
.set({
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
coalesce((${businesses_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
})
.where(eq(businesses_json.id, id));
}
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn
.update(businesses_json)
.set({
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', array_remove((${businesses_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`,
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));
}

View File

@@ -16,9 +16,10 @@ export class BusinessListingsController {
) {}
@UseGuards(OptionalAuthGuard)
@Get(':id')
async findById(@Request() req, @Param('id') id: string): Promise<any> {
return await this.listingsService.findBusinessesById(id, req.user as JwtUser);
@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(AuthGuard)
@Get('favorites/all')
@@ -60,9 +61,17 @@ export class BusinessListingsController {
await this.listingsService.deleteListing(id);
}
@UseGuards(AuthGuard)
@Post('favorite/:id')
async addFavorite(@Request() req, @Param('id') id: string) {
await this.listingsService.addFavorite(id, req.user as JwtUser);
return { success: true, message: 'Added to favorites' };
}
@UseGuards(AuthGuard)
@Delete('favorite/:id')
async deleteFavorite(@Request() req, @Param('id') id: string) {
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
return { success: true, message: 'Removed from favorites' };
}
}

View File

@@ -18,9 +18,10 @@ export class CommercialPropertyListingsController {
) {}
@UseGuards(OptionalAuthGuard)
@Get(':id')
async findById(@Request() req, @Param('id') id: string): Promise<any> {
return await this.listingsService.findCommercialPropertiesById(id, req.user as JwtUser);
@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(AuthGuard)
@@ -64,9 +65,18 @@ export class CommercialPropertyListingsController {
await this.listingsService.deleteListing(id);
this.fileService.deleteDirectoryIfExists(imagePath);
}
@UseGuards(AuthGuard)
@Post('favorite/:id')
async addFavorite(@Request() req, @Param('id') id: string) {
await this.listingsService.addFavorite(id, req.user as JwtUser);
return { success: true, message: 'Added to favorites' };
}
@UseGuards(AuthGuard)
@Delete('favorite/:id')
async deleteFavorite(@Request() req, @Param('id') id: string) {
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
return { success: true, message: 'Removed from favorites' };
}
}

View File

@@ -11,6 +11,7 @@ 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 { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
@Injectable()
export class CommercialPropertyService {
@@ -111,6 +112,41 @@ export class CommercialPropertyService {
}
// #### 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> {
let id = slugOrId;
// Check if it's a slug (contains multiple hyphens) vs UUID
if (isSlug(slugOrId)) {
// Extract short ID from slug and find by slug field
const listing = await this.findCommercialBySlug(slugOrId);
if (listing) {
id = listing.id;
}
}
return this.findCommercialPropertiesById(id, user);
}
/**
* Find commercial property by slug
*/
async findCommercialBySlug(slug: string): Promise<CommercialPropertyListing | null> {
const result = await this.conn
.select()
.from(commercials_json)
.where(sql`${commercials_json.data}->>'slug' = ${slug}`)
.limit(1);
if (result.length > 0) {
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
}
return null;
}
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
const conditions = [];
if (user?.role !== 'admin') {
@@ -146,7 +182,7 @@ export class CommercialPropertyService {
const userFavorites = await this.conn
.select()
.from(commercials_json)
.where(arrayContains(sql`${commercials_json.data}->>'favoritesForUser'`, [user.email]));
.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 ########################################
@@ -182,7 +218,13 @@ export class CommercialPropertyService {
const { id, email, ...rest } = data;
const convertedCommercialPropertyListing = { email, data: rest };
const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning();
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing) };
// Generate and update slug after creation (we need the ID first)
const slug = generateSlug(data.title, data.location, createdListing.id);
const listingWithSlug = { ...(createdListing.data as any), slug };
await this.conn.update(commercials_json).set({ data: listingWithSlug }).where(eq(commercials_json.id, createdListing.id));
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing), slug } as any;
} catch (error) {
if (error instanceof ZodError) {
const filteredErrors = error.errors
@@ -209,14 +251,27 @@ export class CommercialPropertyService {
if (existingListing.email === user?.email || !user) {
data.favoritesForUser = (<CommercialPropertyListing>existingListing.data).favoritesForUser || [];
}
CommercialPropertyListingSchema.parse(data);
const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId));
const difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x)));
if (difference.length > 0) {
this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`);
data.imageOrder = imageOrder;
// 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);
}
const { id: _, email, ...rest } = data;
// Add slug to data before validation
const dataWithSlug = { ...data, slug };
CommercialPropertyListingSchema.parse(dataWithSlug);
const imageOrder = await this.fileService.getPropertyImages(dataWithSlug.imagePath, String(dataWithSlug.serialId));
const difference = imageOrder.filter(x => !dataWithSlug.imageOrder.includes(x)).concat(dataWithSlug.imageOrder.filter(x => !imageOrder.includes(x)));
if (difference.length > 0) {
this.logger.warn(`changes between image directory and imageOrder in listing ${dataWithSlug.serialId}: ${difference.join(',')}`);
dataWithSlug.imageOrder = imageOrder;
}
const { id: _, email, ...rest } = dataWithSlug;
const convertedCommercialPropertyListing = { email, data: rest };
const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning();
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) };
@@ -253,12 +308,25 @@ export class CommercialPropertyService {
async deleteListing(id: string): Promise<void> {
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id));
}
// #### ADD Favorite ######################################
async addFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn
.update(commercials_json)
.set({
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
coalesce((${commercials_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
})
.where(eq(commercials_json.id, id));
}
// #### DELETE Favorite ###################################
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn
.update(commercials_json)
.set({
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', array_remove((${commercials_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`,
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));
}

View File

@@ -19,6 +19,6 @@ async function bootstrap() {
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
});
await app.listen(3000);
await app.listen(process.env.PORT || 3001);
}
bootstrap();

View File

@@ -287,6 +287,7 @@ export const BusinessListingSchema = z
brokerLicencing: z.string().optional().nullable(),
internals: z.string().min(5).optional().nullable(),
imageName: z.string().optional().nullable(),
slug: z.string().optional().nullable(),
created: z.date(),
updated: z.date(),
})
@@ -333,6 +334,7 @@ export const CommercialPropertyListingSchema = z
draft: z.boolean(),
imageOrder: z.array(z.string()),
imagePath: z.string().nullable().optional(),
slug: z.string().optional().nullable(),
created: z.date(),
updated: z.date(),
})
@@ -384,6 +386,6 @@ export const ListingEventSchema = z.object({
locationLat: z.string().max(20).optional().nullable(), // Latitude, als String
locationLng: z.string().max(20).optional().nullable(), // Longitude, als String
referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional
additionalData: z.record(z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
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>;

View File

@@ -359,6 +359,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
updated: new Date(),
subscriptionId: null,
subscriptionPlan: subscriptionPlan,
showInDirectory: false,
};
}
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {

View File

@@ -0,0 +1,51 @@
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);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
import { DrizzleModule } from '../drizzle/drizzle.module';
@Module({
imports: [DrizzleModule],
controllers: [SitemapController],
providers: [SitemapService],
exports: [SitemapService],
})
export class SitemapModule {}

View File

@@ -0,0 +1,292 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../drizzle/schema';
import { PG_CONNECTION } from '../drizzle/schema';
interface SitemapUrl {
loc: string;
lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
}
interface SitemapIndexEntry {
loc: string;
lastmod?: string;
}
@Injectable()
export class SitemapService {
private readonly baseUrl = 'https://biz-match.com';
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) {}
/**
* Generate sitemap index (main sitemap.xml)
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
*/
async generateSitemapIndex(): Promise<string> {
const sitemaps: SitemapIndexEntry[] = [];
// Add static pages sitemap
sitemaps.push({
loc: `${this.baseUrl}/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);
for (let page = 1; page <= businessPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/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);
for (let page = 1; page <= commercialPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/sitemap/commercial-${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];
}
}

View File

@@ -24,12 +24,15 @@ export class UserService {
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
const whereConditions: SQL[] = [];
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
}
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
}
if (criteria.types && criteria.types.length > 0) {
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
@@ -46,11 +49,11 @@ export class UserService {
}
if (criteria.counties && criteria.counties.length > 0) {
whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'county' ILIKE ${`%${county}%`})`)));
whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`)));
}
if (criteria.state) {
whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'state' = ${criteria.state})`);
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
}
//never show user which denied

View File

@@ -0,0 +1,183 @@
/**
* Utility functions for generating and parsing SEO-friendly URL slugs
*
* Slug format: {title}-{location}-{short-id}
* Example: italian-restaurant-austin-tx-a3f7b2c1
*/
/**
* Generate a SEO-friendly URL slug from listing data
*
* @param title - The listing title (e.g., "Italian Restaurant")
* @param location - Location object with name, county, and state
* @param id - The listing UUID
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
*/
export function generateSlug(title: string, location: any, id: string): string {
if (!title || !id) {
throw new Error('Title and ID are required to generate a slug');
}
// Clean and slugify the title
const titleSlug = title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.substring(0, 50); // Limit title to 50 characters
// Get location string
let locationSlug = '';
if (location) {
const locationName = location.name || location.county || '';
const state = location.state || '';
if (locationName) {
locationSlug = locationName
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
if (state) {
locationSlug = locationSlug
? `${locationSlug}-${state.toLowerCase()}`
: state.toLowerCase();
}
}
// Get first 8 characters of UUID for uniqueness
const shortId = id.substring(0, 8);
// Combine parts: title-location-id
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
const slug = parts.join('-');
// Final cleanup
return slug
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.toLowerCase();
}
/**
* Extract the UUID from a slug
* The UUID is always the last segment (8 characters)
*
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
* @returns The short ID (e.g., "a3f7b2c1")
*/
export function extractShortIdFromSlug(slug: string): string {
if (!slug) {
throw new Error('Slug is required');
}
const parts = slug.split('-');
return parts[parts.length - 1];
}
/**
* Validate if a string looks like a valid slug
*
* @param slug - The string to validate
* @returns true if the string looks like a valid slug
*/
export function isValidSlug(slug: string): boolean {
if (!slug || typeof slug !== 'string') {
return false;
}
// Check if slug contains only lowercase letters, numbers, and hyphens
const slugPattern = /^[a-z0-9-]+$/;
if (!slugPattern.test(slug)) {
return false;
}
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
if (slug.length < 10) {
return false;
}
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
const parts = slug.split('-');
const lastPart = parts[parts.length - 1];
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
}
/**
* Check if a parameter is a slug (vs a UUID)
*
* @param param - The URL parameter
* @returns true if it's a slug, false if it's likely a UUID
*/
export function isSlug(param: string): boolean {
if (!param) {
return false;
}
// UUIDs have a specific format with hyphens at specific positions
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
if (uuidPattern.test(param)) {
return false; // It's a UUID
}
// If it contains more than 4 hyphens and looks like our slug format, it's probably a slug
return param.split('-').length > 4 && 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();
}

View File

@@ -19,5 +19,12 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
},
"exclude": [
"node_modules",
"dist",
"src/scripts/seed-database.ts",
"src/scripts/create-test-user.ts",
"src/sitemap"
]
}