diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d349c1..b06e4d6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,6 +7,10 @@ on: branches: - main # Запускать при пуше в ветку dev +concurrency: + group: deploy-main + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest # Использовать последнюю Ubuntu в качестве среды выполнения @@ -24,7 +28,36 @@ jobs: - name: Install dependencies # Шаг 3: Устанавливаем зависимости run: npm ci # Используем 'ci' для быстрой и чистой установки по lock-файлу + - name: Lint + run: npm run lint + + - name: Type check + run: npm run type-check + + - name: Test + run: npm test + + - name: Verify and deploy database migrations + env: + DIRECT_DATABASE_URL: ${{ secrets.DIRECT_DATABASE_URL }} + POSTGRES_URL_NON_POOLING: ${{ secrets.POSTGRES_URL_NON_POOLING }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + PRISMA_DATABASE_URL: ${{ secrets.PRISMA_DATABASE_URL }} + run: | + npm run migrations:verify + npx prisma generate + npm run database:verify-identity + npx prisma migrate deploy + npm run database:verify-identity + - name: Build Next.js app # Шаг 4: Собираем Next.js приложение + env: + DIRECT_DATABASE_URL: ${{ secrets.DIRECT_DATABASE_URL }} + POSTGRES_URL_NON_POOLING: ${{ secrets.POSTGRES_URL_NON_POOLING }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + PRISMA_DATABASE_URL: ${{ secrets.PRISMA_DATABASE_URL }} run: npm run build # Эта команда должна использовать 'standalone' output из next.config.js - name: Deploy to Server via SSH # Шаг 5: Деплоим файлы на сервер diff --git a/README.md b/README.md index 830f303..555d94c 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ npm run dev | `DATABASE_URL` | PostgreSQL connection string (full URL with credentials) | | `POSTGRES_URL` | Alternative Postgres connection string (used by Vercel Postgres) | | `PRISMA_DATABASE_URL` | Explicit Prisma database URL (overrides `DATABASE_URL` if set) | -| `ADMIN_EMAIL` | Email address to grant admin access on first login (e.g., your@email.com) | +| `DIRECT_DATABASE_URL` | Direct PostgreSQL URL for migrations, backup and restore | +| `POSTGRES_URL_NON_POOLING` | Alternative direct PostgreSQL URL used when `DIRECT_DATABASE_URL` is absent | +| `ADMIN_EMAIL` | Existing administrator email used for operational notifications | | `CONSENT_HMAC_KEY` | Dedicated 32-byte secret (64 HEX) for consent signing and verification; required in production | #### Email & Notifications @@ -179,13 +181,12 @@ npm run dev ### Admin Setup -1. **Register** — Sign up with your email or Google OAuth -2. **Set ADMIN_EMAIL** — Add your email to `.env.local`: - ```bash - ADMIN_EMAIL=your@email.com - ``` -3. **Login Again** — Sign in again and your account will automatically be elevated to `ADMIN` role -4. **Access Admin Panel** — Navigate to `/admin` (accessible only to ADMIN users) +1. **Register** — Sign up with your email or Google OAuth. +2. **Grant the role out of band** — Set the existing verified user's `role` to `ADMIN` through a trusted database administration channel. Public registration never grants administrative access. +3. **Set `ADMIN_EMAIL`** — Configure the same email if it should receive operational notifications. +4. **Access Admin Panel** — Sign in again and navigate to `/admin`. + +Production deploys must provide `DIRECT_DATABASE_URL` or `POSTGRES_URL_NON_POOLING` to CI whenever `PRISMA_DATABASE_URL` or `POSTGRES_URL` is configured. `DATABASE_URL` is accepted as a direct fallback only when it is the sole runtime database URL. Before domain migrations, the deploy workflow bootstraps a service-only `DatabaseIdentity` sentinel and verifies that runtime (including Accelerate) and direct connections resolve to the same database instance. --- diff --git a/README.ru.md b/README.ru.md index fea2b9f..e6effb7 100644 --- a/README.ru.md +++ b/README.ru.md @@ -137,7 +137,9 @@ npm run dev | `DATABASE_URL` | Строка подключения PostgreSQL (полный URL с учётными данными) | | `POSTGRES_URL` | Альтернативная строка подключения Postgres (используется Vercel Postgres) | | `PRISMA_DATABASE_URL` | Явный URL базы данных Prisma (переопределяет `DATABASE_URL` если установлена) | -| `ADMIN_EMAIL` | Email-адрес для предоставления доступа администратора при первом входе (например, your@email.com) | +| `DIRECT_DATABASE_URL` | Прямой PostgreSQL URL для миграций, backup и restore | +| `POSTGRES_URL_NON_POOLING` | Альтернативный прямой URL, если `DIRECT_DATABASE_URL` не задан | +| `ADMIN_EMAIL` | Email существующего администратора для получения служебных уведомлений | | `CONSENT_HMAC_KEY` | Отдельный 32-байтный секрет (64 HEX) для подписи и проверки согласий; обязателен в production | #### Email и уведомления @@ -180,13 +182,12 @@ npm run dev ### Настройка администратора -1. **Зарегистрируйтесь** — Создайте учётную запись с вашим email или через Google OAuth -2. **Установите ADMIN_EMAIL** — Добавьте ваш email в `.env.local`: - ```bash - ADMIN_EMAIL=your@email.com - ``` -3. **Войдите снова** — Войдите в систему снова, и ваша учётная запись автоматически будет повышена до роли `ADMIN` -4. **Перейдите в панель администратора** — Откройте `/admin` (доступно только пользователям с ролью ADMIN) +1. **Зарегистрируйтесь** — Создайте учётную запись с вашим email или через Google OAuth. +2. **Назначьте роль вне публичного приложения** — Через доверенный канал администрирования БД задайте существующему подтверждённому пользователю роль `ADMIN`. Публичная регистрация никогда не выдаёт права администратора. +3. **Установите `ADMIN_EMAIL`** — Укажите тот же email, если на него должны приходить служебные уведомления. +4. **Перейдите в панель администратора** — Войдите снова и откройте `/admin`. + +Для production deploy в CI обязателен `DIRECT_DATABASE_URL` или `POSTGRES_URL_NON_POOLING`, если настроен отдельный runtime URL через `PRISMA_DATABASE_URL` или `POSTGRES_URL`. `DATABASE_URL` разрешён как direct fallback только когда он является единственным runtime URL. До доменных миграций workflow создаёт только служебный sentinel `DatabaseIdentity` и проверяет, что runtime-подключение (включая Accelerate) и direct URL ведут к одному экземпляру БД. --- diff --git a/docs/google-calendar-integration.ru.md b/docs/google-calendar-integration.ru.md new file mode 100644 index 0000000..0702736 --- /dev/null +++ b/docs/google-calendar-integration.ru.md @@ -0,0 +1,82 @@ +# Интеграция с Google Calendar + +## Реализованный поток + +Приложение использует серверный OAuth 2.0 flow и минимальный scope +`https://www.googleapis.com/auth/calendar.events`. После подключения: + +- будущие события расписания отправляются в основной Google Calendar; +- создание, изменение, отмена и удаление записи синхронизируются автоматически; +- внешние события подключённого календаря отображаются в расписании администратора с заголовками + из Google, а в личном кабинете клиента — только как занятое время; +- access token обновляется через offline refresh token; +- OAuth-токены хранятся в PostgreSQL в зашифрованном виде; +- кнопка «Синхронизировать сейчас» повторно отправляет будущие записи. + +Каждый администратор синхронизирует только созданные им события со своим подключённым календарём. +Календарь другого администратора не используется как fallback. + +Legacy-поле с приватным iCal URL оставлено только для прежнего read-only отображения занятости. +Оно не используется для отправки записей в Google. + +Если Google возвращает в iCal-фиде заголовок `Busy`, это настройка приватности самого источника: +скрытый Google заголовок восстановить невозможно. Подключение через OAuth показывает доступные +этому аккаунту заголовки событий. + +## Настройка Google Cloud + +1. Создать или выбрать проект в Google Cloud Console. +2. Включить Google Calendar API. +3. Настроить OAuth consent screen. +4. Создать OAuth Client ID с типом `Web application`. +5. Добавить точные redirect URI: + - production: `https://<домен>/api/google-calendar/callback`; + - local: `http://localhost:3000/api/google-calendar/callback`. +6. Добавить переменные окружения: + +```dotenv +GOOGLE_CALENDAR_CLIENT_ID=... +GOOGLE_CALENDAR_CLIENT_SECRET=... +NEXT_PUBLIC_APP_URL=https://<домен> +ENCRYPTION_KEY=<стабильный-секрет-для-шифрования> +``` + +Если уже настроен вход через Google, интеграция также использует совместимые `AUTH_GOOGLE_ID` +и `AUTH_GOOGLE_SECRET`. В Google Cloud всё равно нужно зарегистрировать отдельный callback адрес +календаря из пункта 5. + +`NEXT_PUBLIC_APP_URL` должен в точности совпадать с origin зарегистрированного redirect URI. +`ENCRYPTION_KEY` нельзя менять после подключения календаря, иначе сохранённые токены невозможно +будет расшифровать. + +## Деплой + +Перед включением функции применить миграцию и сгенерировать Prisma Client: + +```bash +npx prisma generate +npx prisma migrate deploy +``` + +После деплоя открыть «Админка → Расписание → Настройки», нажать «Подключить Google Calendar» +и пройти экран согласия Google. Первичная синхронизация будущих записей запускается автоматически. +В том же диалоге доступно необязательное поле секретного iCal-адреса для read-only показа +занятости из календаря. + +## План обратной синхронизации Google → приложение + +Обратную синхронизацию следует реализовать отдельно, чтобы не смешивать её с исходящим потоком: + +1. Сохранять внешние события и выполнять initial sync через `events.list` с `singleEvents=true`, + временным диапазоном и `nextSyncToken`. +3. Подключить Google push notifications (`events.watch`) на отдельный проверяемый webhook. +4. На webhook запускать инкрементальный `events.list` по `syncToken`, а не доверять payload webhook. +5. Хранить внешние события в отдельной модели `ExternalCalendarEvent`, не смешивая их с `Event`. +6. Применить явные правила конфликтов: внешние события блокируют время, но не создают клиента, + консультацию или уведомления автоматически. +7. Исключать события приложения по `extendedProperties.private.vershkovEventId`, чтобы избежать + циклической синхронизации и дублей. +8. Обрабатывать удаление, recurring events, all-day events, смену timezone, `410 Gone` для + протухшего sync token и повторную регистрацию watch channel. +9. Добавить журнал синхронизации, ручной retry и интеграционные тесты с зафиксированными ответами + Google API. diff --git a/docs/in-app-notifications.ru.md b/docs/in-app-notifications.ru.md new file mode 100644 index 0000000..abdcaa6 --- /dev/null +++ b/docs/in-app-notifications.ru.md @@ -0,0 +1,183 @@ +# In-app уведомления + +Документ описывает единый механизм persistent-уведомлений в верхней панели личного кабинета и админки. + +## Основные свойства + +- Уведомления хранятся в PostgreSQL в модели `AppNotification` и синхронизируются между устройствами. +- Колокольчик показывает только записи без `readAt` и `dismissedAt`. +- «Прочитано» устанавливает `readAt`. «Очистить все» устанавливает `dismissedAt`, история не удаляется физически. +- Администратор может удалить запись из своей истории: `deletedAt` скрывает её из центра и истории, но сохраняет ключ дедупликации системного события. +- Системные уведомления используют `dedupeKey` и после прочтения или очистки не создаются повторно. +- Хук работает без React Provider. Все экземпляры используют общий SWR-кэш `/api/notifications`. +- Хук проверяет новые уведомления каждые 30 секунд, только когда вкладка видима и окно сайта находится в фокусе. В фоновой вкладке polling и первоначальный GET не выполняются; при возврате на сайт список обновляется сразу. +- Клиент не может создавать уведомления для других пользователей. Создание выполняется только серверным сервисом. + +## Клиентский хук + +```ts +import { useNotifications } from '@/lib/hooks/use-notifications'; + +const { + notifications, + unreadCount, + isLoading, + isValidating, + error, + refresh, + markAsRead, + markAllAsRead, + clearAll +} = useNotifications(); +``` + +Хук можно использовать в любом Client Component внутри авторизованной части приложения. Дополнительная обёртка или регистрация модуля не требуется. + +### Интерфейс `UseNotificationsResult` + +| Поле | Тип | Назначение | +| ----------------- | ---------------------- | ------------------------------------------------- | +| `notifications` | `AppNotificationDto[]` | Активные непрочитанные уведомления, новые первыми | +| `unreadCount` | `number` | Количество активных уведомлений | +| `isLoading` | `boolean` | Первичная загрузка | +| `isValidating` | `boolean` | Фоновое обновление SWR | +| `error` | `Error \| null` | Ошибка последней загрузки | +| `refresh()` | `Promise` | Принудительно обновить общий кэш | +| `markAsRead(id)` | `Promise` | Optimistic-отметка одной записи как прочитанной | +| `markAllAsRead()` | `Promise` | Отметить все активные записи прочитанными | +| `clearAll()` | `Promise` | Скрыть все активные записи с сохранением истории | + +### DTO уведомления + +```ts +interface AppNotificationDto { + id: string; + kind: 'INFO' | 'WARNING' | 'SUCCESS'; + source: string; + title: string; + message: string; + actionUrl: string | null; + actionLabel: string | null; + createdAt: string; +} +``` + +Пример использования в модуле: + +```tsx +'use client'; + +import { useNotifications } from '@/lib/hooks/use-notifications'; + +export const ModuleNotificationBadge = () => { + const { unreadCount, refresh } = useNotifications(); + + return ( + + ); +}; +``` + +## Серверное создание + +Бизнес-модули не должны обращаться к `prisma.appNotification` напрямую. Используйте сервис: + +```ts +import { createUserNotification } from '@/modules/notifications/notification-service.server'; + +await createUserNotification({ + userId, + kind: 'INFO', + source: 'PAYMENTS', + title: 'Платёж подтверждён', + message: 'Пакет консультаций доступен в личном кабинете.', + actionUrl: '/my/payments', + actionLabel: 'Открыть платежи', + dedupeKey: `payment-confirmed:${paymentId}` +}); +``` + +Для одинакового сообщения группе пользователей: + +```ts +import { createNotificationsForUsers } from '@/modules/notifications/notification-service.server'; + +await createNotificationsForUsers(userIds, { + kind: 'INFO', + source: 'SURVEYS', + title: 'Назначен новый тест', + message: 'Тест доступен в разделе анкет.', + actionUrl: '/my/surveys', + actionLabel: 'Открыть тесты' +}); +``` + +### Правила `dedupeKey` + +- Используйте стабильный ключ для системного события, которое нельзя показывать повторно после прочтения. +- Ключ должен включать модуль и версию сценария: `payments:failed:v1`. +- Для событий конкретной сущности добавляйте её ID: `survey-assigned:${assignmentId}`. +- Не используйте `dedupeKey` для админских рассылок, если каждое отправленное сообщение должно стать новой записью. +- Сервис автоматически добавляет `userId` к ключу, передавать его в `dedupeKey` не нужно. + +## Завершение системного уведомления + +Если пользователь выполнил требуемое действие в другом интерфейсе, модуль может отметить уведомление прочитанным: + +```ts +import { + resolveUserNotificationByKey, + systemNotificationKeys +} from '@/modules/notifications/notification-service.server'; + +await resolveUserNotificationByKey(userId, systemNotificationKeys.incompleteIntake); +``` + +Сейчас автоматически поддерживаются два системных сценария: + +- `missingTimezone`: профиль не содержит часовой пояс; +- `incompleteIntake`: пользователь с ролью `USER` не прошёл первичную анкету. + +## История уведомлений + +- Пользователь открывает историю по адресу `/my/notifications`. +- Администратор открывает личную историю по адресу `/admin/notifications`. +- Первая страница загружается напрямую в Server Component, следующие страницы запрашиваются курсором через API. +- История показывает новые, прочитанные и очищенные записи. Записи с `deletedAt` не возвращаются. +- Только администратор может удалить одну запись или всю собственную историю. Server Actions повторно проверяют роль и всегда ограничивают операцию текущим `userId`. + +## Ограничения и безопасность + +- `title`: от 1 до 120 символов. +- `message`: от 1 до 500 символов. +- `actionLabel`: до 40 символов. +- `actionUrl` разрешает только внутренний путь, начинающийся с одного `/`. +- HTML не поддерживается. `title` и `message` всегда выводятся как обычный React-текст. +- Каждый API повторно проверяет сессию. Изменить можно только уведомления текущего пользователя. +- Массовое создание доступно только роли `ADMIN` через `/api/admin/notifications`. + +## Правила для AI-агентов + +1. Перед добавлением нового in-app уведомления прочитайте этот документ и `src/modules/notifications/types.ts`. +2. Не создавайте отдельный Zustand store, Context Provider или localStorage для уведомлений. +3. В Client Component используйте только `useNotifications()`. +4. В Server Action, Route Handler или workflow используйте `notification-service.server.ts`. +5. Не импортируйте server-сервис в Client Component. +6. Не пишите напрямую в `AppNotification`, кроме миграций и самого notification service. +7. Для повторяемых системных условий обязательно задавайте стабильный `dedupeKey`. +8. Не сбрасывайте `readAt` и `dismissedAt`: прочитанное пользователем решение имеет приоритет. +9. Всегда передавайте DTO, не отдавайте клиенту `userId`, `dedupeKey` или внутренние metadata. +10. Для нового типа поведения добавьте тест схемы/сервиса и обновите этот документ. + +## API + +| Метод | Маршрут | Назначение | +| -------- | ----------------------------- | --------------------------------------------------------------- | +| `GET` | `/api/notifications` | Список активных уведомлений и инициализация системных сценариев | +| `GET` | `/api/notifications/history` | Следующая страница полной истории текущего пользователя | +| `PATCH` | `/api/notifications/:id` | Отметить одну запись прочитанной | +| `POST` | `/api/notifications/read-all` | Отметить все записи прочитанными | +| `DELETE` | `/api/notifications` | Очистить все активные записи | +| `POST` | `/api/admin/notifications` | Массовое создание, только `ADMIN` | diff --git a/messages/en.json b/messages/en.json index b48afc8..b943ae8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -322,6 +322,7 @@ "packages": "Service Packages", "blog": "Blog", "sendEmail": "Send Email", + "notifications": "Notifications", "backups": "Backups", "logs": "System Logs", "apps": "Mini Apps", @@ -653,6 +654,7 @@ "payments": "Payments", "packages": "Service Packages", "data": "Files & Documents", + "notifications": "Notifications", "settings": "Settings" }, "today": "Today", @@ -1198,6 +1200,130 @@ "back": "Back", "home": "Home" }, + "TimezonePicker": { + "placeholder": "Select a timezone", + "searchPlaceholder": "Search by city or region...", + "searchLabel": "Search timezones", + "empty": "No timezone found" + }, + "AccountNotifications": { + "open": "Open notifications", + "title": "Notifications", + "unreadCount": "New: {count}", + "allRead": "All caught up", + "readAll": "Read all", + "viewAll": "All notifications", + "markRead": "Read", + "loading": "Loading notifications...", + "loadError": "Could not load notifications", + "updateError": "Could not update notifications", + "retry": "Try again", + "empty": "No new notifications" + }, + "NotificationsHistory": { + "title": "Notification history", + "description": "Every message that appeared in your notification center.", + "statuses": { + "unread": "New", + "read": "Read", + "cleared": "Cleared" + }, + "emptyTitle": "No notifications yet", + "emptyDescription": "System and personal messages will be stored here.", + "loadMore": "Show more", + "loadingMore": "Loading...", + "loadError": "Could not load notification history", + "deleteOne": "Delete", + "deleteOneAria": "Delete notification “{title}”", + "deleteAll": "Delete all history", + "confirmOneTitle": "Delete notification?", + "confirmOneDescription": "This entry will be permanently removed from your history.", + "confirmAllTitle": "Delete all history?", + "confirmAllDescription": "All your notifications will be permanently removed.", + "cancel": "Cancel", + "confirmDelete": "Delete", + "deleting": "Deleting...", + "deleted": "Notification deleted", + "allDeleted": "Notification history deleted", + "deleteError": "Could not delete the notification" + }, + "AdminBroadcast": { + "eyebrow": "Communications", + "title": "Broadcast", + "description": "Send messages by email, push, or directly to the user's notification center.", + "usersAvailable": "Users: {count}", + "channels": { + "email": { + "label": "Email", + "title": "Email users", + "description": "A full message delivered to the recipient's email address.", + "action": "Send email", + "hint": "Email delivery statuses update automatically after sending." + }, + "push": { + "label": "Push", + "title": "Push notification", + "description": "A short message for devices with an active push subscription.", + "action": "Send push", + "hint": "Only users who allowed browser notifications will receive the push." + }, + "inApp": { + "label": "In app", + "title": "In-app notification", + "description": "A persistent message stays under the bell until it is read or cleared.", + "action": "Create notification", + "hint": "The notification is stored in the database and synced across the user's devices." + } + }, + "audienceTitle": "Recipients", + "audienceDescription": "Choose specific users or the entire available audience.", + "sendToAll": "All users", + "recipients": "Selected users", + "recipientsPlaceholder": "Find a user by name or email...", + "subject": "Title", + "subjectPlaceholder": "Summarize the message topic", + "message": "Message", + "messagePlaceholder": "Write a clear and concise message...", + "actionUrl": "Internal link", + "actionUrlDescription": "Optional, for example /my/sessions", + "actionLabel": "Button label", + "actionLabelPlaceholder": "Open section", + "recipientSummary": "Recipients: {count}", + "preview": "Preview", + "previewSubjectFallback": "Message title", + "previewMessageFallback": "Your message will appear here.", + "defaultActionLabel": "Open", + "sending": "Sending...", + "success": "Sent", + "error": "Send error", + "sendError": "Could not send the message", + "messageTooLong": "The message exceeds the {limit} character limit", + "emailQueued": "Emails were queued for delivery.", + "pushSent": "Push sent: {sent}. Failed: {failed}.", + "inAppCreated": "Notifications created: {count}.", + "deliveryStatus": "Delivery status", + "deliveryFailed": "Not delivered ({status})", + "statuses": { + "queued": "Queued", + "delivered": "Delivered", + "bounced": "Bounced", + "complained": "Complaint", + "rejected": "Rejected", + "error": "Error", + "sent": "Sent" + }, + "confirmTitle": "Confirm sending?", + "confirmDescription": "Channel: {channel}. Recipients: {count}. Sending cannot be undone after confirmation.", + "cancel": "Cancel", + "confirm": "Confirm" + }, + "DateTimePicker": { + "selectDate": "Select date", + "selectTime": "Select time", + "hours": "Hours", + "minutes": "Minutes", + "done": "Done" + }, "Schedule": { "title": "Schedule & Booking", "details": "Details", @@ -1215,19 +1341,37 @@ "eventType": "Event Type", "status": "Status", "eventTitle": "Title (Optional)", + "eventTitlePlaceholder": "For example: initial consultation", + "date": "Date", "startTime": "Start Time", "endTime": "End Time", + "duration": "Duration", + "durationMinutes": "{minutes} min", "meetLink": "Meeting Link", + "savedMeetingLinks": "Saved links", + "savedMeetingLinksDescription": "Choose a link for this event or remove one you no longer need.", + "deleteSavedMeetingLink": "Delete saved link {link}", + "meetingLinkHistoryDescription": "Enter a new link or choose a saved one. A new link is saved with the event.", "sessionReminderLabel": "Client reminder", "sessionReminderAtStart": "At start time", "sessionReminderBeforeMinutes": "{minutes} min before", "description": "Description", "client": "Client", + "admin": "Administrator", + "clientTimezone": "Client time zone", + "clientTimezonePlaceholder": "Select a time zone", + "clientTimezoneDescription": "The date and time above are interpreted in this time zone. Client notifications use it too.", + "clientTimezoneMissing": "Not set — UTC is used", + "clientTimezoneReadOnlyDescription": "The timezone comes from the client profile. It can be changed in the user form.", + "browserTimezoneDescription": "No client is selected, so this browser's timezone is used.", "noneSelected": "None selected", "delete": "Delete", "cancel": "Cancel", "save": "Save", "saving": "Saving...", + "saveError": "Failed to save the event.", + "deleteError": "Failed to delete the event.", + "deleteConfirmation": "Are you sure you want to delete this event?", "noEvents": "No events", "moreEvents": "+{count} more", "pendingRequestsTitle": "Approval Requests", @@ -1235,7 +1379,7 @@ "pendingRequestsEmpty": "No booking requests are waiting for approval.", "pendingRequestsLoading": "Loading requests...", "pendingRequestsCount": "{count} requests", - "pendingStatusBadge": "Needs review", + "openRequest": "Open request", "requestClientLabel": "Client", "requestDateLabel": "Date", "requestTimeLabel": "Time", @@ -1254,6 +1398,29 @@ "workHours": "Working hours", "startHour": "Start (hour)", "endHour": "End (hour)", + "saveWorkHours": "Save hours", + "settingsSaved": "Schedule settings saved.", + "settingsSaveError": "Failed to save schedule settings.", + "googleConnected": "Google Calendar connected", + "googleConnectedCalendar": "New and updated appointments are sent to “{calendar}”.", + "googleConnectedDescription": "New and updated appointments are sent to the connected calendar.", + "googleNotConnected": "Google Calendar is not connected", + "googleOutboundDescription": "Connect your Google account so appointments created in the admin area automatically appear in your calendar.", + "googleIcalUrlLabel": "Secret iCal address (read events)", + "googleIcalUrlDescription": "Optional. Paste a secret iCal address to show external Google events in the schedule. It is not used to send appointments to Google.", + "connectGoogle": "Connect Google Calendar", + "disconnectGoogle": "Disconnect", + "disconnectGoogleTitle": "Disconnect Google Calendar?", + "disconnectGoogleDescription": "Synchronization will stop, and you can then connect another Google account. Existing calendar events will remain.", + "syncNow": "Sync now", + "googleConnectedSuccess": "Google Calendar connected and future appointments synced.", + "googleConnectedWithErrors": "Google Calendar connected, but some appointments could not be synced.", + "googleConnectionError": "Could not connect Google Calendar. Please try again.", + "googleConfigurationError": "Google Calendar OAuth settings are missing on the server.", + "googleDisconnected": "Google Calendar disconnected.", + "googleDisconnectError": "Could not disconnect Google Calendar.", + "googleSyncSuccess": "Appointments synced: {count}.", + "googleSyncError": "Could not sync all appointments with Google Calendar.", "enableGoogleCalendar": "Enable Google Calendar integration", "webhookUrl": "Webhook URL / Secret iCal address", "howToGetLink": "How to get the link from Google Calendar", diff --git a/messages/ru.json b/messages/ru.json index 6c410ae..08426f0 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -322,6 +322,7 @@ "packages": "Пакеты услуг", "blog": "Блог", "sendEmail": "Рассылка", + "notifications": "Уведомления", "backups": "Резервные копии", "logs": "Системный журнал", "apps": "Мини-приложения", @@ -654,6 +655,7 @@ "sessions": "Мои сессии", "payments": "Платежи", "data": "Файлы и документы", + "notifications": "Уведомления", "settings": "Настройки" }, "today": "Сегодня", @@ -1209,6 +1211,130 @@ "back": "Назад", "home": "Главная" }, + "TimezonePicker": { + "placeholder": "Выберите часовой пояс", + "searchPlaceholder": "Поиск по городу или региону...", + "searchLabel": "Поиск часового пояса", + "empty": "Часовой пояс не найден" + }, + "AccountNotifications": { + "open": "Открыть уведомления", + "title": "Уведомления", + "unreadCount": "Новых: {count}", + "allRead": "Всё прочитано", + "readAll": "Прочитать все", + "viewAll": "Все уведомления", + "markRead": "Прочитано", + "loading": "Загружаем уведомления...", + "loadError": "Не удалось загрузить уведомления", + "updateError": "Не удалось обновить уведомления", + "retry": "Повторить", + "empty": "Новых уведомлений нет" + }, + "NotificationsHistory": { + "title": "История уведомлений", + "description": "Все сообщения, которые появлялись в центре уведомлений.", + "statuses": { + "unread": "Новое", + "read": "Прочитано", + "cleared": "Очищено" + }, + "emptyTitle": "Уведомлений пока нет", + "emptyDescription": "Здесь будут храниться системные и личные сообщения.", + "loadMore": "Показать ещё", + "loadingMore": "Загружаем...", + "loadError": "Не удалось загрузить историю", + "deleteOne": "Удалить", + "deleteOneAria": "Удалить уведомление «{title}»", + "deleteAll": "Удалить всю историю", + "confirmOneTitle": "Удалить уведомление?", + "confirmOneDescription": "Запись будет удалена из истории без возможности восстановления.", + "confirmAllTitle": "Удалить всю историю?", + "confirmAllDescription": "Все ваши уведомления будут удалены без возможности восстановления.", + "cancel": "Отмена", + "confirmDelete": "Удалить", + "deleting": "Удаляем...", + "deleted": "Уведомление удалено", + "allDeleted": "История уведомлений удалена", + "deleteError": "Не удалось удалить уведомление" + }, + "AdminBroadcast": { + "eyebrow": "Коммуникации", + "title": "Рассылка", + "description": "Отправляйте сообщения по email, через push или напрямую в центр уведомлений пользователя.", + "usersAvailable": "Пользователей: {count}", + "channels": { + "email": { + "label": "Email", + "title": "Письмо пользователям", + "description": "Полноформатное сообщение, которое будет отправлено на электронную почту.", + "action": "Отправить email", + "hint": "Статус доставки email обновляется автоматически после отправки." + }, + "push": { + "label": "Push", + "title": "Push-уведомление", + "description": "Короткое сообщение для устройств с активной push-подпиской.", + "action": "Отправить push", + "hint": "Push получат только пользователи, которые разрешили уведомления в браузере." + }, + "inApp": { + "label": "В приложении", + "title": "Уведомление в приложении", + "description": "Сообщение появится под колокольчиком и останется до прочтения или очистки.", + "action": "Создать уведомление", + "hint": "Уведомление хранится в базе и синхронизируется между устройствами пользователя." + } + }, + "audienceTitle": "Получатели", + "audienceDescription": "Выберите конкретных пользователей или всю доступную аудиторию.", + "sendToAll": "Все пользователи", + "recipients": "Выбранные пользователи", + "recipientsPlaceholder": "Найдите пользователя по имени или email...", + "subject": "Заголовок", + "subjectPlaceholder": "Коротко обозначьте тему сообщения", + "message": "Сообщение", + "messagePlaceholder": "Напишите понятный текст без лишних деталей...", + "actionUrl": "Внутренняя ссылка", + "actionUrlDescription": "Необязательно, например /my/sessions", + "actionLabel": "Текст кнопки", + "actionLabelPlaceholder": "Открыть раздел", + "recipientSummary": "Получателей: {count}", + "preview": "Предпросмотр", + "previewSubjectFallback": "Заголовок сообщения", + "previewMessageFallback": "Здесь появится текст сообщения.", + "defaultActionLabel": "Открыть", + "sending": "Отправляем...", + "success": "Отправлено", + "error": "Ошибка отправки", + "sendError": "Не удалось отправить сообщение", + "messageTooLong": "Сообщение превышает лимит {limit} символов", + "emailQueued": "Письма поставлены в очередь на отправку.", + "pushSent": "Push отправлено: {sent}. Ошибок: {failed}.", + "inAppCreated": "Создано уведомлений: {count}.", + "deliveryStatus": "Статус доставки", + "deliveryFailed": "Не доставлено ({status})", + "statuses": { + "queued": "В очереди", + "delivered": "Доставлено", + "bounced": "Возврат", + "complained": "Жалоба", + "rejected": "Отклонено", + "error": "Ошибка", + "sent": "Отправлено" + }, + "confirmTitle": "Подтвердить отправку?", + "confirmDescription": "Канал: {channel}. Получателей: {count}. Отменить отправку после подтверждения нельзя.", + "cancel": "Отмена", + "confirm": "Подтвердить" + }, + "DateTimePicker": { + "selectDate": "Выберите дату", + "selectTime": "Выберите время", + "hours": "Часы", + "minutes": "Минуты", + "done": "Готово" + }, "Schedule": { "title": "Расписание и Записи", "details": "Детали", @@ -1226,19 +1352,37 @@ "eventType": "Тип События", "status": "Статус", "eventTitle": "Заголовок (Необязательно)", + "eventTitlePlaceholder": "Например: первичная консультация", + "date": "Дата", "startTime": "Время Начала", "endTime": "Время Окончания", + "duration": "Длительность", + "durationMinutes": "{minutes} мин", "meetLink": "Ссылка на звонок", + "savedMeetingLinks": "Сохранённые ссылки", + "savedMeetingLinksDescription": "Выберите ссылку для текущего события или удалите ненужную.", + "deleteSavedMeetingLink": "Удалить сохранённую ссылку {link}", + "meetingLinkHistoryDescription": "Введите новую ссылку или выберите ранее сохранённую. Новая ссылка сохранится вместе с событием.", "sessionReminderLabel": "Напоминание клиенту", "sessionReminderAtStart": "В момент начала", "sessionReminderBeforeMinutes": "За {minutes} мин", "description": "Описание", "client": "Клиент", + "admin": "Администратор", + "clientTimezone": "Часовой пояс клиента", + "clientTimezonePlaceholder": "Выберите часовой пояс", + "clientTimezoneDescription": "Дата и время выше интерпретируются в этом часовом поясе. Он также используется в уведомлениях клиента.", + "clientTimezoneMissing": "Не указан — используется UTC", + "clientTimezoneReadOnlyDescription": "Часовой пояс берётся из профиля клиента. Изменить его можно в форме пользователя.", + "browserTimezoneDescription": "Клиент не выбран, поэтому используется часовой пояс этого браузера.", "noneSelected": "Никто не выбран", "delete": "Удалить", "cancel": "Отмена", "save": "Сохранить", "saving": "Сохранение...", + "saveError": "Не удалось сохранить событие.", + "deleteError": "Не удалось удалить событие.", + "deleteConfirmation": "Вы действительно хотите удалить это событие?", "noEvents": "Нет событий", "moreEvents": "+{count} ещё", "pendingRequestsTitle": "Запросы на подтверждение", @@ -1246,7 +1390,7 @@ "pendingRequestsEmpty": "Нет запросов, ожидающих подтверждения.", "pendingRequestsLoading": "Загружаю запросы...", "pendingRequestsCount": "{count} запросов", - "pendingStatusBadge": "Нужно решение", + "openRequest": "Открыть запрос", "requestClientLabel": "Клиент", "requestDateLabel": "Дата", "requestTimeLabel": "Время", @@ -1265,6 +1409,29 @@ "workHours": "Рабочие часы", "startHour": "Начало (час)", "endHour": "Конец (час)", + "saveWorkHours": "Сохранить часы", + "settingsSaved": "Настройки расписания сохранены.", + "settingsSaveError": "Не удалось сохранить настройки расписания.", + "googleConnected": "Google Calendar подключён", + "googleConnectedCalendar": "Новые и изменённые записи отправляются в календарь «{calendar}».", + "googleConnectedDescription": "Новые и изменённые записи отправляются в подключённый календарь.", + "googleNotConnected": "Google Calendar не подключён", + "googleOutboundDescription": "Подключите аккаунт Google, чтобы записи из админки автоматически появлялись в вашем календаре.", + "googleIcalUrlLabel": "Секретный iCal-адрес (чтение событий)", + "googleIcalUrlDescription": "Необязательно. Вставьте секретный адрес iCal, чтобы показывать внешние события Google в расписании. Он не используется для отправки записей в Google.", + "connectGoogle": "Подключить Google Calendar", + "disconnectGoogle": "Отключить", + "disconnectGoogleTitle": "Отключить Google Calendar?", + "disconnectGoogleDescription": "Синхронизация прекратится, после чего можно будет подключить другой аккаунт Google. Уже созданные события останутся в календаре.", + "syncNow": "Синхронизировать сейчас", + "googleConnectedSuccess": "Google Calendar подключён и будущие записи синхронизированы.", + "googleConnectedWithErrors": "Google Calendar подключён, но часть записей не удалось синхронизировать.", + "googleConnectionError": "Не удалось подключить Google Calendar. Повторите попытку.", + "googleConfigurationError": "Не заданы OAuth-настройки Google Calendar на сервере.", + "googleDisconnected": "Google Calendar отключён.", + "googleDisconnectError": "Не удалось отключить Google Calendar.", + "googleSyncSuccess": "Синхронизировано записей: {count}.", + "googleSyncError": "Не удалось синхронизировать все записи с Google Calendar.", "enableGoogleCalendar": "Включить интеграцию с Google Calendar", "webhookUrl": "Webhook URL / Секретный адрес iCal", "howToGetLink": "Как получить ссылку из Google Calendar", diff --git a/messages/sr.json b/messages/sr.json index e50310d..f8612b3 100644 --- a/messages/sr.json +++ b/messages/sr.json @@ -312,6 +312,7 @@ "packages": "Paketi usluga", "blog": "Blog", "sendEmail": "Slanje emaila", + "notifications": "Obaveštenja", "backups": "Rezervne kopije", "logs": "Sistemski dnevnik", "apps": "Mini aplikacije", @@ -641,6 +642,7 @@ "sessions": "Moje sesije", "payments": "Plaćanja", "data": "Fajlovi i dokumenti", + "notifications": "Obaveštenja", "settings": "Podešavanja" }, "today": "Danas", @@ -1131,6 +1133,130 @@ "back": "Nazad", "home": "Početna" }, + "TimezonePicker": { + "placeholder": "Izaberite vremensku zonu", + "searchPlaceholder": "Pretraga po gradu ili regionu...", + "searchLabel": "Pretraga vremenskih zona", + "empty": "Vremenska zona nije pronađena" + }, + "AccountNotifications": { + "open": "Otvori obaveštenja", + "title": "Obaveštenja", + "unreadCount": "Novo: {count}", + "allRead": "Sve je pročitano", + "readAll": "Pročitaj sve", + "viewAll": "Sva obaveštenja", + "markRead": "Pročitano", + "loading": "Učitavanje obaveštenja...", + "loadError": "Obaveštenja nisu učitana", + "updateError": "Obaveštenja nisu ažurirana", + "retry": "Pokušaj ponovo", + "empty": "Nema novih obaveštenja" + }, + "NotificationsHistory": { + "title": "Istorija obaveštenja", + "description": "Sve poruke koje su se pojavile u centru obaveštenja.", + "statuses": { + "unread": "Novo", + "read": "Pročitano", + "cleared": "Očišćeno" + }, + "emptyTitle": "Još nema obaveštenja", + "emptyDescription": "Sistemske i lične poruke biće sačuvane ovde.", + "loadMore": "Prikaži još", + "loadingMore": "Učitavanje...", + "loadError": "Istorija obaveštenja nije učitana", + "deleteOne": "Obriši", + "deleteOneAria": "Obriši obaveštenje „{title}“", + "deleteAll": "Obriši celu istoriju", + "confirmOneTitle": "Obrisati obaveštenje?", + "confirmOneDescription": "Ovaj zapis će biti trajno uklonjen iz istorije.", + "confirmAllTitle": "Obrisati celu istoriju?", + "confirmAllDescription": "Sva vaša obaveštenja biće trajno uklonjena.", + "cancel": "Otkaži", + "confirmDelete": "Obriši", + "deleting": "Brisanje...", + "deleted": "Obaveštenje je obrisano", + "allDeleted": "Istorija obaveštenja je obrisana", + "deleteError": "Obaveštenje nije obrisano" + }, + "AdminBroadcast": { + "eyebrow": "Komunikacije", + "title": "Slanje poruka", + "description": "Šaljite poruke emailom, push-om ili direktno u centar obaveštenja korisnika.", + "usersAvailable": "Korisnika: {count}", + "channels": { + "email": { + "label": "Email", + "title": "Email korisnicima", + "description": "Potpuna poruka koja stiže na email adresu primaoca.", + "action": "Pošalji email", + "hint": "Status dostave emaila automatski se ažurira nakon slanja." + }, + "push": { + "label": "Push", + "title": "Push obaveštenje", + "description": "Kratka poruka za uređaje sa aktivnom push pretplatom.", + "action": "Pošalji push", + "hint": "Push dobijaju samo korisnici koji su dozvolili obaveštenja u pregledaču." + }, + "inApp": { + "label": "U aplikaciji", + "title": "Obaveštenje u aplikaciji", + "description": "Trajna poruka ostaje ispod zvona dok se ne pročita ili obriše.", + "action": "Kreiraj obaveštenje", + "hint": "Obaveštenje se čuva u bazi i sinhronizuje između uređaja korisnika." + } + }, + "audienceTitle": "Primaoci", + "audienceDescription": "Izaberite određene korisnike ili celu dostupnu publiku.", + "sendToAll": "Svi korisnici", + "recipients": "Izabrani korisnici", + "recipientsPlaceholder": "Pronađite korisnika po imenu ili emailu...", + "subject": "Naslov", + "subjectPlaceholder": "Ukratko opišite temu poruke", + "message": "Poruka", + "messagePlaceholder": "Napišite jasnu i sažetu poruku...", + "actionUrl": "Interni link", + "actionUrlDescription": "Opciono, na primer /my/sessions", + "actionLabel": "Tekst dugmeta", + "actionLabelPlaceholder": "Otvori odeljak", + "recipientSummary": "Primalaca: {count}", + "preview": "Pregled", + "previewSubjectFallback": "Naslov poruke", + "previewMessageFallback": "Ovde će se pojaviti tekst poruke.", + "defaultActionLabel": "Otvori", + "sending": "Šaljem...", + "success": "Poslato", + "error": "Greška pri slanju", + "sendError": "Poruka nije poslata", + "messageTooLong": "Poruka prelazi ograničenje od {limit} znakova", + "emailQueued": "Email poruke su stavljene u red za slanje.", + "pushSent": "Push poslato: {sent}. Greške: {failed}.", + "inAppCreated": "Kreirano obaveštenja: {count}.", + "deliveryStatus": "Status dostave", + "deliveryFailed": "Nije dostavljeno ({status})", + "statuses": { + "queued": "U redu", + "delivered": "Dostavljeno", + "bounced": "Vraćeno", + "complained": "Žalba", + "rejected": "Odbijeno", + "error": "Greška", + "sent": "Poslato" + }, + "confirmTitle": "Potvrditi slanje?", + "confirmDescription": "Kanal: {channel}. Primalaca: {count}. Slanje se ne može opozvati nakon potvrde.", + "cancel": "Otkaži", + "confirm": "Potvrdi" + }, + "DateTimePicker": { + "selectDate": "Izaberite datum", + "selectTime": "Izaberite vreme", + "hours": "Sati", + "minutes": "Minuti", + "done": "Gotovo" + }, "Schedule": { "title": "Raspored i termini", "details": "Detalji", @@ -1148,19 +1274,37 @@ "eventType": "Tip događaja", "status": "Status", "eventTitle": "Naslov (opciono)", + "eventTitlePlaceholder": "Na primer: prva konsultacija", + "date": "Datum", "startTime": "Vreme početka", "endTime": "Vreme završetka", + "duration": "Trajanje", + "durationMinutes": "{minutes} min", "meetLink": "Link za poziv", + "savedMeetingLinks": "Sačuvani linkovi", + "savedMeetingLinksDescription": "Izaberite link za ovaj događaj ili uklonite onaj koji vam više nije potreban.", + "deleteSavedMeetingLink": "Obriši sačuvani link {link}", + "meetingLinkHistoryDescription": "Unesite novi link ili izaberite sačuvani. Novi link se čuva zajedno sa događajem.", "sessionReminderLabel": "Podsetnik klijentu", "sessionReminderAtStart": "U trenutku početka", "sessionReminderBeforeMinutes": "{minutes} min ranije", "description": "Opis", "client": "Klijent", + "admin": "Administrator", + "clientTimezone": "Vremenska zona klijenta", + "clientTimezonePlaceholder": "Izaberite vremensku zonu", + "clientTimezoneDescription": "Datum i vreme iznad tumače se u ovoj vremenskoj zoni. Ona se koristi i za obaveštenja klijenta.", + "clientTimezoneMissing": "Nije podešena — koristi se UTC", + "clientTimezoneReadOnlyDescription": "Vremenska zona se preuzima iz profila klijenta. Menja se u formi korisnika.", + "browserTimezoneDescription": "Klijent nije izabran, pa se koristi vremenska zona ovog pregledača.", "noneSelected": "Niko nije izabran", "delete": "Obriši", "cancel": "Otkaži", "save": "Sačuvaj", "saving": "Čuvam...", + "saveError": "Događaj nije sačuvan.", + "deleteError": "Događaj nije obrisan.", + "deleteConfirmation": "Da li zaista želite da obrišete ovaj događaj?", "noEvents": "Nema događaja", "moreEvents": "+{count} još", "pendingRequestsTitle": "Zahtevi za potvrdu", @@ -1168,7 +1312,7 @@ "pendingRequestsEmpty": "Nema zahteva koji čekaju potvrdu.", "pendingRequestsLoading": "Učitavam zahteve...", "pendingRequestsCount": "{count} zahteva", - "pendingStatusBadge": "Čeka odluku", + "openRequest": "Otvori zahtev", "requestClientLabel": "Klijent", "requestDateLabel": "Datum", "requestTimeLabel": "Vreme", @@ -1187,6 +1331,29 @@ "workHours": "Radno vreme", "startHour": "Početak (sat)", "endHour": "Kraj (sat)", + "saveWorkHours": "Sačuvaj radno vreme", + "settingsSaved": "Podešavanja rasporeda su sačuvana.", + "settingsSaveError": "Podešavanja rasporeda nisu sačuvana.", + "googleConnected": "Google Calendar je povezan", + "googleConnectedCalendar": "Novi i izmenjeni termini šalju se u kalendar „{calendar}“.", + "googleConnectedDescription": "Novi i izmenjeni termini šalju se u povezani kalendar.", + "googleNotConnected": "Google Calendar nije povezan", + "googleOutboundDescription": "Povežite Google nalog kako bi se termini iz administracije automatski pojavili u kalendaru.", + "googleIcalUrlLabel": "Tajna iCal adresa (čitanje događaja)", + "googleIcalUrlDescription": "Opciono. Nalepite tajnu iCal adresu da biste prikazali spoljne Google događaje u rasporedu. Ne koristi se za slanje termina u Google.", + "connectGoogle": "Poveži Google Calendar", + "disconnectGoogle": "Prekini vezu", + "disconnectGoogleTitle": "Prekinuti vezu sa Google Calendar-om?", + "disconnectGoogleDescription": "Sinhronizacija će biti zaustavljena, a zatim možete povezati drugi Google nalog. Postojeći događaji će ostati u kalendaru.", + "syncNow": "Sinhronizuj sada", + "googleConnectedSuccess": "Google Calendar je povezan i budući termini su sinhronizovani.", + "googleConnectedWithErrors": "Google Calendar je povezan, ali neki termini nisu sinhronizovani.", + "googleConnectionError": "Google Calendar nije povezan. Pokušajte ponovo.", + "googleConfigurationError": "OAuth podešavanja za Google Calendar nisu postavljena na serveru.", + "googleDisconnected": "Google Calendar je odspojen.", + "googleDisconnectError": "Google Calendar nije moguće odspojiti.", + "googleSyncSuccess": "Sinhronizovano termina: {count}.", + "googleSyncError": "Nije moguće sinhronizovati sve termine sa Google Calendar-om.", "enableGoogleCalendar": "Omogući integraciju sa Google kalendarom", "webhookUrl": "Webhook URL / Tajni iCal link", "howToGetLink": "Kako dobiti link iz Google kalendara", diff --git a/package-lock.json b/package-lock.json index 9f75623..a9e763d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13814,9 +13814,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/devlop": { @@ -23449,9 +23449,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 94f9431..3ae17a1 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,11 @@ "dev": "next dev", "prebuild": "node scripts/generate-sw.js", "build": "prisma generate && next build", + "postbuild": "node scripts/prepare-standalone.mjs", "start": "next start", + "database:verify-identity": "node scripts/verify-database-identity.mjs", "lint": "eslint .", + "migrations:verify": "node scripts/verify-migration-checksums.mjs", "type-check": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -22,8 +25,8 @@ ] }, "overrides": { - "devalue": "5.6.4", - "undici": "7.24.7", + "devalue": "5.8.1", + "undici": "7.28.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3" }, diff --git a/prisma.config.ts b/prisma.config.ts index f2516a8..86845c4 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,11 +1,12 @@ import { defineConfig } from 'prisma/config'; import 'dotenv/config'; +import { resolveDirectDatabaseUrl } from './src/lib/database-url'; /** * Конфигурация Prisma для CLI инструментов. */ export default defineConfig({ datasource: { - url: process.env.DATABASE_URL + url: resolveDirectDatabaseUrl() } }); diff --git a/prisma/migrations/20260716143000_add_google_calendar_oauth/migration.sql b/prisma/migrations/20260716143000_add_google_calendar_oauth/migration.sql new file mode 100644 index 0000000..964e337 --- /dev/null +++ b/prisma/migrations/20260716143000_add_google_calendar_oauth/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "User" +ADD COLUMN "googleCalendarAccessToken" TEXT, +ADD COLUMN "googleCalendarRefreshToken" TEXT, +ADD COLUMN "googleCalendarTokenExpiresAt" TIMESTAMP(3), +ADD COLUMN "googleCalendarId" TEXT, +ADD COLUMN "googleCalendarName" TEXT; diff --git a/prisma/migrations/20260716163000_allow_unset_timezone/migration.sql b/prisma/migrations/20260716163000_allow_unset_timezone/migration.sql new file mode 100644 index 0000000..3e2aafa --- /dev/null +++ b/prisma/migrations/20260716163000_allow_unset_timezone/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ALTER COLUMN "timezone" DROP DEFAULT; diff --git a/prisma/migrations/20260716170000_add_app_notifications/migration.sql b/prisma/migrations/20260716170000_add_app_notifications/migration.sql new file mode 100644 index 0000000..47ff6e6 --- /dev/null +++ b/prisma/migrations/20260716170000_add_app_notifications/migration.sql @@ -0,0 +1,29 @@ +CREATE TYPE "AppNotificationKind" AS ENUM ('INFO', 'WARNING', 'SUCCESS'); + +CREATE TABLE "AppNotification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" "AppNotificationKind" NOT NULL DEFAULT 'INFO', + "source" TEXT NOT NULL, + "dedupeKey" TEXT, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "actionUrl" TEXT, + "actionLabel" TEXT, + "readAt" TIMESTAMP(3), + "dismissedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppNotification_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AppNotification_dedupeKey_key" ON "AppNotification"("dedupeKey"); +CREATE INDEX "AppNotification_userId_readAt_dismissedAt_createdAt_idx" + ON "AppNotification"("userId", "readAt", "dismissedAt", "createdAt" DESC); +CREATE INDEX "AppNotification_userId_createdAt_idx" + ON "AppNotification"("userId", "createdAt" DESC); + +ALTER TABLE "AppNotification" + ADD CONSTRAINT "AppNotification_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260716173500_add_notification_deleted_at/migration.sql b/prisma/migrations/20260716173500_add_notification_deleted_at/migration.sql new file mode 100644 index 0000000..43d58af --- /dev/null +++ b/prisma/migrations/20260716173500_add_notification_deleted_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "AppNotification" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20260717130000_add_workflow_lease/migration.sql b/prisma/migrations/20260717130000_add_workflow_lease/migration.sql new file mode 100644 index 0000000..aeff36c --- /dev/null +++ b/prisma/migrations/20260717130000_add_workflow_lease/migration.sql @@ -0,0 +1,43 @@ +-- AddColumn +ALTER TABLE "PilloIntake" +ADD COLUMN "reminderPushClaimedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "WorkflowLease" ( + "key" TEXT NOT NULL, + "holderId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorkflowLease_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE INDEX "WorkflowLease_expiresAt_idx" ON "WorkflowLease"("expiresAt"); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "DatabaseIdentity" ( + "key" TEXT NOT NULL DEFAULT 'primary', + "instanceId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DatabaseIdentity_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "DatabaseIdentity_instanceId_key" +ON "DatabaseIdentity"("instanceId"); + +-- SeedSingleton +INSERT INTO "DatabaseIdentity" ("key", "instanceId") +VALUES ( + 'primary', + md5( + random()::text + || clock_timestamp()::text + || pg_backend_pid()::text + || current_database() + ) +) +ON CONFLICT ("key") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 207caed..0e33736 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,54 +7,82 @@ datasource db { } model User { - id String @id @default(cuid()) - name String? - email String @unique - emailVerified DateTime? - image String? - password String? - role Role @default(GUEST) - language String @default("ru") - theme String @default("system") - lastSeen DateTime? @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - isDisabled Boolean @default(false) - registrationIp String? - timezone String? @default("UTC") - googleCalendarSyncEnabled Boolean @default(false) - googleCalendarSyncUrl String? - workHourEnd Int @default(20) - workHourStart Int @default(9) - blogNotifications Boolean @default(false) - notificationSettings Json? - dashboardConfig Json? - balance Decimal @default(0) @db.Decimal(12, 2) - accounts Account[] - Authenticator Authenticator[] - blogPosts BlogPost[] @relation("BlogAuthor") - consents ClientConsent[] - uploadedDocs ClientDocument[] @relation("UploadedDocuments") - documents ClientDocument[] @relation("UserDocuments") - clientProfile ClientProfile? - createdEvents Event[] @relation("EventCreator") - clientEvents Event[] @relation("EventClient") - payments Payment[] - pilloIntakes PilloIntake[] - pilloActionTokens PilloIntakeActionToken[] - pilloManualIntakes PilloManualIntake[] - pilloMedications PilloMedication[] - pilloScheduleRules PilloScheduleRule[] - pilloUserSettings PilloUserSettings? - pushSubscriptions PushSubscription[] - sessions Session[] - createdSurveys Survey[] @relation("SurveyCreator") - surveyAssignments SurveyAssignment[] - surveyComments SurveyComment[] - systemLogs SystemLogEntry[] - loginHistory UserLoginHistory[] - clientGroupId String? - clientGroup ClientGroup? @relation(fields: [clientGroupId], references: [id]) + id String @id @default(cuid()) + name String? + email String @unique + emailVerified DateTime? + image String? + password String? + role Role @default(GUEST) + language String @default("ru") + theme String @default("system") + lastSeen DateTime? @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + isDisabled Boolean @default(false) + registrationIp String? + timezone String? + googleCalendarSyncEnabled Boolean @default(false) + googleCalendarSyncUrl String? + googleCalendarAccessToken String? + googleCalendarRefreshToken String? + googleCalendarTokenExpiresAt DateTime? + googleCalendarId String? + googleCalendarName String? + workHourEnd Int @default(20) + workHourStart Int @default(9) + blogNotifications Boolean @default(false) + notificationSettings Json? + dashboardConfig Json? + balance Decimal @default(0) @db.Decimal(12, 2) + accounts Account[] + Authenticator Authenticator[] + blogPosts BlogPost[] @relation("BlogAuthor") + consents ClientConsent[] + uploadedDocs ClientDocument[] @relation("UploadedDocuments") + documents ClientDocument[] @relation("UserDocuments") + clientProfile ClientProfile? + createdEvents Event[] @relation("EventCreator") + clientEvents Event[] @relation("EventClient") + payments Payment[] + pilloIntakes PilloIntake[] + pilloActionTokens PilloIntakeActionToken[] + pilloManualIntakes PilloManualIntake[] + pilloMedications PilloMedication[] + pilloScheduleRules PilloScheduleRule[] + pilloUserSettings PilloUserSettings? + pushSubscriptions PushSubscription[] + sessions Session[] + createdSurveys Survey[] @relation("SurveyCreator") + surveyAssignments SurveyAssignment[] + surveyComments SurveyComment[] + systemLogs SystemLogEntry[] + loginHistory UserLoginHistory[] + clientGroupId String? + clientGroup ClientGroup? @relation(fields: [clientGroupId], references: [id]) + appNotifications AppNotification[] +} + +/// Persistent-уведомление внутри интерфейса приложения. +model AppNotification { + id String @id @default(cuid()) + userId String + kind AppNotificationKind @default(INFO) + source String + dedupeKey String? @unique + title String + message String + actionUrl String? + actionLabel String? + readAt DateTime? + dismissedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, readAt, dismissedAt, createdAt(sort: Desc)]) + @@index([userId, createdAt(sort: Desc)]) } /// Подписка на Web Push уведомления (PWA) @@ -137,6 +165,7 @@ model PilloIntake { reminderWorkflowStartedAt DateTime? reminderSentAt DateTime? reminderEmailSentAt DateTime? + reminderPushClaimedAt DateTime? reminderPushSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -450,6 +479,22 @@ model SystemAlert { @@unique([type, periodKey]) } +model WorkflowLease { + key String @id + holderId String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([expiresAt]) +} + +model DatabaseIdentity { + key String @id @default("primary") + instanceId String @unique + createdAt DateTime @default(now()) +} + model SystemLogEntry { id String @id @default(cuid()) category SystemLogCategory @@ -706,6 +751,12 @@ enum PilloIntakeStatus { MISSED } +enum AppNotificationKind { + INFO + WARNING + SUCCESS +} + enum BlogPostStatus { DRAFT PUBLISHED diff --git a/public/sw-template.js b/public/sw-template.js index 5ff377d..8c56230 100644 --- a/public/sw-template.js +++ b/public/sw-template.js @@ -228,8 +228,8 @@ self.addEventListener('push', event => { body: data.body ?? '', icon: '/web-app-manifest-192x192.png', badge: '/web-app-manifest-192x192.png', - tag: 'vershkov-notification', - renotify: true, + tag: data.tag || 'vershkov-notification', + renotify: data.renotify ?? true, data: { url: data.url || '/my' } diff --git a/scripts/__tests__/verify-database-identity.test.mjs b/scripts/__tests__/verify-database-identity.test.mjs new file mode 100644 index 0000000..44c7010 --- /dev/null +++ b/scripts/__tests__/verify-database-identity.test.mjs @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + assertDatabaseIdentitiesMatch, + bootstrapDirectDatabaseIdentity +} from '../verify-database-identity.mjs'; + +describe('database identity deploy preflight', () => { + it('bootstrap создаёт только идемпотентный служебный sentinel', async () => { + // Arrange + const directPool = { + query: vi.fn().mockResolvedValue({ rows: [] }) + }; + + // Act + await bootstrapDirectDatabaseIdentity(directPool); + + // Assert + const sql = directPool.query.mock.calls[0]?.[0]; + expect(sql).toContain('CREATE TABLE IF NOT EXISTS "DatabaseIdentity"'); + expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS'); + expect(sql).toContain('ON CONFLICT ("key") DO NOTHING'); + expect(sql).not.toContain('"User"'); + expect(sql).not.toContain('"PilloIntake"'); + }); + + it('сравнивает одинаковый instanceId через runtime и direct подключения', async () => { + // Arrange + const runtimeClient = { + databaseIdentity: { + findUnique: vi.fn().mockResolvedValue({ instanceId: 'database-1' }) + } + }; + const directPool = { + query: vi.fn().mockResolvedValue({ + rows: [{ instanceId: 'database-1' }] + }) + }; + + // Act + Assert + await expect(assertDatabaseIdentitiesMatch(runtimeClient, directPool)).resolves.toBeUndefined(); + }); + + it('останавливает deploy при разных instanceId', async () => { + // Arrange + const runtimeClient = { + databaseIdentity: { + findUnique: vi.fn().mockResolvedValue({ instanceId: 'runtime-database' }) + } + }; + const directPool = { + query: vi.fn().mockResolvedValue({ + rows: [{ instanceId: 'direct-database' }] + }) + }; + + // Act + Assert + await expect(assertDatabaseIdentitiesMatch(runtimeClient, directPool)).rejects.toThrow( + 'Runtime и direct URL указывают на разные экземпляры базы данных' + ); + }); +}); diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs new file mode 100644 index 0000000..e323995 --- /dev/null +++ b/scripts/prepare-standalone.mjs @@ -0,0 +1,26 @@ +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * Копирует обязательные runtime-ресурсы в standalone bundle. + * @param {string} sourcePath - исходная директория. + * @param {string} targetPath - директория внутри standalone bundle. + */ +const copyRuntimeDirectory = async (sourcePath, targetPath) => { + await stat(sourcePath); + await rm(targetPath, { force: true, recursive: true }); + await mkdir(dirname(targetPath), { recursive: true }); + await cp(sourcePath, targetPath, { recursive: true }); +}; + +await copyRuntimeDirectory( + join(projectRoot, 'public'), + join(projectRoot, '.next', 'standalone', 'public') +); +await copyRuntimeDirectory( + join(projectRoot, '.next', 'static'), + join(projectRoot, '.next', 'standalone', '.next', 'static') +); diff --git a/scripts/verify-database-identity.mjs b/scripts/verify-database-identity.mjs new file mode 100644 index 0000000..cbf3865 --- /dev/null +++ b/scripts/verify-database-identity.mjs @@ -0,0 +1,184 @@ +import { pathToFileURL } from 'node:url'; + +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { withAccelerate } from '@prisma/extension-accelerate'; +import { Pool } from 'pg'; + +const DATABASE_IDENTITY_KEY = 'primary'; + +const isDirectPostgresUrl = value => + Boolean(value && (value.startsWith('postgres://') || value.startsWith('postgresql://'))); + +const getFirstConfiguredValue = variableNames => { + for (const variableName of variableNames) { + const value = process.env[variableName]?.trim(); + if (value) { + return value; + } + } + + return undefined; +}; + +const resolveRuntimeDatabaseUrl = () => { + const value = getFirstConfiguredValue([ + 'PRISMA_DATABASE_URL', + 'DATABASE_URL', + 'POSTGRES_URL' + ]); + + if (!value) { + throw new Error('Для identity-проверки требуется runtime URL базы данных.'); + } + + return value; +}; + +const resolveDirectDatabaseUrl = () => { + const explicitDirectUrl = getFirstConfiguredValue([ + 'DIRECT_DATABASE_URL', + 'POSTGRES_URL_NON_POOLING' + ]); + if (explicitDirectUrl) { + if (isDirectPostgresUrl(explicitDirectUrl)) { + return explicitDirectUrl; + } + + throw new Error('Явный direct URL имеет неподдерживаемый протокол.'); + } + + if (getFirstConfiguredValue(['PRISMA_DATABASE_URL', 'POSTGRES_URL'])) { + throw new Error( + 'При отдельном runtime URL требуется DIRECT_DATABASE_URL или POSTGRES_URL_NON_POOLING.' + ); + } + + const databaseUrl = process.env.DATABASE_URL?.trim(); + if (isDirectPostgresUrl(databaseUrl)) { + return databaseUrl; + } + + throw new Error('Для identity-проверки требуется direct PostgreSQL URL.'); +}; + +const createRuntimePrismaClient = runtimeUrl => { + if (runtimeUrl.startsWith('prisma://') || runtimeUrl.startsWith('prisma+postgres://')) { + const baseClient = new PrismaClient({ accelerateUrl: runtimeUrl }); + + return { + client: baseClient.$extends(withAccelerate()), + close: () => baseClient.$disconnect() + }; + } + + if (!isDirectPostgresUrl(runtimeUrl)) { + throw new Error('Runtime URL имеет неподдерживаемый протокол.'); + } + + const pool = new Pool({ connectionString: runtimeUrl }); + const baseClient = new PrismaClient({ + adapter: new PrismaPg(pool) + }); + + return { + client: baseClient, + close: async () => { + await baseClient.$disconnect(); + await pool.end(); + } + }; +}; + +/** + * Создаёт только служебный identity sentinel через direct URL до любых domain-миграций. + * Операция идемпотентна и сохраняет уже назначенный instanceId. + * @param {{ query: (sql: string, params?: unknown[]) => Promise }} directPool + */ +export const bootstrapDirectDatabaseIdentity = async directPool => { + await directPool.query(` + BEGIN; + + CREATE TABLE IF NOT EXISTS "DatabaseIdentity" ( + "key" TEXT NOT NULL DEFAULT 'primary', + "instanceId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "DatabaseIdentity_pkey" PRIMARY KEY ("key") + ); + + CREATE UNIQUE INDEX IF NOT EXISTS "DatabaseIdentity_instanceId_key" + ON "DatabaseIdentity"("instanceId"); + + INSERT INTO "DatabaseIdentity" ("key", "instanceId") + VALUES ( + 'primary', + md5( + random()::text + || clock_timestamp()::text + || pg_backend_pid()::text + || current_database() + ) + ) + ON CONFLICT ("key") DO NOTHING; + + COMMIT; + `); +}; + +/** + * Сравнивает identity, полученный через runtime Prisma, с direct PostgreSQL. + * @param {{ databaseIdentity: { findUnique: (args: object) => Promise<{ instanceId: string } | null> } }} runtimeClient + * @param {{ query: (sql: string, params?: unknown[]) => Promise<{ rows: Array<{ instanceId?: string }> }> }} directPool + */ +export const assertDatabaseIdentitiesMatch = async (runtimeClient, directPool) => { + const [runtimeIdentity, directIdentityResult] = await Promise.all([ + runtimeClient.databaseIdentity.findUnique({ + where: { key: DATABASE_IDENTITY_KEY }, + select: { instanceId: true } + }), + directPool.query( + ` + SELECT "instanceId" + FROM "DatabaseIdentity" + WHERE "key" = $1 + LIMIT 1 + `, + [DATABASE_IDENTITY_KEY] + ) + ]); + const directInstanceId = directIdentityResult.rows[0]?.instanceId; + + if (!runtimeIdentity?.instanceId || !directInstanceId) { + throw new Error('DatabaseIdentity отсутствует в runtime или direct БД.'); + } + + if (runtimeIdentity.instanceId !== directInstanceId) { + throw new Error('Runtime и direct URL указывают на разные экземпляры базы данных.'); + } +}; + +const main = async () => { + const runtimeDatabase = createRuntimePrismaClient(resolveRuntimeDatabaseUrl()); + const directPool = new Pool({ + connectionString: resolveDirectDatabaseUrl(), + max: 1 + }); + + try { + await bootstrapDirectDatabaseIdentity(directPool); + await assertDatabaseIdentitiesMatch(runtimeDatabase.client, directPool); + console.info('Runtime и direct database identity совпадают.'); + } finally { + await Promise.all([runtimeDatabase.close(), directPool.end()]); + } +}; + +const isDirectExecution = + typeof process.argv[1] === 'string' && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectExecution) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-migration-checksums.mjs b/scripts/verify-migration-checksums.mjs new file mode 100644 index 0000000..5771189 --- /dev/null +++ b/scripts/verify-migration-checksums.mjs @@ -0,0 +1,251 @@ +import { createHash } from 'node:crypto'; +import { readFile, readdir } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { Pool } from 'pg'; + +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const migrationsDirectory = join(projectRoot, 'prisma', 'migrations'); + +// До 2026-05-02 проект имел развёрнутую историю, затем она была сведена в 0_init. +// Разрешены только точные checksum обеих уже опубликованных веток истории. +const legacyMigrationChecksums = new Map([ + ['0_init', '19e0422fd0226b2fea21ea0e8c60d552eba918e5580042e3ef312244e2c0b79f'], + [ + '20260408000432_client_intake', + 'f5b3cf81c0c5d8c4d870c03692a31d23b2c6d3571148999a4ea50fe062c9954b' + ], + [ + '20260408164448_add_filetype_to_client_document', + '31158b77836adf8de7909ec7d85858d20349b8a138080b838aeb3814de309a61' + ], + [ + '20260414103000_paypal_payments', + 'e9598098d8462570faff5112c51e5bc72da66761d17a96c59f8604284c561a94' + ], + [ + '20260419014023_add_packages_and_balance', + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ], + [ + '20260419014050_add_packages_and_balance', + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ], + [ + '20260419014251_add_packages_and_balance', + '7503bdff14edabf57b9afec6afac7c7245354f10a2e21687bf69298fd1a70e27' + ], + [ + '20260425223000_add_system_logs', + 'b51faabcd9d8d56561a2ba8b340ad4a6c05b6d5d18a1f26629e38dcb2141e53a' + ], + [ + '20260502000000_add_pillo_app', + '8c7f9c86d65d922b401057a2d4008e616a0ea716e8a5c42a88b63f6572065158' + ], + [ + '20260502003348_add_medication_dosage_split', + 'cce8212b6237e853360d44ec22b21d43986da484a0774627454bb2ff1b3a385d' + ], + [ + '20260502023600_add_course_end_notified_at', + '8f80fe228e13f2f09ea9a6c35c516fa92fb29db42d7e706c7ef5b59f90033ee9' + ], + [ + '20260502030000_add_pillo_low_stock_warning_days', + 'b36d664774c91ac913f4acc5a0f0dff34266554e498a3ec2cec624b7fdcdbf7f' + ] +]); + +const isDirectPostgresUrl = value => + Boolean(value && (value.startsWith('postgres://') || value.startsWith('postgresql://'))); + +const resolveDirectDatabaseUrl = () => { + const explicitDirectUrl = [ + 'DIRECT_DATABASE_URL', + 'POSTGRES_URL_NON_POOLING' + ] + .map(variableName => process.env[variableName]?.trim()) + .find(Boolean); + + if (explicitDirectUrl) { + if (isDirectPostgresUrl(explicitDirectUrl)) { + return explicitDirectUrl; + } + + throw new Error('Явный direct URL для проверки миграций имеет неверный протокол.'); + } + + if (process.env.PRISMA_DATABASE_URL?.trim() || process.env.POSTGRES_URL?.trim()) { + throw new Error( + 'При отдельном runtime URL требуется DIRECT_DATABASE_URL или POSTGRES_URL_NON_POOLING.' + ); + } + + const databaseUrl = process.env.DATABASE_URL?.trim(); + if (isDirectPostgresUrl(databaseUrl)) { + return databaseUrl; + } + + throw new Error('Для проверки миграций требуется прямой PostgreSQL URL.'); +}; + +const calculateChecksum = value => createHash('sha256').update(value).digest('hex'); + +const loadLocalMigrationChecksums = async () => { + const entries = await readdir(migrationsDirectory, { withFileTypes: true }); + const checksums = new Map(); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const migrationSql = await readFile( + join(migrationsDirectory, entry.name, 'migration.sql') + ); + checksums.set(entry.name, calculateChecksum(migrationSql)); + } + + return checksums; +}; + +export const verifyAppliedMigrations = (migrations, localChecksums) => { + const problems = []; + const appliedMigrations = new Map(); + + for (const migration of migrations) { + if (!migration.finishedAt && !migration.rolledBackAt) { + problems.push(`${migration.migrationName}: применение не завершено`); + continue; + } + + if (migration.rolledBackAt) { + continue; + } + + if (appliedMigrations.has(migration.migrationName)) { + problems.push(`${migration.migrationName}: обнаружено несколько завершённых применений`); + continue; + } + + appliedMigrations.set(migration.migrationName, migration.checksum); + } + + if (appliedMigrations.size === 0) { + if (problems.length > 0) { + throw new Error(`Проверка истории миграций не пройдена:\n- ${problems.join('\n- ')}`); + } + return; + } + + const localInitialChecksum = localChecksums.get('0_init'); + const databaseInitialChecksum = appliedMigrations.get('0_init'); + const legacyInitialChecksum = legacyMigrationChecksums.get('0_init'); + + if (!localInitialChecksum) { + problems.push('0_init: локальная baseline-миграция отсутствует'); + } else if (!databaseInitialChecksum) { + problems.push('0_init: применённая baseline-миграция отсутствует'); + } + + const isSquashedHistory = + Boolean(localInitialChecksum) && databaseInitialChecksum === localInitialChecksum; + const isLegacyHistory = + Boolean(legacyInitialChecksum) && + databaseInitialChecksum === legacyInitialChecksum && + legacyInitialChecksum !== localInitialChecksum; + + if (!isSquashedHistory && !isLegacyHistory && databaseInitialChecksum) { + problems.push('0_init: checksum не соответствует ни одной полной опубликованной истории'); + } + + if (isSquashedHistory) { + for (const legacyMigrationName of legacyMigrationChecksums.keys()) { + if (legacyMigrationName !== '0_init' && appliedMigrations.has(legacyMigrationName)) { + problems.push( + `${legacyMigrationName}: legacy-миграция не допускается вместе со squashed 0_init` + ); + } + } + } + + if (isLegacyHistory) { + for (const [migrationName, expectedChecksum] of legacyMigrationChecksums.entries()) { + const appliedChecksum = appliedMigrations.get(migrationName); + + if (!appliedChecksum) { + problems.push(`${migrationName}: отсутствует обязательная legacy-миграция`); + } else if (appliedChecksum !== expectedChecksum) { + problems.push(`${migrationName}: checksum legacy-миграции изменён`); + } + } + } + + for (const [migrationName, databaseChecksum] of appliedMigrations.entries()) { + const localChecksum = localChecksums.get(migrationName); + const isExactLocalMigration = localChecksum === databaseChecksum; + const isExactLegacyMigration = + isLegacyHistory && legacyMigrationChecksums.get(migrationName) === databaseChecksum; + + if (!isExactLocalMigration && !isExactLegacyMigration) { + problems.push(`${migrationName}: неизвестный или изменённый checksum`); + } + } + + if (problems.length > 0) { + throw new Error(`Проверка истории миграций не пройдена:\n- ${problems.join('\n- ')}`); + } +}; + +const main = async () => { + const connectionString = resolveDirectDatabaseUrl() + .replace(/([?&])sslmode=[^&]+&?/, '$1') + .replace(/[?&]$/, ''); + const pool = new Pool({ + connectionString, + max: 1, + ssl: process.env.NODE_ENV === 'development' ? { rejectUnauthorized: false } : true + }); + + try { + const localChecksums = await loadLocalMigrationChecksums(); + let result; + + try { + result = await pool.query(` + SELECT + "migration_name" AS "migrationName", + "checksum", + "finished_at" AS "finishedAt", + "rolled_back_at" AS "rolledBackAt" + FROM "_prisma_migrations" + ORDER BY "started_at" ASC + `); + } catch (error) { + if (error && typeof error === 'object' && error.code === '42P01') { + console.info('Таблица _prisma_migrations ещё не создана; проверять нечего.'); + return; + } + + throw error; + } + + verifyAppliedMigrations(result.rows, localChecksums); + console.info(`Checksum ${result.rows.length} записей истории миграций проверен.`); + } finally { + await pool.end(); + } +}; + +const isDirectExecution = + typeof process.argv[1] === 'string' && + fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +if (isDirectExecution) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/app/[locale]/(admin)/admin/_components/admin-breadcrumbs.tsx b/src/app/[locale]/(admin)/admin/_components/admin-breadcrumbs.tsx index 4e7b002..986d1e3 100644 --- a/src/app/[locale]/(admin)/admin/_components/admin-breadcrumbs.tsx +++ b/src/app/[locale]/(admin)/admin/_components/admin-breadcrumbs.tsx @@ -36,6 +36,7 @@ export const AdminBreadcrumbs = () => { packages: t('packages'), blog: t('blog'), 'send-email': t('sendEmail'), + notifications: t('notifications'), backups: t('backups'), logs: t('logs'), apps: t('apps'), diff --git a/src/app/[locale]/(admin)/admin/clients/[id]/_components/client-schedule.tsx b/src/app/[locale]/(admin)/admin/clients/[id]/_components/client-schedule.tsx index b60d85c..9c94f72 100644 --- a/src/app/[locale]/(admin)/admin/clients/[id]/_components/client-schedule.tsx +++ b/src/app/[locale]/(admin)/admin/clients/[id]/_components/client-schedule.tsx @@ -48,6 +48,7 @@ import { FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Select, SelectContent, @@ -55,6 +56,7 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select'; +import { TimePicker } from '@/components/ui/time-picker'; import { addClientEvent, updateClientEvent } from '../../_actions/clients.actions'; import type { EventStatus, EventType } from '@prisma/client'; @@ -436,7 +438,12 @@ export function ClientSchedule({ userId, events }: { userId: string; events: Cli Дата - + @@ -450,7 +457,12 @@ export function ClientSchedule({ userId, events }: { userId: string; events: Cli Время начала - + diff --git a/src/app/[locale]/(admin)/admin/layout.tsx b/src/app/[locale]/(admin)/admin/layout.tsx index 84c40f2..f2dfaa4 100644 --- a/src/app/[locale]/(admin)/admin/layout.tsx +++ b/src/app/[locale]/(admin)/admin/layout.tsx @@ -8,6 +8,7 @@ import { AppSidebar } from './_components/app-sidebar'; import { getDefaultSidebarOpen, SIDEBAR_COOKIE_NAME } from '@/lib/sidebar-state'; import { SidebarWorkspaceLayout } from '@/components/sidebar-workspace-layout'; import { getAdminUnreadSurveysCount } from './surveys/actions'; +import { AccountNotificationCenter } from '@/components/account-notification-center'; export const metadata: Metadata = { robots: { @@ -48,6 +49,7 @@ const AdminLayout = async ({ children }: Readonly) => { defaultSidebarOpen={defaultSidebarOpen} sidebar={} breadcrumbs={} + headerActions={} > {children} diff --git a/src/app/[locale]/(admin)/admin/notifications/page.tsx b/src/app/[locale]/(admin)/admin/notifications/page.tsx new file mode 100644 index 0000000..496f243 --- /dev/null +++ b/src/app/[locale]/(admin)/admin/notifications/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from 'next/navigation'; + +import { auth } from '@/auth'; +import { NotificationsHistory } from '@/components/notifications-history'; +import { + ensureSystemNotifications, + getUserNotificationsHistory +} from '@/modules/notifications/notification-service.server'; + +/** Показывает историю уведомлений администратора с возможностью удаления. */ +const AdminNotificationsPage = async () => { + const session = await auth(); + + if (!session?.user?.id) { + redirect('/auth'); + } + if (session.user.role !== 'ADMIN') { + redirect('/my'); + } + + await ensureSystemNotifications(session.user.id); + const initialPage = await getUserNotificationsHistory(session.user.id); + return ; +}; + +export default AdminNotificationsPage; diff --git a/src/app/[locale]/(admin)/admin/profile/page.tsx b/src/app/[locale]/(admin)/admin/profile/page.tsx index 82f25a0..2db2f29 100644 --- a/src/app/[locale]/(admin)/admin/profile/page.tsx +++ b/src/app/[locale]/(admin)/admin/profile/page.tsx @@ -78,7 +78,7 @@ export default async function AdminProfilePage() { hasPassword={hasPassword} lastLoginAt={lastLogin?.createdAt ?? null} lastLoginIp={lastLogin?.ip ?? null} - timezone={dbUser?.timezone ?? 'UTC'} + timezone={dbUser.timezone} role={session.user.role} userEmail={session.user.email ?? ''} /> diff --git a/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx b/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx index 4662442..51d6a09 100644 --- a/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx +++ b/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx @@ -1,14 +1,17 @@ 'use client'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { Event, EventMutationInput } from './use-events'; +import { Check, ChevronDown, Link2, Trash2 } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import useSWR from 'swr'; +import { z } from 'zod'; +import { badgeVariants } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; import { Dialog, DialogContent, @@ -20,19 +23,24 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; import { Select, SelectContent, + SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { TimePicker } from '@/components/ui/time-picker'; import { DEFAULT_SESSION_REMINDER_MINUTES, MAX_SESSION_REMINDER_MINUTES, @@ -40,6 +48,12 @@ import { SESSION_REMINDER_PRESET_MINUTES } from '@/lib/session-reminders'; import { optionalMeetingUrlSchema } from '@/lib/safe-url'; +import { formatUtcOffset, isValidTimeZone } from '@/lib/timezone'; +import { detectBrowserTimeZone } from '@/lib/browser-timezone'; + +import { getEventDateRange, getEventTemporalValues } from './event-form-utils'; +import type { Event, EventMutationInput } from './use-events'; +import { useSavedMeetingLinks } from './use-saved-meeting-links'; const eventTypeOptions = [ 'CONSULTATION', @@ -50,38 +64,36 @@ const eventTypeOptions = [ 'OTHER' ] as const; const eventStatusOptions = ['SCHEDULED', 'CANCELLED', 'COMPLETED', 'PENDING_CONFIRMATION'] as const; +const defaultDurationOptions = [30, 45, 60, 90, 120]; -const eventSchema = z - .object({ - title: z.string().optional(), - type: z.enum(eventTypeOptions), - status: z.enum(eventStatusOptions), - start: z.string().min(1, 'Required'), - end: z.string().min(1, 'Required'), - meetLink: optionalMeetingUrlSchema, - userId: z.string().optional().nullable(), - reminderMinutesBeforeStart: z - .number() - .int() - .min(MIN_SESSION_REMINDER_MINUTES) - .max(MAX_SESSION_REMINDER_MINUTES) - }) - .superRefine((data, ctx) => { - if (new Date(data.start).getTime() >= new Date(data.end).getTime()) { - ctx.addIssue({ - code: 'custom', - path: ['end'], - message: 'Время окончания должно быть позже времени начала' - }); - } - }); +const eventSchema = z.object({ + title: z.string().optional(), + type: z.enum(eventTypeOptions), + status: z.enum(eventStatusOptions), + date: z.string().min(1, 'Обязательно'), + startTime: z.string().min(1, 'Обязательно'), + duration: z + .number() + .int() + .min(15, 'Минимум 15 минут') + .max(8 * 60, 'Максимум 8 часов'), + meetLink: optionalMeetingUrlSchema, + userId: z.string().optional().nullable(), + reminderMinutesBeforeStart: z + .number() + .int() + .min(MIN_SESSION_REMINDER_MINUTES) + .max(MAX_SESSION_REMINDER_MINUTES) +}); -const fetcher = (url: string) => fetch(url).then(res => res.json()); +type EventFormValues = z.infer; type UserOption = { id: string; name: string | null; email: string; + role: 'ADMIN' | 'USER'; + timezone: string | null; }; interface EventDialogProps { @@ -94,6 +106,55 @@ interface EventDialogProps { onDelete?: (id: string) => Promise; } +const fetchUsers = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error('Не удалось загрузить клиентов'); + } + return response.json() as Promise; +}; + +const getBrowserTimezone = (): string => { + return detectBrowserTimeZone() || 'UTC'; +}; + +const getInitialValues = (params: { + event?: Event | null; + selectedDate?: Date; + selectedEndDate?: Date; + fallbackTimezone: string; +}): EventFormValues => { + const { event, selectedDate, selectedEndDate, fallbackTimezone } = params; + const clientTimezone = event?.user + ? event.user.timezone && isValidTimeZone(event.user.timezone) + ? event.user.timezone + : 'UTC' + : fallbackTimezone; + const fallbackStart = selectedDate ? new Date(selectedDate) : new Date(); + + if (selectedDate && selectedDate.getHours() === 0) { + fallbackStart.setHours(9, 0, 0, 0); + } + + const fallbackEnd = selectedEndDate || new Date(fallbackStart.getTime() + 60 * 60 * 1000); + const temporalValues = getEventTemporalValues( + event?.start || fallbackStart, + event?.end || fallbackEnd, + clientTimezone + ); + + return { + title: event?.title || '', + type: event?.type || 'FREE_SLOT', + status: event?.status || 'SCHEDULED', + ...temporalValues, + meetLink: event?.meetLink || '', + userId: event?.userId || null, + reminderMinutesBeforeStart: + event?.reminderMinutesBeforeStart ?? DEFAULT_SESSION_REMINDER_MINUTES + }; +}; + export const EventDialog = ({ open, onOpenChange, @@ -105,150 +166,264 @@ export const EventDialog = ({ }: EventDialogProps) => { const t = useTranslations('Schedule'); const [loading, setLoading] = useState(false); + const [meetingLinksOpen, setMeetingLinksOpen] = useState(false); + const [browserTimezone] = useState(getBrowserTimezone); + const { links: savedMeetingLinks, saveLink, removeLink } = useSavedMeetingLinks(); const { data: users, isLoading: usersLoading } = useSWR( - open ? '/api/admin/users' : null, - fetcher + open ? '/api/admin/users?roles=USER,ADMIN' : null, + fetchUsers ); - - const toLocalISOString = (d: Date) => { - const pad = (n: number) => n.toString().padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; - }; - - const defaultStart = selectedDate - ? toLocalISOString( - new Date( - new Date(selectedDate).setHours( - selectedDate.getHours() > 0 ? selectedDate.getHours() : 9, - 0, - 0, - 0 - ) - ) - ) - : toLocalISOString(new Date()); - - const defaultEnd = selectedEndDate - ? toLocalISOString(selectedEndDate) - : selectedDate - ? toLocalISOString( - new Date( - new Date(selectedDate).setHours( - (selectedDate.getHours() > 0 ? selectedDate.getHours() : 9) + 1, - 0, - 0, - 0 - ) - ) - ) - : toLocalISOString(new Date(new Date().getTime() + 60 * 60 * 1000)); - - const form = useForm>({ + const form = useForm({ resolver: zodResolver(eventSchema), - defaultValues: { - title: event?.title || '', - type: event?.type || 'FREE_SLOT', - status: event?.status || 'SCHEDULED', - start: event ? toLocalISOString(new Date(event.start)) : defaultStart, - end: event ? toLocalISOString(new Date(event.end)) : defaultEnd, - meetLink: event?.meetLink || '', - userId: event?.userId || null, - reminderMinutesBeforeStart: - event?.reminderMinutesBeforeStart ?? DEFAULT_SESSION_REMINDER_MINUTES - } + defaultValues: getInitialValues({ + event, + selectedDate, + selectedEndDate, + fallbackTimezone: browserTimezone + }) }); + const currentDuration = form.watch('duration'); + const selectedUserId = form.watch('userId'); + const eventDate = form.watch('date'); + const selectedUserOption = users?.find(user => user.id === selectedUserId); + const selectedUser = + selectedUserOption ?? (event?.user && event.user.id === selectedUserId ? event.user : null); + const selectedUserHasTimezone = Boolean( + selectedUser?.timezone && isValidTimeZone(selectedUser.timezone) + ); + const clientTimezone = selectedUserId + ? selectedUserHasTimezone + ? selectedUser?.timezone || 'UTC' + : 'UTC' + : browserTimezone; + const durationOptions = useMemo( + () => Array.from(new Set([...defaultDurationOptions, currentDuration])).sort((a, b) => a - b), + [currentDuration] + ); + + useEffect(() => { + if (!open) { + return; + } - const onSubmit = async (values: z.infer) => { + form.reset( + getInitialValues({ + event, + selectedDate, + selectedEndDate, + fallbackTimezone: browserTimezone + }) + ); + }, [browserTimezone, event, form, open, selectedDate, selectedEndDate]); + + const onSubmit = async (values: EventFormValues) => { setLoading(true); try { + const { start, end } = getEventDateRange({ + date: values.date, + startTime: values.startTime, + duration: values.duration, + timeZone: clientTimezone + }); const payload: EventMutationInput = { - ...values, - start: new Date(values.start).toISOString(), - end: new Date(values.end).toISOString(), - title: values.title ?? '', + type: values.type, + status: values.status, + start: start.toISOString(), + end: end.toISOString(), + title: values.title?.trim() || '', meetLink: values.meetLink || undefined, userId: values.userId ?? null, reminderMinutesBeforeStart: values.reminderMinutesBeforeStart }; + await onSave(payload); + saveLink(values.meetLink); onOpenChange(false); - form.reset(); } catch (error) { - console.error(error); + toast.error(error instanceof Error ? error.message : t('saveError')); } finally { setLoading(false); } }; const handleDelete = async () => { - if (!event || !onDelete) return; - if (confirm('Are you sure you want to delete this event?')) { - setLoading(true); - try { - await onDelete(event.id); - onOpenChange(false); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } + if (!event || !onDelete || !window.confirm(t('deleteConfirmation'))) { + return; + } + + setLoading(true); + try { + await onDelete(event.id); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('deleteError')); + } finally { + setLoading(false); } }; return ( - + {event ? t('editEvent') : t('createEvent')} {event ? t('editEventDesc') : t('createEventDesc')}
- -
+ + ( + + {t('client')} + + + + )} + /> + +
( - {t('eventType')} - + {t('date')} + + + )} /> - ( - {t('status')} - field.onChange(Number(value))} + value={String(field.value)} + > - + - {eventStatusOptions.map(status => ( - - {t(`statuses.${status}` as never)} - - ))} + + {durationOptions.map(minutes => ( + + {t('durationMinutes', { minutes })} + + ))} + @@ -259,66 +434,147 @@ export const EventDialog = ({ ( - {t('eventTitle')} - - - + {t('meetLink')} +
+ + + + + + + + +
+

{t('savedMeetingLinks')}

+

+ {t('savedMeetingLinksDescription')} +

+
+ +
+ {savedMeetingLinks.map(link => ( +
+ + +
+ ))} +
+
+
+
+ {t('meetingLinkHistoryDescription')}
)} /> -
+
( - {t('startTime')} - - - + {t('eventType')} + )} /> - ( - {t('endTime')} - - - + {t('status')} + )} />
- ( - - {t('meetLink')} - - - - - - )} - /> - - {SESSION_REMINDER_PRESET_MINUTES.map(minutes => ( - - {minutes === 0 - ? t('sessionReminderAtStart') - : t('sessionReminderBeforeMinutes', { minutes })} - - ))} + + {SESSION_REMINDER_PRESET_MINUTES.map(minutes => ( + + {minutes === 0 + ? t('sessionReminderAtStart') + : t('sessionReminderBeforeMinutes', { minutes })} + + ))} + @@ -351,35 +609,19 @@ export const EventDialog = ({ ( - {t('client')} - + {t('eventTitle')} + + + )} /> - +
{event && ( + )}
-
+
+
+ +
+
+

{requestClientName}

+ {shouldShowClientEmail && ( +

{request.user?.email}

+ )} +
+
+ +
- + ); })} -
+ )} diff --git a/src/app/[locale]/(admin)/admin/schedule/_components/sync-settings-dialog.tsx b/src/app/[locale]/(admin)/admin/schedule/_components/sync-settings-dialog.tsx index 8ea947e..49934c0 100644 --- a/src/app/[locale]/(admin)/admin/schedule/_components/sync-settings-dialog.tsx +++ b/src/app/[locale]/(admin)/admin/schedule/_components/sync-settings-dialog.tsx @@ -1,8 +1,23 @@ 'use client'; -import { useState } from 'react'; -import { Info, Settings } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { useEffect, useState } from 'react'; +import { CalendarCheck, CalendarSync, Settings } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import { toast } from 'sonner'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog'; +import { Button, buttonVariants } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -14,50 +29,114 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; export interface SyncSettingsDialogProps { - initialUrl?: string | null; - initialEnabled?: boolean; + initialConnected?: boolean; + calendarName?: string | null; + initialGoogleCalendarSyncUrl?: string | null; + googleStatus?: string; initialWorkStart?: number; initialWorkEnd?: number; } -export function SyncSettingsDialog({ - initialUrl, - initialEnabled, +export const SyncSettingsDialog = ({ + initialConnected = false, + calendarName, + initialGoogleCalendarSyncUrl, + googleStatus, initialWorkStart = 9, initialWorkEnd = 20 -}: SyncSettingsDialogProps) { +}: SyncSettingsDialogProps) => { const t = useTranslations('Schedule'); + const locale = useLocale(); + const router = useRouter(); const [open, setOpen] = useState(false); - const [url, setUrl] = useState(initialUrl || ''); - const [enabled, setEnabled] = useState(initialEnabled || false); + const [connected, setConnected] = useState(initialConnected); + const [googleCalendarSyncUrl, setGoogleCalendarSyncUrl] = useState( + initialGoogleCalendarSyncUrl || '' + ); const [workStart, setWorkStart] = useState(initialWorkStart.toString()); const [workEnd, setWorkEnd] = useState(initialWorkEnd.toString()); const [loading, setLoading] = useState(false); + useEffect(() => { + if (!googleStatus) { + return; + } + + if (googleStatus === 'connected') { + toast.success(t('googleConnectedSuccess')); + } else if (googleStatus === 'connected-with-errors') { + toast.warning(t('googleConnectedWithErrors')); + } else if (googleStatus === 'configuration-error') { + toast.error(t('googleConfigurationError')); + } else { + toast.error(t('googleConnectionError')); + } + }, [googleStatus, t]); + const handleSave = async () => { setLoading(true); try { - const res = await fetch('/api/profile/update', { + const response = await fetch('/api/profile/update', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - googleCalendarSyncUrl: url || null, - googleCalendarSyncEnabled: enabled, - workHourStart: parseInt(workStart) || 0, - workHourEnd: parseInt(workEnd) || 24 + workHourStart: Number(workStart), + workHourEnd: Number(workEnd), + googleCalendarSyncUrl: googleCalendarSyncUrl.trim() }) }); - if (res.ok) { - setOpen(false); - } else { - console.error('Failed to update sync settings'); + + if (!response.ok) { + const result = (await response.json()) as { message?: string }; + throw new Error(result.message || t('settingsSaveError')); + } + + toast.success(t('settingsSaved')); + setOpen(false); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('settingsSaveError')); + } finally { + setLoading(false); + } + }; + + const handleDisconnect = async () => { + setLoading(true); + try { + const response = await fetch('/api/google-calendar/disconnect', { method: 'POST' }); + if (!response.ok) { + throw new Error(t('googleDisconnectError')); } - } catch (e) { - console.error(e); + setConnected(false); + toast.success(t('googleDisconnected')); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('googleDisconnectError')); + } finally { + setLoading(false); + } + }; + + const handleSync = async () => { + setLoading(true); + try { + const response = await fetch('/api/google-calendar/sync', { method: 'POST' }); + const result = (await response.json()) as { + synced?: number; + failed?: number; + message?: string; + }; + if (!response.ok || (result.failed ?? 0) > 0) { + throw new Error(result.message || t('googleSyncError')); + } + toast.success(t('googleSyncSuccess', { count: result.synced ?? 0 })); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('googleSyncError')); } finally { setLoading(false); } @@ -66,20 +145,23 @@ export function SyncSettingsDialog({ return ( - - + {t('settingsTitle')} {t('settingsDescription')} -
-
-

