TASKS-1823 WebManager module
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user