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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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: 'Название' },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 $$
|
||||
`,
|
||||
)
|
||||
}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user