Ворклог по задаче "Перенести сайт на haih-agent"

22 сент. 2026 г., 10:19:05

Перенос старого сайта на новый движок и унификация модели данных

Основная работа на этом этапе — не просто перенос отдельных страниц или ресурсов, а фактически пересборка старого сайта на новом движке haih-agent с окончательным отказом от старой структуры данных, которая исторически сложилась ещё во времена MODX.

Проблема здесь по сути такая же, как и при обновлении других старых сайтов: со временем вокруг первоначальной CMS накопилась собственная структура данных, множество шаблонов, TV-полей, специальных типов ресурсов и прикладной логики, завязанной на особенности старого движка. Поэтому обычного «обновления» приложения недостаточно — новый сайт нужно переносить архитектурно, сохраняя существующий контент, но избавляясь от старых технических ограничений.

Что было в старой версии

Изначально сайт работал на MODX, и значительная часть структуры базы данных до сих пор наследовала эту модель. Разные смысловые сущности существовали отдельно друг от друга и зачастую имели собственные шаблоны и наборы TV-полей.

В частности, отдельно существовали:

  • города;
  • компании;
  • бани и сауны;
  • отзывы;
  • статьи и публикации;
  • обычные ресурсы/страницы;
  • блоги и другие типы контента.

То есть различие между объектами было зафиксировано не только на уровне бизнес-смысла, но и на уровне самой структуры хранения: разные сущности, разные шаблоны, разные поля и отдельная логика работы с ними. Для MODX это было естественным способом организации сайта, но при развитии нового приложения такая схема только усложняет поддержку и заставляет переносить старые ограничения в новую архитектуру.

Переход на KbConcept

Сейчас сайт окончательно уходит от старой базы и старой модели сущностей. Для всех основных типов данных уже написаны и работают импортёры, которые переносят записи из старой базы в новую.

Ключевое архитектурное изменение состоит в том, что практически весь контент теперь приводится к одной базовой сущности — KbConcept. Семантическое различие между объектами определяется не отдельной таблицей или отдельным классом модели, а полем type.

Например:

  • city:default — город;
  • company:default — компания;
  • resource:default — обычная веб-страница;
  • review:company — отзыв о компании;
  • blog:default — публичный блог;
  • blog:personal — персональный блог;
  • topic:default — публикация.

Таким образом, вместо большого набора исторически разросшихся сущностей получается единая модель данных с понятной системой типов. Это заметно упрощает GraphQL-схему, фронтенд, повторное использование компонентов, выборки, импорт данных и дальнейшее развитие сайта.

При этом унификация не означает потери типизации. Наоборот, TypeScript позволяет поверх общего KbConcept достаточно строго описать конкретные подтипы и безопасно работать с ними в прикладном коде.

Типизация KbConcept через template literal types

Особенно удачно здесь пригодилась возможность TypeScript использовать шаблонные строковые литералы в типах (template literal types). Сейчас код типов выглядит так:

import { EnumValueConfigMap, SchemaTypes } from '@pothos/core'
import { KbConceptFragment } from 'src/gql/generated'

export const CustomKbConceptType = {
  City: {
    value: 'city:default',
    description: 'Город',
  },
  Company: {
    value: 'company:default',
    description: 'Компания',
  },
  ResourceDefault: {
    value: 'resource:default',
    description: 'Веб-страница',
  },
  ReviewCompany: {
    value: 'review:company',
    description: 'Отзыв о компании',
  },
  BlogDefault: {
    value: 'blog:default',
    description: 'Публичный блог',
  },
  BlogPersonal: {
    value: 'blog:personal',
    description: 'Персональный блог',
  },
  TopicDefault: {
    value: 'topic:default',
    description: 'Публикация',
  },
} as const satisfies EnumValueConfigMap<SchemaTypes>

export type MapItemCompany = KbConceptFragment & {
  type: `company:${string}`
  lat: number
  lng: number
}

export function isMapItemCompany(
  concept: KbConceptFragment,
): concept is MapItemCompany {
  return concept.type?.startsWith('company:') && concept.lat && concept.lng
    ? true
    : false
}

export type Company = KbConceptFragment & {
  type: `company:${string}`
}

export function isCompany(concept: KbConceptFragment): concept is Company {
  return concept.type?.startsWith('company:') ? true : false
}

export type City = KbConceptFragment & {
  type: `city:${string}`
}

export function isCity(concept: KbConceptFragment): concept is City {
  return concept.type?.startsWith('city:') ? true : false
}

