Keep cabinet data private per patient, email new accounts after a request, and store gallery photos as files.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moniki/cms",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,7 @@
|
||||
"seed:news": "tsx src/seed/import-news.ts",
|
||||
"seed:pages": "tsx src/seed/site-pages.ts",
|
||||
"seed:documents": "tsx src/seed/public-documents.ts",
|
||||
"seed:gallery": "tsx src/seed/gallery-photos.ts",
|
||||
"seed:i18n": "python3 src/seed/translate_locales.py",
|
||||
"lint": "next lint"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isValidEmailAddress, isValidFullName, isValidRuPhone, normalizeRuPhone } from '@/lib/contact-fields'
|
||||
import { findOrCreatePatient, notifyInquiry } from '@/lib/inquiry-account'
|
||||
|
||||
function asText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
@@ -31,6 +32,18 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
let patientId: string | number | undefined
|
||||
let cabinetCreated = false
|
||||
let password: string | undefined
|
||||
try {
|
||||
const account = await findOrCreatePatient(payload, { email, name, phone })
|
||||
patientId = account.id
|
||||
cabinetCreated = account.created
|
||||
password = account.password
|
||||
} catch (error) {
|
||||
payload.logger.error({ err: error }, 'Failed to create patient cabinet from inquiry')
|
||||
}
|
||||
|
||||
const doc = await payload.create({
|
||||
collection: "inquiries",
|
||||
overrideAccess: true,
|
||||
@@ -41,10 +54,23 @@ export async function POST(req: Request) {
|
||||
branch,
|
||||
...(service != null ? { service } : {}),
|
||||
...(doctor != null ? { doctor } : {}),
|
||||
...(patientId != null ? { patient: patientId } : {}),
|
||||
status: "new",
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ id: String(doc.id) })
|
||||
|
||||
notifyInquiry(payload, {
|
||||
inquiryId: doc.id,
|
||||
patientName: name,
|
||||
email,
|
||||
phone,
|
||||
cabinetCreated,
|
||||
password,
|
||||
}).catch((error) => {
|
||||
payload.logger.error({ err: error }, 'Failed to send inquiry email')
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: String(doc.id), cabinetCreated })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "failed"
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
|
||||
@@ -13,7 +13,11 @@ export const Inquiries: CollectionConfig = {
|
||||
},
|
||||
access: {
|
||||
create: () => true,
|
||||
read: ({ req: { user } }) => user?.role === 'admin',
|
||||
read: ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.role === 'admin') return true
|
||||
return { patient: { equals: user.id } }
|
||||
},
|
||||
update: ({ req: { user } }) => user?.role === 'admin',
|
||||
delete: ({ req: { user } }) => user?.role === 'admin',
|
||||
},
|
||||
@@ -59,6 +63,7 @@ export const Inquiries: CollectionConfig = {
|
||||
{ name: 'service', type: 'relationship', relationTo: 'services', label: 'Услуга' },
|
||||
{ name: 'doctor', type: 'relationship', relationTo: 'doctors', label: 'Врач' },
|
||||
{ name: 'branch', type: 'relationship', relationTo: 'branches', required: true, label: 'Филиал' },
|
||||
{ name: 'patient', type: 'relationship', relationTo: 'users', label: 'Пациент' },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
|
||||
@@ -40,6 +40,20 @@ export async function ensureInquiriesSchema(payload: Payload) {
|
||||
`,
|
||||
)
|
||||
|
||||
await exec(
|
||||
payload,
|
||||
sql`
|
||||
ALTER TABLE inquiries
|
||||
ADD COLUMN IF NOT EXISTS patient_id integer
|
||||
`,
|
||||
)
|
||||
await exec(
|
||||
payload,
|
||||
sql`
|
||||
CREATE INDEX IF NOT EXISTS inquiries_patient_idx ON inquiries (patient_id)
|
||||
`,
|
||||
)
|
||||
|
||||
await exec(
|
||||
payload,
|
||||
sql`
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { Payload } from 'payload'
|
||||
import { buildInquiryEmail } from './inquiry-email'
|
||||
import { sendNotification } from './notifications'
|
||||
|
||||
function siteUrl() {
|
||||
return (process.env.CORS_ORIGINS || 'http://localhost:3000').split(',')[0]?.trim() || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
function generateCabinetPassword() {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
|
||||
const bytes = randomBytes(10)
|
||||
let out = 'Mk-'
|
||||
for (const byte of bytes) out += alphabet[byte % alphabet.length]
|
||||
return out
|
||||
}
|
||||
|
||||
function relRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
function relText(value: unknown, key: string) {
|
||||
const rec = relRecord(value)
|
||||
const raw = rec?.[key]
|
||||
return typeof raw === 'string' ? raw.trim() : ''
|
||||
}
|
||||
|
||||
export async function findOrCreatePatient(
|
||||
payload: Payload,
|
||||
input: { email: string; name: string; phone: string },
|
||||
): Promise<{ id: string | number; created: boolean; password?: string }> {
|
||||
const email = input.email.trim().toLowerCase()
|
||||
const existing = await payload.find({
|
||||
collection: 'users',
|
||||
where: { email: { equals: email } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (existing.docs[0]) {
|
||||
const user = existing.docs[0]
|
||||
if (!user.phone && input.phone) {
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: user.id,
|
||||
overrideAccess: true,
|
||||
data: { phone: input.phone },
|
||||
})
|
||||
}
|
||||
return { id: user.id, created: false }
|
||||
}
|
||||
|
||||
const password = generateCabinetPassword()
|
||||
const created = await payload.create({
|
||||
collection: 'users',
|
||||
overrideAccess: true,
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
fullName: input.name,
|
||||
phone: input.phone,
|
||||
role: 'patient',
|
||||
emailOptIn: true,
|
||||
},
|
||||
})
|
||||
return { id: created.id, created: true, password }
|
||||
}
|
||||
|
||||
export async function notifyInquiry(
|
||||
payload: Payload,
|
||||
input: {
|
||||
inquiryId: string | number
|
||||
patientName: string
|
||||
email: string
|
||||
phone: string
|
||||
cabinetCreated: boolean
|
||||
password?: string
|
||||
},
|
||||
) {
|
||||
const inquiry = await payload.findByID({
|
||||
collection: 'inquiries',
|
||||
id: input.inquiryId,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const message = buildInquiryEmail({
|
||||
patientName: input.patientName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
serviceTitle: relText(inquiry.service, 'title').replace(/^\d+(?:\.\d+)*\.?\s+/, '').trim(),
|
||||
servicePrice: Number(relRecord(inquiry.service)?.price) || undefined,
|
||||
doctorName: relText(inquiry.doctor, 'fullName'),
|
||||
branchName: relText(inquiry.branch, 'name'),
|
||||
branchAddress: relText(inquiry.branch, 'address'),
|
||||
cabinetCreated: input.cabinetCreated,
|
||||
password: input.password,
|
||||
loginUrl: `${siteUrl()}/ru/login`,
|
||||
})
|
||||
await sendNotification(payload, {
|
||||
channel: 'email',
|
||||
to: input.email,
|
||||
subject: message.subject,
|
||||
body: message.text,
|
||||
html: message.html,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
function escapeHtml(text: string) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function row(label: string, value: string) {
|
||||
if (!value.trim()) return ''
|
||||
return `<tr>
|
||||
<td style="padding:8px 0;color:#64748b;font-size:14px;width:40%;vertical-align:top;">${escapeHtml(label)}</td>
|
||||
<td style="padding:8px 0;color:#0f172a;font-size:14px;font-weight:600;">${escapeHtml(value)}</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
export type InquiryEmailInput = {
|
||||
patientName: string
|
||||
email: string
|
||||
phone: string
|
||||
serviceTitle?: string
|
||||
servicePrice?: number
|
||||
doctorName?: string
|
||||
branchName?: string
|
||||
branchAddress?: string
|
||||
cabinetCreated: boolean
|
||||
password?: string
|
||||
loginUrl: string
|
||||
}
|
||||
|
||||
function formatPrice(amount?: number) {
|
||||
if (amount == null || !Number.isFinite(amount) || amount <= 0) return ''
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function buildInquiryEmail(input: InquiryEmailInput) {
|
||||
const firstName = input.patientName.trim().split(/\s+/)[1] || input.patientName.trim()
|
||||
const price = formatPrice(input.servicePrice)
|
||||
const subject = input.cabinetCreated
|
||||
? 'Личный кабинет и заявка на запись — МОНИКИ'
|
||||
: 'Ваша заявка на запись принята — МОНИКИ'
|
||||
|
||||
const cabinetBlock = input.cabinetCreated
|
||||
? `
|
||||
<div style="margin:24px 0;padding:20px;border-radius:16px;background:#eef6fb;border:1px solid #cfe3f2;">
|
||||
<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#163a56;">Для вас создан личный кабинет</p>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#334155;">
|
||||
В кабинете будут ваши заявки, документы и счета. Войти можно по почте, которую вы указали в заявке.
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
${row('Логин', input.email)}
|
||||
${input.password ? row('Пароль', input.password) : ''}
|
||||
</table>
|
||||
<p style="margin:16px 0 0;">
|
||||
<a href="${escapeHtml(input.loginUrl)}" style="display:inline-block;background:#1b4b73;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:999px;font-size:14px;font-weight:700;">
|
||||
Войти в личный кабинет
|
||||
</a>
|
||||
</p>
|
||||
</div>`
|
||||
: `
|
||||
<p style="margin:0 0 20px;font-size:14px;line-height:1.55;color:#334155;">
|
||||
Заявка сохранена в вашем личном кабинете. Войти можно по адресу
|
||||
<a href="${escapeHtml(input.loginUrl)}" style="color:#1b4b73;font-weight:700;">${escapeHtml(input.loginUrl)}</a>.
|
||||
</p>`
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="ru">
|
||||
<body style="margin:0;padding:0;background:#f4f7fb;font-family:'Segoe UI',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:20px;overflow:hidden;box-shadow:0 10px 30px rgba(15,23,42,0.06);">
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#163a56,#1b4b73);padding:28px 32px;color:#ffffff;">
|
||||
<p style="margin:0;font-size:12px;letter-spacing:.12em;text-transform:uppercase;opacity:.8;">КДЦ МОНИКИ</p>
|
||||
<h1 style="margin:8px 0 0;font-size:24px;line-height:1.3;">Заявка принята</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;color:#0f172a;">Здравствуйте, ${escapeHtml(firstName)}!</p>
|
||||
<p style="margin:0 0 20px;font-size:15px;line-height:1.6;color:#334155;">
|
||||
Спасибо, что выбрали платные медицинские услуги КДЦ МОНИКИ им. М. Ф. Владимирского.
|
||||
Мы получили вашу заявку и свяжемся с вами, чтобы уточнить удобную дату и время.
|
||||
</p>
|
||||
<div style="padding:4px 20px 8px;border-radius:16px;background:#f8fafc;border:1px solid #e2e8f0;">
|
||||
<p style="margin:16px 0 4px;font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#1b4b73;">Сводка заявки</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
${row('Пациент', input.patientName)}
|
||||
${row('Услуга', input.serviceTitle || '')}
|
||||
${row('Стоимость', price)}
|
||||
${row('Врач', input.doctorName || '')}
|
||||
${row('Филиал', input.branchName || '')}
|
||||
${row('Адрес', input.branchAddress || '')}
|
||||
${row('Телефон для связи', input.phone)}
|
||||
${row('E-mail', input.email)}
|
||||
</table>
|
||||
</div>
|
||||
${cabinetBlock}
|
||||
<div style="margin-top:8px;padding:20px;border-radius:16px;background:#fff8ef;border:1px solid #f1e0c3;">
|
||||
<p style="margin:0 0 10px;font-size:16px;font-weight:700;color:#9a6700;">Полезная информация</p>
|
||||
<ul style="margin:0;padding-left:18px;color:#57534e;font-size:14px;line-height:1.65;">
|
||||
<li>Возьмите паспорт. Если есть полис ОМС или результаты обследований — их тоже полезно иметь при себе.</li>
|
||||
<li>Приходите за 10–15 минут до приёма, чтобы оформить документы на стойке регистрации.</li>
|
||||
<li>Точное время визита подтвердит администратор: +7 (499) 674-07-09 или 122.</li>
|
||||
<li>Основной филиал: ул. Щепкина, 61/2, метро «Проспект Мира».</li>
|
||||
<li>Если планы изменятся, напишите нам или позвоните — заявку можно уточнить или перенести.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style="margin:24px 0 0;font-size:13px;line-height:1.6;color:#64748b;">
|
||||
С заботой о вашем здоровье,<br>
|
||||
<strong style="color:#163a56;">КДЦ МОНИКИ</strong><br>
|
||||
Платные медицинские услуги
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const textLines = [
|
||||
`Здравствуйте, ${firstName}!`,
|
||||
'',
|
||||
'Мы получили вашу заявку на запись в КДЦ МОНИКИ и свяжемся с вами, чтобы уточнить дату и время.',
|
||||
'',
|
||||
input.serviceTitle ? `Услуга: ${input.serviceTitle}` : '',
|
||||
price ? `Стоимость: ${price}` : '',
|
||||
input.doctorName ? `Врач: ${input.doctorName}` : '',
|
||||
input.branchName ? `Филиал: ${input.branchName}` : '',
|
||||
input.branchAddress ? `Адрес: ${input.branchAddress}` : '',
|
||||
`Телефон: ${input.phone}`,
|
||||
`E-mail: ${input.email}`,
|
||||
'',
|
||||
input.cabinetCreated
|
||||
? `Для вас создан личный кабинет.\nЛогин: ${input.email}\nПароль: ${input.password || ''}\nВход: ${input.loginUrl}`
|
||||
: `Личный кабинет: ${input.loginUrl}`,
|
||||
'',
|
||||
'Возьмите паспорт, приходите за 10–15 минут. Телефон: +7 (499) 674-07-09, 122.',
|
||||
].filter(Boolean)
|
||||
|
||||
return { subject, html, text: textLines.join('\n') }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type NotifyInput = {
|
||||
to: string
|
||||
subject: string
|
||||
body: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
function escapeHtml(text: string) {
|
||||
@@ -36,7 +37,7 @@ export async function sendNotification(
|
||||
const result = await payload.sendEmail({
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
html: `<p>${escapeHtml(input.body)}</p>`,
|
||||
html: input.html || `<p>${escapeHtml(input.body)}</p>`,
|
||||
text: input.body,
|
||||
})
|
||||
if (result && typeof result === 'object' && 'skipped' in result && result.skipped) {
|
||||
|
||||
@@ -96,6 +96,12 @@ export default buildConfig({
|
||||
} catch (err) {
|
||||
payload.logger.error({ err }, 'Failed to seed public documents')
|
||||
}
|
||||
try {
|
||||
const { seedGalleryPhotos } = await import('./seed/gallery-photos')
|
||||
await seedGalleryPhotos(payload)
|
||||
} catch (err) {
|
||||
payload.logger.error({ err }, 'Failed to seed gallery photos')
|
||||
}
|
||||
},
|
||||
editor: lexicalEditor(),
|
||||
i18n: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 493 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 397 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 535 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,110 @@
|
||||
import 'dotenv/config'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
export const GALLERY_PHOTOS = [
|
||||
{
|
||||
filename: 'photo-6.jpg',
|
||||
mime: 'image/jpeg',
|
||||
title: 'Ресепшен КДЦ МОНИКИ',
|
||||
alt: 'Стойка регистрации КДЦ МОНИКИ',
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
filename: 'photo69.jpeg',
|
||||
mime: 'image/jpeg',
|
||||
title: 'Администратор на ресепшене',
|
||||
alt: 'Администратор КДЦ МОНИКИ за стойкой регистрации',
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
filename: 'photo68.jpg',
|
||||
mime: 'image/jpeg',
|
||||
title: 'Зона ожидания',
|
||||
alt: 'Зал ожидания отделения платных медицинских услуг КДЦ МОНИКИ',
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
filename: 'photo-9.jpg',
|
||||
mime: 'image/jpeg',
|
||||
title: 'Стойка регистрации',
|
||||
alt: 'Интерьер стойки регистрации КДЦ МОНИКИ',
|
||||
sortOrder: 40,
|
||||
},
|
||||
]
|
||||
|
||||
function filePath(filename: string) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const bundled = path.join(here, 'files', 'gallery', filename)
|
||||
return fs.existsSync(bundled) ? bundled : null
|
||||
}
|
||||
|
||||
export async function seedGalleryPhotos(payload: Payload) {
|
||||
for (const item of GALLERY_PHOTOS) {
|
||||
const source = filePath(item.filename)
|
||||
if (!source) {
|
||||
payload.logger.warn(`Gallery photo not found: ${item.filename}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const already = await payload.find({
|
||||
collection: 'gallery',
|
||||
where: { title: { equals: item.title } },
|
||||
locale: 'ru',
|
||||
limit: 1,
|
||||
})
|
||||
if (already.docs.length) continue
|
||||
|
||||
const existingMedia = await payload.find({
|
||||
collection: 'media',
|
||||
where: { filename: { equals: item.filename } },
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
let mediaId = existingMedia.docs[0]?.id
|
||||
if (!mediaId) {
|
||||
const buf = fs.readFileSync(source)
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
locale: 'ru',
|
||||
data: { alt: item.alt },
|
||||
file: {
|
||||
data: buf,
|
||||
mimetype: item.mime,
|
||||
name: item.filename,
|
||||
size: buf.length,
|
||||
},
|
||||
})
|
||||
mediaId = media.id
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'gallery',
|
||||
locale: 'ru',
|
||||
data: {
|
||||
title: item.title,
|
||||
image: mediaId,
|
||||
category: 'clinic',
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
})
|
||||
payload.logger.info(`Seeded gallery photo ${item.filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { getPayload } = await import('payload')
|
||||
const config = (await import('../payload.config')).default
|
||||
const payload = await getPayload({ config })
|
||||
await seedGalleryPhotos(payload)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (process.argv[1]?.includes('gallery-photos')) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user