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

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