Этап 1a: фундамент

- Next 16.4 и Payload 3.91 в одном приложении, PostgreSQL с UUID,
  TypeScript 6, ESLint 9, Prettier, Vitest
- Payload: администраторы с ролями, multi-tenant по сайтам, локализация
  en/ru/de/fr/es, интерфейс админки ru/en, языки сайта (sites.locales)
- Коллекции sites, domains, currencies, countries, locations, tags,
  tariffs, payment-systems (manual), media и начальные данные (pnpm seed)
- Proxy: хост → домен, язык и валюта, внутренние маршруты по хосту;
  /admin и /api закрыты на доменах брендов
- Токены и темы брендов, оболочки сайта и кабинета, переключатели языка,
  валюты и темы, заглушки страниц
- /svc/health, юнит systemd veilix-dev.service
- Документация: решения D-022…D-024, команды в CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreypr207andClaude Opus 5.5 committed 2026-10-21 05:08:05 +02:00
1 parent 3400f58a5a
commit 7e331c2c9d
140 files changed
+15885 -43

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import type { Access, FieldAccess } from 'payload'
export const ROLES = ['owner', 'admin', 'support', 'content'] as const
export type Role = (typeof ROLES)[number]
type AdminUser = { collection: 'admins'; id: string; roles?: Role[] | null }
export const isAdminUser = (user: unknown): user is AdminUser =>
typeof user === 'object' && user !== null && 'collection' in user && user.collection === 'admins'
export const hasRole = (user: unknown, ...roles: Role[]): boolean =>
isAdminUser(user) && (user.roles ?? []).some((role) => roles.includes(role))
/** Любой администратор с хотя бы одной ролью */
export const anyRole: Access = ({ req }) =>
isAdminUser(req.user) && (req.user.roles ?? []).length > 0
/** owner и admin: правка настроек и справочников */
export const managers: Access = ({ req }) => hasRole(req.user, 'owner', 'admin')
export const owners: Access = ({ req }) => hasRole(req.user, 'owner')
/** owner, admin и content: правка контента */
export const contentEditors: Access = ({ req }) => hasRole(req.user, 'owner', 'admin', 'content')
export const ownersField: FieldAccess = ({ req }) => hasRole(req.user, 'owner')
/** owner видит всех, остальные — только себя */
export const ownerOrSelf: Access = ({ req }) => {
if (hasRole(req.user, 'owner')) return true
if (isAdminUser(req.user)) return { id: { equals: req.user.id } }
return false
}
+155
View File
@@ -0,0 +1,155 @@
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
import type {
ArrayField,
CollectionBeforeChangeHook,
CollectionBeforeDeleteHook,
CollectionConfig,
Field,
PayloadRequest,
} from 'payload'
import { APIError } from 'payload'
import { ownerOrSelf, owners, ownersField, type Role } from '../access/roles'
import { groups, label, message } from '../i18n'
const roleOptions: { value: Role; label: { en: string; ru: string } }[] = [
{ value: 'owner', label: label('Owner', 'Владелец') },
{ value: 'admin', label: label('Admin', 'Администратор') },
{ value: 'support', label: label('Support', 'Поддержка') },
{ value: 'content', label: label('Content', 'Контент') },
]
/** Первый администратор окружения (экран первого входа Payload) становится owner */
const firstAdminIsOwner: CollectionBeforeChangeHook = async ({ data, operation, req }) => {
if (operation !== 'create') return data
const { totalDocs } = await req.payload.count({
collection: 'admins',
overrideAccess: true,
req,
})
return totalDocs === 0 ? { ...data, roles: ['owner'] } : data
}
const otherOwnersCount = async (req: PayloadRequest, id: number | string) => {
const { totalDocs } = await req.payload.count({
collection: 'admins',
where: { and: [{ roles: { in: ['owner'] } }, { id: { not_equals: id } }] },
overrideAccess: true,
req,
})
return totalDocs
}
const lastOwnerError = (req: PayloadRequest) =>
new APIError(
message(req, {
en: 'This is the last owner. Make someone else an owner first.',
ru: 'Это последний владелец. Сначала назначьте владельцем кого-то ещё.',
}),
400,
undefined,
true,
)
/** Без владельца админкой некому управлять: последний owner не теряет роль */
const keepLastOwner: CollectionBeforeChangeHook = async ({ data, operation, originalDoc, req }) => {
if (operation !== 'update' || !originalDoc?.roles?.includes('owner')) return data
const roles: Role[] | undefined = data.roles
if (!roles || roles.includes('owner')) return data
if ((await otherOwnersCount(req, originalDoc.id)) === 0) throw lastOwnerError(req)
return data
}
const keepLastOwnerOnDelete: CollectionBeforeDeleteHook = async ({ id, req }) => {
const doc = await req.payload.findByID({
collection: 'admins',
id,
depth: 0,
overrideAccess: true,
req,
})
if (doc.roles?.includes('owner') && (await otherOwnersCount(req, id)) === 0) {
throw lastOwnerError(req)
}
}
const pluginSites = tenantsArrayField({
tenantsArrayFieldName: 'sites',
tenantsArrayTenantFieldName: 'site',
tenantsCollectionSlug: 'sites',
arrayFieldAccess: { create: ownersField, update: ownersField },
})
/**
* Сайты, которые видит администратор (поле плагина multi-tenant). Нужно только поддержке
* и контенту: owner и admin видят все сайты. На экране первого входа пользователя ещё нет,
* а первый администратор и так становится owner, поэтому там поле скрыто.
*/
const sitesField: ArrayField = {
...pluginSites,
label: label('Sites', 'Сайты'),
labels: { singular: label('Site', 'Сайт'), plural: label('Sites', 'Сайты') },
fields: pluginSites.fields.map((field: Field) =>
'name' in field && field.name === 'site' ? { ...field, label: label('Site', 'Сайт') } : field,
) as ArrayField['fields'],
admin: {
condition: (data, _siblingData, { user }) =>
Boolean(user) &&
!(data.roles ?? []).some((role: Role) => role === 'owner' || role === 'admin'),
description: label(
'Sites available to support and content roles',
'Сайты, доступные ролям «Поддержка» и «Контент»',
),
},
}
export const Admins: CollectionConfig = {
slug: 'admins',
labels: {
singular: label('Administrator', 'Администратор'),
plural: label('Administrators', 'Администраторы'),
},
admin: {
group: groups.settings,
useAsTitle: 'email',
defaultColumns: ['email', 'name', 'roles'],
},
auth: {
maxLoginAttempts: 5,
lockTime: 15 * 60 * 1000,
// админка открывается только по HTTPS через nginx
cookies: { secure: true, sameSite: 'Lax' },
},
access: {
read: ownerOrSelf,
create: owners,
update: ownerOrSelf,
delete: owners,
},
hooks: {
beforeChange: [firstAdminIsOwner, keepLastOwner],
beforeDelete: [keepLastOwnerOnDelete],
},
fields: [
{
name: 'name',
type: 'text',
label: label('Name', 'Имя'),
},
{
name: 'roles',
type: 'select',
hasMany: true,
options: roleOptions,
label: label('Roles', 'Роли'),
access: { create: ownersField, update: ownersField },
admin: {
description: label(
'Owner and admin see all sites; support and content see only the sites below.',
'Владелец и администратор видят все сайты, поддержка и контент — только сайты ниже.',
),
},
},
sitesField,
],
}
+75
View File
@@ -0,0 +1,75 @@
import type { CollectionConfig, FieldHook, TextFieldSingleValidation } from 'payload'
import { anyRole, managers } from '../access/roles'
import { sortField } from '../fields/common'
import { groups, label, message } from '../i18n'
const upperCase: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value
const validateIso2: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{2}$/.test(value)
? true
: message(req, {
en: 'Two capital letters ISO 3166-1, e.g. NL',
ru: 'Две заглавные буквы ISO 3166-1, например NL',
})
export const Countries: CollectionConfig = {
slug: 'countries',
labels: {
singular: label('Country', 'Страна'),
plural: label('Countries', 'Страны'),
},
admin: {
group: groups.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'iso2', 'sort'],
description: label('Shared by both brands', 'Общий справочник обоих брендов'),
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
fields: [
{
type: 'row',
fields: [
{
name: 'iso2',
type: 'text',
required: true,
unique: true,
label: label('Code (ISO 3166-1)', 'Код (ISO 3166-1)'),
hooks: { beforeValidate: [upperCase] },
validate: validateIso2,
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
],
},
{
type: 'row',
fields: [
{
name: 'lat',
type: 'number',
min: -90,
max: 90,
label: label('Latitude', 'Широта'),
admin: { description: label('Default point on the map', 'Точка на карте по умолчанию') },
},
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
],
},
sortField,
],
}
+130
View File
@@ -0,0 +1,130 @@
import type {
CollectionBeforeDeleteHook,
CollectionConfig,
FieldHook,
TextFieldSingleValidation,
} from 'payload'
import { APIError } from 'payload'
import { anyRole, managers } from '../access/roles'
import { integerField, sortField } from '../fields/common'
import { groups, label, message } from '../i18n'
const upperCase: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{3}$/.test(value)
? true
: message(req, {
en: 'Three capital letters ISO 4217, e.g. USD',
ru: 'Три заглавные буквы ISO 4217, например USD',
})
/** Используемую валюту не удаляем: на её код ссылаются домены, тарифы и платёжные системы */
const keepUsedCurrency: CollectionBeforeDeleteHook = async ({ id, req }) => {
const { code } = await req.payload.findByID({
collection: 'currencies',
id,
depth: 0,
overrideAccess: true,
req,
})
const domains = await req.payload.count({
collection: 'domains',
where: { or: [{ currencies: { in: [code] } }, { defaultCurrency: { equals: code } }] },
overrideAccess: true,
req,
})
const paymentSystems = await req.payload.count({
collection: 'payment-systems',
where: { currencies: { in: [code] } },
overrideAccess: true,
req,
})
const tariffs = await req.payload.count({
collection: 'tariffs',
where: { 'prices.currency': { equals: code } },
overrideAccess: true,
req,
})
if (domains.totalDocs + paymentSystems.totalDocs + tariffs.totalDocs > 0) {
throw new APIError(
message(req, {
en: 'The currency is used by domains, tariffs or payment systems. Turn it off instead.',
ru: 'Валюта используется в доменах, тарифах или платёжных системах. Вместо удаления выключите её.',
}),
400,
undefined,
true,
)
}
}
export const Currencies: CollectionConfig = {
slug: 'currencies',
labels: {
singular: label('Currency', 'Валюта'),
plural: label('Currencies', 'Валюты'),
},
admin: {
group: groups.settings,
useAsTitle: 'code',
defaultColumns: ['code', 'symbol', 'name', 'active', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
hooks: {
beforeDelete: [keepUsedCurrency],
},
fields: [
{
type: 'row',
fields: [
{
name: 'code',
type: 'text',
required: true,
unique: true,
label: label('Code (ISO 4217)', 'Код (ISO 4217)'),
hooks: { beforeValidate: [upperCase] },
validate: validateCode,
// на код ссылаются денежные записи, после создания не меняется
access: { update: () => false },
},
{
name: 'symbol',
type: 'text',
required: true,
label: label('Symbol', 'Символ'),
},
integerField('minorUnits', label('Minor units', 'Знаков после запятой'), {
required: true,
defaultValue: 2,
min: 0,
max: 4,
}),
],
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Active', 'Включена'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+267
View File
@@ -0,0 +1,267 @@
import type {
CollectionBeforeChangeHook,
CollectionConfig,
FieldHook,
TextFieldSingleValidation,
Where,
} from 'payload'
import { ValidationError } from 'payload'
import { text } from 'payload/shared'
import { anyRole, managers } from '../access/roles'
import { currenciesField, currencyField } from '../fields/currency'
import { localeOptions } from '../fields/locales'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
const normalizeHostname: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : value
const validateHostname: TextFieldSingleValidation = (value, args) => {
const base = text(value, args)
if (base !== true) return base
return typeof value === 'string' && HOSTNAME.test(value)
? true
: message(args.req, {
en: 'Hostname without protocol and path, e.g. veilix.app',
ru: 'Имя хоста без протокола и пути, например veilix.app',
})
}
type LocaleCurrencyRow = { locale?: string | null; currency?: string | null }
/** Проверки, которым нужны несколько полей сразу и сайт домена */
const validateDomain: CollectionBeforeChangeHook = async ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const locales: string[] = doc.locales ?? []
const currencies: string[] = doc.currencies ?? []
const errors: { path: string; message: string }[] = []
const notInLocales = message(req, {
en: 'Not among the domain languages',
ru: 'Не входит в языки домена',
})
const notInCurrencies = message(req, {
en: 'Not among the domain currencies',
ru: 'Не входит в валюты домена',
})
const siteId = relationId(doc.site)
if (siteId) {
const site = await req.payload.findByID({
collection: 'sites',
id: siteId,
depth: 0,
overrideAccess: true,
req,
})
const extra = locales.filter((locale) => !(site.locales ?? []).includes(locale as never))
if (extra.length > 0) {
errors.push({
path: 'locales',
message: message(req, {
en: `The site has no content in these languages: ${extra.join(', ')}`,
ru: `У сайта нет контента на этих языках: ${extra.join(', ')}`,
}),
})
}
}
if (doc.defaultLocale && !locales.includes(doc.defaultLocale)) {
errors.push({ path: 'defaultLocale', message: notInLocales })
}
if (doc.defaultCurrency && !currencies.includes(doc.defaultCurrency)) {
errors.push({ path: 'defaultCurrency', message: notInCurrencies })
}
const seen = new Set<string>()
const rows: LocaleCurrencyRow[] = doc.localeCurrency ?? []
rows.forEach((row, index) => {
if (row.locale && !locales.includes(row.locale)) {
errors.push({ path: `localeCurrency.${index}.locale`, message: notInLocales })
} else if (row.locale && seen.has(row.locale)) {
errors.push({
path: `localeCurrency.${index}.locale`,
message: message(req, { en: 'Language is listed twice', ru: 'Язык указан дважды' }),
})
}
if (row.locale) seen.add(row.locale)
if (row.currency && !currencies.includes(row.currency)) {
errors.push({ path: `localeCurrency.${index}.currency`, message: notInCurrencies })
}
})
if (doc.isPrimary && siteId) {
const where: Where = {
and: [
{ site: { equals: siteId } },
{ app: { equals: doc.app } },
{ isPrimary: { equals: true } },
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
],
}
const { docs } = await req.payload.find({
collection: 'domains',
where,
depth: 0,
limit: 1,
overrideAccess: true,
req,
})
if (docs[0]) {
errors.push({
path: 'isPrimary',
message: message(req, {
en: `The site already has a primary domain of this type: ${docs[0].hostname}`,
ru: `У сайта уже есть основной домен этого типа: ${docs[0].hostname}`,
}),
})
}
}
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
return data
}
export const Domains: CollectionConfig = {
slug: 'domains',
labels: {
singular: label('Domain', 'Домен'),
plural: label('Domains', 'Домены'),
},
admin: {
group: groups.settings,
useAsTitle: 'hostname',
defaultColumns: ['hostname', 'app', 'locales', 'currencies', 'active'],
description: label(
'A new mirror is a record here plus an nginx block and a certificate.',
'Новое зеркало — запись здесь плюс блок nginx и сертификат.',
),
},
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
hooks: {
beforeChange: [validateDomain],
},
fields: [
{
name: 'hostname',
type: 'text',
required: true,
unique: true,
label: label('Hostname', 'Хост'),
hooks: { beforeValidate: [normalizeHostname] },
validate: validateHostname,
},
{
name: 'app',
type: 'select',
required: true,
options: [
{ value: 'marketing', label: label('Website', 'Сайт') },
{ value: 'cabinet', label: label('Account and checkout', 'Кабинет и оформление заказа') },
{ value: 'miniapp', label: label('Telegram MiniApp', 'MiniApp в Telegram') },
],
label: label('Frontend', 'Фронтенд'),
},
{
type: 'row',
fields: [
{
name: 'locales',
type: 'select',
hasMany: true,
required: true,
options: localeOptions,
label: label('Languages', 'Языки'),
},
{
name: 'defaultLocale',
type: 'select',
required: true,
defaultValue: 'en',
options: localeOptions,
label: label('Default language', 'Язык по умолчанию'),
admin: {
description: label('Opens without a URL prefix', 'Открывается без префикса в адресе'),
},
},
],
},
{
type: 'row',
fields: [
currenciesField({
name: 'currencies',
label: label('Currencies', 'Валюты'),
required: true,
}),
currencyField({
name: 'defaultCurrency',
label: label('Default currency', 'Валюта по умолчанию'),
required: true,
}),
],
},
{
name: 'localeCurrency',
type: 'array',
label: label('Currency by language', 'Валюта для языка'),
labels: { singular: label('Rule', 'Правило'), plural: label('Rules', 'Правила') },
admin: {
description: label(
'Default currency for a language, e.g. ru → RUB. Other languages get the default currency.',
'Валюта по умолчанию для языка, например ru → RUB. Для остальных языков — валюта по умолчанию.',
),
},
fields: [
{
type: 'row',
fields: [
{
name: 'locale',
type: 'select',
required: true,
options: localeOptions,
label: label('Language', 'Язык'),
},
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
],
},
],
},
{
name: 'isPrimary',
type: 'checkbox',
label: label('Primary domain of this type', 'Основной домен этого типа'),
admin: {
position: 'sidebar',
description: label('Used in links and the sitemap', 'Используется в ссылках и sitemap'),
},
},
{
name: 'indexable',
type: 'checkbox',
defaultValue: false,
label: label('Allow search indexing', 'Разрешить индексацию'),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Active', 'Включён'),
admin: { position: 'sidebar' },
},
],
}
+149
View File
@@ -0,0 +1,149 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { ValidationError } from 'payload'
import type { SiteCode } from '@/core/sites'
import { anyRole, managers } from '../access/roles'
import { integerField, sortField } from '../fields/common'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
export const LOCATION_STATUSES = ['available', 'on_demand', 'soon'] as const
/** dedicated: «Серверы в наличии» и «Установим за 24 часа»; mass: «Включено» и «Скоро» */
const STATUSES_BY_SITE: Record<SiteCode, readonly string[]> = {
dedicated: ['available', 'on_demand'],
mass: ['available', 'soon'],
}
const validateStatus: CollectionBeforeChangeHook = async ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const siteId = relationId(doc.site)
if (!siteId || !doc.status) return data
const site = await req.payload.findByID({
collection: 'sites',
id: siteId,
depth: 0,
overrideAccess: true,
req,
})
if (!STATUSES_BY_SITE[site.code].includes(doc.status)) {
throw new ValidationError({
collection: collection.slug,
errors: [
{
path: 'status',
message: message(req, {
en: 'This status is not used by the brand of the site',
ru: 'Этот статус не используется у бренда сайта',
}),
},
],
req,
})
}
return data
}
export const Locations: CollectionConfig = {
slug: 'locations',
labels: {
singular: label('Location', 'Локация'),
plural: label('Locations', 'Локации'),
},
admin: {
group: groups.catalog,
useAsTitle: 'city',
defaultColumns: ['city', 'country', 'status', 'visible', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
indexes: [{ fields: ['site', 'country'], unique: true }],
hooks: {
beforeChange: [
validateStatus,
uniqueInSite('country', {
en: 'The site already has a location in this country',
ru: 'У сайта уже есть локация в этой стране',
}),
],
},
fields: [
{
type: 'row',
fields: [
{
name: 'country',
type: 'relationship',
relationTo: 'countries',
required: true,
label: label('Country', 'Страна'),
},
{
name: 'city',
type: 'text',
required: true,
localized: true,
label: label('City', 'Город'),
},
],
},
{
type: 'row',
fields: [
{
name: 'lat',
type: 'number',
min: -90,
max: 90,
label: label('Latitude', 'Широта'),
admin: { description: label('City point on the map', 'Точка города на карте') },
},
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
],
},
{
name: 'status',
type: 'select',
required: true,
options: [
{ value: 'available', label: label('Available now', 'Доступна сейчас') },
{
value: 'on_demand',
label: label('Ready within 24 hours (Veilix)', 'Установим за 24 часа (Вейликс)'),
},
{ value: 'soon', label: label('Coming soon (VeilixVPN)', 'Скоро (ВейликсВПН)') },
],
label: label('Status', 'Статус'),
},
integerField('freeServers', label('Free servers', 'Свободных серверов'), {
min: 0,
admin: {
description: label(
'Optional counter. Whether to show it on the site is decided in stage 1b.',
'Необязательный счётчик. Показывать ли его на сайте — решаем на этапе 1b.',
),
},
}),
{
name: 'visible',
type: 'checkbox',
defaultValue: true,
label: label('Shown on the site', 'Показывать на сайте'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+36
View File
@@ -0,0 +1,36 @@
import path from 'node:path'
import type { CollectionConfig } from 'payload'
import { contentEditors } from '../access/roles'
import { groups, label } from '../i18n'
export const Media: CollectionConfig = {
slug: 'media',
labels: {
singular: label('File', 'Файл'),
plural: label('Media', 'Медиатека'),
},
admin: {
group: groups.content,
},
access: {
// файлы открыты всем: их показывают сайты брендов
read: () => true,
create: contentEditors,
update: contentEditors,
delete: contentEditors,
},
upload: {
// папка media в корне проекта; в prod это ссылка на shared/media
staticDir: path.resolve(process.cwd(), 'media'),
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
localized: true,
label: label('Alt text', 'Описание изображения'),
},
],
}
+209
View File
@@ -0,0 +1,209 @@
import type { CollectionBeforeChangeHook, CollectionConfig, FieldHook } from 'payload'
import { ValidationError } from 'payload'
import {
isPaymentAdapterCode,
PAYMENT_ADAPTER_CODES,
paymentAdapters,
} from '@/core/payments/adapters'
import { anyRole, managers } from '../access/roles'
import { amountField, sortField } from '../fields/common'
import { currenciesField, currencyField } from '../fields/currency'
import { groups, label, message } from '../i18n'
type LimitRow = { currency?: string | null; min?: number | null; max?: number | null }
const validatePaymentSystem: CollectionBeforeChangeHook = ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const currencies: string[] = doc.currencies ?? []
const errors: { path: string; message: string }[] = []
// ручным платежом пользуются только администраторы
if (doc.adapter === 'manual' && doc.visibleToCustomers) {
errors.push({
path: 'visibleToCustomers',
message: message(req, {
en: 'Manual payments are for administrators only',
ru: 'Ручным платежом пользуются только администраторы',
}),
})
}
const rows: LimitRow[] = doc.limits ?? []
rows.forEach((row, index) => {
if (row.currency && !currencies.includes(row.currency)) {
errors.push({
path: `limits.${index}.currency`,
message: message(req, {
en: 'Not among the payment system currencies',
ru: 'Не входит в валюты платёжной системы',
}),
})
}
if (typeof row.min === 'number' && typeof row.max === 'number' && row.min > row.max) {
errors.push({
path: `limits.${index}.max`,
message: message(req, {
en: 'Maximum is less than minimum',
ru: 'Максимум меньше минимума',
}),
})
}
})
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
return data
}
/** Возможности не хранятся: их объявляет адаптер в коде */
const readCapabilities: FieldHook = ({ siblingData }) => {
const adapter: unknown = siblingData?.adapter
if (!isPaymentAdapterCode(adapter)) return null
const capabilities: readonly string[] = paymentAdapters[adapter].capabilities
return capabilities.length > 0 ? capabilities.join(', ') : '—'
}
export const PaymentSystems: CollectionConfig = {
slug: 'payment-systems',
labels: {
singular: label('Payment system', 'Платёжная система'),
plural: label('Payment systems', 'Платёжные системы'),
},
admin: {
group: groups.settings,
useAsTitle: 'name',
defaultColumns: ['name', 'adapter', 'currencies', 'mode', 'active'],
description: label(
'Available in its currencies on every site where they are enabled. Provider keys are in .env only.',
'Доступна в своих валютах на всех сайтах, где они включены. Ключи провайдеров — только в .env.',
),
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
hooks: {
beforeChange: [validatePaymentSystem],
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
label: label('Name in admin', 'Название в админке'),
},
{
name: 'adapter',
type: 'select',
required: true,
options: PAYMENT_ADAPTER_CODES.map((code) => ({ value: code, label: code })),
label: label('Adapter', 'Адаптер'),
},
],
},
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: label('Title for customers', 'Название для пользователя'),
admin: { description: label('e.g. “Bank card”', 'например, «Банковская карта»') },
},
{
name: 'description',
type: 'text',
localized: true,
label: label('Hint for customers', 'Подсказка для пользователя'),
},
{
name: 'icon',
type: 'upload',
relationTo: 'media',
label: label('Icon', 'Иконка'),
},
{
type: 'row',
fields: [
currenciesField({
name: 'currencies',
label: label('Currencies', 'Валюты'),
required: true,
}),
{
name: 'purposes',
type: 'select',
hasMany: true,
required: true,
options: [
{ value: 'order', label: label('Order payment', 'Оплата заказа') },
{ value: 'topup', label: label('Balance top-up', 'Пополнение баланса') },
],
label: label('Purposes', 'Назначения'),
},
],
},
{
name: 'limits',
type: 'array',
label: label('Limits', 'Лимиты'),
labels: { singular: label('Limit', 'Лимит'), plural: label('Limits', 'Лимиты') },
fields: [
{
type: 'row',
fields: [
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
amountField('min', label('Minimum', 'Минимум')),
amountField('max', label('Maximum', 'Максимум')),
],
},
],
},
{
name: 'capabilities',
type: 'text',
virtual: true,
label: label('Capabilities (from the adapter)', 'Возможности (из адаптера)'),
admin: { readOnly: true },
hooks: { afterRead: [readCapabilities] },
},
{
name: 'visibleToCustomers',
type: 'checkbox',
defaultValue: false,
label: label('Visible to customers', 'Видна пользователям'),
admin: { position: 'sidebar' },
},
{
name: 'mode',
type: 'select',
required: true,
defaultValue: 'test',
options: [
{ value: 'test', label: label('Test', 'Тестовый') },
{ value: 'live', label: label('Live', 'Боевой') },
],
label: label('Mode', 'Режим'),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Active', 'Включена'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+123
View File
@@ -0,0 +1,123 @@
import type {
CollectionConfig,
SelectFieldManyValidation,
TextFieldSingleValidation,
} from 'payload'
import { select } from 'payload/shared'
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/locales'
import { anyRole, managers, owners } from '../access/roles'
import { localeOptions } from '../fields/locales'
import { groups, label, message } from '../i18n'
/**
* Языки сайта — языки его контента. Домен показывает любую их часть.
* Английский обязателен: на него откатывается непереведённый контент.
*/
const validateLocales: SelectFieldManyValidation = async (value, args) => {
const base = select(value, args)
if (base !== true) return base
const locales = value ?? []
if (!locales.includes(DEFAULT_LOCALE)) {
return message(args.req, {
en: 'English is required: untranslated content falls back to it',
ru: 'Английский обязателен: на него откатывается непереведённый контент',
})
}
if (args.id) {
const { docs } = await args.req.payload.find({
collection: 'domains',
where: { site: { equals: args.id } },
depth: 0,
pagination: false,
overrideAccess: true,
req: args.req,
})
const used = new Set(docs.flatMap((domain) => domain.locales ?? []))
const missing = [...used].filter((locale) => !locales.includes(locale))
if (missing.length > 0) {
return message(args.req, {
en: `These languages are enabled on the site's domains, remove them there first: ${missing.join(', ')}`,
ru: `Эти языки включены на доменах сайта, сначала уберите их там: ${missing.join(', ')}`,
})
}
}
return true
}
const validateOrderPrefix: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{2,4}$/.test(value)
? true
: message(req, {
en: '2–4 capital Latin letters, e.g. VX',
ru: '2–4 заглавные латинские буквы, например VX',
})
export const Sites: CollectionConfig = {
slug: 'sites',
labels: {
singular: label('Site', 'Сайт'),
plural: label('Sites', 'Сайты'),
},
admin: {
group: groups.settings,
useAsTitle: 'name',
defaultColumns: ['name', 'code', 'orderPrefix', 'locales'],
},
access: {
read: anyRole,
create: managers,
update: managers,
// вместе с сайтом плагин удаляет все его документы
delete: owners,
},
fields: [
{
name: 'code',
type: 'select',
required: true,
unique: true,
options: [
{ value: 'dedicated', label: label('Veilix (dedicated)', 'Вейликс (dedicated)') },
{ value: 'mass', label: label('VeilixVPN (mass)', 'ВейликсВПН (mass)') },
],
label: label('Brand', 'Бренд'),
// по коду выбираются тема и страницы бренда, после создания не меняется
access: { update: () => false },
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'orderPrefix',
type: 'text',
required: true,
unique: true,
label: label('Order number prefix', 'Префикс номеров'),
validate: validateOrderPrefix,
},
{
name: 'locales',
type: 'select',
hasMany: true,
required: true,
options: localeOptions,
defaultValue: [...LOCALES],
label: label('Content languages', 'Языки контента'),
validate: validateLocales,
admin: {
description: label(
'Each domain of the site shows some of these languages.',
'Каждый домен сайта показывает часть этих языков.',
),
},
},
],
}
+51
View File
@@ -0,0 +1,51 @@
import type { CollectionConfig, TextFieldSingleValidation } from 'payload'
import { anyRole, managers } from '../access/roles'
import { groups, label, message } from '../i18n'
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
? true
: message(req, {
en: 'Lowercase Latin letters, digits, - and _',
ru: 'Строчные латинские буквы, цифры, - и _',
})
export const Tags: CollectionConfig = {
slug: 'tags',
labels: {
singular: label('Tag', 'Тег'),
plural: label('Tags', 'Теги'),
},
admin: {
group: groups.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'code'],
description: label(
'For filtering tariffs and, later, automatic key provisioning',
'Для фильтрации тарифов и будущей автоматической выдачи ключей',
),
},
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
fields: [
{
type: 'row',
fields: [
{ name: 'name', type: 'text', required: true, label: label('Name', 'Название') },
{
name: 'code',
type: 'text',
required: true,
unique: true,
label: label('Code', 'Код'),
validate: validateCode,
},
],
},
],
}
+213
View File
@@ -0,0 +1,213 @@
import type { ArrayFieldValidation, CollectionConfig, TextFieldSingleValidation } from 'payload'
import { array } from 'payload/shared'
import { anyRole, managers } from '../access/roles'
import { amountField, integerField, sortField } from '../fields/common'
import { currencyField } from '../fields/currency'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label, message } from '../i18n'
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
? true
: message(req, {
en: 'Lowercase Latin letters, digits, - and _',
ru: 'Строчные латинские буквы, цифры, - и _',
})
/** Строка на валюту: нет строки — тариф в этой валюте не продаётся */
const validatePrices: ArrayFieldValidation = (value, args) => {
const base = array(value, args)
if (base !== true) return base
const codes = ((value ?? []) as { currency?: string }[])
.map((row) => row.currency)
.filter((code): code is string => Boolean(code))
const duplicates = codes.filter((code, index) => codes.indexOf(code) !== index)
return duplicates.length === 0
? true
: message(args.req, {
en: `One row per currency: ${duplicates.join(', ')}`,
ru: `По одной строке на валюту: ${duplicates.join(', ')}`,
})
}
export const Tariffs: CollectionConfig = {
slug: 'tariffs',
labels: {
singular: label('Tariff', 'Тариф'),
plural: label('Tariffs', 'Тарифы'),
},
admin: {
group: groups.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'code', 'type', 'active', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
indexes: [{ fields: ['site', 'code'], unique: true }],
hooks: {
beforeChange: [
uniqueInSite('code', {
en: 'The site already has a tariff with this code',
ru: 'У сайта уже есть тариф с таким кодом',
}),
],
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'code',
type: 'text',
required: true,
label: label('Code', 'Код'),
validate: validateCode,
},
{
name: 'type',
type: 'select',
required: true,
options: [
{ value: 'dedicated', label: label('Dedicated IP', 'Индивидуальный IP') },
{ value: 'mass', label: label('Shared VPN', 'Общий VPN') },
],
label: label('Key type', 'Тип ключа'),
admin: {
description: label(
'dedicated: the country is chosen at purchase; mass: all tariff countries',
'dedicated — страна выбирается при покупке, mass — все страны тарифа',
),
},
},
],
},
{
name: 'description',
type: 'textarea',
localized: true,
label: label('Description', 'Описание'),
},
{
name: 'features',
type: 'array',
label: label('What is included', 'Что входит'),
labels: { singular: label('Item', 'Пункт'), plural: label('Items', 'Пункты') },
fields: [
{
name: 'text',
type: 'text',
required: true,
localized: true,
label: label('Text', 'Текст'),
},
],
},
{
name: 'period',
type: 'group',
label: label('Period', 'Период'),
fields: [
{
type: 'row',
fields: [
{
name: 'unit',
type: 'select',
required: true,
defaultValue: 'month',
options: [
{ value: 'month', label: label('Month', 'Месяц') },
{ value: 'year', label: label('Year', 'Год') },
],
label: label('Unit', 'Единица'),
},
integerField('count', label('Count', 'Количество'), {
required: true,
defaultValue: 1,
min: 1,
}),
],
},
],
},
integerField('devicesLimit', label('Devices per key', 'Устройств на ключ'), {
required: true,
defaultValue: 5,
min: 1,
}),
{
name: 'prices',
type: 'array',
label: label('Prices', 'Цены'),
labels: { singular: label('Price', 'Цена'), plural: label('Prices', 'Цены') },
validate: validatePrices,
admin: {
description: label(
'One row per currency. No row means the tariff is not sold in that currency.',
'Строка на валюту. Нет строки — тариф в этой валюте не продаётся.',
),
},
fields: [
{
type: 'row',
fields: [
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
amountField('firstPeriodAmount', label('First period', 'Первый период')),
amountField('renewalAmount', label('Renewal', 'Продление')),
],
},
],
},
{
name: 'countries',
type: 'relationship',
relationTo: 'countries',
hasMany: true,
label: label('Countries', 'Страны'),
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
label: label('Tags', 'Теги'),
},
{
name: 'badge',
type: 'text',
localized: true,
label: label('Badge', 'Плашка'),
admin: { description: label('e.g. “best value”', 'например, «выгоднее»') },
},
{
name: 'isDefault',
type: 'checkbox',
label: label(
'Selected by default on the pricing page',
'Выбран по умолчанию на странице цен',
),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('On sale', 'В продаже'),
admin: { position: 'sidebar' },
},
sortField,
],
}
@@ -0,0 +1,72 @@
'use client'
import { SelectInput, useConfig, useField } from '@payloadcms/ui'
import type { OptionObject, StaticDescription, TextFieldClientComponent } from 'payload'
import { useCallback, useEffect, useState } from 'react'
type CurrencyDoc = { code: string; symbol: string }
/** Поле с кодом валюты: выпадающий список из справочника «Валюты» */
export const CurrencyCodeField: TextFieldClientComponent = ({
field,
path: pathFromProps,
readOnly,
}) => {
const { admin, hasMany = false, label, localized, name, required } = field
const {
config: {
routes: { api },
serverURL,
},
} = useConfig()
const { disabled, path, setValue, showError, value } = useField<string | string[]>({
potentiallyStalePath: pathFromProps,
})
const [options, setOptions] = useState<OptionObject[]>([])
useEffect(() => {
const controller = new AbortController()
fetch(`${serverURL}${api}/currencies?depth=0&limit=100&sort=sort`, {
credentials: 'include',
signal: controller.signal,
})
.then((res) => res.json())
.then((data: { docs?: CurrencyDoc[] }) =>
setOptions(
(data.docs ?? []).map((doc) => ({ label: `${doc.code} ${doc.symbol}`, value: doc.code })),
),
)
.catch(() => {})
return () => controller.abort()
}, [api, serverURL])
const onChange = useCallback(
(selected: unknown) => {
if (hasMany) {
setValue(
Array.isArray(selected) ? selected.map((option: OptionObject) => option.value) : [],
)
} else {
setValue(selected && !Array.isArray(selected) ? (selected as OptionObject).value : null)
}
},
[hasMany, setValue],
)
return (
<SelectInput
description={admin?.description as StaticDescription | undefined}
hasMany={hasMany}
label={label}
localized={localized}
name={name}
onChange={onChange}
options={options}
path={path}
readOnly={readOnly || disabled}
required={required}
showError={showError}
value={value ?? undefined}
/>
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { NumberField, NumberFieldSingleValidation } from 'payload'
import { number } from 'payload/shared'
import { label, message } from '../i18n'
export const sortField: NumberField = {
name: 'sort',
type: 'number',
defaultValue: 0,
label: label('Sort order', 'Порядок'),
admin: { position: 'sidebar' },
}
const integer: NumberFieldSingleValidation = (value, args) => {
const base = number(value, args)
if (base !== true) return base
if (typeof value === 'number' && !Number.isInteger(value)) {
return message(args.req, { en: 'Must be a whole number', ru: 'Нужно целое число' })
}
return true
}
/** Сумма денег: целое число в минимальных единицах валюты */
export const amountField = (name: string, fieldLabel: { en: string; ru: string }): NumberField => ({
name,
type: 'number',
required: true,
min: 0,
label: fieldLabel,
validate: integer,
admin: {
description: label(
'In minor units: 249900 = 2,499.00',
'В минимальных единицах: 249900 = 2 499,00',
),
},
})
export const integerField = (
name: string,
fieldLabel: { en: string; ru: string },
options: Pick<NumberField, 'defaultValue' | 'min' | 'max' | 'required' | 'admin'> = {},
): NumberField => ({
name,
type: 'number',
label: fieldLabel,
validate: integer,
...options,
})
+95
View File
@@ -0,0 +1,95 @@
import type {
FieldHook,
TextField,
TextFieldManyValidation,
TextFieldSingleValidation,
} from 'payload'
import { text } from 'payload/shared'
import { message } from '../i18n'
type Options = {
name: string
label: TextField['label']
required?: boolean
description?: { en: string; ru: string }
}
const normalize: FieldHook = ({ value }) => {
if (Array.isArray(value)) return value.map((code) => String(code).trim().toUpperCase())
return typeof value === 'string' ? value.trim().toUpperCase() : value
}
/**
* Коды из списка есть в справочнике «Валюты» и не повторяются.
* Валюту храним кодом ISO 4217, а не ссылкой: так её хранят и денежные записи.
*/
const checkCodes = async (
codes: string[],
req: Parameters<TextFieldSingleValidation>[1]['req'],
): Promise<string | true> => {
if (codes.length === 0) return true
const duplicates = codes.filter((code, index) => codes.indexOf(code) !== index)
if (duplicates.length > 0) {
return message(req, {
en: `Currency is listed twice: ${duplicates.join(', ')}`,
ru: `Валюта указана дважды: ${duplicates.join(', ')}`,
})
}
const { docs } = await req.payload.find({
collection: 'currencies',
where: { code: { in: codes } },
depth: 0,
pagination: false,
overrideAccess: true,
req,
})
const known = new Set(docs.map((doc) => doc.code))
const unknown = codes.filter((code) => !known.has(code))
if (unknown.length > 0) {
return message(req, {
en: `Not in the currencies list: ${unknown.join(', ')}`,
ru: `Нет в справочнике валют: ${unknown.join(', ')}`,
})
}
return true
}
const validateOne: TextFieldSingleValidation = async (value, args) => {
const base = text(value, args)
if (base !== true) return base
return checkCodes(value ? [value] : [], args.req)
}
const validateMany: TextFieldManyValidation = async (value, args) => {
const codes = value ?? []
if (args.required && codes.length === 0) return args.req.t('validation:required')
return checkCodes(codes, args.req)
}
const component = '/payload/components/CurrencyCodeField#CurrencyCodeField'
/** Код одной валюты: в админке — выпадающий список из справочника */
export const currencyField = ({ name, label, required, description }: Options): TextField => ({
name,
type: 'text',
label,
required,
hooks: { beforeValidate: [normalize] },
validate: validateOne,
admin: { components: { Field: component }, description },
})
/** Несколько валют */
export const currenciesField = ({ name, label, required, description }: Options): TextField => ({
name,
type: 'text',
hasMany: true,
label,
required,
hooks: { beforeValidate: [normalize] },
validate: validateMany,
admin: { components: { Field: component }, description },
})
+8
View File
@@ -0,0 +1,8 @@
import type { OptionObject } from 'payload'
import { LOCALE_NAMES, LOCALES } from '@/i18n/locales'
export const localeOptions: OptionObject[] = LOCALES.map((code) => ({
value: code,
label: LOCALE_NAMES[code],
}))
+40
View File
@@ -0,0 +1,40 @@
import type { CollectionBeforeChangeHook, CollectionSlug, Where } from 'payload'
import { ValidationError } from 'payload'
import { message } from '../i18n'
import { relationId } from '../utils'
/**
* Значение поля уникально в пределах сайта. Гарантию даёт составной индекс в базе,
* а хук нужен для понятного сообщения вместо ошибки базы.
*/
export const uniqueInSite =
(field: string, text: { en: string; ru: string }): CollectionBeforeChangeHook =>
async ({ collection, data, originalDoc, req }) => {
const doc = { ...originalDoc, ...data }
const siteId = relationId(doc.site)
const value = relationId(doc[field]) ?? doc[field]
if (!siteId || value === undefined || value === null) return data
const where: Where = {
and: [
{ site: { equals: siteId } },
{ [field]: { equals: value } },
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
],
}
const { totalDocs } = await req.payload.count({
collection: collection.slug as CollectionSlug,
where,
overrideAccess: true,
req,
})
if (totalDocs > 0) {
throw new ValidationError({
collection: collection.slug,
errors: [{ path: field, message: message(req, text) }],
req,
})
}
return data
}
+16
View File
@@ -0,0 +1,16 @@
import type { PayloadRequest } from 'payload'
type Text = { en: string; ru: string }
/** Подпись в админке на двух её языках интерфейса */
export const label = (en: string, ru: string): Text => ({ en, ru })
/** Сообщение (например, об ошибке проверки) на языке интерфейса админки */
export const message = (req: Partial<PayloadRequest> | undefined, text: Text): string =>
req?.i18n?.language === 'ru' ? text.ru : text.en
export const groups = {
settings: label('Settings', 'Настройки'),
catalog: label('Catalog', 'Каталог'),
content: label('Content', 'Контент'),
}
+34
View File
@@ -0,0 +1,34 @@
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import type { Config } from 'payload'
import { DEFAULT_LOCALE, LOCALE_NAMES, LOCALES } from '@/i18n/locales'
type Localization = Exclude<Config['localization'], false | undefined>
/** В админке видны только языки сайта, выбранного в переключателе. Без выбранного сайта — все. */
const filterAvailableLocales: NonNullable<Localization['filterAvailableLocales']> = async ({
locales,
req,
}) => {
const siteId = getTenantFromCookie(req.headers, 'text')
if (!siteId) return locales
try {
const site = await req.payload.findByID({
collection: 'sites',
id: siteId,
depth: 0,
overrideAccess: true,
})
const enabled = new Set<string>(site.locales ?? [])
return locales.filter((locale) => enabled.has(locale.code))
} catch {
return locales
}
}
export const localization: Localization = {
locales: LOCALES.map((code) => ({ code, label: LOCALE_NAMES[code] })),
defaultLocale: DEFAULT_LOCALE,
fallback: true,
filterAvailableLocales,
}
+13
View File
@@ -0,0 +1,13 @@
/** id связанного документа: в зависимости от depth Payload отдаёт id или сам документ */
export const relationId = (value: unknown): string | null => {
if (typeof value === 'string') return value
if (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string'
) {
return value.id
}
return null
}