{t('workHours')}

-
-
+ +
+
+

+ {t('workHours')} +

+
+
setWorkStart(e.target.value)} + onChange={event => setWorkStart(event.target.value)} disabled={loading} />
-
+
setWorkEnd(e.target.value)} + onChange={event => setWorkEnd(event.target.value)} disabled={loading} />
-
- -
- setEnabled(checked as boolean)} - /> - -
- -
- - setUrl(e.target.value)} - placeholder="https://..." - disabled={loading || !enabled} - /> -
- -
- - - {t('howToGetLink')} - - ▼ - - -
    -
  1. {t('googleStep1')}
  2. -
  3. {t('googleStep2')}
  4. -
  5. {t('googleStep3')}
  6. -
  7. {t('googleStep4')}
  8. -
  9. {t('googleStep5')}
  10. -
-
+
+ +
+

+ Google Calendar +

+ {connected ? ( + + + {t('googleConnected')} + + {calendarName + ? t('googleConnectedCalendar', { calendar: calendarName }) + : t('googleConnectedDescription')} + + + ) : ( + + + {t('googleNotConnected')} + {t('googleOutboundDescription')} + + )} + +
+ {connected ? ( + <> + + + + + + + + {t('disconnectGoogleTitle')} + + {t('disconnectGoogleDescription')} + + + + {t('cancel')} + + {t('disconnectGoogle')} + + + + + + ) : ( + + )} +
+ +
+ + setGoogleCalendarSyncUrl(event.target.value)} + disabled={loading} + /> +

