From 6cbf9a5d69f2eda057a55c420a29c2aa34585293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=92=D0=B5=D1=80=D1=88=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Thu, 16 Jul 2026 15:05:14 +0200 Subject: [PATCH 1/8] feat(schedule): improve editing and Google sync --- docs/google-calendar-integration.ru.md | 68 +++ messages/en.json | 33 ++ messages/ru.json | 33 ++ messages/sr.json | 33 ++ .../migration.sql | 6 + prisma/schema.prisma | 5 + .../schedule/_components/event-dialog.tsx | 556 ++++++++++++------ .../_components/event-form-utils.test.ts | 31 + .../schedule/_components/event-form-utils.ts | 42 ++ .../_components/sync-settings-dialog.tsx | 229 +++++--- .../admin/schedule/_components/use-events.ts | 3 +- .../_components/use-saved-meeting-links.ts | 67 +++ .../[locale]/(admin)/admin/schedule/page.tsx | 23 +- src/app/api/admin/events/[id]/route.ts | 44 +- src/app/api/admin/events/route.ts | 51 +- src/app/api/admin/users/route.ts | 7 +- src/app/api/google-calendar/callback/route.ts | 67 +++ src/app/api/google-calendar/connect/route.ts | 53 ++ .../api/google-calendar/disconnect/route.ts | 31 + src/app/api/google-calendar/sync/route.ts | 20 + src/app/api/user/events/[id]/route.ts | 3 +- src/lib/__tests__/google-sync.test.ts | 167 ++++++ src/lib/google-sync.ts | 469 ++++++++++++++- 23 files changed, 1737 insertions(+), 304 deletions(-) create mode 100644 docs/google-calendar-integration.ru.md create mode 100644 prisma/migrations/20260716143000_add_google_calendar_oauth/migration.sql create mode 100644 src/app/[locale]/(admin)/admin/schedule/_components/event-form-utils.test.ts create mode 100644 src/app/[locale]/(admin)/admin/schedule/_components/event-form-utils.ts create mode 100644 src/app/[locale]/(admin)/admin/schedule/_components/use-saved-meeting-links.ts create mode 100644 src/app/api/google-calendar/callback/route.ts create mode 100644 src/app/api/google-calendar/connect/route.ts create mode 100644 src/app/api/google-calendar/disconnect/route.ts create mode 100644 src/app/api/google-calendar/sync/route.ts create mode 100644 src/lib/__tests__/google-sync.test.ts diff --git a/docs/google-calendar-integration.ru.md b/docs/google-calendar-integration.ru.md new file mode 100644 index 0000000..849f10f --- /dev/null +++ b/docs/google-calendar-integration.ru.md @@ -0,0 +1,68 @@ +# Интеграция с Google Calendar + +## Реализованный поток + +Приложение использует серверный OAuth 2.0 flow и минимальный scope +`https://www.googleapis.com/auth/calendar.events`. После подключения: + +- будущие события расписания отправляются в основной Google Calendar; +- создание, изменение, отмена и удаление записи синхронизируются автоматически; +- access token обновляется через offline refresh token; +- OAuth-токены хранятся в PostgreSQL в зашифрованном виде; +- кнопка «Синхронизировать сейчас» повторно отправляет будущие записи. + +Legacy-поле с приватным iCal URL оставлено только для прежнего read-only отображения занятости. +Оно не используется для отправки записей в Google. + +## Настройка 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=<стабильный-секрет-для-шифрования> +``` + +`NEXT_PUBLIC_APP_URL` должен в точности совпадать с origin зарегистрированного redirect URI. +`ENCRYPTION_KEY` нельзя менять после подключения календаря, иначе сохранённые токены невозможно +будет расшифровать. + +## Деплой + +Перед включением функции применить миграцию и сгенерировать Prisma Client: + +```bash +npx prisma generate +npx prisma migrate deploy +``` + +После деплоя открыть «Админка → Расписание → Настройки», нажать «Подключить Google Calendar» +и пройти экран согласия Google. Первичная синхронизация будущих записей запускается автоматически. + +## План обратной синхронизации Google → приложение + +Обратную синхронизацию следует реализовать отдельно, чтобы не смешивать её с исходящим потоком: + +1. Расширить scope на чтение событий только после отдельного согласия администратора. +2. Выполнить 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/messages/en.json b/messages/en.json index b48afc8..1a08aa0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1215,19 +1215,33 @@ "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", + "deleteSavedMeetingLink": "Delete saved link {link}", + "meetingLinkHistoryDescription": "After saving, the link will appear in the local list for this browser.", "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.", "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", @@ -1254,6 +1268,25 @@ "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.", + "connectGoogle": "Connect Google Calendar", + "disconnectGoogle": "Disconnect", + "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..53aeaa8 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1226,19 +1226,33 @@ "eventType": "Тип События", "status": "Статус", "eventTitle": "Заголовок (Необязательно)", + "eventTitlePlaceholder": "Например: первичная консультация", + "date": "Дата", "startTime": "Время Начала", "endTime": "Время Окончания", + "duration": "Длительность", + "durationMinutes": "{minutes} мин", "meetLink": "Ссылка на звонок", + "savedMeetingLinks": "Сохранённые ссылки", + "deleteSavedMeetingLink": "Удалить сохранённую ссылку {link}", + "meetingLinkHistoryDescription": "После сохранения ссылка появится в локальном списке этого браузера.", "sessionReminderLabel": "Напоминание клиенту", "sessionReminderAtStart": "В момент начала", "sessionReminderBeforeMinutes": "За {minutes} мин", "description": "Описание", "client": "Клиент", + "admin": "Администратор", + "clientTimezone": "Часовой пояс клиента", + "clientTimezonePlaceholder": "Выберите часовой пояс", + "clientTimezoneDescription": "Дата и время выше интерпретируются в этом часовом поясе. Он также используется в уведомлениях клиента.", "noneSelected": "Никто не выбран", "delete": "Удалить", "cancel": "Отмена", "save": "Сохранить", "saving": "Сохранение...", + "saveError": "Не удалось сохранить событие.", + "deleteError": "Не удалось удалить событие.", + "deleteConfirmation": "Вы действительно хотите удалить это событие?", "noEvents": "Нет событий", "moreEvents": "+{count} ещё", "pendingRequestsTitle": "Запросы на подтверждение", @@ -1265,6 +1279,25 @@ "workHours": "Рабочие часы", "startHour": "Начало (час)", "endHour": "Конец (час)", + "saveWorkHours": "Сохранить часы", + "settingsSaved": "Настройки расписания сохранены.", + "settingsSaveError": "Не удалось сохранить настройки расписания.", + "googleConnected": "Google Calendar подключён", + "googleConnectedCalendar": "Новые и изменённые записи отправляются в календарь «{calendar}».", + "googleConnectedDescription": "Новые и изменённые записи отправляются в подключённый календарь.", + "googleNotConnected": "Google Calendar не подключён", + "googleOutboundDescription": "Подключите аккаунт Google, чтобы записи из админки автоматически появлялись в вашем календаре.", + "connectGoogle": "Подключить Google Calendar", + "disconnectGoogle": "Отключить", + "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..8d2fc11 100644 --- a/messages/sr.json +++ b/messages/sr.json @@ -1148,19 +1148,33 @@ "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", + "deleteSavedMeetingLink": "Obriši sačuvani link {link}", + "meetingLinkHistoryDescription": "Nakon čuvanja, link će se pojaviti u lokalnoj listi ovog pregledača.", "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.", "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", @@ -1187,6 +1201,25 @@ "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.", + "connectGoogle": "Poveži Google Calendar", + "disconnectGoogle": "Prekini vezu", + "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/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/schema.prisma b/prisma/schema.prisma index 207caed..0b1396f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,11 @@ model User { timezone String? @default("UTC") 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) 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..2e7258b 100644 --- a/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx +++ b/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx @@ -1,12 +1,13 @@ '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 { ChevronDown, 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 { Button } from '@/components/ui/button'; import { @@ -20,15 +21,18 @@ 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 { Select, SelectContent, + SelectGroup, SelectItem, SelectTrigger, SelectValue @@ -40,6 +44,11 @@ import { SESSION_REMINDER_PRESET_MINUTES } from '@/lib/session-reminders'; import { optionalMeetingUrlSchema } from '@/lib/safe-url'; +import { isValidTimeZone } from '@/lib/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 +59,40 @@ 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(), + clientTimezone: z + .string() + .trim() + .refine(isValidTimeZone, { message: 'Некорректный часовой пояс' }), + 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 +105,53 @@ 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 => { + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return isValidTimeZone(browserTimezone) ? browserTimezone : 'UTC'; +}; + +const getInitialValues = (params: { + event?: Event | null; + selectedDate?: Date; + selectedEndDate?: Date; + fallbackTimezone: string; +}): EventFormValues => { + const { event, selectedDate, selectedEndDate, fallbackTimezone } = params; + const clientTimezone = event?.user?.timezone || 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, + clientTimezone, + reminderMinutesBeforeStart: + event?.reminderMinutesBeforeStart ?? DEFAULT_SESSION_REMINDER_MINUTES + }; +}; + export const EventDialog = ({ open, onOpenChange, @@ -105,150 +163,229 @@ 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 timezones = useMemo(() => { + try { + return ['UTC', ...Intl.supportedValuesOf('timeZone').filter(timezone => timezone !== 'UTC')]; + } catch { + return ['UTC']; + } + }, []); - 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 durationOptions = useMemo( + () => Array.from(new Set([...defaultDurationOptions, currentDuration])).sort((a, b) => a - b), + [currentDuration] + ); + + useEffect(() => { + if (!open) { + return; + } + + form.reset( + getInitialValues({ + event, + selectedDate, + selectedEndDate, + fallbackTimezone: browserTimezone + }) + ); + }, [browserTimezone, event, form, open, selectedDate, selectedEndDate]); - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: EventFormValues) => { setLoading(true); try { + const { start, end } = getEventDateRange({ + date: values.date, + startTime: values.startTime, + duration: values.duration, + timeZone: values.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, + clientTimezone: values.clientTimezone, 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('clientTimezone')} + + {t('clientTimezoneDescription')} + + + )} + /> + +
( - {t('eventType')} - + {t('date')} + + + )} /> - ( - {t('status')} - + + + + )} + /> + ( + + {t('duration')} + @@ -259,66 +396,125 @@ export const EventDialog = ({ ( - {t('eventTitle')} - - - + {t('meetLink')} +
+ + + + + + + + +

{t('savedMeetingLinks')}

+
+ {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 +549,19 @@ export const EventDialog = ({ ( - {t('client')} - + {t('eventTitle')} + + + )} /> - +
{event && ( - + {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 ? ( + <> + + + + ) : ( + + )} +
+
+ -
); -} +}; 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..3b3d38d 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 = { @@ -13,6 +13,7 @@ export type EventMutationInput = { title: string; meetLink?: string; userId: string | null; + clientTimezone: string; reminderMinutesBeforeStart: number; }; 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..f97fb19 --- /dev/null +++ b/src/app/[locale]/(admin)/admin/schedule/_components/use-saved-meeting-links.ts @@ -0,0 +1,67 @@ +'use client'; + +import { useEffect, 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([]); + + useEffect(() => { + const frameId = window.requestAnimationFrame(() => { + setLinks(parseStoredLinks(window.localStorage.getItem(MEETING_LINKS_STORAGE_KEY))); + }); + + return () => window.cancelAnimationFrame(frameId); + }, []); + + const persistLinks = (nextLinks: string[]) => { + setLinks(nextLinks); + window.localStorage.setItem(MEETING_LINKS_STORAGE_KEY, JSON.stringify(nextLinks)); + }; + + const saveLink = (value: string | null | undefined) => { + const safeLink = getSafeMeetingUrl(value); + if (!safeLink) { + return; + } + + persistLinks( + [safeLink, ...links.filter(link => link !== safeLink)].slice(0, MAX_SAVED_MEETING_LINKS) + ); + }; + + const removeLink = (value: string) => { + persistLinks(links.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..431e1de 100644 --- a/src/app/[locale]/(admin)/admin/schedule/page.tsx +++ b/src/app/[locale]/(admin)/admin/schedule/page.tsx @@ -3,10 +3,15 @@ import { SyncSettingsDialog } from './_components/sync-settings-dialog'; import { auth } from '@/auth'; import prisma from '@/lib/prisma'; -export default async function SchedulePage() { +export default async function SchedulePage({ + searchParams +}: { + searchParams: Promise<{ google?: string }>; +}) { const session = await auth(); - let syncUrl = ''; - let syncEnabled = false; + const { google: googleStatus } = await searchParams; + let googleConnected = false; + let calendarName: string | null = null; let workStart = 9; let workEnd = 20; @@ -14,14 +19,15 @@ 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, workHourStart: true, workHourEnd: true } }); - syncUrl = user?.googleCalendarSyncUrl || ''; - syncEnabled = user?.googleCalendarSyncEnabled || false; + googleConnected = Boolean(user?.googleCalendarSyncEnabled && user.googleCalendarRefreshToken); + calendarName = user?.googleCalendarName || null; if (user?.workHourStart !== undefined) workStart = user.workHourStart; if (user?.workHourEnd !== undefined) workEnd = user.workHourEnd; } @@ -31,8 +37,9 @@ export default async function SchedulePage() {

Schedule

diff --git a/src/app/api/admin/events/[id]/route.ts b/src/app/api/admin/events/[id]/route.ts index 8d5a808..50ec2a3 100644 --- a/src/app/api/admin/events/[id]/route.ts +++ b/src/app/api/admin/events/[id]/route.ts @@ -8,6 +8,7 @@ import { syncEventWithGoogle } from '@/lib/google-sync'; import { doesDateRangeOverlap, isValidDateRange } from '@/lib/event-utils'; import { optionalMeetingUrlSchema } from '@/lib/safe-url'; import { startSessionReminderWorkflow } from '@/lib/session-reminder-workflow'; +import { isValidTimeZone } from '@/lib/timezone'; import { MAX_SESSION_REMINDER_MINUTES, MIN_SESSION_REMINDER_MINUTES @@ -31,6 +32,11 @@ const updateEventSchema = z.object({ title: z.string().optional().nullable(), meetLink: optionalMeetingUrlSchema, userId: z.string().optional().nullable(), + clientTimezone: z + .string() + .trim() + .refine(isValidTimeZone, { message: 'Invalid client timezone' }) + .optional(), reminderMinutesBeforeStart: z.coerce .number() .int() @@ -98,13 +104,14 @@ async function patchHandler(req: Request, props: { params: Promise<{ id: string return NextResponse.json({ message: 'Event not found' }, { status: 404 }); } - const { start, end, status, cancelReason, ...restData } = result.data; + const { start, end, status, cancelReason, clientTimezone, ...restData } = result.data; const nextStart = start ? new Date(start) : event.start; const nextEnd = end ? new Date(end) : event.end; const nextStatus = status ?? event.status; const normalizedCancelReason = normalizeCancelReason(cancelReason); const hasUserField = Object.prototype.hasOwnProperty.call(result.data, 'userId'); const hasCancelReasonField = Object.prototype.hasOwnProperty.call(result.data, 'cancelReason'); + const targetUserId = hasUserField ? result.data.userId : event.userId; const isStartChanged = Boolean(start) && nextStart.getTime() !== event.start.getTime(); const isEndChanged = Boolean(end) && nextEnd.getTime() !== event.end.getTime(); const isStatusChanged = typeof status === 'string' && status !== event.status; @@ -135,6 +142,21 @@ async function patchHandler(req: Request, props: { params: Promise<{ id: string return NextResponse.json({ message: 'Invalid date range' }, { status: 400 }); } + let selectedUser: { id: string; timezone: string | null } | null = null; + if (targetUserId) { + selectedUser = await prisma.user.findFirst({ + where: { + id: targetUserId, + role: { in: ['USER', 'ADMIN'] } + }, + select: { id: true, timezone: true } + }); + + if (!selectedUser) { + return NextResponse.json({ message: 'Client not found' }, { status: 400 }); + } + } + if (effectiveNextStatus !== 'CANCELLED') { const conflictingEvents = (await prisma.event.findMany({ where: { @@ -162,6 +184,13 @@ async function patchHandler(req: Request, props: { params: Promise<{ id: string } } + if (selectedUser && clientTimezone && clientTimezone !== selectedUser.timezone) { + await prisma.user.update({ + where: { id: selectedUser.id }, + data: { timezone: clientTimezone } + }); + } + const updateData: Prisma.EventUncheckedUpdateInput = { ...restData, status: effectiveNextStatus @@ -286,10 +315,9 @@ async function patchHandler(req: Request, props: { params: Promise<{ id: string reminderWorkflowVersion: updatedEvent.reminderWorkflowVersion }); - // Trigger Google Calendar sync hook - syncEventWithGoogle(updatedEvent.id, 'UPDATE'); + const googleSyncResult = await syncEventWithGoogle(updatedEvent.id, 'UPDATE'); - return NextResponse.json(updatedEvent); + return NextResponse.json({ ...updatedEvent, isGoogleSynced: googleSyncResult.success }); } catch (error) { console.error('Failed to update event:', error); return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); @@ -325,7 +353,13 @@ async function deleteHandler(req: Request, props: { params: Promise<{ id: string } // Trigger Google Calendar sync before deletion - await syncEventWithGoogle(eventId, 'DELETE'); + const googleSyncResult = await syncEventWithGoogle(eventId, 'DELETE'); + if (!googleSyncResult.success && !googleSyncResult.skipped) { + return NextResponse.json( + { message: 'Failed to delete the event from Google Calendar' }, + { status: 502 } + ); + } await prisma.event.delete({ where: { id: eventId } diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index a65f2ca..26c2ffd 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -8,6 +8,7 @@ import { fetchGoogleEvents, syncEventWithGoogle } from '@/lib/google-sync'; import { doesDateRangeOverlap, isValidDateRange } from '@/lib/event-utils'; import { optionalMeetingUrlSchema } from '@/lib/safe-url'; import { startSessionReminderWorkflow } from '@/lib/session-reminder-workflow'; +import { isValidTimeZone } from '@/lib/timezone'; import { DEFAULT_SESSION_REMINDER_MINUTES, MAX_SESSION_REMINDER_MINUTES, @@ -86,7 +87,7 @@ async function getHandler(req: Request) { const events = await prisma.event.findMany({ where: whereClause, include: { - user: { select: { id: true, name: true, email: true } } + user: { select: { id: true, name: true, email: true, timezone: true } } }, orderBy: { start: 'asc' } }); @@ -119,6 +120,11 @@ const createEventSchema = z.object({ title: z.string().optional(), meetLink: optionalMeetingUrlSchema, userId: z.string().nullable().optional(), + clientTimezone: z + .string() + .trim() + .refine(isValidTimeZone, { message: 'Invalid client timezone' }) + .optional(), reminderMinutesBeforeStart: z.coerce .number() .int() @@ -149,8 +155,17 @@ async function postHandler(req: Request) { ); } - const { type, start, end, status, title, meetLink, userId, reminderMinutesBeforeStart } = - result.data; + const { + type, + start, + end, + status, + title, + meetLink, + userId, + clientTimezone, + reminderMinutesBeforeStart + } = result.data; const startDate = new Date(start); const endDate = new Date(end); @@ -158,6 +173,21 @@ async function postHandler(req: Request) { return NextResponse.json({ message: 'Invalid date range' }, { status: 400 }); } + let selectedUser: { id: string; timezone: string | null } | null = null; + if (userId) { + selectedUser = await prisma.user.findFirst({ + where: { + id: userId, + role: { in: ['USER', 'ADMIN'] } + }, + select: { id: true, timezone: true } + }); + + if (!selectedUser) { + return NextResponse.json({ message: 'Client not found' }, { status: 400 }); + } + } + if (status !== 'CANCELLED') { const conflictingEvents = (await prisma.event.findMany({ where: { @@ -188,6 +218,13 @@ async function postHandler(req: Request) { } } + if (selectedUser && clientTimezone && clientTimezone !== selectedUser.timezone) { + await prisma.user.update({ + where: { id: selectedUser.id }, + data: { timezone: clientTimezone } + }); + } + const newEvent = await prisma.event.create({ data: { type, @@ -228,10 +265,12 @@ async function postHandler(req: Request) { reminderWorkflowVersion: newEvent.reminderWorkflowVersion }); - // Trigger Google Calendar sync hook - syncEventWithGoogle(newEvent.id, 'CREATE'); + const googleSyncResult = await syncEventWithGoogle(newEvent.id, 'CREATE'); - return NextResponse.json(newEvent, { status: 201 }); + return NextResponse.json( + { ...newEvent, isGoogleSynced: googleSyncResult.success }, + { status: 201 } + ); } catch (error) { console.error('Failed to create event:', error); return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 84120ec..62e0d9a 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -73,10 +73,15 @@ async function getHandler(req: Request) { } const users = await prisma.user.findMany({ + where: { + role: { in: [Role.USER, Role.ADMIN] } + }, select: { id: true, name: true, - email: true + email: true, + role: true, + timezone: true }, orderBy: { name: 'asc' } }); diff --git a/src/app/api/google-calendar/callback/route.ts b/src/app/api/google-calendar/callback/route.ts new file mode 100644 index 0000000..c247a6e --- /dev/null +++ b/src/app/api/google-calendar/callback/route.ts @@ -0,0 +1,67 @@ +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +import { auth } from '@/auth'; +import { + exchangeGoogleCalendarCode, + getGoogleCalendarRedirectUri, + saveGoogleCalendarConnection, + syncFutureEventsWithGoogle +} from '@/lib/google-sync'; + +const GOOGLE_OAUTH_STATE_COOKIE = 'google_calendar_oauth_state'; +const GOOGLE_OAUTH_LOCALE_COOKIE = 'google_calendar_oauth_locale'; +const SUPPORTED_LOCALES = new Set(['ru', 'en', 'sr']); + +const createScheduleRedirect = (requestUrl: string, locale: string, status: string): URL => { + return new URL(`/${locale}/admin/schedule?google=${status}`, requestUrl); +}; + +/** + * Завершает OAuth-поток, сохраняет зашифрованные токены и запускает первичную синхронизацию. + */ +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id || session.user.role !== 'ADMIN') { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const cookieStore = await cookies(); + const storedState = cookieStore.get(GOOGLE_OAUTH_STATE_COOKIE)?.value; + const storedLocale = cookieStore.get(GOOGLE_OAUTH_LOCALE_COOKIE)?.value || 'ru'; + const locale = SUPPORTED_LOCALES.has(storedLocale) ? storedLocale : 'ru'; + const requestUrl = new URL(request.url); + const state = requestUrl.searchParams.get('state'); + const code = requestUrl.searchParams.get('code'); + const oauthError = requestUrl.searchParams.get('error'); + + if (oauthError || !code || !state || !storedState || state !== storedState) { + return NextResponse.redirect(createScheduleRedirect(request.url, locale, 'connection-error')); + } + + try { + const connection = await exchangeGoogleCalendarCode({ + code, + redirectUri: getGoogleCalendarRedirectUri(request.url) + }); + await saveGoogleCalendarConnection(session.user.id, connection); + const syncResult = await syncFutureEventsWithGoogle(session.user.id); + const status = syncResult.failed > 0 ? 'connected-with-errors' : 'connected'; + const response = NextResponse.redirect(createScheduleRedirect(request.url, locale, status)); + response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE, '', { + path: '/api/google-calendar', + maxAge: 0 + }); + response.cookies.set(GOOGLE_OAUTH_LOCALE_COOKIE, '', { + path: '/api/google-calendar', + maxAge: 0 + }); + return response; + } catch (error) { + console.error('Failed to complete Google Calendar OAuth', { + userId: session.user.id, + error: error instanceof Error ? error.message : 'unknown-error' + }); + return NextResponse.redirect(createScheduleRedirect(request.url, locale, 'connection-error')); + } +} diff --git a/src/app/api/google-calendar/connect/route.ts b/src/app/api/google-calendar/connect/route.ts new file mode 100644 index 0000000..8d75b63 --- /dev/null +++ b/src/app/api/google-calendar/connect/route.ts @@ -0,0 +1,53 @@ +import crypto from 'node:crypto'; +import { NextResponse } from 'next/server'; + +import { auth } from '@/auth'; +import { + createGoogleCalendarAuthorizationUrl, + getGoogleCalendarRedirectUri +} from '@/lib/google-sync'; + +const GOOGLE_OAUTH_STATE_COOKIE = 'google_calendar_oauth_state'; +const GOOGLE_OAUTH_LOCALE_COOKIE = 'google_calendar_oauth_locale'; +const SUPPORTED_LOCALES = new Set(['ru', 'en', 'sr']); + +/** + * Начинает безопасный OAuth-поток подключения Google Calendar. + */ +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id || session.user.role !== 'ADMIN') { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const requestUrl = new URL(request.url); + const requestedLocale = requestUrl.searchParams.get('locale') || 'ru'; + const locale = SUPPORTED_LOCALES.has(requestedLocale) ? requestedLocale : 'ru'; + const state = crypto.randomBytes(32).toString('hex'); + + try { + const authorizationUrl = createGoogleCalendarAuthorizationUrl({ + state, + redirectUri: getGoogleCalendarRedirectUri(request.url) + }); + const response = NextResponse.redirect(authorizationUrl); + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/api/google-calendar', + maxAge: 10 * 60 + }; + + response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE, state, cookieOptions); + response.cookies.set(GOOGLE_OAUTH_LOCALE_COOKIE, locale, cookieOptions); + return response; + } catch (error) { + console.error('Failed to start Google Calendar OAuth', { + error: error instanceof Error ? error.message : 'unknown-error' + }); + return NextResponse.redirect( + new URL(`/${locale}/admin/schedule?google=configuration-error`, request.url) + ); + } +} diff --git a/src/app/api/google-calendar/disconnect/route.ts b/src/app/api/google-calendar/disconnect/route.ts new file mode 100644 index 0000000..96c0aa5 --- /dev/null +++ b/src/app/api/google-calendar/disconnect/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +import { auth } from '@/auth'; +import prisma from '@/lib/prisma'; +import { withApiLogging } from '@/modules/system-logs/with-api-logging.server'; + +/** + * Отключает Google Calendar и удаляет локально сохранённые OAuth-токены. + */ +async function postHandler() { + const session = await auth(); + if (!session?.user?.id || session.user.role !== 'ADMIN') { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + await prisma.user.update({ + where: { id: session.user.id }, + data: { + googleCalendarSyncEnabled: false, + googleCalendarAccessToken: null, + googleCalendarRefreshToken: null, + googleCalendarTokenExpiresAt: null, + googleCalendarId: null, + googleCalendarName: null + } + }); + + return NextResponse.json({ success: true }); +} + +export const POST = withApiLogging(postHandler); diff --git a/src/app/api/google-calendar/sync/route.ts b/src/app/api/google-calendar/sync/route.ts new file mode 100644 index 0000000..b000303 --- /dev/null +++ b/src/app/api/google-calendar/sync/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +import { auth } from '@/auth'; +import { syncFutureEventsWithGoogle } from '@/lib/google-sync'; +import { withApiLogging } from '@/modules/system-logs/with-api-logging.server'; + +/** + * Повторно отправляет будущие события расписания в подключённый Google Calendar. + */ +async function postHandler() { + const session = await auth(); + if (!session?.user?.id || session.user.role !== 'ADMIN') { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const result = await syncFutureEventsWithGoogle(session.user.id); + return NextResponse.json({ success: result.failed === 0, ...result }); +} + +export const POST = withApiLogging(postHandler); diff --git a/src/app/api/user/events/[id]/route.ts b/src/app/api/user/events/[id]/route.ts index f1d294b..f61c215 100644 --- a/src/app/api/user/events/[id]/route.ts +++ b/src/app/api/user/events/[id]/route.ts @@ -335,8 +335,7 @@ async function patchHandler(req: Request, props: { params: Promise<{ id: string } } - // Trigger Google Calendar sync hook - syncEventWithGoogle(updatedEvent.id, 'UPDATE'); + await syncEventWithGoogle(updatedEvent.id, 'UPDATE'); return NextResponse.json(updatedEvent); } catch (error) { diff --git a/src/lib/__tests__/google-sync.test.ts b/src/lib/__tests__/google-sync.test.ts new file mode 100644 index 0000000..4938300 --- /dev/null +++ b/src/lib/__tests__/google-sync.test.ts @@ -0,0 +1,167 @@ +import { EventStatus, EventType } from '@prisma/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const prismaMock = vi.hoisted(() => ({ + event: { + findUnique: vi.fn(), + update: vi.fn() + }, + user: { + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn() + } +})); + +vi.mock('server-only', () => ({})); +vi.mock('@/lib/prisma', () => ({ default: prismaMock })); +vi.mock('@/lib/crypto', () => ({ + decryptData: (value: string) => value, + encryptData: (value: string) => value +})); + +import { + createGoogleCalendarAuthorizationUrl, + createGoogleCalendarEventPayload, + syncEventWithGoogle +} from '@/lib/google-sync'; + +const baseEvent = { + id: 'event-1', + title: 'Первая консультация', + type: EventType.CONSULTATION, + status: EventStatus.SCHEDULED, + start: new Date('2026-07-16T14:30:00.000Z'), + end: new Date('2026-07-16T15:30:00.000Z'), + meetLink: 'https://meet.google.com/abc-defg-hij', + googleEventId: null, + authorId: 'admin-1', + user: { + name: 'Анна Клиент', + email: 'client@example.com', + timezone: 'America/New_York' + } +}; + +const calendarOwner = { + id: 'admin-1', + googleCalendarAccessToken: 'access-token', + googleCalendarRefreshToken: 'refresh-token', + googleCalendarTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + googleCalendarId: 'primary', + googleCalendarSyncEnabled: true +}; + +describe('Google Calendar sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.event.findUnique.mockResolvedValue(baseEvent); + prismaMock.event.update.mockResolvedValue(baseEvent); + prismaMock.user.findFirst.mockResolvedValue(calendarOwner); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.GOOGLE_CALENDAR_CLIENT_ID; + delete process.env.GOOGLE_CALENDAR_CLIENT_SECRET; + }); + + it('формирует OAuth URL с offline-доступом и защитным state', () => { + process.env.GOOGLE_CALENDAR_CLIENT_ID = 'client-id'; + process.env.GOOGLE_CALENDAR_CLIENT_SECRET = 'client-secret'; + + const authorizationUrl = new URL( + createGoogleCalendarAuthorizationUrl({ + state: 'secure-state', + redirectUri: 'https://example.com/api/google-calendar/callback' + }) + ); + + expect(authorizationUrl.origin).toBe('https://accounts.google.com'); + expect(authorizationUrl.searchParams.get('access_type')).toBe('offline'); + expect(authorizationUrl.searchParams.get('state')).toBe('secure-state'); + expect(authorizationUrl.searchParams.get('scope')).toBe( + 'https://www.googleapis.com/auth/calendar.events' + ); + }); + + it('формирует событие с timezone клиента и внутренним идентификатором', () => { + const payload = createGoogleCalendarEventPayload(baseEvent); + + expect(payload.start).toEqual({ + dateTime: '2026-07-16T14:30:00.000Z', + timeZone: 'America/New_York' + }); + expect(payload.extendedProperties.private.vershkovEventId).toBe('event-1'); + expect(payload.description).toContain('Анна Клиент'); + expect(payload.description).not.toContain('client@example.com'); + expect(payload.visibility).toBe('private'); + }); + + it('создаёт новое событие в основном Google Calendar', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'google-event-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await syncEventWithGoogle('event-1', 'CREATE'); + + expect(result).toEqual({ success: true, skipped: false }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://www.googleapis.com/calendar/v3/calendars/primary/events', + expect.objectContaining({ method: 'POST' }) + ); + expect(prismaMock.event.update).toHaveBeenCalledWith({ + where: { id: 'event-1' }, + data: { googleEventId: 'google-event-1', isGoogleSynced: true } + }); + }); + + it('удаляет связанное событие Google при отмене записи', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...baseEvent, + status: EventStatus.CANCELLED, + googleEventId: 'google-event-1' + }); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await syncEventWithGoogle('event-1', 'UPDATE'); + + expect(result).toEqual({ success: true, skipped: false }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://www.googleapis.com/calendar/v3/calendars/primary/events/google-event-1', + expect.objectContaining({ method: 'DELETE' }) + ); + expect(prismaMock.event.update).toHaveBeenCalledWith({ + where: { id: 'event-1' }, + data: { googleEventId: null, isGoogleSynced: true } + }); + }); + + it('обновляет ранее связанное событие без создания дубля', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...baseEvent, + googleEventId: 'google-event-1' + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'google-event-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await syncEventWithGoogle('event-1', 'UPDATE'); + + expect(result).toEqual({ success: true, skipped: false }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://www.googleapis.com/calendar/v3/calendars/primary/events/google-event-1', + expect.objectContaining({ method: 'PATCH' }) + ); + }); +}); diff --git a/src/lib/google-sync.ts b/src/lib/google-sync.ts index 1034db5..6b6219f 100644 --- a/src/lib/google-sync.ts +++ b/src/lib/google-sync.ts @@ -1,11 +1,452 @@ +import 'server-only'; + +import { EventStatus, EventType, Role } from '@prisma/client'; +import { z } from 'zod'; + +import { decryptData, encryptData } from '@/lib/crypto'; import prisma from '@/lib/prisma'; import { getSafeGoogleCalendarSyncUrl } from '@/lib/safe-url'; + import { parseICal } from './ical-parser'; -export async function fetchGoogleEvents(userId: string) { +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_CALENDAR_API_URL = 'https://www.googleapis.com/calendar/v3'; +const GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.events'; +const TOKEN_REFRESH_BUFFER_MS = 60_000; + +const googleTokenSchema = z.object({ + access_token: z.string().min(1), + expires_in: z.number().int().positive(), + refresh_token: z.string().min(1).optional() +}); + +const googleEventResponseSchema = z.object({ + id: z.string().min(1) +}); + +type GoogleTokenResponse = z.infer; + +type GoogleCalendarConnection = { + accessToken: string; + refreshToken?: string; + expiresAt: Date; + calendarId: string; + calendarName: string; +}; + +export type GoogleSyncResult = { + success: boolean; + skipped: boolean; + message?: string; +}; + +const getGoogleCredentials = (): { clientId: string; clientSecret: string } => { + const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error('Google Calendar OAuth credentials are not configured'); + } + + return { clientId, clientSecret }; +}; + +/** + * Возвращает стабильный callback URL для Google OAuth. + * @param requestUrl - URL текущего запроса для локального fallback. + * @returns Абсолютный URL callback-обработчика. + */ +export const getGoogleCalendarRedirectUri = (requestUrl: string): string => { + const configuredBaseUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, ''); + const baseUrl = configuredBaseUrl || new URL(requestUrl).origin; + return `${baseUrl}/api/google-calendar/callback`; +}; + +/** + * Формирует URL согласия Google OAuth для доступа к событиям календаря. + * @param params - state и callback URL текущей OAuth-сессии. + * @returns URL страницы согласия Google. + */ +export const createGoogleCalendarAuthorizationUrl = (params: { + state: string; + redirectUri: string; +}): string => { + const { clientId } = getGoogleCredentials(); + const searchParams = new URLSearchParams({ + client_id: clientId, + redirect_uri: params.redirectUri, + response_type: 'code', + scope: GOOGLE_CALENDAR_SCOPE, + access_type: 'offline', + prompt: 'consent', + include_granted_scopes: 'true', + state: params.state + }); + + return `${GOOGLE_AUTH_URL}?${searchParams.toString()}`; +}; + +const requestGoogleToken = async (body: URLSearchParams): Promise => { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + + if (!response.ok) { + throw new Error(`Google token request failed with status ${response.status}`); + } + + return googleTokenSchema.parse(await response.json()); +}; + +/** + * Обменивает authorization code на токены и сведения об основном календаре. + * @param params - code и callback URL OAuth-сессии. + * @returns Данные подключения, готовые к безопасному сохранению. + */ +export const exchangeGoogleCalendarCode = async (params: { + code: string; + redirectUri: string; +}): Promise => { + const { clientId, clientSecret } = getGoogleCredentials(); + const token = await requestGoogleToken( + new URLSearchParams({ + code: params.code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: params.redirectUri, + grant_type: 'authorization_code' + }) + ); + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresAt: new Date(Date.now() + token.expires_in * 1000), + calendarId: 'primary', + calendarName: 'Google Calendar' + }; +}; + +/** + * Сохраняет OAuth-подключение Google Calendar с шифрованием токенов. + * @param userId - идентификатор администратора. + * @param connection - токены и сведения о календаре. + */ +export const saveGoogleCalendarConnection = async ( + userId: string, + connection: GoogleCalendarConnection +): Promise => { + const existingUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { googleCalendarRefreshToken: true } + }); + + const encryptedRefreshToken = connection.refreshToken + ? encryptData(connection.refreshToken) + : existingUser?.googleCalendarRefreshToken; + + if (!encryptedRefreshToken) { + throw new Error('Google did not return a refresh token'); + } + + await prisma.user.update({ + where: { id: userId }, + data: { + googleCalendarAccessToken: encryptData(connection.accessToken), + googleCalendarRefreshToken: encryptedRefreshToken, + googleCalendarTokenExpiresAt: connection.expiresAt, + googleCalendarId: connection.calendarId, + googleCalendarName: connection.calendarName, + googleCalendarSyncEnabled: true + } + }); +}; + +type CalendarOwner = { + id: string; + googleCalendarAccessToken: string | null; + googleCalendarRefreshToken: string | null; + googleCalendarTokenExpiresAt: Date | null; + googleCalendarId: string | null; + googleCalendarSyncEnabled: boolean; +}; + +const calendarOwnerSelect = { + id: true, + googleCalendarAccessToken: true, + googleCalendarRefreshToken: true, + googleCalendarTokenExpiresAt: true, + googleCalendarId: true, + googleCalendarSyncEnabled: true +} as const; + +const getCalendarOwner = async ( + authorId: string, + explicitOwnerId?: string +): Promise => { + const preferredOwnerId = explicitOwnerId || authorId; + const preferredOwner = await prisma.user.findFirst({ + where: { + id: preferredOwnerId, + role: Role.ADMIN, + googleCalendarSyncEnabled: true, + googleCalendarRefreshToken: { not: null } + }, + select: calendarOwnerSelect + }); + + if (preferredOwner) { + return preferredOwner; + } + + if (explicitOwnerId) { + return null; + } + + return prisma.user.findFirst({ + where: { + role: Role.ADMIN, + googleCalendarSyncEnabled: true, + googleCalendarRefreshToken: { not: null } + }, + orderBy: { createdAt: 'asc' }, + select: calendarOwnerSelect + }); +}; + +const getValidAccessToken = async (owner: CalendarOwner): Promise => { + if ( + owner.googleCalendarAccessToken && + owner.googleCalendarTokenExpiresAt && + owner.googleCalendarTokenExpiresAt.getTime() > Date.now() + TOKEN_REFRESH_BUFFER_MS + ) { + return decryptData(owner.googleCalendarAccessToken); + } + + if (!owner.googleCalendarRefreshToken) { + throw new Error('Google Calendar refresh token is missing'); + } + + const { clientId, clientSecret } = getGoogleCredentials(); + const token = await requestGoogleToken( + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: decryptData(owner.googleCalendarRefreshToken), + grant_type: 'refresh_token' + }) + ); + const expiresAt = new Date(Date.now() + token.expires_in * 1000); + + await prisma.user.update({ + where: { id: owner.id }, + data: { + googleCalendarAccessToken: encryptData(token.access_token), + googleCalendarTokenExpiresAt: expiresAt, + ...(token.refresh_token && { + googleCalendarRefreshToken: encryptData(token.refresh_token) + }) + } + }); + + return token.access_token; +}; + +type SyncableEvent = { + id: string; + title: string | null; + type: EventType; + status: EventStatus; + start: Date; + end: Date; + meetLink: string | null; + googleEventId: string | null; + authorId: string; + user: { name: string | null; email: string; timezone: string | null } | null; +}; + +/** + * Формирует тело события Google Calendar из доменного события расписания. + * @param event - событие приложения с минимальными данными клиента. + * @returns Тело запроса Google Calendar API. + */ +export const createGoogleCalendarEventPayload = (event: SyncableEvent) => { + const defaultTitle = + event.type === EventType.CONSULTATION ? 'Консультация' : 'Событие расписания'; + const clientLine = event.user ? `Клиент: ${event.user.name || event.user.email}` : null; + const meetingLine = event.meetLink ? `Ссылка на звонок: ${event.meetLink}` : null; + + return { + summary: event.title?.trim() || defaultTitle, + description: [clientLine, meetingLine].filter(Boolean).join('\n') || undefined, + location: event.meetLink || undefined, + visibility: 'private', + start: { dateTime: event.start.toISOString(), timeZone: event.user?.timezone || undefined }, + end: { dateTime: event.end.toISOString(), timeZone: event.user?.timezone || undefined }, + extendedProperties: { + private: { + vershkovEventId: event.id + } + } + }; +}; + +const requestGoogleCalendarApi = async ( + url: string, + accessToken: string, + init: RequestInit +): Promise => { + const response = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...init.headers + } + }); + + if (!response.ok && response.status !== 404 && response.status !== 410) { + throw new Error(`Google Calendar API request failed with status ${response.status}`); + } + + return response; +}; + +/** + * Создаёт, обновляет или удаляет событие в подключённом Google Calendar. + * @param eventId - идентификатор события приложения. + * @param action - вид синхронизируемой мутации. + * @param explicitOwnerId - администратор-владелец календаря для массовой синхронизации. + * @returns Результат синхронизации без утечки OAuth-данных. + */ +export const syncEventWithGoogle = async ( + eventId: string, + action: 'CREATE' | 'UPDATE' | 'DELETE', + explicitOwnerId?: string +): Promise => { + const event = await prisma.event.findUnique({ + where: { id: eventId }, + include: { + user: { select: { name: true, email: true, timezone: true } } + } + }); + + if (!event) { + return { success: false, skipped: true, message: 'event-not-found' }; + } + + const owner = await getCalendarOwner(event.authorId, explicitOwnerId); + if (!owner) { + return { success: false, skipped: true, message: 'calendar-not-connected' }; + } + + try { + const accessToken = await getValidAccessToken(owner); + const calendarId = encodeURIComponent(owner.googleCalendarId || 'primary'); + const eventPath = event.googleEventId + ? `${GOOGLE_CALENDAR_API_URL}/calendars/${calendarId}/events/${encodeURIComponent(event.googleEventId)}` + : null; + const shouldDelete = action === 'DELETE' || event.status === EventStatus.CANCELLED; + + if (shouldDelete) { + if (eventPath) { + await requestGoogleCalendarApi(eventPath, accessToken, { method: 'DELETE' }); + } + + if (action !== 'DELETE') { + await prisma.event.update({ + where: { id: event.id }, + data: { googleEventId: null, isGoogleSynced: true } + }); + } + + return { success: true, skipped: !eventPath }; + } + + const payload = createGoogleCalendarEventPayload(event); + let response = eventPath + ? await requestGoogleCalendarApi(eventPath, accessToken, { + method: 'PATCH', + body: JSON.stringify(payload) + }) + : null; + + if (!response || response.status === 404 || response.status === 410) { + response = await requestGoogleCalendarApi( + `${GOOGLE_CALENDAR_API_URL}/calendars/${calendarId}/events`, + accessToken, + { + method: 'POST', + body: JSON.stringify(payload) + } + ); + } + + const googleEventId = googleEventResponseSchema.parse(await response.json()).id; + await prisma.event.update({ + where: { id: event.id }, + data: { googleEventId, isGoogleSynced: true } + }); + + return { success: true, skipped: false }; + } catch (error) { + console.error('Failed to sync event with Google Calendar', { + eventId, + action, + error: error instanceof Error ? error.message : 'unknown-error' + }); + await prisma.event.update({ + where: { id: event.id }, + data: { isGoogleSynced: false } + }); + return { success: false, skipped: false, message: 'google-sync-failed' }; + } +}; + +/** + * Отправляет в Google Calendar все актуальные будущие события расписания. + * @param ownerId - администратор с подключённым календарём. + * @returns Количество успешных и неуспешных операций. + */ +export const syncFutureEventsWithGoogle = async ( + ownerId: string +): Promise<{ synced: number; failed: number }> => { + const events: Array<{ id: string }> = await prisma.event.findMany({ + where: { + end: { gte: new Date() }, + OR: [{ status: { not: EventStatus.CANCELLED } }, { googleEventId: { not: null } }] + }, + select: { id: true } + }); + const results: GoogleSyncResult[] = await Promise.all( + events.map(event => syncEventWithGoogle(event.id, 'UPDATE', ownerId)) + ); + + return results.reduce( + (summary, result) => ({ + synced: summary.synced + (result.success ? 1 : 0), + failed: summary.failed + (!result.success && !result.skipped ? 1 : 0) + }), + { synced: 0, failed: 0 } + ); +}; + +/** + * Читает legacy iCal-фид Google для отображения занятости в приложении. + * Исходящая синхронизация работает отдельно через OAuth и Calendar API. + * @param userId - идентификатор владельца legacy iCal-настройки. + */ +export const fetchGoogleEvents = async (userId: string) => { try { const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user || !user.googleCalendarSyncEnabled || !user.googleCalendarSyncUrl) { + if ( + !user || + !user.googleCalendarSyncEnabled || + user.googleCalendarRefreshToken || + !user.googleCalendarSyncUrl + ) { return []; } @@ -15,14 +456,14 @@ export async function fetchGoogleEvents(userId: string) { return []; } - // Скачиваем iCal формат по указанному Secret URL - const res = await fetch(syncUrl, { cache: 'no-store' }); - if (!res.ok) return []; + const response = await fetch(syncUrl, { cache: 'no-store' }); + if (!response.ok) { + return []; + } - const text = await res.text(); - return parseICal(text).map(e => ({ - ...e, - // Делаем заглушки для обязательных полей + const text = await response.text(); + return parseICal(text).map(event => ({ + ...event, user: null, authorId: userId, createdAt: new Date(), @@ -30,13 +471,7 @@ export async function fetchGoogleEvents(userId: string) { cancelReason: null })); } catch (error) { - console.error('Failed to fetch google events', error); + console.error('Failed to fetch Google events', error); return []; } -} - -// Заглушка, если где-то используется старая нерабочая логика POST (можно безопасно убрать, но оставим пустой для совместимости) -export async function syncEventWithGoogle(eventId: string, action: 'CREATE' | 'UPDATE' | 'DELETE') { - // Google Calendar iCal не принимает POST-запросы. - // Синхронизация работает только в одну сторону через чтение fetchGoogleEvents. -} +}; From f561cca96be3f0eab08e58362a93090ed5b80e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=92=D0=B5=D1=80=D1=88=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Thu, 16 Jul 2026 19:39:45 +0200 Subject: [PATCH 2/8] feat(notifications): add persistent center and history --- docs/in-app-notifications.ru.md | 183 ++++ messages/en.json | 122 +++ messages/ru.json | 122 +++ messages/sr.json | 122 +++ .../migration.sql | 1 + .../migration.sql | 29 + .../migration.sql | 1 + prisma/schema.prisma | 133 +-- .../admin/_components/admin-breadcrumbs.tsx | 1 + src/app/[locale]/(admin)/admin/layout.tsx | 2 + .../(admin)/admin/notifications/page.tsx | 26 + .../[locale]/(admin)/admin/profile/page.tsx | 2 +- .../schedule/_components/event-dialog.tsx | 104 +-- .../admin/schedule/_components/use-events.ts | 1 - .../(admin)/admin/send-email/actions.ts | 3 + .../(admin)/admin/send-email/page.tsx | 1 - .../admin/send-email/send-email-form.tsx | 848 +++++++++--------- .../users/_components/edit-user-dialog.tsx | 31 +- .../my/_components/my-breadcrumbs.tsx | 1 + src/app/[locale]/(dashboard)/my/layout.tsx | 2 + .../(dashboard)/my/notifications/page.tsx | 31 + .../[locale]/(dashboard)/my/profile/page.tsx | 2 +- src/app/[locale]/(public)/auth/page.tsx | 19 +- src/app/api/admin/events/[id]/route.ts | 20 +- src/app/api/admin/events/route.ts | 31 +- src/app/api/admin/notifications/route.ts | 51 ++ src/app/api/auth/register/route.ts | 10 +- src/app/api/notifications/[id]/route.ts | 24 + src/app/api/notifications/history/route.ts | 30 + src/app/api/notifications/read-all/route.ts | 18 + src/app/api/notifications/route.ts | 40 + src/app/api/send/push/route.ts | 1 + src/app/api/send/route.ts | 1 + src/auth.ts | 6 +- .../account-notification-center.tsx | 175 ++++ src/components/notifications-history.tsx | 288 ++++++ src/components/profile-form.tsx | 46 +- src/components/sidebar-workspace-layout.tsx | 3 + src/components/timezone-combobox.tsx | 148 +++ src/components/ui/multi-email-select.tsx | 3 + src/lib/__tests__/browser-timezone.test.ts | 18 + src/lib/__tests__/timezones.test.ts | 22 + src/lib/browser-timezone.ts | 23 + src/lib/hooks/use-notifications.ts | 91 ++ src/lib/timezones.ts | 38 + .../notification-actions.server.test.ts | 53 ++ .../notification-service.server.test.ts | 201 +++++ .../notifications/__tests__/schemas.test.ts | 41 + .../notification-actions.server.ts | 46 + .../notification-service.server.ts | 399 ++++++++ src/modules/notifications/schemas.ts | 64 ++ src/modules/notifications/types.ts | 45 + 52 files changed, 3098 insertions(+), 625 deletions(-) create mode 100644 docs/in-app-notifications.ru.md create mode 100644 prisma/migrations/20260716163000_allow_unset_timezone/migration.sql create mode 100644 prisma/migrations/20260716170000_add_app_notifications/migration.sql create mode 100644 prisma/migrations/20260716173500_add_notification_deleted_at/migration.sql create mode 100644 src/app/[locale]/(admin)/admin/notifications/page.tsx create mode 100644 src/app/[locale]/(dashboard)/my/notifications/page.tsx create mode 100644 src/app/api/admin/notifications/route.ts create mode 100644 src/app/api/notifications/[id]/route.ts create mode 100644 src/app/api/notifications/history/route.ts create mode 100644 src/app/api/notifications/read-all/route.ts create mode 100644 src/app/api/notifications/route.ts create mode 100644 src/components/account-notification-center.tsx create mode 100644 src/components/notifications-history.tsx create mode 100644 src/components/timezone-combobox.tsx create mode 100644 src/lib/__tests__/browser-timezone.test.ts create mode 100644 src/lib/__tests__/timezones.test.ts create mode 100644 src/lib/browser-timezone.ts create mode 100644 src/lib/hooks/use-notifications.ts create mode 100644 src/lib/timezones.ts create mode 100644 src/modules/notifications/__tests__/notification-actions.server.test.ts create mode 100644 src/modules/notifications/__tests__/notification-service.server.test.ts create mode 100644 src/modules/notifications/__tests__/schemas.test.ts create mode 100644 src/modules/notifications/notification-actions.server.ts create mode 100644 src/modules/notifications/notification-service.server.ts create mode 100644 src/modules/notifications/schemas.ts create mode 100644 src/modules/notifications/types.ts diff --git a/docs/in-app-notifications.ru.md b/docs/in-app-notifications.ru.md new file mode 100644 index 0000000..d8f4cc4 --- /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`. +- Пока вкладка активна, хук проверяет новые уведомления каждые 5 секунд; при возврате во вкладку или восстановлении сети обновление выполняется сразу. +- Клиент не может создавать уведомления для других пользователей. Создание выполняется только серверным сервисом. + +## Клиентский хук + +```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 1a08aa0..7abeb01 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,123 @@ "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" + }, "Schedule": { "title": "Schedule & Booking", "details": "Details", @@ -1234,6 +1353,9 @@ "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", diff --git a/messages/ru.json b/messages/ru.json index 53aeaa8..fe66877 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,123 @@ "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": "Подтвердить" + }, "Schedule": { "title": "Расписание и Записи", "details": "Детали", @@ -1245,6 +1364,9 @@ "clientTimezone": "Часовой пояс клиента", "clientTimezonePlaceholder": "Выберите часовой пояс", "clientTimezoneDescription": "Дата и время выше интерпретируются в этом часовом поясе. Он также используется в уведомлениях клиента.", + "clientTimezoneMissing": "Не указан — используется UTC", + "clientTimezoneReadOnlyDescription": "Часовой пояс берётся из профиля клиента. Изменить его можно в форме пользователя.", + "browserTimezoneDescription": "Клиент не выбран, поэтому используется часовой пояс этого браузера.", "noneSelected": "Никто не выбран", "delete": "Удалить", "cancel": "Отмена", diff --git a/messages/sr.json b/messages/sr.json index 8d2fc11..2f660d7 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,123 @@ "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" + }, "Schedule": { "title": "Raspored i termini", "details": "Detalji", @@ -1167,6 +1286,9 @@ "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", 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/schema.prisma b/prisma/schema.prisma index 0b1396f..8030915 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,59 +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? - googleCalendarAccessToken String? - googleCalendarRefreshToken String? + 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]) + 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) @@ -711,6 +734,12 @@ enum PilloIntakeStatus { MISSED } +enum AppNotificationKind { + INFO + WARNING + SUCCESS +} + enum BlogPostStatus { DRAFT PUBLISHED 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/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 2e7258b..d5bd34c 100644 --- a/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx +++ b/src/app/[locale]/(admin)/admin/schedule/_components/event-dialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ChevronDown, Trash2 } from 'lucide-react'; +import { ChevronDown, Clock3, Trash2, TriangleAlert } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; @@ -45,6 +45,7 @@ import { } from '@/lib/session-reminders'; import { optionalMeetingUrlSchema } from '@/lib/safe-url'; import { isValidTimeZone } from '@/lib/timezone'; +import { detectBrowserTimeZone } from '@/lib/browser-timezone'; import { getEventDateRange, getEventTemporalValues } from './event-form-utils'; import type { Event, EventMutationInput } from './use-events'; @@ -74,10 +75,6 @@ const eventSchema = z.object({ .max(8 * 60, 'Максимум 8 часов'), meetLink: optionalMeetingUrlSchema, userId: z.string().optional().nullable(), - clientTimezone: z - .string() - .trim() - .refine(isValidTimeZone, { message: 'Некорректный часовой пояс' }), reminderMinutesBeforeStart: z .number() .int() @@ -114,8 +111,7 @@ const fetchUsers = async (url: string): Promise => { }; const getBrowserTimezone = (): string => { - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return isValidTimeZone(browserTimezone) ? browserTimezone : 'UTC'; + return detectBrowserTimeZone() || 'UTC'; }; const getInitialValues = (params: { @@ -125,7 +121,11 @@ const getInitialValues = (params: { fallbackTimezone: string; }): EventFormValues => { const { event, selectedDate, selectedEndDate, fallbackTimezone } = params; - const clientTimezone = event?.user?.timezone || fallbackTimezone; + 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) { @@ -146,7 +146,6 @@ const getInitialValues = (params: { ...temporalValues, meetLink: event?.meetLink || '', userId: event?.userId || null, - clientTimezone, reminderMinutesBeforeStart: event?.reminderMinutesBeforeStart ?? DEFAULT_SESSION_REMINDER_MINUTES }; @@ -170,14 +169,6 @@ export const EventDialog = ({ open ? '/api/admin/users?roles=USER,ADMIN' : null, fetchUsers ); - const timezones = useMemo(() => { - try { - return ['UTC', ...Intl.supportedValuesOf('timeZone').filter(timezone => timezone !== 'UTC')]; - } catch { - return ['UTC']; - } - }, []); - const form = useForm({ resolver: zodResolver(eventSchema), defaultValues: getInitialValues({ @@ -188,6 +179,18 @@ export const EventDialog = ({ }) }); const currentDuration = form.watch('duration'); + const selectedUserId = form.watch('userId'); + const selectedUser = + users?.find(user => user.id === selectedUserId) ?? + (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] @@ -215,7 +218,7 @@ export const EventDialog = ({ date: values.date, startTime: values.startTime, duration: values.duration, - timeZone: values.clientTimezone + timeZone: clientTimezone }); const payload: EventMutationInput = { type: values.type, @@ -225,7 +228,6 @@ export const EventDialog = ({ title: values.title?.trim() || '', meetLink: values.meetLink || undefined, userId: values.userId ?? null, - clientTimezone: values.clientTimezone, reminderMinutesBeforeStart: values.reminderMinutesBeforeStart }; @@ -272,17 +274,7 @@ export const EventDialog = ({ {t('client')} - - - - - - - - {timezones.map(timezone => ( - - {timezone} - - ))} - - - - {t('clientTimezoneDescription')} - - - )} - /> +
+
+ {selectedUserId && !selectedUserHasTimezone ? ( + + ) : ( + + )} +
+

{t('clientTimezone')}

+

+ {selectedUserId && !selectedUserHasTimezone + ? t('clientTimezoneMissing') + : clientTimezone} +

+

+ {selectedUserId + ? t('clientTimezoneReadOnlyDescription') + : t('browserTimezoneDescription')} +

+
+
+
{ } 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} + +
)} /> - )} - ( - - Тема - - - - - - )} - /> - ( - - Сообщение - -