[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:fr":3,"public-menus:all":38,"post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:fr":205,"related:post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:fr:1":912},{"statusCode":4,"data":5,"message":37},200,{"tenantId":6,"lang":7,"defaultLang":8,"siteUrl":9,"contactEmail":10,"brandName":11,"logoUrl":12,"siteName":11,"siteDescription":13,"ogImage":10,"robotsIndex":14,"socialLinks":10,"reservedSlugs":10,"seoPolicy":15},"stajic","fr","de","https:\u002F\u002Fstajic.de",null,"Stajic Platform","\u002FLogo_Planet.svg","Stajic Portal",true,{"branding":16,"relatedContent":17,"crossDomainLinks":18},{"logoUrl":12},{"enabled":14},[19,22,25,28,31,34],{"url":20,"label":21,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Ffigure.rocks","figure.rocks",{"url":23,"label":24,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Floving.rocks","loving.rocks",{"url":26,"label":27,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.com","bazify.com",{"url":29,"label":30,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.de","bazify.de",{"url":32,"label":33,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.at","bazify.at",{"url":35,"label":36,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.ba","bazify.ba","Portal settings resolved",[39,45],{"id":40,"name":41,"location":42,"isActive":14,"isDefault":43,"items":44},1,"main-navigation","header",false,[],{"id":46,"name":47,"location":48,"isActive":14,"isDefault":14,"items":49},4,"main-menu","sidebar",[50,66,79,93,103,118,133],{"id":51,"title":52,"url":60,"target":61,"icon":62,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":64,"portfolioId":10,"children":65},"item-18",{"de":53,"en":54,"es":55,"fr":56,"it":54,"ru":57,"sr":58,"zh":59},"Startseite","Home","Inicio","Accueil","Главная","Почетна","首页","\u002Ffull-stack-web-developer-munich-performance-seo-and-maintainable-builds","_self","i-lucide-home","page",111,[],{"id":67,"title":68,"url":75,"target":61,"icon":76,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":77,"portfolioId":10,"children":78},"item-22",{"de":69,"en":69,"es":70,"fr":69,"it":71,"ru":72,"sr":73,"zh":74},"Vision","Visión","Visione","Видение","Визија","想象","\u002Fueber-uns-webdesign-muenchen-webaplikation","i-lucide-eye",113,[],{"id":80,"title":81,"url":89,"target":61,"icon":90,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":91,"portfolioId":10,"children":92},"item-19",{"de":82,"en":83,"es":84,"fr":83,"it":85,"ru":86,"sr":87,"zh":88},"Leistungen","Services","Servicios","Servizi","Услуги","Услуге","服务","\u002Fservices-dienstleistungen-muenchen","i-lucide-wrench",116,[],{"id":94,"title":95,"url":99,"target":61,"icon":100,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":101,"portfolioId":10,"children":102},"item-23",{"de":96,"en":96,"es":96,"fr":96,"it":96,"ru":97,"sr":97,"zh":98},"Blog","Блог","博客","\u002Fblog","i-lucide-book-open",112,[],{"id":104,"title":105,"url":114,"target":61,"icon":115,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":116,"portfolioId":10,"children":117},"item-32",{"de":106,"en":107,"es":108,"fr":109,"it":110,"ru":111,"sr":112,"zh":113},"Neue Technologien","New Technologies","Nuevas tecnologías","Nouvelles technologies","Nuove tecnologie","Новые технологии","Нове технологије","新技术！","\u002Fneue-webtechnologien","i-lucide-sparkles",122,[],{"id":119,"title":120,"url":129,"target":61,"icon":130,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":131,"portfolioId":10,"children":132},"item-20",{"de":121,"en":122,"es":123,"fr":124,"it":125,"ru":126,"sr":127,"zh":128},"Kontakt","Contact us!","Contacto","Contact","Contatto","Контакт","Контактирајте нас","联系我们！","\u002Fcontact","i-lucide-mail",115,[],{"id":134,"title":135,"url":144,"target":61,"icon":145,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":147},"item-21",{"de":136,"en":137,"es":138,"fr":139,"it":140,"ru":141,"sr":142,"zh":143},"Unsere Arbeit","Our Work","Nuestro trabajo","Nos réalisations","I nostri lavori","Наши работы","Наши радови","文件夹","\u002Fportfolio","i-lucide-briefcase",114,[148,161,175,181,193],{"id":149,"title":150,"url":144,"target":61,"icon":159,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":160},"item-24",{"de":151,"en":152,"es":153,"fr":154,"it":155,"ru":156,"sr":157,"zh":158},"Alle Projekte","All Projects","Todos los proyectos","Tous les projets","Tutti i progetti","Все проекты","Сви пројекти","所有项目","i-lucide-grid-3x3",[],{"id":162,"title":163,"url":171,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":174},"item-29",{"de":164,"en":165,"es":166,"fr":167,"it":168,"ru":169,"sr":170,"zh":143},"Local Roots, Global Reach","Local Roots - Global Reach","Empresa local ","Entreprise locale","Azienda locale","Местная компания","Локално предузеће глобално тржиште","\u002Fportfolio\u002Flocal-roots-global-reach-communication-media-systems-for-modern-business","i-lucide-folder","custom",[],{"id":176,"title":177,"url":179,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":180},"item-28",{"de":178,"en":178,"es":178,"fr":178,"it":178,"ru":178,"sr":178,"zh":178},"Solr Suggester","\u002Fportfolio\u002Fsolr-fuzzy-suggester-und-solr-infix-suggester-abfrage-ueber-ajax-und-filterung",[],{"id":182,"title":183,"url":191,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":192},"item-27",{"de":184,"en":185,"es":186,"fr":187,"it":188,"ru":189,"sr":190,"zh":185},"Firmenwebseite SEO","Company Website SEO","Sitio web corporativo SEO","Site web d’entreprise SEO","Sito web aziendale SEO","Корпоративный сайт SEO","Пословна веб-страница SEO","\u002Fportfolio\u002Fseo-sem-branding-mobile-webseite-muenchen",[],{"id":194,"title":195,"url":203,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":204},"item-31",{"de":196,"en":197,"es":198,"fr":199,"it":200,"ru":201,"sr":202,"zh":197},"Digitalisierungsportal","Digitalization Portal","Portal de digitalización","Portail de numérisation","Portale di digitalizzazione","Портал цифровизации","Портал за дигитализацију","\u002Fportfolio\u002Fdigitalisierungsportal-archiv-museum-bibliothek-ead-lido-mets-mods",[],{"statusCode":4,"data":206,"message":911},{"id":207,"title":208,"slug":209,"content":210,"contentJson":211,"excerpt":510,"featuredImage":511,"featuredImageAlt":512,"featuredImageCaption":10,"featuredImageTitle":10,"featuredImageCopyright":10,"featuredImageAuthor":10,"featuredImageSourceUrl":10,"featuredImageLicense":10,"featuredImageIsAiGenerated":43,"status":513,"publishedAt":514,"createdAt":515,"updatedAt":516,"seoLocalePaths":517,"categories":526,"author":547,"translations":552},"383","Architecture Canonique, Conception d'URL, Logique de Résolution, Spécification d'API et d'Évolutivité","canonical-architecture-url-design-resolver-logic-api-scalability-specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery : architecture canonique, conception des URL, logique du resolver, API et spécification de scalabilité\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"Ce document spécifie la surface de découverte géographique (pays\u002Fville\u002Frayon) à travers plusieurs portails (multi-tenant), sans forcer un refactoring immédiat de la base de données et sans couplage avec le routage du CMS (page\u002Farticle). Il maintient une SEO stable, reste compatible avec le cache et laisse de la place pour la réservation, les avis et les cartes sans transformer l’application en un bloc monolithique.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Contraintes et non-objectifs\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant : la même base de code dessert plusieurs portails. Le tenant influence la portée du contenu, l’identité visuelle et parfois la source de données.\",\"La découverte géographique doit supporter : pays, ville et recherche par rayon (autour d’un point).\",\"Aucun refactoring immédiat de la base de données : nous ne pouvons pas restructurer les tables existantes en un schéma geo idéal à ce stade.\",\"Indépendance vis-à-vis du routage CMS : les pages geo ne sont ni des « posts » ni des « pages ». Elles ne peuvent pas être bloquées par des conflits de slug CMS.\",\"Stabilité SEO : les URL canoniques ne doivent pas changer lorsque les filtres ou les options de tri changent.\",\"Compatibilité cache : le CDN et le cache serveur doivent avoir des clés prévisibles. Éviter toute variation par utilisateur.\",\"Séparation stricte des responsabilités : discovery, CMS et résolution du tenant sont des modules distincts avec des frontières explicites.\",\"Non-objectif (pour l’instant) : géocodage parfait. Nous acceptons un seul géocodeur, une stratégie de normalisation unique et stockons le résultat normalisé.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Architecture canonique\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"Nous implémentons la découverte géographique comme un bounded context autonome avec une surface publique minimale : (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Réponse → métadonnées SEO et cache. Les routes CMS n’appellent jamais la discovery. La discovery n’appelle jamais le routage CMS. Elles ne partagent que des utilitaires bas niveau (HTTP, cache, contexte tenant).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Composants clés\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext : résout le tenant à partir de l’en-tête Host (ou d’un identifiant de portail explicite pour les appels internes).\",\"GeoResolver : analyse et valide les segments d’URL géographiques ; émet un GeoQuery normalisé.\",\"GeoIndex (Read Model) : table ou collection séparée qui mappe entityId → lat\u002Flng + scope tenant + champs minimaux recherchables. Cela évite le refactoring des tables de la base source.\",\"Data Providers : sources interchangeables (par exemple tables SQL existantes, API WordPress, autre service de portail). Elles sont encapsulées derrière une interface.\",\"SEO Router : produit l’URL canonique et les métadonnées (canonical, hreflang si nécessaire, indicateurs robots).\",\"Couche de cache : clés de cache CDN et cache côté serveur avec clés tenant-scopées et versionnement.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Structure des dossiers\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"Conception des URL (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"Nous conservons l’URL canonique strictement hiérarchique et lisible par l’humain. Les filtres et le tri restent dans la querystring mais n’affectent PAS le canonical. La recherche par rayon utilise une page « near » stable avec un canonical basé sur une cellule de coordonnées arrondie, et non sur les lat\u002Flng bruts. Cela empêche la génération infinie de variantes d’URL et évite l’explosion du cache. C’est un compromis délibéré.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Landing pays : \u002Fdiscover\u002F{countryCode} (exemple : \u002Fdiscover\u002Fde)\",\"Landing ville : \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (exemple : \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (rayon) : \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (exemple : \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) où cellId est un identifiant court de type geohash\",\"Paramètres de requête optionnels (non canoniques) : ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Le tenant n’est PAS dans le chemin. Le tenant est déterminé par l’hôte (portal-a.tld, portal-b.tld). Les appels internes transmettent x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Règles de canonicalisation\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Les pages pays\u002Fville canonisent vers leur propre chemin propre (sans querystring).\",\"Les pages near canonisent vers \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. Le canonical est dérivé d’une cellule arrondie, pas des coordonnées entrantes.\",\"page=1 est omis du canonical et de la génération des liens internes.\",\"Les combinaisons non supportées (par ex. incohérence de pays) retournent un 404, pas une redirection. Les chaînes de redirection nuisaient au budget de crawl lors des tests.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Logique du resolver\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"L’entrée du resolver est (tenant, pathname, query). La sortie est un GeoQuery normalisé avec un query plan. Aucun accès à la base de données dans le resolver. Cette séparation a été cruciale plus tard : elle nous a évité un bug de cache particulièrement pénible.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: { ...filters, sort: filters.sort ?? 'relevance' },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country': return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city': return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near': return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Flux de requête étape par étape\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"L’Edge\u002FCDN reçoit la requête. La clé de cache inclut l’hôte + le chemin + des paramètres de requête bornés (q, category, sort, page, pageSize, r).\",\"Le serveur applicatif crée le TenantContext à partir de l’en-tête Host. Aucune base de données à ce stade.\",\"Le GeoResolver analyse l’URL et produit un GeoQuery avec canonicalPath et cacheKey côté serveur.\",\"Le GeoController construit un QueryPlan et décide quels providers appeler selon la configuration du tenant et le type de scope.\",\"Le provider exécute une requête read-model sur le GeoIndex (rapide), puis hydrate les résultats depuis la source existante (DB SQL, API CMS) via les entity IDs.\",\"L’assembleur de réponse ajoute les métadonnées SEO : canonical, robots, liens de pagination.\",\"Le serveur définit les en-têtes de cache (s-maxage + stale-while-revalidate). Le body est tenant-scopé et n’est jamais partagé entre tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"Stratégie sans refactor : Read Model GeoIndex\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"Nous ne pouvons pas restructurer les tables de contenu existantes immédiatement. Nous ajoutons donc un GeoIndex séparé, reconstructible indépendamment. Il stocke le strict nécessaire pour une discovery efficace : tenantId, entityId, entityType, countryCode, citySlug, lat, lng et quelques champs de filtrage. L’hydratation récupère l’objet complet depuis la source actuelle.\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Compromis : cohérence éventuelle. Le délai de reconstruction de l’index est acceptable pour les pages de discovery. SLA : les mises à jour apparaissent sous 15 minutes. Les besoins temps réel ultérieurs (disponibilité de réservation) constituent une surface différente et ne doivent pas réutiliser le cache de discovery.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"Surface API\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Deux surfaces : (A) pages HTML pour le SEO et les utilisateurs, (B) API JSON pour le rendu client et les extensions futures. Même resolver et même query plan. Présentateurs différents.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML : GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API : GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (interne) : POST \u002Finternal\u002Fgeoindex\u002Frebuild (protégé), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optionnel, ultérieurement)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Scalabilité et performances\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Objectif principal de performance : servir le HTML de discovery depuis le CDN pour les landings geo populaires (pays\u002Fville).\",\"Les pages near sont cacheables mais présentent davantage de variantes (cellId + r + q + category + sort + page). Nous bornons r et pageSize et validons strictement les filtres.\",\"La requête GeoIndex doit être rapide : index sur tenantId + countryCode + citySlug ; pour near, utiliser des buckets de cellules (match par préfixe) puis affiner par distance dans l’application.\",\"Éviter les calculs haversine SQL coûteux sur de grands jeux de données. Cela semble simple. Ce ne l’est pas.\",\"L’hydratation est batchée par liste d’entityId : une requête par entityType, pas de N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Stratégie de recherche par rayon (pages near)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"Nous n’exécutons pas un scan de rayon complet sur toutes les lignes. À la place : (1) le cellId mappe vers un bucket de délimitation (préfixe de type geohash), (2) récupération des candidats depuis le GeoIndex par préfixe de bucket, (3) affinage dans l’application via la distance haversine, (4) tri et pagination. Cela rend la charge DB prévisible.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Modèle de cache (CDN + serveur)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"Nous mettons en cache à deux niveaux. Le CDN met en cache l’intégralité du HTML\u002FJSON pour le trafic anonyme. Le cache serveur stocke les résultats des providers indexés par GeoQuery.cacheKey. La clé inclut le tenant, le scope et des filtres bornés. Tout ne doit pas être mis en cache. La disponibilité de réservation sera exclue ultérieurement.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"La clé de cache CDN varie selon l’en-tête Host : c’est la frontière tenant.\",\"La clé de cache serveur inclut explicitement tenantId. Ne jamais se reposer sur un host implicite en mémoire.\",\"TTL de cache : pays\u002Fville 30–60 minutes au CDN (stale-while-revalidate activé). Pages near : 5 minutes.\",\"Nous conservons un levier manuel de purge par tenant (suffixe de version dans la clé de cache), utilisé lors des migrations et des déploiements défectueux.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"Détails de stabilité SEO\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Balises canonical : pointent toujours vers le chemin hiérarchique propre (sans querystring).\",\"Robots : si des paramètres de requête non whitelistés sont présents (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), définir noindex afin de bloquer les paramètres parasites issus de liens externes.\",\"Pagination : rel=next\u002Fprev générés uniquement pour les pages > 1 et si resultCount > pageSize.\",\"Liens internes stables : l’UI émet toujours des chemins canoniques ; la querystring n’est utilisée que pour les filtres sélectionnés par l’utilisateur.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Extensions futures sans alourdissement\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"Nous étendons le système en ajoutant des providers et des presenters, pas en surchargeant le resolver. Les réservations, avis et cartes vivent sur des pages d’entité ou des API dédiées. La discovery reste une surface de liste et de filtrage. Cette frontière est strictement appliquée lors des revues de code.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Réservation : endpoints \u002Fapi\u002Fbooking séparés. La discovery n’affiche des badges de disponibilité que s’ils sont mis en cache et non personnalisés.\",\"Avis : service\u002Fprovider d’avis séparé. La discovery lit des champs de notation agrégés depuis le GeoIndex (pré-calculés).\",\"Cartes : tuiles et marqueurs récupérés via \u002Fapi\u002Fdiscovery\u002Fmarkers avec un cache agressif ; ne pas intégrer dans la réponse HTML si cela dégrade le TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"Un problème rencontré (et ce que nous avons changé)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"Nous avons livré la première version avec une clé de cache serveur qui n’incluait PAS le tenantId. En local, cela « fonctionnait ». En staging, tout semblait correct. Puis en production : le portail A a commencé à afficher des listings du portail B sur les pages ville. Même chemin, tenant différent. Collision de cache. Moche.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"La correction a été simple mais stricte : le tenantId est devenu obligatoire dans le GeoQuery et la construction de la clé de cache a été déplacée dans le resolver afin de ne pas pouvoir être omise. Nous avons aussi ajouté une assertion runtime : si des entités hydratées contiennent un tenantId différent, nous levons une erreur et sautons l’écriture en cache. Volontairement bruyant.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan et contrat des providers\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"Le QueryPlan est un simple aiguillage basé sur la configuration du tenant et le type de scope. Par exemple : certains tenants utilisent uniquement SQL ; d’autres un service de portail distant. Même entrée GeoQuery. Même sortie GeoResult. Cela rend les déploiements prévisibles.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"GeoIndex SQL : schéma minimal\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Compromis (explicites)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"Nous échangeons la précision parfaite contre des URL stables : les pages near canonisent par cellId. Deux utilisateurs à 300 m peuvent atterrir sur la même page canonique. Acceptable.\",\"Nous échangeons la fraîcheur contre la cacheabilité : les pages de discovery ne sont pas en temps réel. Les mises à jour de l’index peuvent être différées.\",\"Nous évitons un refactor DB immédiat, mais payons un chemin d’écriture supplémentaire pour maintenir le GeoIndex.\",\"Nous évitons le couplage au routage CMS, mais possédons désormais un namespace de routes séparé (\u002Fdiscover). C’est intentionnel et c’est une promesse à tenir.\",\"Nous affinons la distance dans le code applicatif pour les recherches near afin d’éviter des calculs DB coûteux, déplaçant le CPU vers la couche applicative, plus facile à scaler horizontalement.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Plan de déploiement (pratique)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Livrer le resolver et des pages vides retournant 404 derrière un feature flag. Vérifier que le routage n’entre pas en conflit avec le catch-all CMS.\",\"Construire un job de backfill GeoIndex pour un tenant. Valider les comptes par rapport aux données source. S’attendre à des écarts et les journaliser.\",\"Activer uniquement les pages pays. Surveiller le taux de hit cache et les statistiques de crawl.\",\"Activer ensuite les pages ville. Les pages near en dernier (elles génèrent des variantes).\",\"Une fois les clés de cache stables, ajouter des consommateurs de l’API JSON (marqueurs de carte, filtrage client).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Liste d’acceptation\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Isolation multi-tenant : un même chemin sur deux hôtes ne partage jamais des entrées de cache serveur.\",\"Les URL canoniques n’incluent pas de paramètres de requête et restent stables lors des changements de filtres.\",\"Les conflits de slug CMS n’affectent pas les routes \u002Fdiscover.\",\"Les pages near ne génèrent pas de permutations d’URL non bornées (r et pageSize limités).\",\"Le contrat provider retourne une pagination déterministe et des totaux cohérents.\",\"La reconstruction du GeoIndex peut s’exécuter sans downtime et sans verrouiller longtemps les tables source.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":213,"version":509},1769827200000,[214,218,223,228,242,246,250,255,265,269,275,279,283,292,296,304,308,312,317,321,325,337,341,345,349,353,357,364,368,372,381,385,389,393,397,401,409,413,421,425,429,436,440,444,448,452,456,460,464,469,473,482,486,495,499],{"id":215,"data":216,"type":42},"h1",{"text":217,"level":40},"Geo Discovery : architecture canonique, conception des URL, logique du resolver, API et spécification de scalabilité",{"id":219,"data":220,"type":222},"p-scope",{"text":221},"Ce document spécifie la surface de découverte géographique (pays\u002Fville\u002Frayon) à travers plusieurs portails (multi-tenant), sans forcer un refactoring immédiat de la base de données et sans couplage avec le routage du CMS (page\u002Farticle). Il maintient une SEO stable, reste compatible avec le cache et laisse de la place pour la réservation, les avis et les cartes sans transformer l’application en un bloc monolithique.","paragraph",{"id":224,"data":225,"type":42},"h-goals",{"text":226,"level":227},"Contraintes et non-objectifs",2,{"id":229,"data":230,"type":241},"list-constraints",{"items":231,"style":240},[232,233,234,235,236,237,238,239],"Multi-tenant : la même base de code dessert plusieurs portails. Le tenant influence la portée du contenu, l’identité visuelle et parfois la source de données.","La découverte géographique doit supporter : pays, ville et recherche par rayon (autour d’un point).","Aucun refactoring immédiat de la base de données : nous ne pouvons pas restructurer les tables existantes en un schéma geo idéal à ce stade.","Indépendance vis-à-vis du routage CMS : les pages geo ne sont ni des « posts » ni des « pages ». Elles ne peuvent pas être bloquées par des conflits de slug CMS.","Stabilité SEO : les URL canoniques ne doivent pas changer lorsque les filtres ou les options de tri changent.","Compatibilité cache : le CDN et le cache serveur doivent avoir des clés prévisibles. Éviter toute variation par utilisateur.","Séparation stricte des responsabilités : discovery, CMS et résolution du tenant sont des modules distincts avec des frontières explicites.","Non-objectif (pour l’instant) : géocodage parfait. Nous acceptons un seul géocodeur, une stratégie de normalisation unique et stockons le résultat normalisé.","unordered","list",{"id":243,"data":244,"type":42},"h-arch",{"text":245,"level":227},"Architecture canonique",{"id":247,"data":248,"type":222},"p-arch",{"text":249},"Nous implémentons la découverte géographique comme un bounded context autonome avec une surface publique minimale : (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Réponse → métadonnées SEO et cache. Les routes CMS n’appellent jamais la discovery. La discovery n’appelle jamais le routage CMS. Elles ne partagent que des utilitaires bas niveau (HTTP, cache, contexte tenant).",{"id":251,"data":252,"type":42},"h-components",{"text":253,"level":254},"Composants clés",3,{"id":256,"data":257,"type":241},"list-components",{"items":258,"style":240},[259,260,261,262,263,264],"TenantContext : résout le tenant à partir de l’en-tête Host (ou d’un identifiant de portail explicite pour les appels internes).","GeoResolver : analyse et valide les segments d’URL géographiques ; émet un GeoQuery normalisé.","GeoIndex (Read Model) : table ou collection séparée qui mappe entityId → lat\u002Flng + scope tenant + champs minimaux recherchables. Cela évite le refactoring des tables de la base source.","Data Providers : sources interchangeables (par exemple tables SQL existantes, API WordPress, autre service de portail). Elles sont encapsulées derrière une interface.","SEO Router : produit l’URL canonique et les métadonnées (canonical, hreflang si nécessaire, indicateurs robots).","Couche de cache : clés de cache CDN et cache côté serveur avec clés tenant-scopées et versionnement.",{"id":266,"data":267,"type":42},"h-folder",{"text":268,"level":227},"Structure des dossiers",{"id":270,"data":271,"type":274},"code-folder",{"code":272,"language":273},"apps\u002F\n  web\u002F\n    routes\u002F\n      discovery\u002F                  # discovery entry points (not CMS)\n    pages\u002F\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\n  server\u002F\n    src\u002F\n      tenant\u002F\n        TenantContext.ts\n        tenantConfig.ts\n      discovery\u002F\n        geo\u002F\n          GeoResolver.ts\n          GeoQuery.ts\n          GeoCanonical.ts\n          GeoController.ts\n          providers\u002F\n            GeoProvider.ts\n            SqlGeoProvider.ts\n            RemoteGeoProvider.ts\n          index\u002F\n            GeoIndexRepository.ts\n            migrations\u002F\n      cache\u002F\n        Cache.ts\n        cacheKeys.ts\n      http\u002F\n        errors.ts\n        requestContext.ts\npackages\u002F\n  shared\u002F\n    src\u002F\n      geo\u002F\n        normalize.ts\n        haversine.ts\n      validation\u002F\n        zod.ts\n","text","code",{"id":276,"data":277,"type":42},"h-url",{"text":278,"level":227},"Conception des URL (SEO Canonical)",{"id":280,"data":281,"type":222},"p-url",{"text":282},"Nous conservons l’URL canonique strictement hiérarchique et lisible par l’humain. Les filtres et le tri restent dans la querystring mais n’affectent PAS le canonical. La recherche par rayon utilise une page « near » stable avec un canonical basé sur une cellule de coordonnées arrondie, et non sur les lat\u002Flng bruts. Cela empêche la génération infinie de variantes d’URL et évite l’explosion du cache. C’est un compromis délibéré.",{"id":284,"data":285,"type":241},"list-url",{"items":286,"style":240},[287,288,289,290,291],"Landing pays : \u002Fdiscover\u002F{countryCode} (exemple : \u002Fdiscover\u002Fde)","Landing ville : \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (exemple : \u002Fdiscover\u002Fde\u002Fmunich)","Near (rayon) : \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (exemple : \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) où cellId est un identifiant court de type geohash","Paramètres de requête optionnels (non canoniques) : ?q=shih-tzu&category=pet-care&sort=rating&page=2","Le tenant n’est PAS dans le chemin. Le tenant est déterminé par l’hôte (portal-a.tld, portal-b.tld). Les appels internes transmettent x-tenant-id.",{"id":293,"data":294,"type":42},"h-canonical-rules",{"text":295,"level":254},"Règles de canonicalisation",{"id":297,"data":298,"type":241},"list-canonical",{"items":299,"style":240},[300,301,302,303],"Les pages pays\u002Fville canonisent vers leur propre chemin propre (sans querystring).","Les pages near canonisent vers \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. Le canonical est dérivé d’une cellule arrondie, pas des coordonnées entrantes.","page=1 est omis du canonical et de la génération des liens internes.","Les combinaisons non supportées (par ex. incohérence de pays) retournent un 404, pas une redirection. Les chaînes de redirection nuisaient au budget de crawl lors des tests.",{"id":305,"data":306,"type":42},"h-resolver",{"text":307,"level":227},"Logique du resolver",{"id":309,"data":310,"type":222},"p-resolver",{"text":311},"L’entrée du resolver est (tenant, pathname, query). La sortie est un GeoQuery normalisé avec un query plan. Aucun accès à la base de données dans le resolver. Cette séparation a été cruciale plus tard : elle nous a évité un bug de cache particulièrement pénible.",{"id":313,"data":314,"type":274},"code-types",{"code":315,"language":316},"export type TenantId = string;\n\nexport type GeoScope =\n  | { kind: 'country'; countryCode: string }\n  | { kind: 'city'; countryCode: string; citySlug: string }\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\n\nexport type GeoFilters = {\n  q?: string;\n  category?: string;\n  sort?: 'relevance' | 'rating' | 'distance';\n  page: number;\n  pageSize: number;\n};\n\nexport type GeoQuery = {\n  tenantId: TenantId;\n  scope: GeoScope;\n  filters: GeoFilters;\n  canonicalPath: string;\n  cacheKey: string;\n};\n","ts",{"id":318,"data":319,"type":274},"code-resolver",{"code":320,"language":316},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: { ...filters, sort: filters.sort ?? 'relevance' },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country': return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city': return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near': return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":322,"data":323,"type":42},"h-flow",{"text":324,"level":227},"Flux de requête étape par étape",{"id":326,"data":327,"type":241},"list-flow",{"items":328,"style":336},[329,330,331,332,333,334,335],"L’Edge\u002FCDN reçoit la requête. La clé de cache inclut l’hôte + le chemin + des paramètres de requête bornés (q, category, sort, page, pageSize, r).","Le serveur applicatif crée le TenantContext à partir de l’en-tête Host. Aucune base de données à ce stade.","Le GeoResolver analyse l’URL et produit un GeoQuery avec canonicalPath et cacheKey côté serveur.","Le GeoController construit un QueryPlan et décide quels providers appeler selon la configuration du tenant et le type de scope.","Le provider exécute une requête read-model sur le GeoIndex (rapide), puis hydrate les résultats depuis la source existante (DB SQL, API CMS) via les entity IDs.","L’assembleur de réponse ajoute les métadonnées SEO : canonical, robots, liens de pagination.","Le serveur définit les en-têtes de cache (s-maxage + stale-while-revalidate). Le body est tenant-scopé et n’est jamais partagé entre tenants.","ordered",{"id":338,"data":339,"type":42},"h-read-model",{"text":340,"level":227},"Stratégie sans refactor : Read Model GeoIndex",{"id":342,"data":343,"type":222},"p-read-model",{"text":344},"Nous ne pouvons pas restructurer les tables de contenu existantes immédiatement. Nous ajoutons donc un GeoIndex séparé, reconstructible indépendamment. Il stocke le strict nécessaire pour une discovery efficace : tenantId, entityId, entityType, countryCode, citySlug, lat, lng et quelques champs de filtrage. L’hydratation récupère l’objet complet depuis la source actuelle.",{"id":346,"data":347,"type":222},"p-tradeoffs-read-model",{"text":348},"Compromis : cohérence éventuelle. Le délai de reconstruction de l’index est acceptable pour les pages de discovery. SLA : les mises à jour apparaissent sous 15 minutes. Les besoins temps réel ultérieurs (disponibilité de réservation) constituent une surface différente et ne doivent pas réutiliser le cache de discovery.",{"id":350,"data":351,"type":42},"h-api",{"text":352,"level":227},"Surface API",{"id":354,"data":355,"type":222},"p-api",{"text":356},"Deux surfaces : (A) pages HTML pour le SEO et les utilisateurs, (B) API JSON pour le rendu client et les extensions futures. Même resolver et même query plan. Présentateurs différents.",{"id":358,"data":359,"type":241},"list-api",{"items":360,"style":240},[361,362,363],"HTML : GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API : GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","Admin (interne) : POST \u002Finternal\u002Fgeoindex\u002Frebuild (protégé), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optionnel, ultérieurement)",{"id":365,"data":366,"type":274},"code-api-handler",{"code":367,"language":316},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":369,"data":370,"type":42},"h-scalability",{"text":371,"level":227},"Scalabilité et performances",{"id":373,"data":374,"type":241},"list-scale",{"items":375,"style":240},[376,377,378,379,380],"Objectif principal de performance : servir le HTML de discovery depuis le CDN pour les landings geo populaires (pays\u002Fville).","Les pages near sont cacheables mais présentent davantage de variantes (cellId + r + q + category + sort + page). Nous bornons r et pageSize et validons strictement les filtres.","La requête GeoIndex doit être rapide : index sur tenantId + countryCode + citySlug ; pour near, utiliser des buckets de cellules (match par préfixe) puis affiner par distance dans l’application.","Éviter les calculs haversine SQL coûteux sur de grands jeux de données. Cela semble simple. Ce ne l’est pas.","L’hydratation est batchée par liste d’entityId : une requête par entityType, pas de N+1.",{"id":382,"data":383,"type":42},"h-near-strategy",{"text":384,"level":254},"Stratégie de recherche par rayon (pages near)",{"id":386,"data":387,"type":222},"p-near",{"text":388},"Nous n’exécutons pas un scan de rayon complet sur toutes les lignes. À la place : (1) le cellId mappe vers un bucket de délimitation (préfixe de type geohash), (2) récupération des candidats depuis le GeoIndex par préfixe de bucket, (3) affinage dans l’application via la distance haversine, (4) tri et pagination. Cela rend la charge DB prévisible.",{"id":390,"data":391,"type":274},"code-haversine",{"code":392,"language":316},"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\n  const R = 6371000;\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\n\n  const dLat = toRad(b.lat - a.lat);\n  const dLng = toRad(b.lng - a.lng);\n\n  const lat1 = toRad(a.lat);\n  const lat2 = toRad(b.lat);\n\n  const sinDLat = Math.sin(dLat \u002F 2);\n  const sinDLng = Math.sin(dLng \u002F 2);\n\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\n\n  return R * c;\n}\n",{"id":394,"data":395,"type":42},"h-cache",{"text":396,"level":227},"Modèle de cache (CDN + serveur)",{"id":398,"data":399,"type":222},"p-cache",{"text":400},"Nous mettons en cache à deux niveaux. Le CDN met en cache l’intégralité du HTML\u002FJSON pour le trafic anonyme. Le cache serveur stocke les résultats des providers indexés par GeoQuery.cacheKey. La clé inclut le tenant, le scope et des filtres bornés. Tout ne doit pas être mis en cache. La disponibilité de réservation sera exclue ultérieurement.",{"id":402,"data":403,"type":241},"list-cache",{"items":404,"style":240},[405,406,407,408],"La clé de cache CDN varie selon l’en-tête Host : c’est la frontière tenant.","La clé de cache serveur inclut explicitement tenantId. Ne jamais se reposer sur un host implicite en mémoire.","TTL de cache : pays\u002Fville 30–60 minutes au CDN (stale-while-revalidate activé). Pages near : 5 minutes.","Nous conservons un levier manuel de purge par tenant (suffixe de version dans la clé de cache), utilisé lors des migrations et des déploiements défectueux.",{"id":410,"data":411,"type":42},"h-seo",{"text":412,"level":227},"Détails de stabilité SEO",{"id":414,"data":415,"type":241},"list-seo",{"items":416,"style":240},[417,418,419,420],"Balises canonical : pointent toujours vers le chemin hiérarchique propre (sans querystring).","Robots : si des paramètres de requête non whitelistés sont présents (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), définir noindex afin de bloquer les paramètres parasites issus de liens externes.","Pagination : rel=next\u002Fprev générés uniquement pour les pages > 1 et si resultCount > pageSize.","Liens internes stables : l’UI émet toujours des chemins canoniques ; la querystring n’est utilisée que pour les filtres sélectionnés par l’utilisateur.",{"id":422,"data":423,"type":42},"h-extensions",{"text":424,"level":227},"Extensions futures sans alourdissement",{"id":426,"data":427,"type":222},"p-extensions",{"text":428},"Nous étendons le système en ajoutant des providers et des presenters, pas en surchargeant le resolver. Les réservations, avis et cartes vivent sur des pages d’entité ou des API dédiées. La discovery reste une surface de liste et de filtrage. Cette frontière est strictement appliquée lors des revues de code.",{"id":430,"data":431,"type":241},"list-extensions",{"items":432,"style":240},[433,434,435],"Réservation : endpoints \u002Fapi\u002Fbooking séparés. La discovery n’affiche des badges de disponibilité que s’ils sont mis en cache et non personnalisés.","Avis : service\u002Fprovider d’avis séparé. La discovery lit des champs de notation agrégés depuis le GeoIndex (pré-calculés).","Cartes : tuiles et marqueurs récupérés via \u002Fapi\u002Fdiscovery\u002Fmarkers avec un cache agressif ; ne pas intégrer dans la réponse HTML si cela dégrade le TTFB.",{"id":437,"data":438,"type":42},"h-went-wrong",{"text":439,"level":227},"Un problème rencontré (et ce que nous avons changé)",{"id":441,"data":442,"type":222},"p-wrong",{"text":443},"Nous avons livré la première version avec une clé de cache serveur qui n’incluait PAS le tenantId. En local, cela « fonctionnait ». En staging, tout semblait correct. Puis en production : le portail A a commencé à afficher des listings du portail B sur les pages ville. Même chemin, tenant différent. Collision de cache. Moche.",{"id":445,"data":446,"type":222},"p-fix",{"text":447},"La correction a été simple mais stricte : le tenantId est devenu obligatoire dans le GeoQuery et la construction de la clé de cache a été déplacée dans le resolver afin de ne pas pouvoir être omise. Nous avons aussi ajouté une assertion runtime : si des entités hydratées contiennent un tenantId différent, nous levons une erreur et sautons l’écriture en cache. Volontairement bruyant.",{"id":449,"data":450,"type":42},"h-query-plan",{"text":451,"level":227},"Query Plan et contrat des providers",{"id":453,"data":454,"type":274},"code-provider",{"code":455,"language":316},"import type { GeoQuery } from '..\u002FGeoQuery';\n\nexport type GeoHit = {\n  entityId: string;\n  entityType: 'place' | 'service' | 'listing';\n  lat: number;\n  lng: number;\n  citySlug?: string;\n  countryCode: string;\n  score?: number;\n  distanceMeters?: number;\n};\n\nexport type GeoResult = {\n  hits: GeoHit[];\n  total: number;\n  page: number;\n  pageSize: number;\n};\n\nexport interface GeoProvider {\n  search(query: GeoQuery): Promise\u003CGeoResult>;\n}\n",{"id":457,"data":458,"type":222},"p-queryplan",{"text":459},"Le QueryPlan est un simple aiguillage basé sur la configuration du tenant et le type de scope. Par exemple : certains tenants utilisent uniquement SQL ; d’autres un service de portail distant. Même entrée GeoQuery. Même sortie GeoResult. Cela rend les déploiements prévisibles.",{"id":461,"data":462,"type":42},"h-sql",{"text":463,"level":254},"GeoIndex SQL : schéma minimal",{"id":465,"data":466,"type":274},"code-sql",{"code":467,"language":468},"CREATE TABLE geo_index (\n  tenant_id     TEXT NOT NULL,\n  entity_id     TEXT NOT NULL,\n  entity_type   TEXT NOT NULL,\n  country_code  TEXT NOT NULL,\n  city_slug     TEXT,\n  lat           DOUBLE PRECISION NOT NULL,\n  lng           DOUBLE PRECISION NOT NULL,\n  cell_prefix   TEXT NOT NULL,\n  category      TEXT,\n  rating_avg    DOUBLE PRECISION,\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\n  PRIMARY KEY (tenant_id, entity_id)\n);\n\nCREATE INDEX geo_index_country_city\n  ON geo_index (tenant_id, country_code, city_slug);\n\nCREATE INDEX geo_index_cell_prefix\n  ON geo_index (tenant_id, country_code, cell_prefix);\n","sql",{"id":470,"data":471,"type":42},"h-tradeoffs",{"text":472,"level":227},"Compromis (explicites)",{"id":474,"data":475,"type":241},"list-tradeoffs",{"items":476,"style":240},[477,478,479,480,481],"Nous échangeons la précision parfaite contre des URL stables : les pages near canonisent par cellId. Deux utilisateurs à 300 m peuvent atterrir sur la même page canonique. Acceptable.","Nous échangeons la fraîcheur contre la cacheabilité : les pages de discovery ne sont pas en temps réel. Les mises à jour de l’index peuvent être différées.","Nous évitons un refactor DB immédiat, mais payons un chemin d’écriture supplémentaire pour maintenir le GeoIndex.","Nous évitons le couplage au routage CMS, mais possédons désormais un namespace de routes séparé (\u002Fdiscover). C’est intentionnel et c’est une promesse à tenir.","Nous affinons la distance dans le code applicatif pour les recherches near afin d’éviter des calculs DB coûteux, déplaçant le CPU vers la couche applicative, plus facile à scaler horizontalement.",{"id":483,"data":484,"type":42},"h-rollout",{"text":485,"level":227},"Plan de déploiement (pratique)",{"id":487,"data":488,"type":241},"list-rollout",{"items":489,"style":336},[490,491,492,493,494],"Livrer le resolver et des pages vides retournant 404 derrière un feature flag. Vérifier que le routage n’entre pas en conflit avec le catch-all CMS.","Construire un job de backfill GeoIndex pour un tenant. Valider les comptes par rapport aux données source. S’attendre à des écarts et les journaliser.","Activer uniquement les pages pays. Surveiller le taux de hit cache et les statistiques de crawl.","Activer ensuite les pages ville. Les pages near en dernier (elles génèrent des variantes).","Une fois les clés de cache stables, ajouter des consommateurs de l’API JSON (marqueurs de carte, filtrage client).",{"id":496,"data":497,"type":42},"h-acceptance",{"text":498,"level":227},"Liste d’acceptation",{"id":500,"data":501,"type":241},"list-acceptance",{"items":502,"style":240},[503,504,505,506,507,508],"Isolation multi-tenant : un même chemin sur deux hôtes ne partage jamais des entrées de cache serveur.","Les URL canoniques n’incluent pas de paramètres de requête et restent stables lors des changements de filtres.","Les conflits de slug CMS n’affectent pas les routes \u002Fdiscover.","Les pages near ne génèrent pas de permutations d’URL non bornées (r et pageSize limités).","Le contrat provider retourne une pagination déterministe et des totaux cohérents.","La reconstruction du GeoIndex peut s’exécuter sans downtime et sans verrouiller longtemps les tables source.","2.29.1","Architecture de découverte géolocalisée pour les portails multi-locataires. Définit les URL canoniques, la logique de résolution, la stratégie de mise en cache et un modèle de lecture géo sans couplage CMS ni refactorisation de base de données. Conçue pour la stabilité SEO, l'évolutivité et les futures extensions comme la réservation et les cartes.","\u002Fuploads\u002F2026\u002F01\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp.webp","canonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp","PUBLISHED","2026-01-31T06:12:00.000Z","2026-01-31T20:12:05.337Z","2026-02-20T20:40:40.678Z",{"en":518,"de":519,"sr":520,"es":521,"fr":522,"it":523,"ru":524,"zh":525},"\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fde\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fsr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fes\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Ffr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fit\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fru\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fzh\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification",[527,531,535,539,543],{"id":528,"name":529,"slug":530},108,"Évaluation de livraison","delivery-assessment",{"id":532,"name":533,"slug":534},107,"Évaluations","assessments",{"id":536,"name":537,"slug":538},45,"Modèle de référence : Plateforme numérique","digital-platform",{"id":540,"name":541,"slug":542},66,"Opérations de contenu","content-ops",{"id":544,"name":545,"slug":546},79,"Playbook : Durcissement sécurité","security-hardening",{"id":548,"login":549,"email":550,"displayName":551},"20","rooth8233","aleksandar@stajic.de","Aleksandar Stajić",[553,785],{"lang":554,"title":555,"content":556,"contentJson":557,"excerpt":784},"en","Canonical Architecture, URL Design, Resolver Logic, API & Scalability Specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Constraints and Non-Goals\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.\",\"Geo discovery must support: country, city, radius search (around a point).\",\"No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.\",\"Independence from CMS routing: geo pages are not \\\"posts\\\" or \\\"pages\\\". They cannot be blocked by CMS slug conflicts.\",\"SEO stability: canonical URLs must not change when filters\u002Fsort options change.\",\"Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.\",\"Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.\",\"Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Canonical Architecture\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Key Components\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).\",\"GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.\",\"GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.\",\"Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.\",\"SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).\",\"Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Folder Structure\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL Design (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \\\"near\\\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)\",\"City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier\",\"Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Canonical Rules\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Country\u002Fcity pages canonicalize to their own clean path (no querystring).\",\"Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.\",\"page=1 is omitted from canonical and from internal link generation.\",\"Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Resolver Logic\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F radius is NOT in the path, but is bounded.\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\\n  \u002F\u002F But we keep it bounded and explicit.\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Step-by-Step Request Flow\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).\",\"App server creates TenantContext from Host header. No DB yet.\",\"GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.\",\"GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.\",\"Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).\",\"Response assembler attaches SEO metadata: canonical, robots, pagination links.\",\"Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"No-Refactor Strategy: GeoIndex Read Model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API Surface\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\\n  \u002F\u002F This keeps logic aligned between HTML and API.\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Scalability & Performance\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).\",\"Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.\",\"GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.\",\"Avoid expensive SQL haversine over large datasets. It looks simple. It is not.\",\"Hydration is batched by entityId list. One query per entityType, not N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Radius Search Strategy (Near Pages)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Caching Model (CDN + Server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"CDN cache key varies by Host header. That is the tenant boundary.\",\"Server cache key includes tenantId explicitly. Never rely on implicit host in-process.\",\"Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.\",\"We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"SEO Stability Details\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical tags: always point to the clean hierarchical path (no querystring).\",\"Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.\",\"Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.\",\"Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Future Extensions Without Bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.\",\"Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).\",\"Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"One Thing That Went Wrong (and What We Changed)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan and Provider Contract\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: Minimal Schema\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Trade-offs (Explicit)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.\",\"We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.\",\"We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.\",\"We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.\",\"We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Rollout Plan (Practical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.\",\"Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.\",\"Enable country pages only. Watch cache hit ratio and crawl stats.\",\"Enable city pages next. Then near pages last (they are the variant generator).\",\"After we see stable cache keys, add JSON API consumers (maps markers, client filtering).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Acceptance Checklist\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Multi-tenant isolation: same path on two hosts never shares server cache entries.\",\"Canonical URLs do not include query params and remain stable across filter changes.\",\"CMS slug conflicts do not affect \u002Fdiscover routes.\",\"Near pages do not generate unbounded URL permutations (bounded r, pageSize).\",\"Provider contract returns deterministic paging and total counts.\",\"GeoIndex rebuild can run without downtime and without locking source tables for long periods.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":558,"version":509},[559,562,565,568,579,582,585,588,597,600,602,605,608,616,619,626,629,632,634,637,640,650,653,656,659,662,665,671,674,677,685,688,691,693,696,699,706,709,716,719,722,728,731,734,737,740,742,745,748,750,753,761,764,772,775],{"id":215,"data":560,"type":42},{"text":561,"level":40},"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec",{"id":219,"data":563,"type":222},{"text":564},"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.",{"id":224,"data":566,"type":42},{"text":567,"level":227},"Constraints and Non-Goals",{"id":229,"data":569,"type":241},{"items":570,"style":240},[571,572,573,574,575,576,577,578],"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.","Geo discovery must support: country, city, radius search (around a point).","No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.","Independence from CMS routing: geo pages are not \"posts\" or \"pages\". They cannot be blocked by CMS slug conflicts.","SEO stability: canonical URLs must not change when filters\u002Fsort options change.","Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.","Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.","Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.",{"id":243,"data":580,"type":42},{"text":581,"level":227},"Canonical Architecture",{"id":247,"data":583,"type":222},{"text":584},"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).",{"id":251,"data":586,"type":42},{"text":587,"level":254},"Key Components",{"id":256,"data":589,"type":241},{"items":590,"style":240},[591,592,593,594,595,596],"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).","GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.","GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.","Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.","SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).","Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.",{"id":266,"data":598,"type":42},{"text":599,"level":227},"Folder Structure",{"id":270,"data":601,"type":274},{"code":272,"language":273},{"id":276,"data":603,"type":42},{"text":604,"level":227},"URL Design (SEO Canonical)",{"id":280,"data":606,"type":222},{"text":607},"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \"near\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.",{"id":284,"data":609,"type":241},{"items":610,"style":240},[611,612,613,614,615],"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)","City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)","Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier","Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.",{"id":293,"data":617,"type":42},{"text":618,"level":254},"Canonical Rules",{"id":297,"data":620,"type":241},{"items":621,"style":240},[622,623,624,625],"Country\u002Fcity pages canonicalize to their own clean path (no querystring).","Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.","page=1 is omitted from canonical and from internal link generation.","Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.",{"id":305,"data":627,"type":42},{"text":628,"level":227},"Resolver Logic",{"id":309,"data":630,"type":222},{"text":631},"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.",{"id":313,"data":633,"type":274},{"code":315,"language":316},{"id":318,"data":635,"type":274},{"code":636,"language":316},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  \u002F\u002F \u002Fdiscover\u002F{country}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    \u002F\u002F radius is NOT in the path, but is bounded.\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: {\n      ...filters,\n      sort: filters.sort ?? 'relevance'\n    },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country':\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\n  \u002F\u002F But we keep it bounded and explicit.\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":322,"data":638,"type":42},{"text":639,"level":227},"Step-by-Step Request Flow",{"id":326,"data":641,"type":241},{"items":642,"style":336},[643,644,645,646,647,648,649],"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).","App server creates TenantContext from Host header. No DB yet.","GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.","GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.","Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).","Response assembler attaches SEO metadata: canonical, robots, pagination links.","Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.",{"id":338,"data":651,"type":42},{"text":652,"level":227},"No-Refactor Strategy: GeoIndex Read Model",{"id":342,"data":654,"type":222},{"text":655},"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).",{"id":346,"data":657,"type":222},{"text":658},"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.",{"id":350,"data":660,"type":42},{"text":661,"level":227},"API Surface",{"id":354,"data":663,"type":222},{"text":664},"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.",{"id":358,"data":666,"type":241},{"items":667,"style":240},[668,669,670],"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)",{"id":365,"data":672,"type":274},{"code":673,"language":316},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\n  \u002F\u002F This keeps logic aligned between HTML and API.\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":369,"data":675,"type":42},{"text":676,"level":227},"Scalability & Performance",{"id":373,"data":678,"type":241},{"items":679,"style":240},[680,681,682,683,684],"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).","Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.","GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.","Avoid expensive SQL haversine over large datasets. It looks simple. It is not.","Hydration is batched by entityId list. One query per entityType, not N+1.",{"id":382,"data":686,"type":42},{"text":687,"level":254},"Radius Search Strategy (Near Pages)",{"id":386,"data":689,"type":222},{"text":690},"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.",{"id":390,"data":692,"type":274},{"code":392,"language":316},{"id":394,"data":694,"type":42},{"text":695,"level":227},"Caching Model (CDN + Server)",{"id":398,"data":697,"type":222},{"text":698},"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.",{"id":402,"data":700,"type":241},{"items":701,"style":240},[702,703,704,705],"CDN cache key varies by Host header. That is the tenant boundary.","Server cache key includes tenantId explicitly. Never rely on implicit host in-process.","Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.","We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.",{"id":410,"data":707,"type":42},{"text":708,"level":227},"SEO Stability Details",{"id":414,"data":710,"type":241},{"items":711,"style":240},[712,713,714,715],"Canonical tags: always point to the clean hierarchical path (no querystring).","Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.","Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.","Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.",{"id":422,"data":717,"type":42},{"text":718,"level":227},"Future Extensions Without Bloat",{"id":426,"data":720,"type":222},{"text":721},"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.",{"id":430,"data":723,"type":241},{"items":724,"style":240},[725,726,727],"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.","Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).","Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.",{"id":437,"data":729,"type":42},{"text":730,"level":227},"One Thing That Went Wrong (and What We Changed)",{"id":441,"data":732,"type":222},{"text":733},"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.",{"id":445,"data":735,"type":222},{"text":736},"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.",{"id":449,"data":738,"type":42},{"text":739,"level":227},"Query Plan and Provider Contract",{"id":453,"data":741,"type":274},{"code":455,"language":316},{"id":457,"data":743,"type":222},{"text":744},"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.",{"id":461,"data":746,"type":42},{"text":747,"level":254},"SQL GeoIndex: Minimal Schema",{"id":465,"data":749,"type":274},{"code":467,"language":468},{"id":470,"data":751,"type":42},{"text":752,"level":227},"Trade-offs (Explicit)",{"id":474,"data":754,"type":241},{"items":755,"style":240},[756,757,758,759,760],"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.","We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.","We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.","We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.","We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.",{"id":483,"data":762,"type":42},{"text":763,"level":227},"Rollout Plan (Practical)",{"id":487,"data":765,"type":241},{"items":766,"style":336},[767,768,769,770,771],"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.","Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.","Enable country pages only. Watch cache hit ratio and crawl stats.","Enable city pages next. Then near pages last (they are the variant generator).","After we see stable cache keys, add JSON API consumers (maps markers, client filtering).",{"id":496,"data":773,"type":42},{"text":774,"level":227},"Acceptance Checklist",{"id":500,"data":776,"type":241},{"items":777,"style":240},[778,779,780,781,782,783],"Multi-tenant isolation: same path on two hosts never shares server cache entries.","Canonical URLs do not include query params and remain stable across filter changes.","CMS slug conflicts do not affect \u002Fdiscover routes.","Near pages do not generate unbounded URL permutations (bounded r, pageSize).","Provider contract returns deterministic paging and total counts.","GeoIndex rebuild can run without downtime and without locking source tables for long periods.","Geo-based discovery architecture for multi-tenant portals. Defines canonical URLs, resolver logic, caching strategy, and a geo read-model without CMS coupling or database refactoring. Designed for SEO stability, scalability, and future extensions like booking and maps.",{"lang":7,"title":208,"content":210,"contentJson":786,"excerpt":510},{"time":212,"blocks":787,"version":509},[788,790,792,794,797,799,801,803,806,808,810,812,814,817,819,822,824,826,828,830,832,835,837,839,841,843,845,848,850,852,855,857,859,861,863,865,868,870,873,875,877,880,882,884,886,888,890,892,894,896,898,901,903,906,908],{"id":215,"data":789,"type":42},{"text":217,"level":40},{"id":219,"data":791,"type":222},{"text":221},{"id":224,"data":793,"type":42},{"text":226,"level":227},{"id":229,"data":795,"type":241},{"items":796,"style":240},[232,233,234,235,236,237,238,239],{"id":243,"data":798,"type":42},{"text":245,"level":227},{"id":247,"data":800,"type":222},{"text":249},{"id":251,"data":802,"type":42},{"text":253,"level":254},{"id":256,"data":804,"type":241},{"items":805,"style":240},[259,260,261,262,263,264],{"id":266,"data":807,"type":42},{"text":268,"level":227},{"id":270,"data":809,"type":274},{"code":272,"language":273},{"id":276,"data":811,"type":42},{"text":278,"level":227},{"id":280,"data":813,"type":222},{"text":282},{"id":284,"data":815,"type":241},{"items":816,"style":240},[287,288,289,290,291],{"id":293,"data":818,"type":42},{"text":295,"level":254},{"id":297,"data":820,"type":241},{"items":821,"style":240},[300,301,302,303],{"id":305,"data":823,"type":42},{"text":307,"level":227},{"id":309,"data":825,"type":222},{"text":311},{"id":313,"data":827,"type":274},{"code":315,"language":316},{"id":318,"data":829,"type":274},{"code":320,"language":316},{"id":322,"data":831,"type":42},{"text":324,"level":227},{"id":326,"data":833,"type":241},{"items":834,"style":336},[329,330,331,332,333,334,335],{"id":338,"data":836,"type":42},{"text":340,"level":227},{"id":342,"data":838,"type":222},{"text":344},{"id":346,"data":840,"type":222},{"text":348},{"id":350,"data":842,"type":42},{"text":352,"level":227},{"id":354,"data":844,"type":222},{"text":356},{"id":358,"data":846,"type":241},{"items":847,"style":240},[361,362,363],{"id":365,"data":849,"type":274},{"code":367,"language":316},{"id":369,"data":851,"type":42},{"text":371,"level":227},{"id":373,"data":853,"type":241},{"items":854,"style":240},[376,377,378,379,380],{"id":382,"data":856,"type":42},{"text":384,"level":254},{"id":386,"data":858,"type":222},{"text":388},{"id":390,"data":860,"type":274},{"code":392,"language":316},{"id":394,"data":862,"type":42},{"text":396,"level":227},{"id":398,"data":864,"type":222},{"text":400},{"id":402,"data":866,"type":241},{"items":867,"style":240},[405,406,407,408],{"id":410,"data":869,"type":42},{"text":412,"level":227},{"id":414,"data":871,"type":241},{"items":872,"style":240},[417,418,419,420],{"id":422,"data":874,"type":42},{"text":424,"level":227},{"id":426,"data":876,"type":222},{"text":428},{"id":430,"data":878,"type":241},{"items":879,"style":240},[433,434,435],{"id":437,"data":881,"type":42},{"text":439,"level":227},{"id":441,"data":883,"type":222},{"text":443},{"id":445,"data":885,"type":222},{"text":447},{"id":449,"data":887,"type":42},{"text":451,"level":227},{"id":453,"data":889,"type":274},{"code":455,"language":316},{"id":457,"data":891,"type":222},{"text":459},{"id":461,"data":893,"type":42},{"text":463,"level":254},{"id":465,"data":895,"type":274},{"code":467,"language":468},{"id":470,"data":897,"type":42},{"text":472,"level":227},{"id":474,"data":899,"type":241},{"items":900,"style":240},[477,478,479,480,481],{"id":483,"data":902,"type":42},{"text":485,"level":227},{"id":487,"data":904,"type":241},{"items":905,"style":336},[490,491,492,493,494],{"id":496,"data":907,"type":42},{"text":498,"level":227},{"id":500,"data":909,"type":241},{"items":910,"style":240},[503,504,505,506,507,508],"Post erfolgreich abgerufen",{"items":913,"source":949,"manualIds":950,"manualMatchedIds":951},[914,921,928,935,942],{"id":915,"slug":916,"title":917,"excerpt":918,"featuredImage":919,"publishedAt":920},"456","zbt-z8102ax-hardware-packaging-review","Test du matériel et de l'emballage du ZBT Z8102AX : routeur solide, boîte fragile","Le ZBT Z8102AX fait une solide première impression en tant que routeur OpenWrt 5G fin en métal noir avec plusieurs connecteurs d'antenne, des emplacements double SIM, des ports USB, LAN\u002FWAN et un ensemble d'accessoires pratique. Le matériel semble utile et sérieux, mais l'emballage est clairement le point faible.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-02-1781620590938-y33j4b.webp","2026-06-16T04:40:00.000Z",{"id":922,"slug":923,"title":924,"excerpt":925,"featuredImage":926,"publishedAt":927},"361","model-view-controller-mvc","Modèle-Vue-Contrôleur (MVC) : l'épine dorsale structurelle des applications web modernes","Modèle-Vue-Contrôleur, généralement abrégé en MVC, reste l'un des modèles d'architecture les plus durables dans le développement de logiciels. Il offre aux équipes un moyen pratique de séparer la logique métier, la présentation et l'interaction utilisateur afin que les applications restent plus faciles à construire, à étendre, à tester et à maintenir. Cet article explique ce qu'est le MVC, pourquoi il est toujours important, où il s'intègre dans les piles Web d'aujourd'hui et comment il se connecte à l'architecture de plateforme plus large, à la qualité de la livraison, à la stratégie de migration et à la maturité opérationnelle.","\u002Fuploads\u002F2026\u002F03\u002Fmodel-view-controller-mvc-1774872805793-0bjubu.webp","2023-04-12T12:57:00.000Z",{"id":929,"slug":930,"title":931,"excerpt":932,"featuredImage":933,"publishedAt":934},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","Enterprise-Grade Multi-Tenant Architecture for an International Platform","Loving Rocks is an enterprise-grade wedding platform designed with a true multi-tenant architecture, isolated databases per tenant, and built-in internationalization for global scalability, security, and long-term operational stability.","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z",{"id":936,"slug":937,"title":938,"excerpt":939,"featuredImage":940,"publishedAt":941},"454","zbt-z8102ax-rm500u-ea-5g-modem-test","Quectel RM500U-EA dans le ZBT Z8102AX : bandes 5G, o2 Allemagne et comportement du signal en conditions réelles","Le ZBT Z8102AX utilise un modem Quectel RM500U-EA pour la connectivité 4G et 5G. Lors du premier test pratique, le routeur s'est connecté avec succès à o2 Germany avec la bande LTE 3 et la NR n28. Le modem fonctionne, mais des diagnostics plus approfondis tels que le RSRP, le RSRQ, le SINR, le verrouillage de bande et le comportement des cellules nécessitent encore des tests appropriés.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-06-1781620597879-qay2sx.webp","2026-06-16T08:39:00.000Z",{"id":943,"slug":944,"title":945,"excerpt":946,"featuredImage":947,"publishedAt":948},"471","how-to-know-whether-an-ai-agent-actually-used-the-right-evidence","Comment savoir si un agent IA a réellement utilisé les bonnes preuves","Un agent IA peut citer des sources et tout de même utiliser les mauvais éléments de preuve. Cet article présente une méthode pratique pour vérifier le soutien des affirmations, l'autorité de la source, l'applicabilité, la provenance et si les éléments de preuve ont réellement influencé la réponse.","\u002Fuploads\u002F2026\u002F09\u002Fhow-to-know-whether-an-ai-agent-actually-used-the-right-evidence-1790351317188-o5z9ve.webp","2026-09-25T11:47:00.000Z","fallback",[],[]]