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

@@ -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));
}