LendingTelecom/backend/schema.ts

825 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { list } from "@keystone-6/core";
import { allowAll } from "@keystone-6/core/access";
import { text, password, timestamp, image, relationship, select, integer, checkbox } from "@keystone-6/core/fields";
import { document } from '@keystone-6/fields-document';
import { type Lists } from ".keystone/types";
export const lists: Lists = {
// 👤 Пользователь
User: list({
access: allowAll,
fields: {
name: text({ validation: { isRequired: true } }),
email: text({ validation: { isRequired: true }, isIndexed: "unique" }),
password: password({ validation: { isRequired: true } }),
createdAt: timestamp({ defaultValue: { kind: "now" } }),
},
}),
System: list({
access: allowAll,
ui: {
label: 'System settings',
description: 'Глобальные настройки сайта',
listView: { initialColumns: ['siteTitle', 'updatedAt'] },
},
fields: {
siteTitle: text({
validation: { isRequired: true },
ui: { description: 'Title сайта (metatitle / отображение в шапке и т.п.)' },
}),
linedUrl: text({
validation: { isRequired: true },
ui: { description: 'LinedIn url' },
}),
headerLogo: image({
storage: 'local_images',
ui: { description: 'Логотип для хедера (светлый/темный вариант по макету)' },
}),
footerLogo: image({
storage: 'local_images',
ui: { description: 'Логотип для футера' },
}),
footerDisclaimer: document({
ui: { description: 'Дисклеймер в футере (rich-text)' },
formatting: {
headingLevels: [1, 2, 3, 4],
inlineMarks: {
bold: true, italic: true, underline: true, strikethrough: true, code: true,
superscript: true, subscript: true,
},
listTypes: { ordered: true, unordered: true },
alignment: { center: true, end: true },
blockTypes: { blockquote: true, code: true },
softBreaks: true,
},
links: true,
dividers: true,
}),
updatedAt: timestamp({
defaultValue: { kind: 'now' },
ui: { itemView: { fieldMode: 'read' } },
}),
},
hooks: {
async beforeOperation({ operation, context, listKey }) {
// Разрешаем только ОДНУ запись
if (operation === 'create') {
const count = await context.db[listKey].count({});
if (count > 0) throw new Error('Можно создать только одну запись System');
}
// Запрет удаления (чтобы случайно не потерять глобальные настройки)
if (operation === 'delete') {
throw new Error('Нельзя удалить System; измените существующую запись.');
}
},
async resolveInput({ operation, resolvedData }) {
if (operation === 'update') {
resolvedData.updatedAt = new Date().toISOString();
}
return resolvedData;
},
},
}),
NavItem: list({
access: allowAll,
ui: {
label: 'Navigation',
labelField: 'label',
listView: { initialColumns: ['label', 'href', 'order', 'parent'] },
},
fields: {
label: text({ validation: { isRequired: true } }),
href: text({
validation: { isRequired: true },
ui: { description: 'Например: /about, #contact, https://example.com' },
}),
order: integer({ defaultValue: 0 }),
isExternal: checkbox({
defaultValue: false,
ui: { description: 'Открывать в новой вкладке (target=_blank)' },
}),
enabled: checkbox({ defaultValue: true }),
// древовидная структура (подменю)
parent: relationship({ ref: 'NavItem.children' }),
children: relationship({
ref: 'NavItem.parent',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['label', 'href', 'order', 'isExternal', 'enabled'],
inlineCreate: { fields: ['label', 'href', 'order', 'isExternal', 'enabled'] },
inlineEdit: { fields: ['label', 'href', 'order', 'isExternal', 'enabled'] },
},
}),
},
}),
// 🟩 Hero (как было)
HeroSection: list({
access: allowAll,
ui: { label: "Hero Section", listView: { initialColumns: ["title"] } },
fields: {
title: text({ validation: { isRequired: true }, ui: { description: "Главный заголовок секции" } }),
description: text({ ui: { displayMode: "textarea", description: "Подзаголовок / описание" } }),
backgroundImage: image({ storage: "local_images", ui: { description: "Фоновая картинка (1360x640)" } }),
buttonText: text({ ui: { description: "Текст кнопки" } }),
buttonLink: text({ ui: { description: "Ссылка кнопки (например: /contact)" } }),
},
hooks: {
async beforeOperation({ operation, listKey, context }) {
if (operation === "create") {
const existing = await context.db[listKey].findMany({ take: 1 });
if (existing.length > 0) throw new Error("Можно создать только один HeroSection");
}
},
},
}),
// 🟨 Универсальная плитка секции (и «статистика», и «финансы»)
FinanceRow: list({
access: allowAll,
ui: {
label: "Finance Row",
listView: { initialColumns: ["order", "value", "label", "trend"] },
},
fields: {
value: text({ validation: { isRequired: true } }), // напр. "$2,05 bln" или "5 mln"
label: text({ validation: { isRequired: true } }), // подпись под значением
trend: select({
type: "enum",
options: [
{ label: "Нет стрелки", value: "none" },
{ label: "Вверх", value: "up" },
{ label: "Вниз", value: "down" },
],
defaultValue: "none",
ui: { displayMode: "segmented-control" },
}),
order: integer({ defaultValue: 0 }), // для сортировки и «бить по 3»
section: relationship({ ref: "ScreenSecondSection.rows" }),
},
}),
// 🟦 Вторая секция (About/Overview) — singleton
ScreenSecondSection: list({
access: allowAll,
ui: { label: "Screen Second", listView: { initialColumns: ["title"] } },
fields: {
title: text({
validation: { isRequired: true },
ui: { description: "Заголовок секции (About Freedom Holding Corp)" },
}),
description: text({
ui: { displayMode: "textarea", description: "Описание под заголовком" },
}),
logo: image({ storage: "local_images", ui: { description: "Логотип справа" } }),
rows: relationship({
ref: "FinanceRow.section",
many: true,
ui: {
displayMode: "cards",
cardFields: ["value", "label", "trend", "order"],
inlineCreate: { fields: ["value", "label", "trend", "order"] },
inlineEdit: { fields: ["value", "label", "trend", "order"] },
linkToItem: true,
},
}),
footnote: text({ ui: { description: "Сноска (например: * for the year ended ...)" } }),
},
hooks: {
async beforeOperation({ operation, listKey, context }) {
if (operation === "create") {
const existing = await context.db[listKey].findMany({ take: 1 });
if (existing.length > 0) throw new Error("Можно создать только один ScreenSecondSection");
}
},
},
}),
ScreenThreeStatCard: list({
access: allowAll,
ui: {
label: 'Screen3 • Stat Card',
listView: { initialColumns: ['order', 'value', 'label', 'color'] },
},
fields: {
value: text({ validation: { isRequired: true } }), // напр. "USD ~ 10.8 bn*"
label: text({ validation: { isRequired: true } }), // "Current market capitalization"
color: text({
ui: { description: 'HEX цвет значения (например #16a34a). Можно оставить пустым.' },
}),
order: integer({ defaultValue: 0 }),
section: relationship({ ref: 'ScreenThreeSection.statCards' }),
},
}),
/* 🟨 Элемент списка индексов (bullet) */
ScreenThreeIndex: list({
access: allowAll,
ui: {
label: 'Screen3 • Index Item',
listView: { initialColumns: ['order', 'text'] },
},
fields: {
text: text({ validation: { isRequired: true } }), // напр. "MSCI U.S. Small Cap 1750"
order: integer({ defaultValue: 0 }),
section: relationship({ ref: 'ScreenThreeSection.indexes' }),
},
}),
/* 🟦 Секция Financial performance (singleton) */
ScreenThreeSection: list({
access: allowAll,
ui: {
label: 'Screen Three (Financial performance)',
listView: { initialColumns: ['title'] },
},
fields: {
title: text({
validation: { isRequired: true },
ui: { description: 'Заголовок секции (например: Financial performance)' },
}),
description: text({
ui: { displayMode: 'textarea', description: 'Описание под заголовком' },
}),
// Карточки справа (две у тебя в макете, но можно больше/меньше)
statCards: relationship({
ref: 'ScreenThreeStatCard.section',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['value', 'label', 'color', 'order'],
inlineCreate: { fields: ['value', 'label', 'color', 'order'] },
inlineEdit: { fields: ['value', 'label', 'color', 'order'] },
linkToItem: true,
},
}),
// Список индексов (буллиты)
indexes: relationship({
ref: 'ScreenThreeIndex.section',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['text', 'order'],
inlineCreate: { fields: ['text', 'order'] },
inlineEdit: { fields: ['text', 'order'] },
linkToItem: true,
},
}),
footnote: text({
ui: { description: 'Сноска под секцией (например: * Based on the price ...)' },
}),
},
hooks: {
// singleton: разрешаем только одну запись
async beforeOperation({ operation, listKey, context }) {
if (operation === 'create') {
const existing = await context.db[listKey].findMany({ take: 1 });
if (existing.length > 0) {
throw new Error('Можно создать только одну ScreenThreeSection');
}
}
},
},
}),
KeyBusinessSegmentsSection: list({
access: allowAll,
ui: { label: "Key Business Segments (Section)", listView: { initialColumns: ["title"] } },
fields: {
title: text({ validation: { isRequired: true } }),
segments: relationship({
ref: "KeySegment.section",
many: true,
ui: {
displayMode: "cards",
cardFields: ["name", "key", "order", "logo"],
inlineCreate: { fields: ["name", "key", "logo", "order"] },
inlineEdit: { fields: ["name", "key", "logo", "order"] },
linkToItem: true,
},
}),
},
hooks: {
async beforeOperation({ operation, listKey, context }) {
if (operation === "create") {
const exists = await context.db[listKey].findMany({ take: 1 });
if (exists.length) throw new Error("Можно создать только один KeyBusinessSegmentsSection");
}
},
},
}),
/* Segment */
KeySegment: list({
access: allowAll,
ui: { label: "Key Segment", listView: { initialColumns: ["order", "name", "key"] } },
fields: {
key: text({ isIndexed: 'unique', ui: { description: "Код: broker/bank/telecom…" } }),
name: text({ validation: { isRequired: true } }),
order: integer({ defaultValue: 0 }),
logo: image({ storage: 'local_images' }),
section: relationship({ ref: "KeyBusinessSegmentsSection.segments" }),
info: relationship({
ref: "KeySegmentInfo.segment",
many: true,
ui: {
displayMode: "cards",
cardFields: ["title", "order"],
inlineCreate: { fields: ["title", "order", "brandLink", "brandLogo"] },
inlineEdit: { fields: ["title", "order", "brandLink", "brandLogo"] },
linkToItem: true,
},
}),
},
}),
/* Info card (column block) */
KeySegmentInfo: list({
access: allowAll,
ui: { label: "Key Segment • Info Block", listView: { initialColumns: ["order", "title"] } },
fields: {
title: text({ validation: { isRequired: true } }),
order: integer({ defaultValue: 0 }),
segment: relationship({ ref: "KeySegment.info" }),
// бренд принадлежит целому блоку
brandLink: text({ ui: { description: "Ссылка бренда (опц.)" } }),
brandLogo: image({ storage: 'local_images', ui: { description: "Лого бренда (опц.)" } }),
items: relationship({
ref: "KeySegmentInfoItem.info",
many: true,
ui: {
displayMode: "cards",
cardFields: ["text", "order", "infoLogo", "colSpan"],
inlineCreate: { fields: ["text", "infoLogo", "colSpan", "order"] },
inlineEdit: { fields: ["text", "infoLogo", "colSpan", "order"] },
linkToItem: true,
},
}),
},
}),
/* Info list item (только текст + лого) */
KeySegmentInfoItem: list({
access: allowAll,
ui: { label: "Key Segment • Info Item", listView: { initialColumns: ["order", "text", "infoLogo", "colSpan"] } },
fields: {
text: text({ validation: { isRequired: true } }),
infoLogo: image({ storage: 'local_images', ui: { description: "Лого в зелёном квадратике" } }),
colSpan: select({
type: 'enum',
options: [
{ label: 'Normal (1 column)', value: 'ONE' },
{ label: 'Wide (2 columns)', value: 'TWO' },
],
defaultValue: 'ONE',
ui: { displayMode: 'segmented-control' },
}),
order: integer({ defaultValue: 0 }),
info: relationship({ ref: "KeySegmentInfo.items" }),
},
}),
WhyChooseUsItem: list({
access: allowAll,
ui: {
label: 'Why Choose Us Item',
listView: { initialColumns: ['text', 'order', 'section'] },
},
fields: {
text: text({
validation: { isRequired: true },
ui: { displayMode: 'textarea' },
}),
order: integer({
defaultValue: 0,
ui: { description: 'Сортировка по возрастанию' },
}),
section: relationship({ ref: 'WhyChooseUsSection.items' }),
createdAt: timestamp({ defaultValue: { kind: 'now' } }),
},
}),
WhyChooseUsSection: list({
access: allowAll,
ui: {
label: 'Why Choose Us (section)',
listView: { initialColumns: ['title'] },
},
fields: {
// Заголовок секции ("Why Choose Us")
title: text({ defaultValue: 'Why Choose Us' }),
// Левая карточка
leftTitle: text({
validation: { isRequired: true },
ui: { description: 'Текст в левой карточке' },
}),
leftBackground: image({
storage: 'local_images',
ui: { description: 'Фон для левой карточки (например Backgroung_2.png)' },
}),
ctaText: text({ defaultValue: 'Partner with us' }),
ctaLink: text({ ui: { description: 'Ссылка для кнопки (опционально)' } }),
// Правая колонка: пункты (много)
items: relationship({
ref: 'WhyChooseUsItem.section',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['text', 'order'],
inlineCreate: { fields: ['text', 'order'] },
inlineEdit: { fields: ['text', 'order'] },
linkToItem: true,
},
}),
}
}),
InnovationSection: list({
access: allowAll,
ui: {
label: 'Innovation (section)',
listView: {
initialColumns: ['title', 'updatedAt'],
},
},
fields: {
// Заголовок секции
title: text({
defaultValue: 'Innovation at the intersection of telecom and fintech',
ui: { description: 'Заголовок над сеткой карточек' },
}),
// ВЕРХ: левая карточка (текст + картинка справа)
topLeftTitle: text({
validation: { isRequired: true },
ui: { description: 'Заголовок левой верхней карточки' },
}),
topLeftText: text({
ui: { displayMode: 'textarea', description: 'Текст левой верхней карточки' },
}),
topLeftImage: image({
storage: 'local_images',
ui: { description: 'Картинка в правой части левой верхней карточки' },
}),
// ВЕРХ: правая зелёная карточка
topRightTitle: text({
validation: { isRequired: true },
ui: { description: 'Заголовок правой верхней зелёной карточки' },
}),
topRightText: text({
ui: { displayMode: 'textarea', description: 'Текст правой верхней карточки' },
}),
// НИЗ: две карточки
bottom1Title: text({ ui: { description: 'Заголовок левой нижней карточки' } }),
bottom1Text: text({
ui: { displayMode: 'textarea', description: 'Текст левой нижней карточки' },
}),
bottom2Title: text({ ui: { description: 'Заголовок правой нижней карточки' } }),
bottom2Text: text({
ui: { displayMode: 'textarea', description: 'Текст правой нижней карточки' },
}),
// тех.поля
createdAt: timestamp({ defaultValue: { kind: 'now' } }),
updatedAt: timestamp({
db: { updatedAt: true },
}),
},
// Singleton: разрешаем только одну запись
hooks: {
async beforeOperation({ operation, listKey, context }) {
if (operation === 'create') {
const existing = await context.db[listKey].findMany({ where: {} });
if (existing.length > 0) {
throw new Error('Можно создать только один InnovationSection');
}
}
},
},
}),
SolutionsSection : list({
access: allowAll,
ui: {
label: 'Solutions (Section)',
listView: { initialColumns: ['title'] },
},
fields: {
title: text({
ui: { description: 'Заголовок секции (на фронте "Solutions")' },
defaultValue: 'Solutions',
}),
buttonText: text({
ui: { description: 'Текст кнопки внизу (CTA)' },
defaultValue: 'Partner with us',
}),
buttonHref: text({
validation: { isRequired: false },
ui: { description: "Куда ведёт кнопка 'Partner with us'" },
}),
// массив карточек
items: relationship({
ref: 'SolutionItem.section',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['title', 'order', 'image'],
inlineCreate: { fields: ['title', 'text', 'href', 'image', 'order'] },
inlineEdit: { fields: ['title', 'text', 'href', 'image', 'order'] },
linkToItem: true,
inlineConnect: true,
},
}),
},
hooks: {
// Ограничиваем до одной записи
async beforeOperation({ operation, listKey, context }) {
if (operation === 'create') {
const existing = await context.db[listKey].findMany({ take: 1 });
if (existing.length) throw new Error('Можно создать только один SolutionsSection');
}
},
},
}),
SolutionItem: list({
access: allowAll,
ui: {
label: 'Solutions → Item',
listView: { initialColumns: ['title', 'order'] },
},
fields: {
title: text({ validation: { isRequired: true } }),
text: text({ ui: { displayMode: 'textarea' } }),
href: text({ ui: { description: 'Опциональная ссылка "Learn more"' } }),
image: image({
storage: 'local_images',
ui: { description: 'Фоновая картинка карточки' },
}),
order: integer({
defaultValue: 0,
ui: { description: 'Порядок сортировки (чем меньше, тем выше)' },
}),
// обратная связь к секции
section: relationship({
ref: 'SolutionsSection.items',
ui: { hideCreate: true },
}),
},
}),
CountriesMapSection: list({
access: allowAll,
ui: {
label: 'Countries Map (Section)',
listView: { initialColumns: ['title'] },
},
fields: {
title: text({
ui: { description: 'Опциональный заголовок секции' },
}),
countries: relationship({
ref: 'MapCountry.section',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['code', 'name', 'isEnabled', 'flag'],
inlineCreate: { fields: ['code', 'name', 'isEnabled', 'flag'] },
inlineEdit: { fields: ['name', 'isEnabled', 'flag'] },
linkToItem: true,
},
}),
},
hooks: {
async beforeOperation({ operation, listKey, context }) {
if (operation === 'create') {
const existing = await context.db[listKey].findMany({ where: {} });
if (existing.length > 0) {
throw new Error('Можно создать только одну CountriesMapSection');
}
}
},
},
}),
/* === Страна на карте === */
MapCountry: list({
access: allowAll,
ui: {
label: 'Countries',
listView: { initialColumns: ['code', 'name', 'isEnabled'] },
},
fields: {
section: relationship({
ref: 'CountriesMapSection.countries',
ui: { hideCreate: true },
}),
code: select({
type: 'enum',
// value — то, что увидит фронт; label — удобочитаемое имя в админке
options: [
{ label: 'United States of America', value: 'usa' },
{ label: 'Kazakhstan', value: 'kaz' },
{ label: 'Uzbekistan', value: 'uzb' },
{ label: 'Kyrgyzstan', value: 'kgz' },
{ label: 'Tajikistan', value: 'tjk' },
{ label: 'Turkey', value: 'tur' },
{ label: 'Cyprus', value: 'cyp' },
{ label: 'Armenia', value: 'arm' },
{ label: 'United Arab Emirates', value: 'uae' },
],
validation: { isRequired: true },
defaultValue: 'kaz',
ui: { description: 'Код страны (фиксированный набор для фронта)' },
isIndexed: 'unique', // одна запись на код
}),
name: text({
validation: { isRequired: true },
ui: { description: 'Подпись на карточке (можно локализовать)' },
}),
isEnabled: checkbox({
defaultValue: true,
ui: { description: 'Показывать страну на карте' },
}),
flag: image({
storage: 'local_images',
ui: { description: 'Иконка флага (SVG/PNG), будет вместо зелёной точки' },
}),
items: relationship({
ref: 'MapCountryItem.country',
many: true,
ui: {
displayMode: 'cards',
cardFields: ['text', 'order'],
inlineCreate: { fields: ['text', 'order'] },
inlineEdit: { fields: ['text', 'order'] },
linkToItem: false,
},
}),
order: integer({
defaultValue: 0,
ui: { description: 'Произвольная сортировка стран (меньше — раньше)' },
}),
},
}),
/* === Пункты внутри карточки страны === */
MapCountryItem: list({
access: allowAll,
ui: {
label: 'Country Items',
isHidden: false,
listView: { initialColumns: ['country', 'text', 'order'] },
},
fields: {
country: relationship({
ref: 'MapCountry.items',
ui: { hideCreate: true },
}),
text: text({
validation: { isRequired: true },
ui: { description: 'Строка в списке (bullet)' },
}),
order: integer({
defaultValue: 0,
ui: { description: 'Порядок внутри карточки' },
}),
},
}),
ContactRequest: list({
access: {
// Создавать может кто угодно, читать/править — только авторизованные (админка)
operation: {
query: ({ session }) => !!session,
create: allowAll,
update: ({ session }) => !!session,
delete: ({ session }) => !!session,
},
},
ui: {
label: 'Contact requests',
listView: {
initialColumns: ['createdAt', 'name', 'email', 'company', 'message', 'countryCode'],
initialSort: { field: 'createdAt', direction: 'DESC' },
},
},
fields: {
name: text({ validation: { isRequired: true } }),
company: text(),
message: text(),
position: text(),
email: text({ validation: { isRequired: true } }),
phone: text(),
countryCode: text({ ui: { description: 'ISO-код страны (например, US)' } }),
createdAt: timestamp({ defaultValue: { kind: 'now' } }),
},
hooks: {
// после успешного создания — отправляем письмо
afterOperation: async ({ operation, item }) => {
if (operation !== 'create' || !item) return;
const {
SMTP_HOST,
SMTP_PORT = '587',
SMTP_USER,
SMTP_PASS,
SMTP_SECURE = 'false',
MAIL_FROM = 'no-reply@example.com',
CONTACT_TO = MAIL_FROM,
} = process.env;
if (!SMTP_HOST || !CONTACT_TO) {
console.warn('[ContactRequest] SMTP not configured — skip email.');
return;
}
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT),
secure: SMTP_SECURE === 'true',
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
});
const html = `
<h2>New contact request</h2>
<ul>
<li><b>Name:</b> ${item.name ?? ''}</li>
<li><b>Company:</b> ${item.company ?? ''}</li>
<li><b>Position:</b> ${item.position ?? ''}</li>
<li><b>Email:</b> ${item.email ?? ''}</li>
<li><b>Phone:</b> ${item.phone ?? ''}</li>
<li><b>Country:</b> ${item.countryCode ?? ''}</li>
<li><b>Created:</b> ${item.createdAt ?? ''}</li>
</ul>
`;
try {
await transporter.sendMail({
from: MAIL_FROM,
to: CONTACT_TO,
subject: 'New contact request',
html,
});
} catch (err) {
console.error('[ContactRequest] Email send failed:', err);
}
},
},
}),
Privacy : list({
access: allowAll,
ui: {
label: "Privacy Policy",
isHidden: false,
},
graphql: {
singular: "privacy",
plural: "privacys", // можно и "privacyList", если хочешь по-другому
},
fields: {
title: text({
validation: { isRequired: true },
defaultValue: "Privacy Policy",
}),
content: document({
label: "Privacy Policy Content",
formatting: {
inlineMarks: {
bold: true,
italic: true,
underline: true,
strikethrough: true,
},
listTypes: true,
headingLevels: [1, 2, 3],
alignment: true,
},
links: true,
layouts: [[1], [1, 1]],
}),
},
})
};