SEO/AEO, Farb schema, breadcrumbs
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
51
bizmatch-server/src/sitemap/sitemap.controller.ts
Normal file
51
bizmatch-server/src/sitemap/sitemap.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
bizmatch-server/src/sitemap/sitemap.module.ts
Normal file
12
bizmatch-server/src/sitemap/sitemap.module.ts
Normal 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 {}
|
||||
292
bizmatch-server/src/sitemap/sitemap.service.ts
Normal file
292
bizmatch-server/src/sitemap/sitemap.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
183
bizmatch-server/src/utils/slug.utils.ts
Normal file
183
bizmatch-server/src/utils/slug.utils.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user