{t('googleIcalUrlDescription')}

+
+
+ -
); -} +}; diff --git a/src/app/[locale]/(admin)/admin/schedule/_components/use-events.ts b/src/app/[locale]/(admin)/admin/schedule/_components/use-events.ts index 1f612a8..699fa47 100644 --- a/src/app/[locale]/(admin)/admin/schedule/_components/use-events.ts +++ b/src/app/[locale]/(admin)/admin/schedule/_components/use-events.ts @@ -2,7 +2,7 @@ import useSWR from 'swr'; import { Event as PrismaEvent, EventStatus, EventType } from '@prisma/client'; export type Event = PrismaEvent & { - user?: { id: string; name: string; email: string } | null; + user?: { id: string; name: string | null; email: string; timezone: string | null } | null; }; export type EventMutationInput = { diff --git a/src/app/[locale]/(admin)/admin/schedule/_components/use-saved-meeting-links.ts b/src/app/[locale]/(admin)/admin/schedule/_components/use-saved-meeting-links.ts new file mode 100644 index 0000000..7b1fd3d --- /dev/null +++ b/src/app/[locale]/(admin)/admin/schedule/_components/use-saved-meeting-links.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { getSafeMeetingUrl } from '@/lib/safe-url'; + +const MEETING_LINKS_STORAGE_KEY = 'schedule_savedMeetingLinks'; +const MAX_SAVED_MEETING_LINKS = 12; + +const parseStoredLinks = (value: string | null): string[] => { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter((item): item is string => typeof item === 'string') + .map(item => getSafeMeetingUrl(item)) + .filter((item): item is string => Boolean(item)) + .slice(0, MAX_SAVED_MEETING_LINKS); + } catch { + return []; + } +}; + +/** + * Управляет локальной историей безопасных ссылок на звонок. + * @returns Список ссылок и операции сохранения/удаления. + */ +export const useSavedMeetingLinks = () => { + const [links, setLinks] = useState([]); + const linksRef = useRef([]); + + useEffect(() => { + const frameId = window.requestAnimationFrame(() => { + try { + const storedLinks = parseStoredLinks( + window.localStorage.getItem(MEETING_LINKS_STORAGE_KEY) + ); + linksRef.current = storedLinks; + setLinks(storedLinks); + } catch { + // История ссылок необязательна и не должна ломать форму при запрете localStorage. + } + }); + + return () => window.cancelAnimationFrame(frameId); + }, []); + + const persistLinks = (updateLinks: (currentLinks: string[]) => string[]) => { + const nextLinks = updateLinks(linksRef.current); + linksRef.current = nextLinks; + setLinks(nextLinks); + + try { + window.localStorage.setItem(MEETING_LINKS_STORAGE_KEY, JSON.stringify(nextLinks)); + } catch { + // В памяти история остаётся доступной до перезагрузки страницы. + } + }; + + const saveLink = (value: string | null | undefined) => { + const safeLink = getSafeMeetingUrl(value); + if (!safeLink) { + return; + } + + persistLinks(currentLinks => + [safeLink, ...currentLinks.filter(link => link !== safeLink)].slice( + 0, + MAX_SAVED_MEETING_LINKS + ) + ); + }; + + const removeLink = (value: string) => { + persistLinks(currentLinks => currentLinks.filter(link => link !== value)); + }; + + return { links, saveLink, removeLink }; +}; diff --git a/src/app/[locale]/(admin)/admin/schedule/page.tsx b/src/app/[locale]/(admin)/admin/schedule/page.tsx index e084116..b3a0d17 100644 --- a/src/app/[locale]/(admin)/admin/schedule/page.tsx +++ b/src/app/[locale]/(admin)/admin/schedule/page.tsx @@ -2,11 +2,21 @@ import { ScheduleDashboard } from './_components/schedule-dashboard'; import { SyncSettingsDialog } from './_components/sync-settings-dialog'; import { auth } from '@/auth'; import prisma from '@/lib/prisma'; +import { getTranslations } from 'next-intl/server'; -export default async function SchedulePage() { +export default async function SchedulePage({ + params, + searchParams +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ google?: string }>; +}) { const session = await auth(); - let syncUrl = ''; - let syncEnabled = false; + const [{ google: googleStatus }, { locale }] = await Promise.all([searchParams, params]); + const t = await getTranslations({ locale, namespace: 'Schedule' }); + let googleConnected = false; + let calendarName: string | null = null; + let googleCalendarSyncUrl: string | null = null; let workStart = 9; let workEnd = 20; @@ -14,25 +24,30 @@ export default async function SchedulePage() { const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { - googleCalendarSyncUrl: true, googleCalendarSyncEnabled: true, + googleCalendarRefreshToken: true, + googleCalendarName: true, + googleCalendarSyncUrl: true, workHourStart: true, workHourEnd: true } }); - syncUrl = user?.googleCalendarSyncUrl || ''; - syncEnabled = user?.googleCalendarSyncEnabled || false; + googleConnected = Boolean(user?.googleCalendarSyncEnabled && user.googleCalendarRefreshToken); + calendarName = user?.googleCalendarName || null; + googleCalendarSyncUrl = user?.googleCalendarSyncUrl || null; if (user?.workHourStart !== undefined) workStart = user.workHourStart; if (user?.workHourEnd !== undefined) workEnd = user.workHourEnd; } return (
-
-

Schedule

+
+

{t('title')}

diff --git a/src/app/[locale]/(admin)/admin/send-email/actions.ts b/src/app/[locale]/(admin)/admin/send-email/actions.ts index dbea048..1a5c471 100644 --- a/src/app/[locale]/(admin)/admin/send-email/actions.ts +++ b/src/app/[locale]/(admin)/admin/send-email/actions.ts @@ -15,6 +15,9 @@ export async function getUsersForEmail(): Promise { } const users = await prisma.user.findMany({ + where: { + role: { not: 'GUEST' } + }, select: { id: true, name: true, diff --git a/src/app/[locale]/(admin)/admin/send-email/page.tsx b/src/app/[locale]/(admin)/admin/send-email/page.tsx index 680c2e9..a1da9f3 100644 --- a/src/app/[locale]/(admin)/admin/send-email/page.tsx +++ b/src/app/[locale]/(admin)/admin/send-email/page.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { SendEmailForm } from './send-email-form'; import { getUsersForEmail } from './actions'; diff --git a/src/app/[locale]/(admin)/admin/send-email/send-email-form.tsx b/src/app/[locale]/(admin)/admin/send-email/send-email-form.tsx index 88a15b2..1db0748 100644 --- a/src/app/[locale]/(admin)/admin/send-email/send-email-form.tsx +++ b/src/app/[locale]/(admin)/admin/send-email/send-email-form.tsx @@ -1,26 +1,15 @@ 'use client'; -import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Bell, CheckCircle2, Mail, Megaphone, Send, Smartphone } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from '@/components/ui/form'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; + +import type { EmailUser } from './actions'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AlertDialog, AlertDialogAction, @@ -31,52 +20,61 @@ import { AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { AlertTriangle, Bell, Settings } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; import { MultiEmailSelect } from '@/components/ui/multi-email-select'; -import { EmailUser } from './actions'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { isSafeNotificationActionUrl } from '@/modules/notifications/schemas'; +import { cn } from '@/lib/utils'; const PUSH_BODY_LIMIT = 178; +const MESSAGE_LIMIT = 500; -// --- Схема валидации --- -const emailSchema = z +const deliveryChannels = ['email', 'push', 'inApp'] as const; +type DeliveryChannel = (typeof deliveryChannels)[number]; + +const broadcastSchema = z .object({ - to: z.array(z.string().email({ message: 'Один из адресов имеет неверный формат' })), - subject: z.string().min(1, { message: 'Тема обязательна' }), - message: z.string().min(1, { message: 'Заполните текст сообщения' }), - sendToAll: z.boolean() + to: z.array(z.email({ message: 'Некорректный email' })), + subject: z.string().trim().min(1, 'Укажите заголовок').max(120), + message: z.string().trim().min(1, 'Введите сообщение').max(MESSAGE_LIMIT), + sendToAll: z.boolean(), + actionUrl: z.union([ + z.literal(''), + z.string().trim().refine(isSafeNotificationActionUrl, { + message: 'Используйте внутреннюю ссылку вида /my/sessions' + }) + ]), + actionLabel: z.string().trim().max(40) }) .refine(data => data.sendToAll || data.to.length > 0, { message: 'Выберите хотя бы одного получателя', path: ['to'] + }) + .refine(data => !data.actionLabel || Boolean(data.actionUrl), { + message: 'Для подписи действия укажите ссылку', + path: ['actionLabel'] }); -type EmailFormValues = z.infer; - -/** - * Отправляет запрос на отправку письма через API - */ -async function sendEmailRequest(data: EmailFormValues): Promise { - const response = await fetch('/api/send', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Ошибка при отправке письма'); - } -} +type BroadcastFormValues = z.infer; interface SendEmailFormProps { users: EmailUser[]; } -export interface DeliveryStatus { +interface DeliveryStatus { id?: string; email: string; success: boolean; @@ -84,46 +82,63 @@ export interface DeliveryStatus { error?: string; } -/** - * Клиентская форма отправки писем - */ -export function SendEmailForm({ users }: SendEmailFormProps) { - const [isConfirmOpen, setIsConfirmOpen] = React.useState(false); - const [isConfirmPushOpen, setIsConfirmPushOpen] = React.useState(false); - const [isSending, setIsSending] = React.useState(false); - const [isSendingPush, setIsSendingPush] = React.useState(false); - const [deliveryStatuses, setDeliveryStatuses] = React.useState(null); - const [errorMessage, setErrorMessage] = React.useState(null); - const [pushResult, setPushResult] = React.useState<{ sent: number; failed: number } | null>(null); - const [pushError, setPushError] = React.useState(null); - - const form = useForm({ - resolver: zodResolver(emailSchema), +interface BroadcastResult { + tone: 'success' | 'error'; + message: string; +} + +const channelIcons: Record = { + email: Mail, + push: Smartphone, + inApp: Bell +}; + +/** Унифицированная форма отправки email, push и persistent in-app уведомлений. */ +export const SendEmailForm = ({ users }: SendEmailFormProps) => { + const t = useTranslations('AdminBroadcast'); + const [channel, setChannel] = useState('email'); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [isSending, setIsSending] = useState(false); + const [deliveryStatuses, setDeliveryStatuses] = useState(null); + const [result, setResult] = useState(null); + + const form = useForm({ + resolver: zodResolver(broadcastSchema), defaultValues: { to: [], subject: '', message: '', - sendToAll: false + sendToAll: false, + actionUrl: '', + actionLabel: '' } }); - const watchSendToAll = form.watch('sendToAll'); - const watchTo = form.watch('to'); - const watchMessage = form.watch('message'); - const isPushDisabled = watchMessage.length > PUSH_BODY_LIMIT || isSending || isSendingPush; - - // --- Фоновый опрос (Polling) статусов --- - React.useEffect(() => { - if (!deliveryStatuses || deliveryStatuses.length === 0) return; + const sendToAll = form.watch('sendToAll'); + const selectedRecipients = form.watch('to'); + const subject = form.watch('subject'); + const message = form.watch('message'); + const actionUrl = form.watch('actionUrl'); + const actionLabel = form.watch('actionLabel'); + const ChannelIcon = channelIcons[channel]; + const recipientCount = sendToAll ? users.length : selectedRecipients.length; + const messageLimit = channel === 'push' ? PUSH_BODY_LIMIT : MESSAGE_LIMIT; + const isMessageOverLimit = message.length > messageLimit; + + useEffect(() => { + if (!deliveryStatuses?.length) { + return; + } - // Ищем только те письма, которые еще в процессе const pendingIds = deliveryStatuses - .filter(s => s.id && (s.status === 'queued' || s.status === 'sent')) - .map(s => s.id as string); + .filter(status => status.id && ['queued', 'sent'].includes(status.status)) + .map(status => status.id as string); - if (pendingIds.length === 0) return; + if (pendingIds.length === 0) { + return; + } - const intervalId = setInterval(async () => { + const intervalId = window.setInterval(async () => { try { const response = await fetch('/api/send/status', { method: 'POST', @@ -131,394 +146,425 @@ export function SendEmailForm({ users }: SendEmailFormProps) { body: JSON.stringify({ ids: pendingIds }) }); - if (response.ok) { - const { statuses } = await response.json(); - if (statuses) { - setDeliveryStatuses(prev => { - if (!prev) return prev; - - let hasChanges = false; - const next = prev.map(item => { - if (item.id && statuses[item.id] && item.status !== statuses[item.id]) { - hasChanges = true; - const newStatus = statuses[item.id]; - const isError = ['bounced', 'complained', 'rejected', 'error'].includes( - newStatus - ); - const isSuccess = newStatus === 'delivered'; - - return { - ...item, - status: newStatus, - success: isSuccess || (!isError && item.success), // Keep old success if pending - error: isError ? `Не доставлено (${newStatus})` : item.error - }; - } - return item; - }); + if (!response.ok) { + return; + } - return hasChanges ? next : prev; - }); - } + const payload = (await response.json()) as { statuses?: Record }; + if (!payload.statuses) { + return; } - } catch (err) { - console.error('Ошибка опроса статусов:', err); + + setDeliveryStatuses( + current => + current?.map(item => { + const nextStatus = item.id ? payload.statuses?.[item.id] : undefined; + if (!nextStatus || nextStatus === item.status) { + return item; + } + + const failed = ['bounced', 'complained', 'rejected', 'error'].includes(nextStatus); + return { + ...item, + status: nextStatus as DeliveryStatus['status'], + success: nextStatus === 'delivered' || (!failed && item.success), + error: failed ? t('deliveryFailed', { status: nextStatus }) : item.error + }; + }) || null + ); + } catch { + // Следующий polling повторит запрос, отдельное UI-сообщение здесь создаст лишний шум. } - }, 4000); // Опрос каждые 4 секунды + }, 4000); - return () => clearInterval(intervalId); - }, [deliveryStatuses]); + return () => window.clearInterval(intervalId); + }, [deliveryStatuses, t]); - // --- Обработчики --- - const handleTrigerConfirm = form.handleSubmit(() => { + const openConfirmation = form.handleSubmit(() => { + if (isMessageOverLimit) { + setResult({ tone: 'error', message: t('messageTooLong', { limit: messageLimit }) }); + return; + } + setResult(null); setIsConfirmOpen(true); }); - const handleTriggerPushConfirm = form.handleSubmit(() => { - setIsConfirmPushOpen(true); - }); - - const handleConfirmPush = async () => { - setIsConfirmPushOpen(false); - setIsSendingPush(true); - setPushResult(null); - setPushError(null); - - try { - const data = form.getValues(); - const response = await fetch('/api/send/push', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - to: data.to, - sendToAll: data.sendToAll, - title: data.subject, - message: data.message - }) - }); - - const result = await response.json(); + const sendEmail = async (values: BroadcastFormValues): Promise => { + const response = await fetch('/api/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: values.to, + subject: values.subject, + message: values.message, + sendToAll: values.sendToAll + }) + }); + const payload = (await response.json()) as { + error?: string | { message?: string }; + statuses?: DeliveryStatus[]; + }; + if (!response.ok) { + throw new Error( + typeof payload.error === 'string' ? payload.error : payload.error?.message || t('sendError') + ); + } + setDeliveryStatuses(payload.statuses || null); + setResult({ tone: 'success', message: t('emailQueued') }); + }; - if (!response.ok) { - throw new Error( - typeof result.error === 'string' ? result.error : 'Ошибка при отправке push' - ); - } + const sendPush = async (values: BroadcastFormValues): Promise => { + const response = await fetch('/api/send/push', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: values.to, + sendToAll: values.sendToAll, + title: values.subject, + message: values.message + }) + }); + const payload = (await response.json()) as { + error?: string; + results?: Array<{ success: boolean }>; + }; + if (!response.ok) { + throw new Error(payload.error || t('sendError')); + } + const sent = payload.results?.filter(item => item.success).length || 0; + const failed = payload.results?.filter(item => !item.success).length || 0; + setResult({ tone: 'success', message: t('pushSent', { sent, failed }) }); + }; - const results: { success: boolean }[] = result.results ?? []; - setPushResult({ - sent: results.filter(r => r.success).length, - failed: results.filter(r => !r.success).length - }); - } catch (error) { - setPushError(error instanceof Error ? error.message : 'Произошла неизвестная ошибка'); - } finally { - setIsSendingPush(false); + const sendInApp = async (values: BroadcastFormValues): Promise => { + const response = await fetch('/api/admin/notifications', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: values.to, + sendToAll: values.sendToAll, + title: values.subject, + message: values.message, + actionUrl: values.actionUrl || null, + actionLabel: values.actionLabel || null + }) + }); + const payload = (await response.json()) as { message?: string; created?: number }; + if (!response.ok) { + throw new Error(payload.message || t('sendError')); } + setResult({ tone: 'success', message: t('inAppCreated', { count: payload.created || 0 }) }); }; - const handleConfirmSend = async () => { + const confirmSend = async () => { setIsConfirmOpen(false); setIsSending(true); - setErrorMessage(null); - setDeliveryStatuses(null); + setResult(null); + if (channel !== 'email') { + setDeliveryStatuses(null); + } try { - const data = form.getValues(); - const response = await fetch('/api/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error( - typeof result.error === 'string' - ? result.error - : result.error?.message || 'Ошибка при отправке письма' - ); - } - - if (result.statuses) { - setDeliveryStatuses(result.statuses); + const values = form.getValues(); + if (channel === 'email') { + await sendEmail(values); + } else if (channel === 'push') { + await sendPush(values); + } else { + await sendInApp(values); } - - form.reset(); } catch (error) { - console.error(error); - setErrorMessage(error instanceof Error ? error.message : 'Произошла неизвестная ошибка'); + setResult({ + tone: 'error', + message: error instanceof Error ? error.message : t('sendError') + }); } finally { setIsSending(false); } }; - // --- Render --- return ( -
-
-

Отправка писем

-
- - - -
- Новое письмо - - Отправьте письмо одному или всем пользователям напрямую. Письмо будет оформлено от - имени администратора. - +
+
+
+
+ + + {t('eyebrow')} +
- - - - - -
Настройки рассылки
- +

{t('title')}

+

{t('description')}

+
+ + {t('usersAvailable', { count: users.length })} + +
+ + { + if (deliveryChannels.includes(value as DeliveryChannel)) { + setChannel(value as DeliveryChannel); + setResult(null); + setDeliveryStatuses(null); + } + }} + > + + {deliveryChannels.map(item => { + const Icon = channelIcons[item]; + return ( + + + {t(`channels.${item}.label`)} + + ); + })} + + + +
+
+
+
+ + + +
+

{t(`channels.${channel}.title`)}

+

+ {t(`channels.${channel}.description`)} +

+
+
+
+ + + +
+
+
+

{t('audienceTitle')}

+

+ {t('audienceDescription')} +

+
+ + ( + + + field.onChange(checked === true)} + /> + + + {t('sendToAll')} + + + )} + /> +
+ + {!sendToAll && ( + ( + + + + + + + )} + /> + )} +
+ +
( - - - Отправить всем пользователям - + + {t('subject')} + + + + + )} /> - - - - - - {deliveryStatuses && ( -
-

Статус рассылки:

-
    - {deliveryStatuses.map((item, index) => { - const isError = ['bounced', 'complained', 'rejected', 'error'].includes( - item.status - ); - const isPending = ['queued', 'sent'].includes(item.status); - const isSuccess = item.status === 'delivered'; - - return ( -
  • - - {item.email} - - {isSuccess ? ( - - Доставлено - - ) : isPending ? ( - - В процессе ({item.status}) - - ) : ( -
    - - Ошибка - - - {item.error || 'Неизвестная ошибка'} - -
    - )} -
  • - ); - })} -
-
- )} - {errorMessage && ( -
- {errorMessage} -
- )} - -
- - {!watchSendToAll && ( ( - Кому (Email) + {t('message')} - - +
+ + + {message.length} / {messageLimit} + +
)} /> - )} - ( - - Тема - - - - - - )} - /> - ( - - Сообщение - -