Replace public legal files and switch booking to a contact request without a schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexey Novikov
2026-09-17 00:53:37 +03:00
co-authored by Cursor
parent 26ed4e6a22
commit d6b7723113
30 changed files with 5846 additions and 212 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@moniki/cms",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,51 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { NextResponse } from 'next/server'
function asText(value: unknown) {
return typeof value === "string" ? value.trim() : ""
}
function asId(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string" && value.trim()) {
const n = Number(value)
return Number.isFinite(n) ? n : value.trim()
}
return undefined
}
export async function POST(req: Request) {
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>
const name = asText(body.name)
const email = asText(body.email)
const phone = asText(body.phone)
const branch = asId(body.branch)
const service = asId(body.service)
const doctor = asId(body.doctor)
if (!name || !email || !phone || branch == null || (service == null && doctor == null)) {
return NextResponse.json({ error: "invalid" }, { status: 400 })
}
try {
const payload = await getPayload({ config: configPromise })
const doc = await payload.create({
collection: "inquiries",
overrideAccess: true,
data: {
name,
email,
phone,
branch,
...(service != null ? { service } : {}),
...(doctor != null ? { doctor } : {}),
status: "new",
},
})
return NextResponse.json({ id: String(doc.id) })
} catch (error) {
const message = error instanceof Error ? error.message : "failed"
return NextResponse.json({ error: message }, { status: 400 })
}
}
+1
View File
@@ -21,6 +21,7 @@ export const Documents: CollectionConfig = {
}) as Access,
create: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{ name: 'title', type: 'text', required: true, localized: true, label: 'Название' },
+40
View File
@@ -0,0 +1,40 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Inquiries: CollectionConfig = {
slug: 'inquiries',
labels: { singular: 'Заявка на запись', plural: 'Заявки на запись' },
admin: {
group: ADMIN_GROUP.booking,
useAsTitle: 'name',
defaultColumns: ['name', 'phone', 'email', 'status', 'createdAt'],
description: 'Заявки с сайта без выбора времени. Свяжитесь с пациентом и уточните детали.',
},
access: {
create: () => true,
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{ name: 'name', type: 'text', required: true, label: 'ФИО' },
{ name: 'email', type: 'email', required: true, label: 'E-mail' },
{ name: 'phone', type: 'text', required: true, label: 'Телефон' },
{ name: 'service', type: 'relationship', relationTo: 'services', label: 'Услуга' },
{ name: 'doctor', type: 'relationship', relationTo: 'doctors', label: 'Врач' },
{ name: 'branch', type: 'relationship', relationTo: 'branches', required: true, label: 'Филиал' },
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'new',
label: 'Статус',
options: [
{ label: 'Новая', value: 'new' },
{ label: 'В работе', value: 'in_progress' },
{ label: 'Закрыта', value: 'done' },
],
},
],
timestamps: true,
}
+4
View File
@@ -15,6 +15,10 @@ export const Media: CollectionConfig = {
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/rtf',
'text/rtf',
'application/x-rtf',
'text/richtext',
],
},
access: {
@@ -0,0 +1,68 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
type DrizzleAdapter = {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
async function exec(payload: Payload, query: unknown) {
const adapter = payload.db as DrizzleAdapter
await adapter.drizzle.execute(query)
}
export async function ensureInquiriesSchema(payload: Payload) {
await exec(
payload,
sql`
DO $$ BEGIN
CREATE TYPE enum_inquiries_status AS ENUM ('new', 'in_progress', 'done');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS inquiries (
id serial PRIMARY KEY,
name character varying NOT NULL,
email character varying NOT NULL,
phone character varying NOT NULL,
service_id integer,
doctor_id integer,
branch_id integer NOT NULL,
status enum_inquiries_status DEFAULT 'new' NOT NULL,
updated_at timestamp(3) with time zone DEFAULT now() NOT NULL,
created_at timestamp(3) with time zone DEFAULT now() NOT NULL
)
`,
)
await exec(
payload,
sql`
ALTER TABLE payload_locked_documents_rels
ADD COLUMN IF NOT EXISTS inquiries_id integer
`,
)
await exec(
payload,
sql`
CREATE INDEX IF NOT EXISTS payload_locked_documents_rels_inquiries_id_idx
ON payload_locked_documents_rels (inquiries_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE payload_locked_documents_rels
ADD CONSTRAINT payload_locked_documents_rels_inquiries_fk
FOREIGN KEY (inquiries_id) REFERENCES inquiries(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
}
+4
View File
@@ -24,6 +24,7 @@ import { Documents } from './collections/Documents'
import { Favorites } from './collections/Favorites'
import { Notifications } from './collections/Notifications'
import { Invoices } from './collections/Invoices'
import { Inquiries } from './collections/Inquiries'
import { Settings } from './globals/Settings'
import { settingsResendAdapter } from './lib/email'
import { ensureSettingsSchema } from './lib/ensure-settings-schema'
@@ -31,6 +32,7 @@ import { ensurePagesSchema } from './lib/ensure-pages-schema'
import { ensureDocumentsSchema } from './lib/ensure-documents-schema'
import { ensureServicesSchema } from './lib/ensure-services-schema'
import { ensureBannersSchema } from './lib/ensure-banners-schema'
import { ensureInquiriesSchema } from './lib/ensure-inquiries-schema'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -59,6 +61,7 @@ export default buildConfig({
Services,
Schedules,
Bookings,
Inquiries,
Invoices,
Reviews,
Pages,
@@ -80,6 +83,7 @@ export default buildConfig({
await ensureDocumentsSchema(payload)
await ensureServicesSchema(payload)
await ensureBannersSchema(payload)
await ensureInquiriesSchema(payload)
try {
const { seedSitePages } = await import('./seed/site-pages')
await seedSitePages(payload)
Binary file not shown.
File diff suppressed because it is too large Load Diff
+84 -53
View File
@@ -4,80 +4,111 @@ import path from 'path'
import { fileURLToPath } from 'url'
import type { Payload } from 'payload'
export const PUBLIC_CONTRACT_FILENAME = 'Dogovor-platnie-2026.docx'
export const PUBLIC_CONTRACT_TITLE = 'Договор на оказание платных медицинских услуг (2026)'
const CONTRACT_MIME =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const RTF_MIME = 'application/rtf'
function contractPath() {
export const PUBLIC_DOCUMENTS = [
{
filename: 'Soglasie-personalnye-dannye.rtf',
title: 'Согласие пациента на обработку персональных данных',
mime: RTF_MIME,
type: 'other' as const,
issuedAt: '2026-07-15T00:00:00.000Z',
},
{
filename: 'IDS-meditsinskoe-vmeshatelstvo.docx',
title:
'Информированное добровольное согласие пациента (законного представителя пациента) на медицинское вмешательство, предоставление сведений, составляющих врачебную тайну, и консультацию врача-специалиста с применением телемедицинских технологий',
mime: DOCX_MIME,
type: 'other' as const,
issuedAt: '2026-09-01T00:00:00.000Z',
},
{
filename: 'Dogovor-platnye-uslugi-fiz-litso.docx',
title: 'Договор на оказание платных медицинских услуг (с физическим лицом)',
mime: DOCX_MIME,
type: 'contract' as const,
issuedAt: '2026-06-30T00:00:00.000Z',
},
]
const OLD_CONTRACT_FILENAME = 'Dogovor-platnie-2026.docx'
function filePath(filename: string) {
const here = path.dirname(fileURLToPath(import.meta.url))
const bundled = path.join(here, 'files', PUBLIC_CONTRACT_FILENAME)
const bundled = path.join(here, 'files', filename)
if (fs.existsSync(bundled)) return bundled
const icloud = path.join(
process.env.HOME || '',
'Library/Mobile Documents/com~apple~CloudDocs/Downloads icloud',
PUBLIC_CONTRACT_FILENAME,
)
if (fs.existsSync(icloud)) return icloud
return null
}
function copyToWebPublic(source: string) {
function copyToWebPublic(source: string, filename: string) {
const here = path.dirname(fileURLToPath(import.meta.url))
const dest = path.resolve(here, '../../../web/public/documents', PUBLIC_CONTRACT_FILENAME)
const dest = path.resolve(here, '../../../web/public/documents', filename)
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.copyFileSync(source, dest)
}
export async function seedPublicDocuments(payload: Payload) {
const filePath = contractPath()
if (!filePath) {
payload.logger.warn('Public contract file not found, skip seed')
return
}
copyToWebPublic(filePath)
const existing = await payload.find({
collection: 'documents',
where: {
and: [{ isPublic: { equals: true } }, { type: { equals: 'contract' } }],
},
limit: 50,
where: { isPublic: { equals: true } },
limit: 100,
depth: 1,
})
const already = existing.docs.some((doc) => {
for (const doc of existing.docs) {
const file = typeof doc.file === 'object' && doc.file ? doc.file : null
const filename = file && 'filename' in file ? String(file.filename || '') : ''
return filename === PUBLIC_CONTRACT_FILENAME || String(doc.title || '').includes('платных медицинских услуг')
})
if (already) return
const title = String(doc.title || '')
const stale =
filename === OLD_CONTRACT_FILENAME || title.includes('(2026)') || title.includes('платных медицинских услуг (2026)')
if (!stale) continue
await payload.delete({ collection: 'documents', id: doc.id })
payload.logger.info(`Removed public document ${title}`)
}
const buf = fs.readFileSync(filePath)
const media = await payload.create({
collection: 'media',
data: { alt: PUBLIC_CONTRACT_TITLE },
file: {
data: buf,
mimetype: CONTRACT_MIME,
name: PUBLIC_CONTRACT_FILENAME,
size: buf.length,
},
})
for (const item of PUBLIC_DOCUMENTS) {
const source = filePath(item.filename)
if (!source) {
payload.logger.warn(`Public document file not found: ${item.filename}`)
continue
}
copyToWebPublic(source, item.filename)
await payload.create({
collection: 'documents',
locale: 'ru',
data: {
title: PUBLIC_CONTRACT_TITLE,
type: 'contract',
isPublic: true,
file: media.id,
issuedAt: '2026-01-01T00:00:00.000Z',
},
})
payload.logger.info(`Seeded public document ${PUBLIC_CONTRACT_FILENAME}`)
const already = await payload.find({
collection: 'documents',
where: {
and: [{ isPublic: { equals: true } }, { title: { equals: item.title } }],
},
limit: 1,
})
if (already.docs.length) continue
const buf = fs.readFileSync(source)
const media = await payload.create({
collection: 'media',
data: { alt: item.title },
file: {
data: buf,
mimetype: item.mime,
name: item.filename,
size: buf.length,
},
})
await payload.create({
collection: 'documents',
locale: 'ru',
data: {
title: item.title,
type: item.type,
isPublic: true,
file: media.id,
issuedAt: item.issuedAt,
},
})
payload.logger.info(`Seeded public document ${item.filename}`)
}
}
async function main() {