SEO/AEO, Farb schema, breadcrumbs
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user