[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:it":3,"public-menus:all":38,"post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:it":205,"related:post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:it:1":908},{"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","it","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":907},{"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","Architettura Canonica, Progettazione URL, Logica del Resolver, Specifiche API e Scalabilità","canonical-architecture-url-design-resolver-logic-api-scalability-specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: architettura canonica, design URL, logica del resolver, API e specifica di scalabilità\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"Questo documento definisce la superficie di discovery basata sulla geolocalizzazione (paese\u002Fcittà\u002Fraggio) su più portali (multi-tenant), senza forzare un refactoring immediato del DB e senza accoppiarsi al routing del CMS (pagina\u002Fpost). Mantiene stabile la SEO, resta cache-friendly e lascia spazio a prenotazioni\u002Frecensioni\u002Fmappe senza trasformare l’app in un blob.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Vincoli e non-obiettivi\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: la stessa codebase serve più portali. Il tenant influenza lo scope dei contenuti, il branding e talvolta la fonte dati.\",\"La geo discovery deve supportare: paese, città, ricerca per raggio (attorno a un punto).\",\"Nessun refactoring immediato del database: non possiamo rimodellare le tabelle esistenti in uno schema geo perfetto adesso.\",\"Indipendenza dal routing del CMS: le pagine geo non sono “post” o “pagine”. Non possono essere bloccate da conflitti di slug del CMS.\",\"Stabilità SEO: gli URL canonici non devono cambiare quando cambiano filtri\u002Fopzioni di ordinamento.\",\"Cache-friendliness: CDN + cache server devono avere chiavi prevedibili. Evitare variazioni per utente.\",\"Separazione rigorosa delle responsabilità: discovery, CMS e risoluzione del tenant sono moduli separati con confini espliciti.\",\"Non-obiettivo (per ora): geocoding perfetto. Accettiamo un geocoder, una strategia di normalizzazione e memorizziamo il risultato normalizzato.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Architettura canonica\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"Implementiamo la geo discovery come un bounded context separato con una piccola superficie pubblica: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> metadati SEO + Cache. Le route CMS non chiamano mai la discovery. La discovery non chiama mai il routing del CMS. Condividono solo utility di basso livello (HTTP, caching, contesto tenant).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Componenti chiave\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: risolve il tenant dall’header Host (o da un portal id esplicito nelle chiamate interne).\",\"GeoResolver: analizza + valida i segmenti URL geo; emette un GeoQuery normalizzato.\",\"GeoIndex (Read Model): una tabella\u002Fcollezione separata che mappa entityId -> lat\u002Flng + scope tenant + campi minimi ricercabili. Evita il refactoring delle tabelle DB sorgente.\",\"Data Providers: sorgenti plug-in (es. tabelle SQL esistenti, API WordPress, un altro servizio di portale). Sono dietro un’interfaccia.\",\"SEO Router: produce URL canonico e meta (canonical, hreflang se necessario, flag robots).\",\"Caching Layer: chiavi cache CDN + cache lato server con chiavi tenant-scoped e versioning.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Struttura delle cartelle\",\"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\":\"Design URL (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"Manteniamo l’URL canonico puramente gerarchico e leggibile. Filtri\u002Fordinamento restano nella querystring ma NON influenzano il canonical. La ricerca per raggio usa una pagina “near” stabile con un canonical basato su una cella di coordinate arrotondata, non sul lat\u002Flng grezzo. Questo evita varianti infinite di URL e previene l’esplosione della cache. È un compromesso deliberato.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Landing paese: \u002Fdiscover\u002F{countryCode} (esempio: \u002Fdiscover\u002Fde)\",\"Landing città: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (esempio: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (raggio): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (esempio: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) dove cellId è un identificatore breve tipo geohash\",\"Query params opzionali (non canonici): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Il tenant NON è nel path. Il tenant è l’host (portal-a.tld, portal-b.tld). Le chiamate interne passano x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Regole canonical\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Le pagine paese\u002Fcittà canonizzano sul proprio path pulito (senza querystring).\",\"Le pagine near canonizzano su \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. Il canonical deriva da una cella arrotondata, non dalle coordinate in ingresso.\",\"page=1 è omesso dal canonical e dalla generazione dei link interni.\",\"Combinazioni non supportate (es. mismatch paese) restituiscono 404, non un redirect. Le catene di redirect danneggiavano il crawl budget nei test.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Logica del resolver\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"L’input del resolver è (tenant, pathname, query). L’output è un GeoQuery normalizzato con un query plan. Nessun accesso DB dentro il resolver. Questa separazione è stata importante in seguito: ci ha salvato da un brutto bug di caching.\"},\"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\":\"Flusso della richiesta passo per passo\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN riceve la richiesta. La chiave cache include host + path + query params limitati (q, category, sort, page, pageSize, r).\",\"Il server applicativo crea TenantContext dall’header Host. Nessun DB ancora.\",\"GeoResolver analizza l’URL. Produce GeoQuery con canonicalPath e cacheKey del server.\",\"GeoController costruisce un QueryPlan. Decide quali provider colpire in base alla configurazione tenant e al tipo di scope.\",\"Il provider esegue la query read-model su GeoIndex (veloce). Poi idrata i risultati dalla sorgente DB\u002FAPI esistente usando gli entity ID (nessun refactor richiesto).\",\"L’assembler della risposta aggiunge metadati SEO: canonical, robots, link di paginazione.\",\"Il server imposta gli header cache (s-maxage + stale-while-revalidate). Il body è tenant-scoped, mai condiviso tra tenant.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"Strategia senza refactor: GeoIndex read model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"Non possiamo ristrutturare ora le tabelle contenuto esistenti. Quindi aggiungiamo un GeoIndex separato che possiamo ricostruire indipendentemente. Memorizza quanto basta per una discovery efficiente: tenantId, entityId, entityType, countryCode, citySlug, lat, lng e alcuni campi filtro. L’idratazione recupera l’oggetto completo dalla fonte attuale (righe SQL, API CMS, ecc.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Compromesso: consistenza eventuale. Il ritardo di rebuild dell’indice è accettabile per le pagine discovery. Impostiamo uno SLA: gli aggiornamenti appaiono entro 15 minuti. Se in futuro serve real-time (disponibilità prenotazioni), è una superficie diversa e non deve riusare la cache di discovery.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"Superficie API\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Due superfici: (A) pagine HTML per SEO e utenti, (B) API JSON per rendering client ed estensioni future. Stesso resolver + stesso query plan. Presenter diversi.\"},\"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 (interno): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protetto), POST \u002Finternal\u002Fgeoindex\u002Fupsert (opzionale, più avanti)\"],\"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\":\"Scalabilità e prestazioni\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Obiettivo principale di performance: servire HTML di discovery in cache dal CDN per landing geo popolari (paese\u002Fcittà).\",\"Le pagine near sono cacheabili ma hanno più varianti (cellId + r + q + category + sort + page). Limitiamo r, pageSize e validiamo i filtri in modo rigoroso.\",\"La query su GeoIndex deve essere veloce: usare indici tenantId + countryCode + citySlug; per near usare bucket di celle (match per prefisso) e poi rifinire per distanza nell’app.\",\"Evitare l’haversine SQL costoso su dataset grandi. Sembra semplice. Non lo è.\",\"L’idratazione è batched per lista di entityId. Una query per entityType, non N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Strategia di ricerca per raggio (pagine near)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"Non eseguiamo una scansione completa a raggio su tutte le righe. Invece: (1) cellId mappa a un bucket di bounding (prefisso tipo geohash), (2) recuperiamo candidati da GeoIndex tramite prefisso bucket, (3) rifiniamo nell’app con distanza haversine, (4) ordiniamo + paginiamo. Questo rende il carico DB prevedibile.\"},\"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\":\"Modello di caching (CDN + server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"Cache su due livelli. Il CDN cachea l’intero HTML\u002FJSON per traffico anonimo. La cache server memorizza i risultati dei provider indicizzati da GeoQuery.cacheKey. La chiave include tenant, scope e filtri limitati. Non tutto deve essere cacheato. La disponibilità prenotazioni verrà esclusa più avanti.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"La chiave cache CDN varia in base all’header Host. È il confine tenant.\",\"La chiave cache server include tenantId esplicitamente. Mai affidarsi all’host implicito in-process.\",\"TTL cache: paese\u002Fcittà 30–60 minuti su CDN (stale-while-revalidate abilitato). Pagine near 5 minuti.\",\"Manteniamo una leva manuale di bust per tenant (suffisso di versione nella cache key). Usata durante migrazioni e deploy problematici.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"Dettagli di stabilità SEO\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Tag canonical: puntano sempre al path gerarchico pulito (senza querystring).\",\"Robots: se sono presenti query params non in whitelist (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), impostare noindex. Questo blocca parametri spazzatura da link esterni.\",\"Paginazione: rel=next\u002Fprev generati solo per pagine > 1 e se resultCount > pageSize.\",\"Linking interno stabile: i link UI emettono sempre path canonici; querystring solo per filtri selezionati dall’utente.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Estensioni future senza bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"Estendiamo aggiungendo provider e presenter, non infilando funzionalità nel resolver. Prenotazioni, recensioni e mappe vivono su pagine entity o API dedicate. La discovery resta una superficie list-and-filter. Questo confine viene imposto in code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Prenotazioni: endpoint \u002Fapi\u002Fbooking separati. La discovery mostra badge di disponibilità solo se cacheati e non personali.\",\"Recensioni: servizio\u002Fprovider separato. La discovery legge campi di rating aggregati da GeoIndex (precalcolati).\",\"Mappe: tile e marker da \u002Fapi\u002Fdiscovery\u002Fmarkers con caching aggressivo; non incorporare nell’HTML se peggiora TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"Una cosa andata storta (e cosa abbiamo cambiato)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"Abbiamo rilasciato la prima versione con una cache key server che NON includeva tenantId. In locale “funzionava”. In staging sembrava ok. Poi produzione. Il Portale A ha iniziato a mostrare listing del Portale B sulle pagine città. Stesso path, tenant diverso. Collisione di cache. Brutto.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"La correzione è stata noiosa ma rigorosa: tenantId è diventato obbligatorio in GeoQuery e la costruzione della cache key è stata spostata nel resolver così non può essere saltata. Abbiamo anche aggiunto un’asserzione runtime: se le entità idratate contengono un tenantId diverso, lanciamo un errore e saltiamo la scrittura in cache. È rumoroso apposta.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query plan e contratto del provider\",\"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\":\"Il QueryPlan è un piccolo switch su (tenant config + scope kind). Esempio: alcuni tenant usano solo SQL; altri un servizio remoto del portale. Stesso input GeoQuery. Stesso output GeoResult. Questo rende il rollout prevedibile.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: schema minimo\",\"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\":\"Compromessi (espliciti)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"Scambiamo precisione perfetta per URL stabili: le pagine near canonizzano per cellId. Due utenti a 300 m possono finire sulla stessa pagina canonical. Va bene.\",\"Scambiamo freschezza per cacheabilità: le pagine discovery non sono real-time. Gli update dell’indice possono ritardare.\",\"Evitiamo il refactor DB ora, ma paghiamo un percorso di scrittura extra per mantenere GeoIndex.\",\"Evitiamo l’accoppiamento al routing CMS, ma ora possediamo un namespace di route separato (\u002Fdiscover). È intenzionale. Ed è una promessa da mantenere.\",\"Rifiniamo la distanza nel codice applicativo per le ricerche near per evitare matematica DB costosa. Sposta CPU sul tier applicativo, più facile da scalare orizzontalmente.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Piano di rollout (pratico)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Rilasciare resolver + pagine vuote che restituiscono 404 dietro feature flag. Confermare che il routing non collide col catch-all del CMS.\",\"Costruire un job di backfill GeoIndex per un tenant. Validare i conteggi vs dati sorgente. Aspettarsi mismatch; loggarli.\",\"Abilitare solo le pagine paese. Monitorare cache hit ratio e statistiche di crawl.\",\"Abilitare poi le pagine città. Le pagine near per ultime (sono generatrici di varianti).\",\"Dopo chiavi cache stabili, aggiungere consumer della JSON API (marker mappa, filtering client).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Checklist di accettazione\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Isolamento multi-tenant: lo stesso path su due host non condivide mai entry di cache server.\",\"Gli URL canonici non includono query params e restano stabili quando cambiano i filtri.\",\"I conflitti di slug del CMS non influenzano le route \u002Fdiscover.\",\"Le pagine near non generano permutazioni URL illimitate (r e pageSize limitati).\",\"Il contratto del provider restituisce paginazione deterministica e total count.\",\"Il rebuild di GeoIndex può girare senza downtime e senza bloccare a lungo le tabelle sorgente.\"],\"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: architettura canonica, design URL, logica del resolver, API e specifica di scalabilità",{"id":219,"data":220,"type":222},"p-scope",{"text":221},"Questo documento definisce la superficie di discovery basata sulla geolocalizzazione (paese\u002Fcittà\u002Fraggio) su più portali (multi-tenant), senza forzare un refactoring immediato del DB e senza accoppiarsi al routing del CMS (pagina\u002Fpost). Mantiene stabile la SEO, resta cache-friendly e lascia spazio a prenotazioni\u002Frecensioni\u002Fmappe senza trasformare l’app in un blob.","paragraph",{"id":224,"data":225,"type":42},"h-goals",{"text":226,"level":227},"Vincoli e non-obiettivi",2,{"id":229,"data":230,"type":241},"list-constraints",{"items":231,"style":240},[232,233,234,235,236,237,238,239],"Multi-tenant: la stessa codebase serve più portali. Il tenant influenza lo scope dei contenuti, il branding e talvolta la fonte dati.","La geo discovery deve supportare: paese, città, ricerca per raggio (attorno a un punto).","Nessun refactoring immediato del database: non possiamo rimodellare le tabelle esistenti in uno schema geo perfetto adesso.","Indipendenza dal routing del CMS: le pagine geo non sono “post” o “pagine”. Non possono essere bloccate da conflitti di slug del CMS.","Stabilità SEO: gli URL canonici non devono cambiare quando cambiano filtri\u002Fopzioni di ordinamento.","Cache-friendliness: CDN + cache server devono avere chiavi prevedibili. Evitare variazioni per utente.","Separazione rigorosa delle responsabilità: discovery, CMS e risoluzione del tenant sono moduli separati con confini espliciti.","Non-obiettivo (per ora): geocoding perfetto. Accettiamo un geocoder, una strategia di normalizzazione e memorizziamo il risultato normalizzato.","unordered","list",{"id":243,"data":244,"type":42},"h-arch",{"text":245,"level":227},"Architettura canonica",{"id":247,"data":248,"type":222},"p-arch",{"text":249},"Implementiamo la geo discovery come un bounded context separato con una piccola superficie pubblica: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> metadati SEO + Cache. Le route CMS non chiamano mai la discovery. La discovery non chiama mai il routing del CMS. Condividono solo utility di basso livello (HTTP, caching, contesto tenant).",{"id":251,"data":252,"type":42},"h-components",{"text":253,"level":254},"Componenti chiave",3,{"id":256,"data":257,"type":241},"list-components",{"items":258,"style":240},[259,260,261,262,263,264],"TenantContext: risolve il tenant dall’header Host (o da un portal id esplicito nelle chiamate interne).","GeoResolver: analizza + valida i segmenti URL geo; emette un GeoQuery normalizzato.","GeoIndex (Read Model): una tabella\u002Fcollezione separata che mappa entityId -> lat\u002Flng + scope tenant + campi minimi ricercabili. Evita il refactoring delle tabelle DB sorgente.","Data Providers: sorgenti plug-in (es. tabelle SQL esistenti, API WordPress, un altro servizio di portale). Sono dietro un’interfaccia.","SEO Router: produce URL canonico e meta (canonical, hreflang se necessario, flag robots).","Caching Layer: chiavi cache CDN + cache lato server con chiavi tenant-scoped e versioning.",{"id":266,"data":267,"type":42},"h-folder",{"text":268,"level":227},"Struttura delle cartelle",{"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},"Design URL (SEO Canonical)",{"id":280,"data":281,"type":222},"p-url",{"text":282},"Manteniamo l’URL canonico puramente gerarchico e leggibile. Filtri\u002Fordinamento restano nella querystring ma NON influenzano il canonical. La ricerca per raggio usa una pagina “near” stabile con un canonical basato su una cella di coordinate arrotondata, non sul lat\u002Flng grezzo. Questo evita varianti infinite di URL e previene l’esplosione della cache. È un compromesso deliberato.",{"id":284,"data":285,"type":241},"list-url",{"items":286,"style":240},[287,288,289,290,291],"Landing paese: \u002Fdiscover\u002F{countryCode} (esempio: \u002Fdiscover\u002Fde)","Landing città: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (esempio: \u002Fdiscover\u002Fde\u002Fmunich)","Near (raggio): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (esempio: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) dove cellId è un identificatore breve tipo geohash","Query params opzionali (non canonici): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Il tenant NON è nel path. Il tenant è l’host (portal-a.tld, portal-b.tld). Le chiamate interne passano x-tenant-id.",{"id":293,"data":294,"type":42},"h-canonical-rules",{"text":295,"level":254},"Regole canonical",{"id":297,"data":298,"type":241},"list-canonical",{"items":299,"style":240},[300,301,302,303],"Le pagine paese\u002Fcittà canonizzano sul proprio path pulito (senza querystring).","Le pagine near canonizzano su \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. Il canonical deriva da una cella arrotondata, non dalle coordinate in ingresso.","page=1 è omesso dal canonical e dalla generazione dei link interni.","Combinazioni non supportate (es. mismatch paese) restituiscono 404, non un redirect. Le catene di redirect danneggiavano il crawl budget nei test.",{"id":305,"data":306,"type":42},"h-resolver",{"text":307,"level":227},"Logica del resolver",{"id":309,"data":310,"type":222},"p-resolver",{"text":311},"L’input del resolver è (tenant, pathname, query). L’output è un GeoQuery normalizzato con un query plan. Nessun accesso DB dentro il resolver. Questa separazione è stata importante in seguito: ci ha salvato da un brutto bug di caching.",{"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  \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":323,"type":42},"h-flow",{"text":324,"level":227},"Flusso della richiesta passo per passo",{"id":326,"data":327,"type":241},"list-flow",{"items":328,"style":336},[329,330,331,332,333,334,335],"Edge\u002FCDN riceve la richiesta. La chiave cache include host + path + query params limitati (q, category, sort, page, pageSize, r).","Il server applicativo crea TenantContext dall’header Host. Nessun DB ancora.","GeoResolver analizza l’URL. Produce GeoQuery con canonicalPath e cacheKey del server.","GeoController costruisce un QueryPlan. Decide quali provider colpire in base alla configurazione tenant e al tipo di scope.","Il provider esegue la query read-model su GeoIndex (veloce). Poi idrata i risultati dalla sorgente DB\u002FAPI esistente usando gli entity ID (nessun refactor richiesto).","L’assembler della risposta aggiunge metadati SEO: canonical, robots, link di paginazione.","Il server imposta gli header cache (s-maxage + stale-while-revalidate). Il body è tenant-scoped, mai condiviso tra tenant.","ordered",{"id":338,"data":339,"type":42},"h-read-model",{"text":340,"level":227},"Strategia senza refactor: GeoIndex read model",{"id":342,"data":343,"type":222},"p-read-model",{"text":344},"Non possiamo ristrutturare ora le tabelle contenuto esistenti. Quindi aggiungiamo un GeoIndex separato che possiamo ricostruire indipendentemente. Memorizza quanto basta per una discovery efficiente: tenantId, entityId, entityType, countryCode, citySlug, lat, lng e alcuni campi filtro. L’idratazione recupera l’oggetto completo dalla fonte attuale (righe SQL, API CMS, ecc.).",{"id":346,"data":347,"type":222},"p-tradeoffs-read-model",{"text":348},"Compromesso: consistenza eventuale. Il ritardo di rebuild dell’indice è accettabile per le pagine discovery. Impostiamo uno SLA: gli aggiornamenti appaiono entro 15 minuti. Se in futuro serve real-time (disponibilità prenotazioni), è una superficie diversa e non deve riusare la cache di discovery.",{"id":350,"data":351,"type":42},"h-api",{"text":352,"level":227},"Superficie API",{"id":354,"data":355,"type":222},"p-api",{"text":356},"Due superfici: (A) pagine HTML per SEO e utenti, (B) API JSON per rendering client ed estensioni future. Stesso resolver + stesso query plan. Presenter diversi.",{"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 (interno): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protetto), POST \u002Finternal\u002Fgeoindex\u002Fupsert (opzionale, più avanti)",{"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  \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":370,"type":42},"h-scalability",{"text":371,"level":227},"Scalabilità e prestazioni",{"id":373,"data":374,"type":241},"list-scale",{"items":375,"style":240},[376,377,378,379,380],"Obiettivo principale di performance: servire HTML di discovery in cache dal CDN per landing geo popolari (paese\u002Fcittà).","Le pagine near sono cacheabili ma hanno più varianti (cellId + r + q + category + sort + page). Limitiamo r, pageSize e validiamo i filtri in modo rigoroso.","La query su GeoIndex deve essere veloce: usare indici tenantId + countryCode + citySlug; per near usare bucket di celle (match per prefisso) e poi rifinire per distanza nell’app.","Evitare l’haversine SQL costoso su dataset grandi. Sembra semplice. Non lo è.","L’idratazione è batched per lista di entityId. Una query per entityType, non N+1.",{"id":382,"data":383,"type":42},"h-near-strategy",{"text":384,"level":254},"Strategia di ricerca per raggio (pagine near)",{"id":386,"data":387,"type":222},"p-near",{"text":388},"Non eseguiamo una scansione completa a raggio su tutte le righe. Invece: (1) cellId mappa a un bucket di bounding (prefisso tipo geohash), (2) recuperiamo candidati da GeoIndex tramite prefisso bucket, (3) rifiniamo nell’app con distanza haversine, (4) ordiniamo + paginiamo. Questo rende il carico DB prevedibile.",{"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},"Modello di caching (CDN + server)",{"id":398,"data":399,"type":222},"p-cache",{"text":400},"Cache su due livelli. Il CDN cachea l’intero HTML\u002FJSON per traffico anonimo. La cache server memorizza i risultati dei provider indicizzati da GeoQuery.cacheKey. La chiave include tenant, scope e filtri limitati. Non tutto deve essere cacheato. La disponibilità prenotazioni verrà esclusa più avanti.",{"id":402,"data":403,"type":241},"list-cache",{"items":404,"style":240},[405,406,407,408],"La chiave cache CDN varia in base all’header Host. È il confine tenant.","La chiave cache server include tenantId esplicitamente. Mai affidarsi all’host implicito in-process.","TTL cache: paese\u002Fcittà 30–60 minuti su CDN (stale-while-revalidate abilitato). Pagine near 5 minuti.","Manteniamo una leva manuale di bust per tenant (suffisso di versione nella cache key). Usata durante migrazioni e deploy problematici.",{"id":410,"data":411,"type":42},"h-seo",{"text":412,"level":227},"Dettagli di stabilità SEO",{"id":414,"data":415,"type":241},"list-seo",{"items":416,"style":240},[417,418,419,420],"Tag canonical: puntano sempre al path gerarchico pulito (senza querystring).","Robots: se sono presenti query params non in whitelist (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), impostare noindex. Questo blocca parametri spazzatura da link esterni.","Paginazione: rel=next\u002Fprev generati solo per pagine > 1 e se resultCount > pageSize.","Linking interno stabile: i link UI emettono sempre path canonici; querystring solo per filtri selezionati dall’utente.",{"id":422,"data":423,"type":42},"h-extensions",{"text":424,"level":227},"Estensioni future senza bloat",{"id":426,"data":427,"type":222},"p-extensions",{"text":428},"Estendiamo aggiungendo provider e presenter, non infilando funzionalità nel resolver. Prenotazioni, recensioni e mappe vivono su pagine entity o API dedicate. La discovery resta una superficie list-and-filter. Questo confine viene imposto in code review.",{"id":430,"data":431,"type":241},"list-extensions",{"items":432,"style":240},[433,434,435],"Prenotazioni: endpoint \u002Fapi\u002Fbooking separati. La discovery mostra badge di disponibilità solo se cacheati e non personali.","Recensioni: servizio\u002Fprovider separato. La discovery legge campi di rating aggregati da GeoIndex (precalcolati).","Mappe: tile e marker da \u002Fapi\u002Fdiscovery\u002Fmarkers con caching aggressivo; non incorporare nell’HTML se peggiora TTFB.",{"id":437,"data":438,"type":42},"h-went-wrong",{"text":439,"level":227},"Una cosa andata storta (e cosa abbiamo cambiato)",{"id":441,"data":442,"type":222},"p-wrong",{"text":443},"Abbiamo rilasciato la prima versione con una cache key server che NON includeva tenantId. In locale “funzionava”. In staging sembrava ok. Poi produzione. Il Portale A ha iniziato a mostrare listing del Portale B sulle pagine città. Stesso path, tenant diverso. Collisione di cache. Brutto.",{"id":445,"data":446,"type":222},"p-fix",{"text":447},"La correzione è stata noiosa ma rigorosa: tenantId è diventato obbligatorio in GeoQuery e la costruzione della cache key è stata spostata nel resolver così non può essere saltata. Abbiamo anche aggiunto un’asserzione runtime: se le entità idratate contengono un tenantId diverso, lanciamo un errore e saltiamo la scrittura in cache. È rumoroso apposta.",{"id":449,"data":450,"type":42},"h-query-plan",{"text":451,"level":227},"Query plan e contratto del provider",{"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},"Il QueryPlan è un piccolo switch su (tenant config + scope kind). Esempio: alcuni tenant usano solo SQL; altri un servizio remoto del portale. Stesso input GeoQuery. Stesso output GeoResult. Questo rende il rollout prevedibile.",{"id":461,"data":462,"type":42},"h-sql",{"text":463,"level":254},"SQL GeoIndex: schema minimo",{"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},"Compromessi (espliciti)",{"id":474,"data":475,"type":241},"list-tradeoffs",{"items":476,"style":240},[477,478,479,480,481],"Scambiamo precisione perfetta per URL stabili: le pagine near canonizzano per cellId. Due utenti a 300 m possono finire sulla stessa pagina canonical. Va bene.","Scambiamo freschezza per cacheabilità: le pagine discovery non sono real-time. Gli update dell’indice possono ritardare.","Evitiamo il refactor DB ora, ma paghiamo un percorso di scrittura extra per mantenere GeoIndex.","Evitiamo l’accoppiamento al routing CMS, ma ora possediamo un namespace di route separato (\u002Fdiscover). È intenzionale. Ed è una promessa da mantenere.","Rifiniamo la distanza nel codice applicativo per le ricerche near per evitare matematica DB costosa. Sposta CPU sul tier applicativo, più facile da scalare orizzontalmente.",{"id":483,"data":484,"type":42},"h-rollout",{"text":485,"level":227},"Piano di rollout (pratico)",{"id":487,"data":488,"type":241},"list-rollout",{"items":489,"style":336},[490,491,492,493,494],"Rilasciare resolver + pagine vuote che restituiscono 404 dietro feature flag. Confermare che il routing non collide col catch-all del CMS.","Costruire un job di backfill GeoIndex per un tenant. Validare i conteggi vs dati sorgente. Aspettarsi mismatch; loggarli.","Abilitare solo le pagine paese. Monitorare cache hit ratio e statistiche di crawl.","Abilitare poi le pagine città. Le pagine near per ultime (sono generatrici di varianti).","Dopo chiavi cache stabili, aggiungere consumer della JSON API (marker mappa, filtering client).",{"id":496,"data":497,"type":42},"h-acceptance",{"text":498,"level":227},"Checklist di accettazione",{"id":500,"data":501,"type":241},"list-acceptance",{"items":502,"style":240},[503,504,505,506,507,508],"Isolamento multi-tenant: lo stesso path su due host non condivide mai entry di cache server.","Gli URL canonici non includono query params e restano stabili quando cambiano i filtri.","I conflitti di slug del CMS non influenzano le route \u002Fdiscover.","Le pagine near non generano permutazioni URL illimitate (r e pageSize limitati).","Il contratto del provider restituisce paginazione deterministica e total count.","Il rebuild di GeoIndex può girare senza downtime e senza bloccare a lungo le tabelle sorgente.","2.29.1","Architettura di scoperta geobasata per portali multi-tenant. Definisce URL canonici, logica di risoluzione, strategia di caching e un modello di lettura geografico senza accoppiamento con CMS o rifattorizzazione del database. Progettata per stabilità SEO, scalabilità ed estensioni future come prenotazioni e mappe.","\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,"Valutazione delivery","delivery-assessment",{"id":532,"name":533,"slug":534},107,"Valutazioni","assessments",{"id":536,"name":537,"slug":538},45,"Modello di riferimento: Piattaforma digitale","digital-platform",{"id":540,"name":541,"slug":542},66,"Operazioni contenuti","content-ops",{"id":544,"name":545,"slug":546},79,"Playbook: Hardening sicurezza","security-hardening",{"id":548,"login":549,"email":550,"displayName":551},"20","rooth8233","aleksandar@stajic.de","Aleksandar Stajić",[553,781],{"lang":554,"title":555,"content":556,"contentJson":557,"excerpt":780},"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,636,639,649,652,655,658,661,664,668,670,673,681,684,687,689,692,695,702,705,712,715,718,724,727,730,733,736,738,741,744,746,749,757,760,768,771],{"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":320,"language":316},{"id":322,"data":637,"type":42},{"text":638,"level":227},"Step-by-Step Request Flow",{"id":326,"data":640,"type":241},{"items":641,"style":336},[642,643,644,645,646,647,648],"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":650,"type":42},{"text":651,"level":227},"No-Refactor Strategy: GeoIndex Read Model",{"id":342,"data":653,"type":222},{"text":654},"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":656,"type":222},{"text":657},"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":659,"type":42},{"text":660,"level":227},"API Surface",{"id":354,"data":662,"type":222},{"text":663},"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":665,"type":241},{"items":666,"style":240},[361,362,667],"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)",{"id":365,"data":669,"type":274},{"code":367,"language":316},{"id":369,"data":671,"type":42},{"text":672,"level":227},"Scalability & Performance",{"id":373,"data":674,"type":241},{"items":675,"style":240},[676,677,678,679,680],"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":682,"type":42},{"text":683,"level":254},"Radius Search Strategy (Near Pages)",{"id":386,"data":685,"type":222},{"text":686},"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":688,"type":274},{"code":392,"language":316},{"id":394,"data":690,"type":42},{"text":691,"level":227},"Caching Model (CDN + Server)",{"id":398,"data":693,"type":222},{"text":694},"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":696,"type":241},{"items":697,"style":240},[698,699,700,701],"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":703,"type":42},{"text":704,"level":227},"SEO Stability Details",{"id":414,"data":706,"type":241},{"items":707,"style":240},[708,709,710,711],"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":713,"type":42},{"text":714,"level":227},"Future Extensions Without Bloat",{"id":426,"data":716,"type":222},{"text":717},"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":719,"type":241},{"items":720,"style":240},[721,722,723],"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":725,"type":42},{"text":726,"level":227},"One Thing That Went Wrong (and What We Changed)",{"id":441,"data":728,"type":222},{"text":729},"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":731,"type":222},{"text":732},"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":734,"type":42},{"text":735,"level":227},"Query Plan and Provider Contract",{"id":453,"data":737,"type":274},{"code":455,"language":316},{"id":457,"data":739,"type":222},{"text":740},"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":742,"type":42},{"text":743,"level":254},"SQL GeoIndex: Minimal Schema",{"id":465,"data":745,"type":274},{"code":467,"language":468},{"id":470,"data":747,"type":42},{"text":748,"level":227},"Trade-offs (Explicit)",{"id":474,"data":750,"type":241},{"items":751,"style":240},[752,753,754,755,756],"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":758,"type":42},{"text":759,"level":227},"Rollout Plan (Practical)",{"id":487,"data":761,"type":241},{"items":762,"style":336},[763,764,765,766,767],"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":769,"type":42},{"text":770,"level":227},"Acceptance Checklist",{"id":500,"data":772,"type":241},{"items":773,"style":240},[774,775,776,777,778,779],"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":782,"excerpt":510},{"time":212,"blocks":783,"version":509},[784,786,788,790,793,795,797,799,802,804,806,808,810,813,815,818,820,822,824,826,828,831,833,835,837,839,841,844,846,848,851,853,855,857,859,861,864,866,869,871,873,876,878,880,882,884,886,888,890,892,894,897,899,902,904],{"id":215,"data":785,"type":42},{"text":217,"level":40},{"id":219,"data":787,"type":222},{"text":221},{"id":224,"data":789,"type":42},{"text":226,"level":227},{"id":229,"data":791,"type":241},{"items":792,"style":240},[232,233,234,235,236,237,238,239],{"id":243,"data":794,"type":42},{"text":245,"level":227},{"id":247,"data":796,"type":222},{"text":249},{"id":251,"data":798,"type":42},{"text":253,"level":254},{"id":256,"data":800,"type":241},{"items":801,"style":240},[259,260,261,262,263,264],{"id":266,"data":803,"type":42},{"text":268,"level":227},{"id":270,"data":805,"type":274},{"code":272,"language":273},{"id":276,"data":807,"type":42},{"text":278,"level":227},{"id":280,"data":809,"type":222},{"text":282},{"id":284,"data":811,"type":241},{"items":812,"style":240},[287,288,289,290,291],{"id":293,"data":814,"type":42},{"text":295,"level":254},{"id":297,"data":816,"type":241},{"items":817,"style":240},[300,301,302,303],{"id":305,"data":819,"type":42},{"text":307,"level":227},{"id":309,"data":821,"type":222},{"text":311},{"id":313,"data":823,"type":274},{"code":315,"language":316},{"id":318,"data":825,"type":274},{"code":320,"language":316},{"id":322,"data":827,"type":42},{"text":324,"level":227},{"id":326,"data":829,"type":241},{"items":830,"style":336},[329,330,331,332,333,334,335],{"id":338,"data":832,"type":42},{"text":340,"level":227},{"id":342,"data":834,"type":222},{"text":344},{"id":346,"data":836,"type":222},{"text":348},{"id":350,"data":838,"type":42},{"text":352,"level":227},{"id":354,"data":840,"type":222},{"text":356},{"id":358,"data":842,"type":241},{"items":843,"style":240},[361,362,363],{"id":365,"data":845,"type":274},{"code":367,"language":316},{"id":369,"data":847,"type":42},{"text":371,"level":227},{"id":373,"data":849,"type":241},{"items":850,"style":240},[376,377,378,379,380],{"id":382,"data":852,"type":42},{"text":384,"level":254},{"id":386,"data":854,"type":222},{"text":388},{"id":390,"data":856,"type":274},{"code":392,"language":316},{"id":394,"data":858,"type":42},{"text":396,"level":227},{"id":398,"data":860,"type":222},{"text":400},{"id":402,"data":862,"type":241},{"items":863,"style":240},[405,406,407,408],{"id":410,"data":865,"type":42},{"text":412,"level":227},{"id":414,"data":867,"type":241},{"items":868,"style":240},[417,418,419,420],{"id":422,"data":870,"type":42},{"text":424,"level":227},{"id":426,"data":872,"type":222},{"text":428},{"id":430,"data":874,"type":241},{"items":875,"style":240},[433,434,435],{"id":437,"data":877,"type":42},{"text":439,"level":227},{"id":441,"data":879,"type":222},{"text":443},{"id":445,"data":881,"type":222},{"text":447},{"id":449,"data":883,"type":42},{"text":451,"level":227},{"id":453,"data":885,"type":274},{"code":455,"language":316},{"id":457,"data":887,"type":222},{"text":459},{"id":461,"data":889,"type":42},{"text":463,"level":254},{"id":465,"data":891,"type":274},{"code":467,"language":468},{"id":470,"data":893,"type":42},{"text":472,"level":227},{"id":474,"data":895,"type":241},{"items":896,"style":240},[477,478,479,480,481],{"id":483,"data":898,"type":42},{"text":485,"level":227},{"id":487,"data":900,"type":241},{"items":901,"style":336},[490,491,492,493,494],{"id":496,"data":903,"type":42},{"text":498,"level":227},{"id":500,"data":905,"type":241},{"items":906,"style":240},[503,504,505,506,507,508],"Post erfolgreich abgerufen",{"items":909,"source":945,"manualIds":946,"manualMatchedIds":947},[910,917,924,931,938],{"id":911,"slug":912,"title":913,"excerpt":914,"featuredImage":915,"publishedAt":916},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","Architettura Multi-Tenant di Livello Enterprise per una Piattaforma Internazionale","Loving Rocks è una piattaforma per matrimoni di livello enterprise progettata con una vera architettura multi-tenant, database isolati per tenant e internazionalizzazione integrata per scalabilità globale, sicurezza e stabilità operativa a lungo termine.","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z",{"id":918,"slug":919,"title":920,"excerpt":921,"featuredImage":922,"publishedAt":923},"471","how-to-know-whether-an-ai-agent-actually-used-the-right-evidence","Come sapere se un agente IA ha effettivamente usato le prove giuste","Un agente IA può citare fonti e comunque utilizzare le prove sbagliate. Questo articolo introduce un metodo pratico per verificare il supporto delle affermazioni, l'autorevolezza della fonte, l'applicabilità, la provenienza e se le prove abbiano effettivamente influenzato la risposta.","\u002Fuploads\u002F2026\u002F09\u002Fhow-to-know-whether-an-ai-agent-actually-used-the-right-evidence-1790351317188-o5z9ve.webp","2026-09-25T11:47:00.000Z",{"id":925,"slug":926,"title":927,"excerpt":928,"featuredImage":929,"publishedAt":930},"456","zbt-z8102ax-hardware-packaging-review","Recensione hardware e confezione di ZBT Z8102AX: router forte, scatola debole","Lo ZBT Z8102AX fa una solida prima impressione come router OpenWrt 5G sottile in metallo nero con molteplici connettori per antenna, slot dual-SIM, porte USB, LAN\u002FWAN e un pratico set di accessori. L'hardware sembra utile e serio, ma la confezione è chiaramente il punto debole.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-02-1781620590938-y33j4b.webp","2026-06-16T04:40:00.000Z",{"id":932,"slug":933,"title":934,"excerpt":935,"featuredImage":936,"publishedAt":937},"361","model-view-controller-mvc","Model-View-Controller (MVC): La spina dorsale strutturale delle moderne applicazioni web","Model-View-Controller, solitamente abbreviato in MVC, rimane uno dei pattern architetturali più duraturi nello sviluppo software. Fornisce ai team un modo pratico per separare la logica di business, la presentazione e l'interazione dell'utente, in modo che le applicazioni rimangano più facili da costruire, estendere, testare e manutenere. Questo articolo spiega cos'è l'MVC, perché è ancora importante, dove si inserisce negli stack web odierni e come si collega a una più ampia architettura di piattaforma, alla qualità del rilascio, alla strategia di migrazione e alla maturità operativa.","\u002Fuploads\u002F2026\u002F03\u002Fmodel-view-controller-mvc-1774872805793-0bjubu.webp","2023-04-12T12:57:00.000Z",{"id":939,"slug":940,"title":941,"excerpt":942,"featuredImage":943,"publishedAt":944},"454","zbt-z8102ax-rm500u-ea-5g-modem-test","Quectel RM500U-EA nel ZBT Z8102AX: bande 5G, o2 Germania e comportamento del segnale nel mondo reale","Lo ZBT Z8102AX utilizza un modem Quectel RM500U-EA per la connettività 4G e 5G. Nel primo test pratico, il router si è connesso con successo a o2 Germany con la banda LTE 3 e NR n28. Il modem funziona, ma diagnostiche più approfondite come RSRP, RSRQ, SINR, il blocco delle bande e il comportamento delle celle richiedono ancora test adeguati.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-06-1781620597879-qay2sx.webp","2026-06-16T08:39:00.000Z","fallback",[],[]]