export type ReviewCompany = KbConceptFragment & {
  type: typeof CustomKbConceptType.ReviewCompany.value
}

export function isReviewCompany(
  concept: KbConceptFragment,
): concept is ReviewCompany {
  return concept.type === CustomKbConceptType.ReviewCompany.value
}

Здесь есть несколько особенно полезных моментов.

as const satisfies ...

Конструкция:

} as const satisfies EnumValueConfigMap<SchemaTypes>

решает сразу две задачи.

as const не даёт TypeScript расширить значения вроде 'city:default' до общего типа string. В результате конкретные строки сохраняются как литеральные типы. Например, CustomKbConceptType.ReviewCompany.value имеет тип именно 'review:company', а не просто string.

При этом satisfies EnumValueConfigMap<SchemaTypes> проверяет, что весь объект соответствует контракту, ожидаемому Pothos, но не уничтожает точную информацию о литеральных значениях внутри объекта. Получается удобное сочетание строгой проверки структуры и максимально точного вывода типов.

Шаблонные литералы в типах

Самая интересная часть:

type: `company:${string}`

и аналогично:

type: `city:${string}`

Это позволяет выразить на уровне системы типов сам принцип устройства KbConcept.type: объект считается компанией не только при одном конкретном значении company:default, а при любом типе из пространства company:*.

Например, если в дальнейшем появятся company:premium, company:branch или другие специализированные варианты, тип Company уже сможет описывать их без создания отдельного union вручную.

То есть соглашение об именовании типов вида:

<группа>:<подтип>

становится не просто строковым соглашением в базе, а частью статической типизации приложения.

Type guards

Функции вида:

export function isCompany(concept: KbConceptFragment): concept is Company

являются пользовательскими type guard'ами. После проверки isCompany(concept) TypeScript уже знает, что внутри соответствующей ветки concept.type имеет форму company:${string}.

То же самое используется для городов и отзывов.

Отдельно полезен isMapItemCompany: он не только проверяет префикс company:, но и сужает объект до типа, в котором гарантированно доступны координаты lat и lng как числа. Благодаря этому код карты дальше работает не с «возможно компанией с возможно координатами», а уже с нормальным типизированным объектом карты.

Для точных специализированных вариантов можно использовать ещё более строгую проверку:

type: typeof CustomKbConceptType.ReviewCompany.value

Здесь тип ReviewCompany привязан непосредственно к значению из центрального объекта CustomKbConceptType. Если строковое значение типа будет изменено там, тип не придётся дублировать вручную в нескольких местах.

В итоге общая сущность KbConcept не превращает приложение в набор нетипизированных объектов. Наоборот, за счёт соглашения о type, template literal types и type guards получается сохранить удобство единой модели данных и одновременно получить строгую типизацию конкретных сценариев на фронтенде.

Импорт старых данных

На текущий момент импортёры старых сущностей уже написаны и работают. Импортированы основные данные, в том числе ресурсы, компании и связанные типы контента.

Это важный этап именно в контексте полного переезда: новая версия сайта уже не должна продолжать читать старую MODX-базу как основной источник данных. Старые сущности преобразуются в новую унифицированную модель и дальше приложение работает уже с новой базой и KbConcept.

Таким образом, задача постепенно перестаёт быть «новым интерфейсом поверх старого сайта» и становится полноценной миграцией на новую платформу.

Карта

Также перенесена карта с отображением компаний. Функциональность кластеризации маркеров сохранена: при большом количестве объектов близко расположенные точки объединяются в кластеры, а при изменении масштаба раскрываются в отдельные элементы.

Для карты как раз используется специализированный тип MapItemCompany, чтобы после фильтрации на уровне TypeScript были гарантированы и принадлежность концепта к company:*, и наличие координат.

Текущий результат

На данный момент:

  • новая архитектура сайта уже строится вокруг KbConcept;
  • основные старые сущности больше не требуют отдельных моделей в новом приложении;
  • написаны и работают импортёры данных из старой базы;
  • ресурсы, компании и другие основные сущности импортированы;
  • типы контента приведены к единой схеме <группа>:<подтип>;
  • на фронтенде добавлены type guards и строгая типизация конкретных разновидностей KbConcept;
  • перенесена карта;
  • восстановлена кластеризация объектов на карте.

Следующий этап — завершить оформление и довести визуальную часть нового сайта. После этого планируется публикация новой версии сайта и окончательный переход на неё.

14.06.2026