TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
@@ -0,0 +1,78 @@
import vine from '@vinejs/vine'
import {
PREVIEW_TOKEN_MAX_TTL_SECONDS,
PREVIEW_TOKEN_MIN_TTL_SECONDS,
SITEMAP_MAX_PAGE_SIZE,
} from '#base_modules/WebManager/config/general'
import {
componentSchema,
groupKeySchema,
localeSchema,
pathSchema,
} from '#base_modules/WebManager/validator/schemas'
import { PAGE_TYPES } from '#base_modules/WebManager/types/web_content'
import { PAGE_STATUSES } from '#base_modules/WebManager/types/page_status'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
const pageFields = {
locale: localeSchema,
path: pathSchema,
groupKey: groupKeySchema,
pageType: vine.enum(PAGE_TYPES),
title: vine.string().trim().minLength(1).maxLength(255),
hero: jsonObjectSchema,
seoTitle: vine.string().trim().maxLength(255).optional(),
seoDescription: vine.string().trim().maxLength(2000).optional(),
components: vine.array(componentSchema).maxLength(200),
}
export const createPageValidator = vine.create(vine.object(pageFields))
export const saveDraftValidator = vine.create(vine.object(pageFields))
export const listPagesValidator = vine.create(
vine.object({
locale: localeSchema.optional(),
status: vine.enum(PAGE_STATUSES).optional(),
groupKey: groupKeySchema.optional(),
pageType: vine.enum(PAGE_TYPES).optional(),
search: vine.string().trim().maxLength(255).optional(),
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(100).optional(),
})
)
export const publicPageValidator = vine.create(
vine.object({
locale: localeSchema,
path: pathSchema,
})
)
export const publicBlogListValidator = vine.create(
vine.object({
locale: localeSchema,
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(100).optional(),
})
)
export const publicSitemapValidator = vine.create(
vine.object({
page: vine.number().min(1).optional(),
perPage: vine.number().min(1).max(SITEMAP_MAX_PAGE_SIZE).optional(),
})
)
export const previewTokenValidator = vine.create(
vine.object({
expiresIn: vine
.number()
.min(PREVIEW_TOKEN_MIN_TTL_SECONDS)
.max(PREVIEW_TOKEN_MAX_TTL_SECONDS)
.optional(),
})
)
export const previewRequestValidator = vine.create(
vine.object({ token: vine.string().trim().minLength(1) })
)
@@ -0,0 +1,32 @@
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
import vine from '@vinejs/vine'
export const localeSchema = vine
.string()
.trim()
.minLength(2)
.maxLength(16)
.regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/)
export const pathSchema = vine
.string()
.trim()
.maxLength(2048)
.regex(/^(?:(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$/)
export const groupKeySchema = vine
.string()
.trim()
.minLength(1)
.maxLength(255)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
export const componentSchema = vine.object({
type: vine
.string()
.trim()
.minLength(1)
.maxLength(100)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
data: jsonObjectSchema,
})
@@ -0,0 +1,14 @@
import vine from '@vinejs/vine'
import { localeSchema } from '#base_modules/WebManager/validator/schemas'
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
export const settingsQueryValidator = vine.create(vine.object({ locale: localeSchema.optional() }))
export const saveSettingsValidator = vine.create(
vine.object({
locale: localeSchema,
header: jsonObjectSchema,
footer: jsonObjectSchema,
legal: jsonObjectSchema,
})
)
@@ -0,0 +1,225 @@
import vine, { errors } from '@vinejs/vine'
import { InvalidPagePayloadException } from '#exceptions/web_manager_exceptions'
import {
BANNER_STYLES,
HERO_TYPES,
MEDIA_CONTENT_POSITIONS,
NAVIGATION_LINK_VARIANTS,
} from '#base_modules/WebManager/types/web_content'
import type {
ComponentContent,
Hero,
SiteSettingsContent,
} from '#base_modules/WebManager/types/web_content'
const richTextSchema = () => vine.object({ content: vine.string() })
const faqItemSchema = () =>
vine.object({
question: vine.string(),
answer: vine.string(),
})
const mediaSchema = () =>
vine.object({
url: vine
.object({
name: vine.string(),
ref: vine.string(),
})
.nullable(),
alt: vine.string(),
width: vine.number().optional(),
height: vine.number().optional(),
mimeType: vine.string().optional(),
})
const navigationLinkBaseShape = () => ({
openInNewTab: vine.boolean(),
label: vine.string(),
variant: vine.enum(NAVIGATION_LINK_VARIANTS),
})
const isObjectWith = (value: unknown, key: string, expected: string) =>
vine.helpers.isObject(value) && value[key] === expected
const navigationLinkSchema = () =>
vine
.union([
vine.union.if(
(value) => isObjectWith(value, 'linkType', 'internal'),
vine.object({
...navigationLinkBaseShape(),
linkType: vine.literal('internal'),
pageId: vine.string().trim().minLength(1),
})
),
vine.union.if(
(value) => isObjectWith(value, 'linkType', 'external'),
vine.object({
...navigationLinkBaseShape(),
linkType: vine.literal('external'),
externalUrl: vine.string().url(),
})
),
])
.otherwise((_, field) => {
field.report('Navigation link must be internal or external', 'navigationLink', field)
})
const heroSchema = () =>
vine.object({
type: vine.enum(HERO_TYPES),
title: vine.string().optional(),
subtitle: vine.string().optional(),
content: richTextSchema().optional(),
ctaItems: vine.array(navigationLinkSchema()),
media: mediaSchema().optional(),
})
const contentBlockDataSchema = () =>
vine.object({
title: vine.string(),
subtitle: vine.string(),
columns: vine.array(
vine.object({
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
),
})
const faqBlockDataSchema = () =>
vine.object({
title: vine.string(),
subtitle: vine.string(),
items: vine.array(faqItemSchema()),
})
const mediaContentBlockDataSchema = () =>
vine.object({
title: vine.string(),
mediaPosition: vine.enum(MEDIA_CONTENT_POSITIONS),
media: mediaSchema(),
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
const articlesBlockDataSchema = () =>
vine.object({
cta: navigationLinkSchema().optional(),
title: vine.string().optional(),
subtitle: richTextSchema().optional(),
items: vine.array(
vine.object({
title: vine.string(),
content: richTextSchema(),
})
),
})
const bannerBlockDataSchema = () =>
vine.object({
style: vine.enum(BANNER_STYLES),
content: richTextSchema(),
ctaItems: vine.array(navigationLinkSchema()),
})
const mediaBlockDataSchema = () =>
vine.object({
media: mediaSchema(),
})
const componentContentSchema = () =>
vine
.union([
vine.union.if(
(value) => isObjectWith(value, 'type', 'content'),
vine.object({ type: vine.literal('content'), data: contentBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'faq'),
vine.object({ type: vine.literal('faq'), data: faqBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'media-content'),
vine.object({ type: vine.literal('media-content'), data: mediaContentBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'articles'),
vine.object({ type: vine.literal('articles'), data: articlesBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'banner'),
vine.object({ type: vine.literal('banner'), data: bannerBlockDataSchema() })
),
vine.union.if(
(value) => isObjectWith(value, 'type', 'media'),
vine.object({ type: vine.literal('media'), data: mediaBlockDataSchema() })
),
])
.otherwise((_, field) => {
field.report('Unknown component type', 'componentType', field)
})
const siteMenuSchema = () =>
vine.object({
title: vine.string(),
items: vine.array(navigationLinkSchema()),
})
const siteSettingsContentSchema = () =>
vine.object({
header: vine.object({
menus: vine.array(siteMenuSchema()),
ctaItems: vine.array(navigationLinkSchema()),
}),
footer: vine.object({
copyright: vine.string(),
menus: vine.array(siteMenuSchema()),
}),
legal: vine.object({
privacyPath: vine.string(),
termsPath: vine.string(),
}),
})
const heroValidator = vine.create(heroSchema())
const componentContentValidator = vine.create(componentContentSchema())
const siteSettingsContentValidator = vine.create(siteSettingsContentSchema())
function describeError(context: string, error: unknown): string {
if (error instanceof errors.E_VALIDATION_ERROR) {
const issue = (error.messages as Array<{ field?: string; message?: string }>)[0]
const path = issue?.field ? ` at "${issue.field}"` : ''
return `${context}${path}: ${issue?.message ?? 'invalid value'}`
}
return context
}
async function runValidator<Output>(
context: string,
validator: { validate(value: unknown): Promise<Output> },
value: unknown
): Promise<Output> {
try {
return await validator.validate(value)
} catch (error) {
throw new InvalidPagePayloadException(describeError(context, error))
}
}
export async function parseHero(value: unknown): Promise<Hero> {
return runValidator('Invalid hero', heroValidator, value)
}
export async function parseComponentContent(
value: unknown,
index: number
): Promise<ComponentContent> {
return runValidator(`Invalid component #${index}`, componentContentValidator, value)
}
export async function parseSiteSettingsContent(value: unknown): Promise<SiteSettingsContent> {
return runValidator('Invalid settings', siteSettingsContentValidator, value)
}