diff --git a/docs/admin-panel/manager/01-osnovy/01-vstup.md b/docs/admin-panel/manager/01-osnovy/01-vstup.md index b5bbff0..51db41d 100644 --- a/docs/admin-panel/manager/01-osnovy/01-vstup.md +++ b/docs/admin-panel/manager/01-osnovy/01-vstup.md @@ -19,7 +19,8 @@ description: Що таке платформа «Залізна Зміна», з | **XP та рейтинг** | За кожен пройдений крок і складений тест учасник отримує бали досвіду (XP). З них будується рейтинг учасників на сайті. | | **Публікації** | Блог платформи: новини, статті, анонси. | | **Сторінки** | Довільні сторінки сайту (наприклад, «Про нас»), які збираються з готових блоків. | -| **Коментарі та лайки** | Учасники можуть коментувати курси й публікації, відповідати одне одному та ставити лайки. | +| **Події** | Зустрічі, табори й онлайн-заходи з реєстрацією учасників, календарем і сторінкою на сайті. | +| **Коментарі та лайки** | Учасники можуть коментувати курси, публікації й події, відповідати одне одному та ставити лайки. | ## Хто користується платформою diff --git a/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md b/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md index def54dd..a3d2e1e 100644 --- a/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md +++ b/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md @@ -11,16 +11,17 @@ description: Дашборд, групи навігації, перемикач ### Швидкі дії -Чотири кнопки для найчастіших операцій: +Пʼять кнопок для найчастіших операцій: - **Новий курс** +- **Нова подія** - **Нова публікація** - **Нова сторінка** - **Медіатека** ### Лічильники -Під швидкими діями — поточна статистика платформи: кількість **курсів**, **користувачів**, **публікацій**, **записів на курси** та **коментарів**. Це швидкий спосіб оцінити масштаб без переходу в окремі розділи. +Під швидкими діями — поточна статистика платформи: кількість **курсів**, **користувачів**, **публікацій**, **записів на курси**, **подій**, **реєстрацій на події** та **коментарів**. Кожен лічильник — посилання на відповідний розділ. Це швидкий спосіб оцінити масштаб без переходу в окремі розділи. ## Меню навігації @@ -39,11 +40,18 @@ description: Дашборд, групи навігації, перемикач | **Спроби тестів** | Історія складання тестів | [Тест курсу](/admin/docs/manager/kursy/test-kursu) | | **Події XP** | Журнал нарахування балів досвіду | — довідковий журнал, редагувати не потрібно | +### Група «Події» + +| Розділ | Що всередині | Стаття | +| --- | --- | --- | +| **Події** | Зустрічі, табори й онлайн-заходи: дати, формат, місця, посилання на зустріч | [Створення події](/admin/docs/manager/podii/stvorennia-podii) | +| **Реєстрації на події** | Хто на яку подію зареєструвався; тут же можна записати учасника вручну | [Реєстрації на події](/admin/docs/manager/podii/reiestratsii-na-podii) | + ### Група «Взаємодія» | Розділ | Що всередині | | --- | --- | -| **Коментарі** | Усі коментарі до курсів і публікацій. Тут можна відредагувати або видалити небажаний коментар | +| **Коментарі** | Усі коментарі до курсів, публікацій і подій. Тут можна відредагувати або видалити небажаний коментар | | **Лайки** | Записи про вподобання. Довідковий розділ | :::info Коментарі публікуються одразу @@ -71,6 +79,10 @@ description: Дашборд, групи навігації, перемикач Також у меню є глобальні елементи сайту: **Хедер сайту** і **Футер сайту** (пункти меню вгорі та внизу сайту) і **Календар змін** (секція «Найближчі зміни» на головній сторінці). +### Панель швидких посилань на сайті + +Коли ви залогінені як адміністратор і відкриваєте публічні сторінки сайту, угорі зʼявляється чорна панель: **Адмін-панель**, а далі швидкі посилання на розділи — **Курси, Події, Календар змін, Публікації, Сторінки, Користувачі** — і кнопка **«Вийти»**. Вона допомагає за один клік перейти від сторінки сайту до її редагування. На вузьких екранах (телефонах) панель прихована, а звичайним відвідувачам вона не показується взагалі. + ### Документація внизу меню У нижній частині меню навігації є посилання на цю документацію — з будь-якого місця адмінки ви за один клік потрапите до статей. diff --git a/docs/admin-panel/manager/03-kontent/01-publikatsii.md b/docs/admin-panel/manager/03-kontent/01-publikatsii.md index 210225b..cbc2181 100644 --- a/docs/admin-panel/manager/03-kontent/01-publikatsii.md +++ b/docs/admin-panel/manager/03-kontent/01-publikatsii.md @@ -42,6 +42,7 @@ description: Як створювати й редагувати публікац | **Банер** | Виділена кольорова вставка для важливої примітки чи попередження. | | **Код** | Блок програмного коду з підсвіткою. | | **Медіа-блок** | Зображення з медіатеки всередині тексту. | +| **Події** | Картки подій: найближчі автоматично або вручну вибрані. Див. [Події на сайті](/admin/docs/manager/podii/podii-na-saiti). | | **Архів** | Автоматична сітка карток: останні публікації чи курси (з фільтром за категоріями) або вручну вибрані документи. | ## Категорії diff --git a/docs/admin-panel/manager/03-kontent/02-storinky.md b/docs/admin-panel/manager/03-kontent/02-storinky.md index 2b67093..b4b7386 100644 --- a/docs/admin-panel/manager/03-kontent/02-storinky.md +++ b/docs/admin-panel/manager/03-kontent/02-storinky.md @@ -46,6 +46,7 @@ description: Конструктор сторінок сайту — секція | **Контент** | Текстова секція з колонками (від однієї до кількох, різної ширини), у кожній — свій rich text і, за потреби, посилання. | | **Медіа-блок** | Зображення з медіатеки на всю ширину секції. | | **Архів** | Автоматична сітка карток. Джерело: колекція (публікації, курси або категорії курсів, із фільтром за категоріями та лімітом, за замовчуванням 10) або вибрані вручну документи. | +| **Події** | Картки подій: найближчі автоматично або вручну вибрані, з вступним текстом і посиланням «Всі події». Див. [Події на сайті](/admin/docs/manager/podii/podii-na-saiti). | | **Блок форми** | Вставляє форму, створену в конструкторі форм — див. [Форми](/admin/docs/manager/kontent/formy). | :::tip diff --git a/docs/admin-panel/manager/04-spilnota/02-komentari-i-laiky.md b/docs/admin-panel/manager/04-spilnota/02-komentari-i-laiky.md index a0902ea..60bcc83 100644 --- a/docs/admin-panel/manager/04-spilnota/02-komentari-i-laiky.md +++ b/docs/admin-panel/manager/04-spilnota/02-komentari-i-laiky.md @@ -11,6 +11,7 @@ description: Як працюють коментарі й лайки, які є | --- | --- | --- | | Публікації | так | так | | Курси (сторінка курсу) | так | так | +| Події (сторінка події) | так | так | | Коментарі інших | відповіді | так | ## Коментарі публікуються одразу @@ -37,7 +38,7 @@ description: Як працюють коментарі й лайки, які є | **Відредагувати** текст коментаря | лише адміністратор | відкрити коментар у колекції «Коментарі» й змінити поле тексту | | **Видалити** коментар | адміністратор або сам автор | на сайті кнопкою біля коментаря, або в адмінці | -При видаленні кореневого коментаря разом із ним видаляються **його відповіді та всі лайки** цих коментарів — окремо чистити нічого не треба. +При видаленні події разом з нею видаляються і всі коментарі та лайки до неї. При видаленні кореневого коментаря разом із ним видаляються **його відповіді та всі лайки** цих коментарів — окремо чистити нічого не треба. :::tip Як знайти проблемний коментар в адмінці У колекції «Коментарі» список показує текст коментаря. Використовуй пошук по колекції або сортування за датою створення — найсвіжіші згори. diff --git a/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md b/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md index 1a283cf..3454155 100644 --- a/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md +++ b/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md @@ -29,6 +29,10 @@ description: Хедер, футер і «Календар змін» на гол Секція «Найближчі зміни» на головній сторінці: список майбутніх таборових змін із кнопкою запису. +:::info Календар змін і Події — різні інструменти +Тут ви вручну ведете короткі анонси з посиланням на зовнішню форму. Якщо захід потребує **реєстрації на платформі**, власної сторінки та обмеження місць — створюйте його в розділі «Події». Порівняння — у статті [Події на сайті](/admin/docs/manager/podii/podii-na-saiti). +::: + Поля секції: | Поле | Призначення | diff --git a/docs/admin-panel/manager/06-podii/01-stvorennia-podii.md b/docs/admin-panel/manager/06-podii/01-stvorennia-podii.md new file mode 100644 index 0000000..c789c8d --- /dev/null +++ b/docs/admin-panel/manager/06-podii/01-stvorennia-podii.md @@ -0,0 +1,97 @@ +--- +title: Створення події +description: Як створити подію — офлайн чи онлайн, поля форми, обмеження місць, чернетка і публікація +--- + +**Подія** — це захід із датою, на який учасники можуть зареєструватися: зустріч, табір, воркшоп в Zoom. Події живуть у групі меню **«Події»** → розділ **«Події»**. Швидко створити нову можна кнопкою **«Нова подія»** на дашборді або з розділу «Події» кнопкою «Створити». + +Подія може бути двох форматів: + +| Формат | Для чого | Що заповнюється | +| --- | --- | --- | +| **Офлайн (за адресою)** | Зустріч чи табір у конкретному місці | Адреса, за бажанням — посилання на мапу | +| **Онлайн (за посиланням)** | Zoom, Google Meet, трансляція | Посилання на зустріч | + +## Поля події + +| Поле | Опис | +| --- | --- | +| **Назва** | Заголовок події. Заповнюється окремо для кожної мови (uk / en) | +| **Slug** | Частина посилання на подію (`/events/…`). Створюється автоматично з назви, вручну зазвичай не чіпаємо | +| **Опис** | Короткий опис (кілька речень). Показується на картці події й на її сторінці. Окремо для кожної мови | +| **Обкладинка** | Зображення з медіатеки. Без нього картка отримає фірмовий фон | +| **Початок** | Дата й час початку. **Обовʼязкове** | +| **Завершення** | Дата й час завершення. Необовʼязкове, але має бути **пізніше за початок** | +| **Формат** | Офлайн або онлайн — від цього залежать наступні поля | +| **Адреса** | Лише для офлайн. Наприклад: «Київ, вул. Хрещатик, 1». Окремо для кожної мови | +| **Посилання на мапу** | Лише для офлайн. Посилання на Google Maps чи інший сервіс | +| **Посилання на зустріч** | Лише для онлайн. Zoom, Google Meet або інша платформа | +| **Кількість місць** | Збоку. Порожньо — кількість учасників не обмежена | +| **Реєстрації** | Список тих, хто вже зареєструвався (див. [Реєстрації на події](/admin/docs/manager/podii/reiestratsii-na-podii)) | +| **Дата публікації** | Збоку. Виставляється автоматично при першій публікації | + +:::info Час — київський +На сайті час події завжди показується **за київським часом** (із підписом «За київським часом»), незалежно від того, де перебуває відвідувач. Адмінка ж показує дату й час у поясі вашого браузера. Якщо ви працюєте не з Києва, врахуйте різницю при введенні. +::: + +## Що перевіряється при публікації + +Чернетку можна зберегти з порожніми полями — перевірки спрацьовують, коли ви натискаєте **«Опублікувати»**: + +- у **офлайн**-події має бути заповнена **адреса**; +- в **онлайн**-події має бути **посилання на зустріч**; +- посилання (на мапу, на зустріч) мають починатися з `http://` або `https://`; +- **завершення** має бути пізніше за **початок**. + +Якщо щось не так — біля поля зʼявиться підказка українською, і публікація не пройде, доки ви не виправите. + +## Посилання на зустріч бачать лише зареєстровані + +Посилання на Zoom чи Meet — це «перепустка» на захід, тому воно **не публікується відкрито**. Його бачать: + +- **зареєстровані учасники** — на сторінці події зʼявляється кнопка **«Приєднатися»**; +- **адміністратори** — в адмінці. + +Незареєстрований відвідувач на сторінці події бачить лише підказку «Зареєструйтесь, щоб отримати посилання на зустріч». Платформа (Zoom, Google Meet, YouTube) визначається за посиланням автоматично, і картка підбирає під неї відповідний акцент. + +## Чернетки, автозбереження, планова публікація + +Події працюють так само, як курси й публікації: + +- зміни в чернетці **зберігаються автоматично**; +- можна **запланувати публікацію** на потрібний момент; +- зберігається до **50 версій** події, до яких можна повернутися. + +Докладніше — у статті [Чернетки та публікація](/admin/docs/manager/kontent/chernetky-i-publikatsiia). + +Поки подія **не опублікована**, її не видно на сайті, у профілях учасників і в пошуку, і зареєструватися на неї неможливо. + +## Локалізація + +Назва, опис і адреса заповнюються **окремо для кожної мови**. Якщо англійська версія порожня, англомовному відвідувачу показується українська. Дати, посилання й кількість місць спільні для обох мов. Перед редагуванням перевірте перемикач мови контенту — див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). + +## Зміни, скасування, видалення + +- **Змінили дату чи місце** — просто відредагуйте й опублікуйте. Сторінки сайту оновляться автоматично, зареєстровані побачать нові дані. Повідомлення на пошту учасникам **не надсилаються** — про суттєві зміни повідомте їх окремо. +- **Подію скасовано** — зніміть її з публікації. Реєстрації збережуться, але подія зникне з сайту й профілів. +- **Видалення** прибирає подію разом з усіма **реєстраціями, коментарями та лайками** до неї. Якщо на подію вже були реєстрації, безпечніше зняти її з публікації, а не видаляти. + +## Типові питання + +### Подію створено, але її не видно на сайті + +Перевірте, що вона **опублікована** (статус у списку), а не чернетка. Оновлення сторінок відбувається протягом кількох хвилин, зазвичай — одразу після публікації. + +### Подія вже почалася, чи можна ще зареєструватися? + +Так — реєстрація відкрита, **доки подія не завершилась** (до часу «Завершення», а якщо його не вказано — до часу «Початок»). Після цього кнопка реєстрації зникає, а подія переходить у вкладку «Минулі». + +### Як обмежити кількість учасників? + +Вкажіть **«Кількість місць»**. Коли всі місця зайняті, кнопка реєстрації змінюється на «Вільних місць немає». Якщо хтось скасує реєстрацію, місце звільниться. + +## Повʼязані статті + +- [Реєстрації на події](/admin/docs/manager/podii/reiestratsii-na-podii) — хто зареєструвався і як записати вручну. +- [Події на сайті](/admin/docs/manager/podii/podii-na-saiti) — де події видно, календар, блок «Події» на сторінках. +- Технічні деталі — [Події: модель даних](/admin/docs/technical/model-danykh/podii). diff --git a/docs/admin-panel/manager/06-podii/02-reiestratsii-na-podii.md b/docs/admin-panel/manager/06-podii/02-reiestratsii-na-podii.md new file mode 100644 index 0000000..b736646 --- /dev/null +++ b/docs/admin-panel/manager/06-podii/02-reiestratsii-na-podii.md @@ -0,0 +1,79 @@ +--- +title: Реєстрації на події +description: Хто і як реєструється, як побачити список учасників, записати людину вручну та скасувати реєстрацію +--- + +Реєструватися на події можуть **залогінені учасники** — кнопкою **«Зареєструватися»** на сторінці події. Реєстрації збираються в розділі **«Реєстрації на події»** (група меню «Події»), а список учасників конкретної події видно прямо на її сторінці в адмінці, у блоці **«Реєстрації»**. + +## Як реєструється учасник + +1. Відкриває сторінку події на сайті. Якщо не залогінений — бачить кнопку «Увійти» і після входу повертається на ту саму подію. +2. Натискає **«Зареєструватися»**. Одразу зʼявляється позначка **«Ви зареєстровані»**, лічильник вільних місць зменшується, а в онлайн-події відкривається кнопка **«Приєднатися»**. +3. Подія зʼявляється у розділі **«Мої події»** в його профілі (поки вона не минула). +4. За потреби учасник сам натискає **«Скасувати реєстрацію»** — місце звільняється. + +### Правила реєстрації + +Система не дозволить зареєструватися, якщо: + +| Ситуація | Що побачить учасник | +| --- | --- | +| Уже зареєстрований на цю подію | «Ви вже зареєстровані на цю подію» | +| Подія не опублікована | «Подію не знайдено» | +| Подія вже завершилась | «Подія вже завершилась» | +| Усі місця зайняті | «Вільних місць більше немає» | +| Забагато спроб поспіль (понад 30 за 10 хвилин) | «Забагато запитів. Спробуйте пізніше» | + +Одна людина — одна реєстрація на подію. Коли місць лишається кілька, а реєструються багато людей одночасно, система гарантує, що **кількість учасників не перевищить ліміт**. + +## Список учасників події + +Відкрийте подію в адмінці — внизу форми є блок **«Реєстрації»** з таблицею: хто зареєструвався і коли (до 50 останніх записів). Повний список із фільтрами — розділ **«Реєстрації на події»**: відфільтруйте за подією або користувачем. + +:::tip Скільки вже зареєструвалось? +Порівняйте кількість рядків у блоці «Реєстрації» з полем «Кількість місць». Вільні місця також видно відвідувачам на сторінці події. +::: + +## Записати учасника вручну + +Корисно, коли людина записалася офлайн, по телефону або не має облікового запису на сайті (спершу їй треба зареєструватися на платформі). + +1. Відкрийте подію в адмінці й у блоці **«Реєстрації»** натисніть **«Додати новий»**. +2. Оберіть **користувача** — подія підставиться сама. +3. Збережіть. Дата реєстрації проставиться автоматично. + +Адміністратор може записати людину **навіть коли місць уже немає**, а також на подію, яка ще не опублікована або вже завершилась. Це свідомий виняток для ручних записів, тому стежте за лімітом самі. Записати двічі одну й ту саму людину не вдасться. + +## Скасувати реєстрацію + +Учасник скасовує сам. Адміністратор може **видалити** запис у розділі «Реєстрації на події» — місце звільниться, а учасник зможе зареєструватися знову (якщо подія ще не завершилась). + +:::info Запис не редагується +Після створення реєстрації **користувача і подію змінити не можна** — тільки видалити й створити нову. Так реєстрації не «переїжджають» між подіями випадково. +::: + +## Чого немає + +- **Листів-підтверджень і нагадувань** про подію система не надсилає. +- **Списку очікування** немає: коли місця скінчились, нові реєстрації неможливі, доки хтось не скасує свою. +- **Вивантаження в таблицю** окремо не налаштоване — список зручно переглядати в адмінці. + +## Типові питання + +### Учасник не бачить кнопку «Приєднатися» + +Кнопка зʼявляється лише в **онлайн**-події, лише для **зареєстрованого** учасника і лише поки подія не завершилась. Перевірте, що в події заповнено «Посилання на зустріч» і що людина є у списку «Реєстрації». + +### Що буде з реєстраціями, якщо видалити користувача? + +Усі його реєстрації видаляються разом з обліковим записом. Видалення події так само прибирає всі її реєстрації. + +### Чому не вдається записати людину вручну? + +Найчастіше — вона вже зареєстрована на цю подію. Перевірте список у блоці «Реєстрації». + +## Повʼязані статті + +- [Створення події](/admin/docs/manager/podii/stvorennia-podii) — поля, публікація, посилання на зустріч. +- [Події на сайті](/admin/docs/manager/podii/podii-na-saiti) — що бачить учасник. +- Технічні деталі — [Логіка подій](/admin/docs/technical/biznes-logika/podii). diff --git a/docs/admin-panel/manager/06-podii/03-podii-na-saiti.md b/docs/admin-panel/manager/06-podii/03-podii-na-saiti.md new file mode 100644 index 0000000..eedcf14 --- /dev/null +++ b/docs/admin-panel/manager/06-podii/03-podii-na-saiti.md @@ -0,0 +1,86 @@ +--- +title: Події на сайті +description: Де відвідувачі бачать події — каталог, сторінка події, календар, профіль, блок «Події» на сторінках і в публікаціях +--- + +Опубліковані події автоматично зʼявляються в кількох місцях сайту. Ця стаття допоможе зрозуміти, що саме побачить відвідувач і як вбудувати події в довільну сторінку чи публікацію. + +## Каталог подій + +Сторінка **`/events`** (англійською — `/en/events`) — головна вітрина. Вона має дві вкладки: + +- **Майбутні** — події, які ще не завершились; найближча подія винесена нагору великою карткою з відліком («3 дні до початку»); +- **Минулі** — архів завершених подій, картки приглушені й підписані «Завершилась». + +Позначка **«Ви зареєстровані»** на картках зʼявляється для залогінених учасників, які вже записалися. + +## Сторінка події + +Кожна подія має власну сторінку з обкладинкою, описом, датою й часом (за Києвом), кількістю вільних місць та кнопкою реєстрації. Залежно від формату: + +- **офлайн** — блок «Місце проведення» з адресою і кнопкою «Відкрити мапу»; +- **онлайн** — картка «Онлайн-зустріч» із кнопкою «Приєднатися» для зареєстрованих. + +Також на сторінці є: + +- **«Додати в календар»** — посилання для Google Календаря і файл `.ics` для Apple/Outlook та інших календарів; +- кнопки **«Поділитися»** (Telegram, Viber, WhatsApp, Facebook, X, копіювання посилання); +- **коментарі та лайки** — працюють так само, як під курсами й публікаціями (див. [Коментарі та лайки](/admin/docs/manager/spilnota/komentari-i-laiky)). + +:::tip Посилання на зустріч у календарі +У файл календаря й посилання Google для онлайн-події потрапляє адреса **сторінки події**, а не саме посилання на Zoom. Так посилання на зустріч не «розходиться» поза реєстрацією: учасник відкриває сторінку події й бере його там. +::: + +## Профіль учасника + +У профілі залогінений учасник бачить розділ **«Мої події»** — майбутні події, на які він зареєстрований, за порядком дат. Минулі та зняті з публікації події там не показуються. + +## Пошук + +Опубліковані події входять до пошуку по сайту разом з курсами й публікаціями (мітка «Подія» на картці результату). Пошуковий індекс оновлюється автоматично при публікації. + +## Блок «Події» на сторінках і в публікаціях + +Щоб показати події на довільній сторінці (наприклад, на «Про нас») або всередині статті, додайте блок **«Події»**: + +- у **сторінці** — на вкладці **Контент** натисніть «Додати» й оберіть **«Події»**; +- у **публікації** — у тексті вставте блок **«Події»** через меню блоків редактора (поруч із блоками «Архів», «Банер»). + +Налаштування блока: + +| Поле | Що робить | +| --- | --- | +| **Вступний текст** | Заголовок або пояснення над картками (необовʼязково) | +| **Джерело наповнення** | **Найближчі події** — автоматично показує події, що ще не завершились, за порядком дат. **Вибрані вручну** — лише ті, які ви оберете | +| **Кількість** | Скільки найближчих подій показати (за замовчуванням 3). Лише для джерела «Найближчі події» | +| **Вибрані події** | Список подій у потрібному порядку. Лише для «Вибрані вручну» | +| **Посилання «Всі події»** | Показувати під картками посилання на каталог | + +Що важливо знати: + +- Блок показує **лише опубліковані** події. Чернетка чи знята з публікації подія в ньому не зʼявиться, навіть якщо вона вибрана вручну. +- У режимі **«Вибрані вручну»** блок **ніколи** не підміняє вибір автоматичними подіями: якщо нічого не вибрано, під вступним текстом буде порожньо. +- Коли подія додається, змінюється чи знімається з публікації, сторінки з блоком оновлюються автоматично. + +## «Події» чи «Календар змін»? + +Це два різні інструменти, і їх легко сплутати: + +| | **Події** | **Календар змін** | +| --- | --- | --- | +| Що це | Повноцінна подія з реєстрацією, сторінкою, місцями | Короткий список анонсів на головній («Найближчі зміни») | +| Реєстрація на сайті | Так | Ні — картка веде на зовнішню форму | +| Де редагується | Розділ «Події» | Глобальний елемент «Календар змін» | +| Де видно | Каталог `/events`, профіль, пошук, блок «Події» | Секція на головній сторінці | + +Якщо захід потребує реєстрації на платформі — створюйте **подію**. «Календар змін» лишається для коротких анонсів із посиланням на зовнішню форму (див. [Глобальні блоки сайту](/admin/docs/manager/servisy/hlobalni-bloky)). + +## Швидкий доступ з сайту + +Коли ви залогінені як адміністратор, угорі кожної сторінки сайту зʼявляється чорна панель швидких посилань: **Адмін-панель, Курси, Події, Календар змін, Публікації, Сторінки, Користувачі** та «Вийти». Вона прихована на вузьких екранах (телефонах). Докладніше — в [Огляді адмін-панелі](/admin/docs/manager/osnovy/ohliad-adminky). + +## Повʼязані статті + +- [Створення події](/admin/docs/manager/podii/stvorennia-podii) — поля й публікація. +- [Реєстрації на події](/admin/docs/manager/podii/reiestratsii-na-podii) — учасники й ручні записи. +- [Сторінки](/admin/docs/manager/kontent/storinky) і [Публікації](/admin/docs/manager/kontent/publikatsii) — інші блоки конструктора. diff --git a/docs/admin-panel/manager/06-podii/_category.json b/docs/admin-panel/manager/06-podii/_category.json new file mode 100644 index 0000000..fa64303 --- /dev/null +++ b/docs/admin-panel/manager/06-podii/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Події", + "description": "Зустрічі, табори й онлайн-заходи: створення, реєстрація учасників, показ на сайті." +} diff --git a/docs/admin-panel/technical/01-arkhitektura/01-ohliad.md b/docs/admin-panel/technical/01-arkhitektura/01-ohliad.md index b7bb26c..644d9fa 100644 --- a/docs/admin-panel/technical/01-arkhitektura/01-ohliad.md +++ b/docs/admin-panel/technical/01-arkhitektura/01-ohliad.md @@ -33,7 +33,7 @@ src/ │ ├── (payload)/ # Адмінка (/admin) + REST (/api/[...slug]) + GraphQL │ └── api/ # Власні API-маршрути: auth, dev-login, │ # reindex-search, courses/[id]/completions, admin-docs -├── collections/ # Конфіги колекцій Payload (13 проєктних) +├── collections/ # Конфіги колекцій Payload (15 проєктних) ├── Header/ Footer/ HomeCalendar/ # Глобали (конфіг + RowLabel + revalidate-хук) ├── components/ # React-компоненти (admin/ і фронтенд) ├── hooks/ # Спільні Payload-хуки (rateLimitCreate, diff --git a/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md b/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md index 9a2316c..ca929b6 100644 --- a/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md +++ b/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md @@ -3,7 +3,7 @@ title: payload.config.ts і плагіни description: Розбір головного конфігу Payload — БД, локалізація, email, jobs — і десяти плагінів, порядок яких критичний --- -Головний конфіг — `src/payload.config.ts`. Він збирає 13 колекцій, 3 глобали, +Головний конфіг — `src/payload.config.ts`. Він збирає 15 колекцій, 3 глобали, масив плагінів із `src/plugins/index.ts` і налаштування, описані нижче. ## Секції конфігу diff --git a/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md b/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md index c6e2058..942c1ce 100644 --- a/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md +++ b/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md @@ -30,11 +30,17 @@ admin: { | Слот | Компонент | Що робить | | --- | --- | --- | | `beforeLogin` | `src/components/BeforeLogin` | Українське привітання над формою входу | -| `beforeDashboard` | `src/components/BeforeDashboard` | Лого, привітання по імені, 4 quick actions (новий курс / публікація / сторінка, медіатека) і лічильники courses/users/posts/enrollments/comments | +| `beforeDashboard` | `src/components/BeforeDashboard` | Лого, привітання по імені, 5 quick actions (новий курс / подія / публікація / сторінка, медіатека) і лічильники courses/users/posts/enrollments/events/event-enrollments/comments. Обидва списки — масиви `quickStats` / `quickActions` угорі файлу: нова колекція = один рядок | | `afterNavLinks` | `src/components/admin/Docs/DocsNavLinks` | Посилання на цю документацію в боковому меню | | `graphics.Icon` / `graphics.Logo` | `src/components/admin/graphics/*` | Лого «Залізна Зміна» замість Payload | | `views.docs` | `src/components/admin/Docs/DocsView` | Кастомний view `/admin/docs` — рендерер цієї документації (`exact: false` → підхоплює всі підшляхи `/admin/docs/...`) | +### Панель адміністратора на сайті (`AdminBar`) + +Це не слот Payload, а фронтенд-компонент: `src/components/AdminBar/index.tsx` рендериться у `[locale]/layout.tsx` і показує чорну смугу швидких посилань **лише адміністраторам** (клієнтська перевірка: сесія Better Auth заявляє роль `admin`, після чого роль підтверджується запитом `/api/users/me`; звичайні користувачі цей запит не роблять). На вузьких екранах панель прихована (`small-break` у `index.scss`). + +Посилання задає масив `quickLinks` — кожен елемент `{ href, icon, label }`: Курси, Події, Календар змін (`/admin/globals/home-calendar`), Публікації, Сторінки, Користувачі. Кнопок «+ створити» біля пунктів **немає** — створення доступне зі сторінок розділів і кнопок дашборду. Додати пункт = додати рядок у `quickLinks` (для глобалів `href` веде на `/admin/globals/`). + Компоненти документації: `DocsView/` (DocsShell, DocsHome, TrackHome, ArticleView, NotFoundView) + клієнтські `DocsSidebar.client.tsx` / `DocsToc.client.tsx`; пошуковий індекс віддає `/api/admin-docs/search-index`. diff --git a/docs/admin-panel/technical/02-model-danykh/01-ohliad.md b/docs/admin-panel/technical/02-model-danykh/01-ohliad.md index b979398..f196054 100644 --- a/docs/admin-panel/technical/02-model-danykh/01-ohliad.md +++ b/docs/admin-panel/technical/02-model-danykh/01-ohliad.md @@ -3,7 +3,7 @@ title: Огляд моделі даних description: Усі колекції платформи, схема звʼязків між ними та повна карта ручних каскадів видалення --- -## Проєктні колекції (13) +## Проєктні колекції (15) Оголошені в `src/collections/` і зареєстровані в `src/payload.config.ts`: @@ -16,12 +16,14 @@ description: Усі колекції платформи, схема звʼязк | `enrollments` | `Enrollments.ts` | Запис користувача на курс + увесь прогрес | | `quiz-attempts` | `QuizAttempts.ts` | Спроби фінального тесту (оцінені сервером) | | `xp-events` | `XpEvents.ts` | Append-only лог нарахувань XP (періодні лідерборди) | +| `events` | `Events.ts` | Події: офлайн/онлайн, дати, місця, drafts/versions — [деталі](/admin/docs/technical/model-danykh/podii) | +| `event-enrollments` | `EventEnrollments.ts` | Реєстрація користувача на подію (unique `user × event`) | | `posts` | `Posts/index.ts` | Публікації (Lexical, SEO, drafts) | | `pages` | `Pages/index.ts` | CMS-сторінки (hero + blocks, drafts) | | `categories` | `Categories.ts` | Категорії постів (nested docs) | | `media` | `Media.ts` | Зображення (Vercel Blob, focal point, folders) | -| `comments` | `Comments.ts` | Коментарі до постів/курсів (треди) | -| `likes` | `Likes.ts` | Лайки постів/курсів/коментарів | +| `comments` | `Comments.ts` | Коментарі до постів/курсів/подій (треди) | +| `likes` | `Likes.ts` | Лайки постів/курсів/подій/коментарів | ## Колекції плагінів @@ -35,7 +37,7 @@ description: Усі колекції платформи, схема звʼязк | `redirects` | plugin-redirects | Перенаправлення для pages/posts | | `forms` | plugin-form-builder | Конструктор форм | | `form-submissions` | plugin-form-builder | Відповіді форм | -| `search` | plugin-search | Пошуковий індекс (posts, courses, course-categories, pages) | +| `search` | plugin-search | Пошуковий індекс (posts, courses, course-categories, pages, events) | | `payload-mcp-api-keys` | plugin-mcp | API-ключі MCP-клієнтів | Службові колекції Payload: `payload-kv`, `payload-jobs` (черга @@ -51,13 +53,17 @@ Mermaid рендерер не підтримує, тому — таблиця в | --- | --- | --- | --- | | `enrollments` | `user` | `users` | rel, required, unique разом із `course` | | `enrollments` | `course` | `courses` | rel, required | +| `event-enrollments` | `user` | `users` | rel, required, unique разом із `event` | +| `event-enrollments` | `event` | `events` | rel, required | +| `events` | `registrations` | `event-enrollments` | join (віртуальне, зворотне до `event`) | +| `events` | `cover` | `media` | upload | | `quiz-attempts` | `user`, `course` | `users`, `courses` | rel, required | | `xp-events` | `user`, `course` | `users`, `courses` | rel, required | | `comments` | `author` | `users` | rel, required | | `comments` | `parent` | `comments` | rel (треди) | -| `comments` | `targetCollection` + `targetId` | `posts` \| `courses` | **поліморфний, без FK** | +| `comments` | `targetCollection` + `targetId` | `posts` \| `courses` \| `events` | **поліморфний, без FK** | | `likes` | `user` | `users` | rel, required | -| `likes` | `targetCollection` + `targetId` | `posts` \| `courses` \| `comments` | **поліморфний, без FK** | +| `likes` | `targetCollection` + `targetId` | `posts` \| `courses` \| `events` \| `comments` | **поліморфний, без FK** | | `courses` | `category` | `course-categories` | rel | | `courses` | `heroImage` | `media` | upload | | `courses` | `steps[].file` (fileStep) | `course-files` | upload, required | @@ -91,14 +97,15 @@ Drizzle генерує для relationship-полів FK з `ON DELETE SET NULL` ### Повна карта каскадів -**`users.beforeDelete`** (`src/collections/Users/index.ts`) — 5 колекцій, у +**`users.beforeDelete`** (`src/collections/Users/index.ts`) — 6 колекцій, у цьому порядку: 1. `xp-events` де `user = id` 2. `quiz-attempts` де `user = id` 3. `enrollments` де `user = id` -4. `likes` де `user = id` -5. `comments` де `author = id` +4. `event-enrollments` де `user = id` +5. `likes` де `user = id` +6. `comments` де `author = id` **`courses.beforeDelete`** (`src/collections/Courses.ts`) — 5 запитів: @@ -108,6 +115,13 @@ Drizzle генерує для relationship-полів FK з `ON DELETE SET NULL` 4. `comments` де `targetCollection = 'courses'` і `targetId = id` 5. `likes` де `targetCollection = 'courses'` і `targetId = id` +**`events.beforeDelete`** (`src/collections/Events.ts`) — 4 кроки: + +1. `event-enrollments` де `event = id` +2. `likes` коментарів події (`targetCollection = 'comments'`, `targetId ∈` коментарі події) — сторінками по 1000 +3. `likes` де `targetCollection = 'events'` і `targetId = id` +4. `comments` де `targetCollection = 'events'` і `targetId = id` + **`deleteComment`** (server action, не хук) — каскад одного коментаря: лайки коментаря → прямі відповіді (`parent = commentId`, **один рівень** — «онуки» осиротіють) → сам коментар. @@ -125,17 +139,17 @@ Drizzle генерує для relationship-полів FK з `ON DELETE SET NULL` ## Спільні патерни колекцій - **Access-функції** з `src/access/`: `admin`, `anyone`, `authenticated`, - `authenticatedOrPublished` + інлайнові `adminOrOwn` (enrollments, likes, + `authenticatedOrPublished` + інлайнові `adminOrOwn` (enrollments, event-enrollments, likes, quiz-attempts) і `adminOrAuthor` (comments). - **`lockDocuments: false`** — всюди, де задано: блокування документів вимкнено свідомо (сольна адмін-команда). -- **Drafts/versions** лише у контентних колекцій: `pages`, `posts`, `courses` - (autosave 10000/2000/10000 мс, `schedulePublish`, `maxPerDoc: 50`). +- **Drafts/versions** лише у контентних колекцій: `pages`, `posts`, `courses`, `events` + (autosave 10000/2000/10000/10000 мс, `schedulePublish`, `maxPerDoc: 50`). - **Слаги** — core `slugField({ slugify: cyrillicSlugify })`: транслітерація кирилиці, `undefined` замість `''` (щоб autosave-чернетки не билися об unique-індекс). - **Rate limit на create** — фабрика `rateLimitCreate` - (`src/hooks/rateLimitCreate.ts`) у enrollments, comments, likes + (`src/hooks/rateLimitCreate.ts`) у enrollments, event-enrollments, comments, likes (див. [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting)). Детальні статті: [courses](/admin/docs/technical/model-danykh/courses), diff --git a/docs/admin-panel/technical/02-model-danykh/07-comments-likes.md b/docs/admin-panel/technical/02-model-danykh/07-comments-likes.md index c8884fb..5f90ea9 100644 --- a/docs/admin-panel/technical/02-model-danykh/07-comments-likes.md +++ b/docs/admin-panel/technical/02-model-danykh/07-comments-likes.md @@ -18,7 +18,7 @@ description: Колекції взаємодії — поліморфний та | --- | --- | --- | | `body` | textarea | required, **maxLength 2000** | | `author` | rel → `users` | required, index, readOnly в адмінці | -| `targetCollection` | select | required, index; options: `posts` («Публікації»), `courses` («Курси») | +| `targetCollection` | select | required, index; options: `posts` («Публікації»), `courses` («Курси»), `events` («Події») | | `targetId` | number | required, index | | `parent` | rel → `comments` | index — тред-відповіді | @@ -51,7 +51,7 @@ description: Колекції взаємодії — поліморфний та | Поле | Тип | Атрибути | | --- | --- | --- | | `user` | rel → `users` | required, index | -| `targetCollection` | select | required, index; options: `posts`, `courses`, **`comments`** (лайкати можна й коментарі — на відміну від самих comments) | +| `targetCollection` | select | required, index; options: `posts`, `courses`, `events`, **`comments`** (лайкати можна й коментарі — на відміну від самих comments) | | `targetId` | number | required, index | ### Unique-індекс @@ -98,7 +98,7 @@ indexes: [{ fields: ['user', 'targetCollection', 'targetId'], unique: true }], `likesCount` + `userLiked`; видалений автор → `{id: 0, name: ''}`. Зворотні каскади від контенту: `courses.beforeDelete` зачищає -comments/likes з `targetCollection='courses'`; `users.beforeDelete` — усі +comments/likes з `targetCollection='courses'`; `events.beforeDelete` — з `targetCollection='events'` (включно з лайками коментарів події); `users.beforeDelete` — усі коментарі/лайки користувача. **Пости каскаду не мають** — їхні коментарі/лайки після видалення поста лишаються сиротами (див. [Огляд моделі даних](/admin/docs/technical/model-danykh/ohliad)). diff --git a/docs/admin-panel/technical/02-model-danykh/09-plahinni-kolektsii.md b/docs/admin-panel/technical/02-model-danykh/09-plahinni-kolektsii.md index 8632e46..0780d89 100644 --- a/docs/admin-panel/technical/02-model-danykh/09-plahinni-kolektsii.md +++ b/docs/admin-panel/technical/02-model-danykh/09-plahinni-kolektsii.md @@ -8,9 +8,9 @@ description: search, redirects, forms, MCP-ключі та службові payl ## search -Створюється `searchPlugin` для чотирьох колекцій +Створюється `searchPlugin` для пʼяти колекцій (`searchIndexedCollections` у `src/search/localeSync.ts`): `posts`, `courses`, -`course-categories`, `pages`. Один документ джерела → один search-рядок, +`course-categories`, `pages`, `events`. Один документ джерела → один search-рядок, який плагін створює/оновлює в afterChange (тільки published; чернетки видаляються з індексу). @@ -99,7 +99,7 @@ Prompts, перекладені як Інструменти / Ресурси / | `likes` | `{find: true, create: true, update: false, delete: true}` — дзеркалить власний access колекції (update заборонений усім) | | глобали `header`, `footer` | enabled | -Прогрес-колекції (`enrollments`, `quiz-attempts`, `xp-events`), `users` та +Прогрес-колекції (`enrollments`, `event-enrollments`, `quiz-attempts`, `xp-events`), а також `events` (поки що), `users` та auth-колекції в MCP **не** експоновані — навмисно: MCP-клієнт (AI-інструмент) може вести контент, але не може торкатися прогресу, сертифікатної підстави чи акаунтів. Кожна експонована колекція має `description` у конфігу плагіна — diff --git a/docs/admin-panel/technical/02-model-danykh/10-podii.md b/docs/admin-panel/technical/02-model-danykh/10-podii.md new file mode 100644 index 0000000..9cff382 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/10-podii.md @@ -0,0 +1,120 @@ +--- +title: Події (events, event-enrollments) +description: Колекції events і event-enrollments — поля, access, індекси, каскади видалення та як вони підключені до коментарів, пошуку й блоків +--- + +Функціонал подій складається з двох колекцій у групі адмінки «Події». Бізнес-правила реєстрації, час, календар і кешування розібрані окремо — [Логіка подій](/admin/docs/technical/biznes-logika/podii). + +## events (`src/collections/Events.ts`) + +Заходи з датою. `useAsTitle: 'title'`, колонки списку `[title, startDate, locationType, _status]`, `lockDocuments: false`. + +### Access + +| Операція | Правило | +| --- | --- | +| create / update / delete | `admin` | +| read | `authenticatedOrPublished` — анонім бачить лише `_status = 'published'` | + +### Версії + +`versions.drafts` з `autosave.interval = 10000`, `schedulePublish: true`, `maxPerDoc: 50` — як у `courses`. Slug через core `slugField({ slugify: cyrillicSlugify, position: undefined })` (дивись [Огляд моделі даних](/admin/docs/technical/model-danykh/ohliad) про `undefined` замість `''`). + +### Поля + +| Поле | Тип | Нотатки | +| --- | --- | --- | +| `title` | text | required, **localized** | +| `slug` | slugField | unique, з `title` | +| `description` | textarea | **localized** | +| `cover` | upload → `media` | картка/hero/OG-зображення | +| `startDate` | date | required, **index** (upcoming/past-запити); dayAndTime | +| `endDate` | date | необовʼязкове; `validate`: строго пізніше за `startDate` | +| `locationType` | select | `local` \| `virtual`, required, default `local` | +| `address` | text | **localized**; `admin.condition` лише для `local`; `validate` вимагає значення при `local` | +| `mapLink` | text | лише `local`; http(s)-валідація | +| `meetingLink` | text | лише `virtual`; `validate` вимагає значення при `virtual`; **field access `read: admin`** | +| `capacity` | number | `min: 1`, sidebar; порожньо = без ліміту | +| `registrations` | **join** → `event-enrollments` (`on: 'event'`) | віртуальне (без колонки/міграції); `defaultLimit: 50`, `defaultSort: '-enrolledAt'` | +| `publishedAt` | date | sidebar; field-`beforeChange` ставить `new Date()` при першій публікації | + +Валідатори `address` і `meetingLink` — це `validate`, який Payload **не запускає для чернеток**, тож чернетку можна зберегти неповною, а от опублікувати без обовʼязкових полів формату — ні. + +:::warning meetingLink — лише адміністратору через REST +Раніше поле читав будь-який залогінений користувач, але акаунт може створити кожен, тож це було фактично публічним. Тепер `access.read = admin`. Сторінки читають подію з `overrideAccess: false` (поле не потрапляє ні в JSON, ні в статичний HTML), а зареєстрованому учаснику посилання віддає server action `getEventJoinInfo` — див. [Логіка подій](/admin/docs/technical/biznes-logika/podii). +::: + +### Join-поле `registrations` + +Показує реєстрації на сторінці події в адмінці (з кнопкою «Додати новий» для ручного запису). Це reverse-relationship, тож читається за правилами `event-enrollments.read` (`adminOrOwn`): адміністратор бачить усе, анонім — нічого, звичайний користувач — лише свій рядок. Тест `events.int.spec.ts` фіксує, що публічне читання події не віддає чужих реєстрацій. + +### Хуки + +- `afterChange: [revalidateEvent]`, `afterDelete: [revalidateEventDelete]` — `src/hooks/revalidateEvent.ts`. +- `beforeDelete` — ручний каскад (див. нижче). + +### Каскад при видаленні події + +`event_enrollments.event_id` — `NOT NULL` з FK `ON DELETE SET NULL`, тож Postgres упав би на видаленні події. `beforeDelete` чистить у такому порядку (усе з `req` — в одній транзакції): + +1. `event-enrollments` де `event = id`; +2. **лайки коментарів події** — сторінками по 1000 коментарів (`select: {}`), щоб не обрізатись на великих обсягах: `likes` де `targetCollection = 'comments'` і `targetId ∈ commentIds`; +3. `likes` де `targetCollection = 'events'` і `targetId = id`; +4. `comments` де `targetCollection = 'events'` і `targetId = id`. + +Лайки/коментарі поліморфні (без FK), тому цю чистку ніхто, крім хука, не зробить. + +## event-enrollments (`src/collections/EventEnrollments.ts`) + +Реєстрація користувача на подію. `useAsTitle: 'id'`, колонки `[user, event, enrolledAt]`. + +### Access + +| Операція | Правило | +| --- | --- | +| create | `authenticated` (правила — у `beforeValidate`) | +| read | `adminOrOwn` | +| update | `admin` | +| delete | `adminOrOwn` — власник може **скасувати** свою реєстрацію напряму | + +На відміну від `enrollments` тут немає прогресу, який можна підробити, тому owner-delete безпечний (як у `likes`). + +### Поля та індекси + +| Поле | Тип | Нотатки | +| --- | --- | --- | +| `user` | rel → `users` | required, index, **field `access.update: () => false`** | +| `event` | rel → `events` | required, index, **field `access.update: () => false`** | +| `enrolledAt` | date | readOnly; штампується в `beforeChange` при create | + +`indexes: [{ fields: ['user', 'event'], unique: true }]` — у БД індекс має імʼя `user_event_idx` (адаптер не додає префікс таблиці до складених індексів). + +`user` і `event` **редагуються лише при створенні** (адмін вибирає учасника вручну в drawer-і join-поля), а існуючий запис перепризначити не можна — field access `update` це блокує навіть для адміністратора. Раніше поля були `admin.readOnly`, через що ручний запис з адмінки був неможливий. + +### Хуки (`beforeValidate`, порядок важливий) + +1. `rateLimitCreate({ prefix: 'event-enroll-create', windowSeconds: 600, max: 30 })`. +2. Привʼязка `data.user = req.user.id` для не-адмінів + перевірка дубліката (`409`). +3. Бізнес-правила для не-адмінів (`404` не опублікована, `400` завершилась, `409` немає місць з advisory lock) — [Логіка подій](/admin/docs/technical/biznes-logika/podii). + +`beforeChange` штампує `enrolledAt` при `create`. + +## Звʼязки з іншими колекціями + +| Що | Як подія підключена | +| --- | --- | +| `comments`, `likes` | `targetCollection` має опцію `events`; міграція `20260928_120000_event_interactions` додає значення в enum-и `enum_comments_target_collection` і `enum_likes_target_collection`; `down()` навмисно лишає значення, щоб не втратити контент | +| `users` | `Users.beforeDelete` тепер чистить і `event-enrollments` (обидві FK `NOT NULL`) | +| `search` | `events` у `searchIndexedCollections` (`src/search/localeSync.ts`) + гілка `collection === 'events'` у `beforeSync.ts` (`meta.image` = `cover`) | +| `pages`, `posts` | блок `eventsBlock` — у `layout` сторінок і в `BlocksFeature` постів | +| MCP | `events` та `event-enrollments` **не** експоновані в MCP; описи `comments`/`likes` згадують події | + +## Схема БД + +Міграція `20260815_120000_events`: таблиці `events`, `events_locales`, `_events_v`, `_events_v_locales`, `event_enrollments`, блокові `pages_blocks_events_block` / `_pages_v_blocks_events_block`, колонки `events_id` у `pages_rels`, `_pages_v_rels`, `search_rels`, пʼять enum-типів. `lockDocuments: false` — тож колонок у `payload_locked_documents_rels` не потрібно. Join-поле `registrations` віртуальне й нічого в схемі не додає. Про те, як міграції потрапляють у прод і чому previews їх не отримують — [Міграції](/admin/docs/technical/infrastruktura/mihratsii). + +## Повʼязані статті + +- [Логіка подій](/admin/docs/technical/biznes-logika/podii) — реєстрація, час, календар, кеш. +- [Comments та likes](/admin/docs/technical/model-danykh/comments-likes) — поліморфні цілі. +- [Ролі та доступ](/admin/docs/technical/autentyfikatsiya/roli-i-dostup) — зведена матриця access. diff --git a/docs/admin-panel/technical/03-biznes-logika/06-rate-limiting.md b/docs/admin-panel/technical/03-biznes-logika/06-rate-limiting.md index 04f5794..2c3045d 100644 --- a/docs/admin-panel/technical/03-biznes-logika/06-rate-limiting.md +++ b/docs/admin-panel/technical/03-biznes-logika/06-rate-limiting.md @@ -120,6 +120,7 @@ if (!limit.ok) return { success: false, error: 'Забагато спроб. С | Ключ / шлях | Вікно | Max | Де застосовано | | --- | --- | --- | --- | | `enroll-create:` | 600 с | 30 | хук `enrollments` (`rateLimitCreate`) | +| `event-enroll-create:` | 600 с | 30 | хук `event-enrollments` (`rateLimitCreate`) | | `comment-create:` | 60 с | 10 | хук `comments` (`userField: 'author'`) | | `like-create:` | 60 с | 60 | хук `likes` | | `quiz-submit:` | 3600 с | 30 | `submitQuizAttempt` (server action) | diff --git a/docs/admin-panel/technical/03-biznes-logika/07-podii.md b/docs/admin-panel/technical/03-biznes-logika/07-podii.md new file mode 100644 index 0000000..884abe0 --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/07-podii.md @@ -0,0 +1,112 @@ +--- +title: Логіка подій +description: Правила реєстрації, захист від перевищення місць, доступ до посилання на зустріч, час і календар, кешування та інтеграції подій +--- + +Модель даних — у статті [Події (events, event-enrollments)](/admin/docs/technical/model-danykh/podii). Тут — поведінка: як обробляється реєстрація, що приховано від публіки, як рахується час і як сторінки залишаються статичними. + +## Server actions (`src/app/(frontend)/[locale]/events/actions.ts`) + +Усі дії — `'use server'`, авторизація через `getSession()`, доступ до даних через Local API. Клієнт ніколи не пише в `event-enrollments` напряму. + +| Дія | Що робить | +| --- | --- | +| `getMyEventEnrollments()` | id усіх подій, на які зареєстрований користувач (для бейджів у каталозі). Для гостя — `[]` | +| `getEventEnrollment(eventId)` | реєстрація користувача на подію або `null` | +| `enrollInEvent(eventId)` | створює реєстрацію; **ідемпотентна** — якщо вже є, повертає її без помилки. `APIError` зі статусом `< 500` перетворює на `{ success: false, error }` (текст правила з хука), `429` — на «Забагато запитів». Після успіху ревалідує сторінки події | +| `unenrollFromEvent(eventId)` | видаляє власну реєстрацію; якщо її немає — успіх (ідемпотентно) | +| `getEventJoinInfo(eventId)` | `{ enrolled, meetingLink }` — посилання віддається **лише** зареєстрованому, лише для опублікованої `virtual`-події | + +Усі правила (опублікована, не завершена, є місце, немає дубліката) живуть у `beforeValidate` колекції, а не в actions — тому діють однаково для server actions, REST і Local API; винятком є адміністратор. + +## Правила реєстрації та перевищення місць + +Для не-адмінів `event-enrollments.beforeValidate` перевіряє: + +1. подія існує й `_status === 'published'` → інакше `404`; +2. подія не завершилась: `endDate ?? startDate` не в минулому → інакше `400`. Тобто реєструватись можна **під час** події; +3. є вільне місце (`count(event-enrollments) < capacity`) → інакше `409`. + +Адміністратор ці перевірки обходить (ручні записи), але дублікат `user × event` не пройде ніколи (`409` + unique-індекс `user_event_idx`). + +### Advisory lock проти гонки + +Перевірка «порахували → створили» сама по собі не атомарна: двоє одночасних запитів на останнє місце обидва бачили б `count = capacity - 1`. Тому перед підрахунком береться **transaction-scoped advisory lock**: + +```ts +select pg_advisory_xact_lock(7301, ) +``` + +`lockEventSeats(req, eventId)` дістає drizzle-сесію поточної транзакції (`req.payload.db.sessions[transactionID].db`) і виконує `pg_advisory_xact_lock`. Ключ — пара `(7301, eventId)`, тож серіалізуються лише реєстрації **на ту саму подію**; лок знімається сам при коміті/роллбеку. Без відкритої транзакції лок відпустився б одразу, тому в цьому разі крок пропускається. + +Тест `event-enrollments.int.spec.ts › never exceeds capacity when registrations arrive concurrently` запускає два create паралельно на подію з `capacity: 1` і очікує рівно один успіх; без виклику `lockEventSeats` він стабільно падає. + +## Посилання на зустріч: три шари захисту + +`meetingLink` — «перепустка» на онлайн-подію, тому приховується так: + +1. **Field access** `read: admin` — REST/GraphQL віддають поле лише адміністраторам. +2. **Публічні запити** (`/events`, `/events/[slug]`, `.ics`, блок) читають подію з `overrideAccess: false` без користувача — поле не потрапляє в дані сторінки, отже й у спільний ISR-кеш. +3. **Зареєстрований учасник** отримує посилання після гідрації через `getEventJoinInfo` (Local API, тому field access його не блокує, а перевірка реєстрації робиться в самій дії). + +Календарні файли (`.ics` та посилання Google) для віртуальної події містять адресу **сторінки події** як `LOCATION`, а не саме посилання на зустріч. + +### Чому без вбудованого Zoom + +Вбудувати Zoom-клієнт можна лише через Meeting SDK: окремий застосунок у Zoom Marketplace, ендпоінт підпису JWT на бекенді (Client ID/Secret) і, з березня 2026, авторизація застосунків для зустрічей поза власним акаунтом (ZAK/OBF-токени). Це важка інфраструктура й відповідальність за секрети заради мінімального виграшу. Тому посилання — звичайне поле, а картка `EventJoinCard` визначає платформу за хостом (`detectMeetingPlatform` у `src/utilities/eventTime.ts`: Zoom / Google Meet / YouTube / інше) і підбирає акцент. Додати нову платформу — один запис у `PLATFORM_ACCENTS` та `MEETING_PLATFORM_LABELS`. + +## Час + +Усі події показуються за **`Europe/Kyiv`** (`EVENT_TIME_ZONE`), незалежно від часового поясу сервера чи відвідувача. Форматери в `src/utilities/eventTime.ts` (`formatEventDate`, `formatEventTime`, `formatEventRange`, `formatEventMonthShort`, `formatEventDayNumber`, `isSameEventDay`) завжди передають `timeZone`, а порівняння «той самий день» роблять за київським ключем дня — інакше подія о 00:30 за Києвом зʼїжджала б на попередній день на UTC-сервері. + +У БД дати зберігаються як `timestamptz` (UTC). Файл `.ics` і посилання Google Calendar використовують UTC-моменти. Подія без `endDate` у календарі отримує тривалість **1 година** (обовʼязкова вимога обох форматів). + +## Рендер і кеш + +| Сторінка | Стратегія | +| --- | --- | +| `/events` | ISR `revalidate = 300`; клієнтський `EventsExplorer` ділить події на «Майбутні / Минулі» за **живим годинником** (початковий стан бере `serverNow` з рендера, щоб гідрація збігалась), а відлік оновлюється щохвилини | +| `/events/[slug]` | ISR `revalidate = 300`, `generateStaticParams` по опублікованих; запит події обгорнуто в `cache()`, щоб `generateMetadata` і сторінка ділили один SELECT | +| `/api/events/[id]/calendar.ics` | функція з `Cache-Control: public, s-maxage=300` | + +Персональний стан (зареєстрований чи ні, посилання на зустріч) підвантажується **на клієнті**: `EventUserStateProvider` викликає `getEventJoinInfo`, `useMyEventEnrollments` — `getMyEventEnrollments`. У публічних компонентах немає `getSession()` і `useSearchParams()` — див. правила продуктивності в [Огляді архітектури](/admin/docs/technical/arkhitektura/ohliad). Після (від)реєстрації `EventActionBar` викликає `refresh()` провайдера (оновлює бейдж і кнопку «Приєднатися»), а лічильник місць оновлює сам Next: server action викликає `revalidatePath`, тож поточний маршрут перерендерюється — явний `router.refresh()` не потрібен. + +### Ревалідація + +`revalidateEvent` / `revalidateEventDelete` (`src/hooks/revalidateEvent.ts`) для кожного префікса локалі (`''` і `/en`) скидають: + +- `/events/` та `/events`; +- `/` (головна), `/[slug]` і `/posts/[slug]` — бо блок «Події» може бути вбудований у головну, CMS-сторінки й пости. + +Слаг-зміна чи зняття з публікації скидає і **попередній** slug. Виклик обгорнуто в `try/catch`: планові публікації (`schedulePublish`) виконуються поза запитом, де `revalidatePath` кидає — тоді це лише попередження в лозі. Прапорець `context.disableRevalidate` вимикає ревалідацію (потрібен у vitest). + +## Інтеграції + +- **Блок `eventsBlock`** (`src/blocks/EventsBlock/`): у `Pages.layout` і `Posts` (Lexical `BlocksFeature`); рендер — `RenderBlocks.tsx` для сторінок і серверний конвертер у `src/components/RichText/WithArchive.tsx` для постів (потрібен Local API, тому не в клієнтському бандлі). Обидва режими читають події з `overrideAccess: false`, `draft: false` і `_status = published`. **`populateBy: 'selection'` ніколи не переходить на запит «найближчих»** — порожній вибір дає порожній блок; вибрані події зберігають порядок вибору. «Найближчі» = `endDate ≥ now` **або** (`endDate` порожнє й `startDate ≥ now`), `sort: startDate`. +- **Профіль** (`profile/page.tsx`): `event-enrollments` користувача з `depth: 1`, відфільтровані до опублікованих і не завершених, за зростанням `startDate`. +- **Коментарі та лайки**: `InteractionSection targetCollection="events"`; `ProfileLatestComments` резолвить назви й посилання подій (лише опублікованих). +- **Пошук**: `beforeSync` мапить подію в search-документ; на сторінці пошуку мітка типу — `searchTypeEvent`, тип картки `CardRelationTo` містить `'events'`. +- **SEO**: `generateMetadata` (title, canonical + `hreflang`-alternates, OpenGraph, Twitter), JSON-LD `Event` (`eventAttendanceMode`, `VirtualLocation` без посилання на зустріч). Абсолютні URL будує `getPreviewAwareServerURL()` (`src/utilities/getURL.ts`) — на preview-деплої це alias гілки, `robots: noindex`. + +## Адмін-поверхня + +- **Меню**: група «Події» (`events`, `event-enrollments`). +- **Дашборд** (`BeforeDashboard`): лічильники «Події» і «Реєстрації на події», швидка дія «Нова подія». +- **Панель адміністратора на сайті** (`src/components/AdminBar`): посилання «Події» (і «Календар змін» — глобал `home-calendar`), без кнопок «+ створити». +- **Ручна реєстрація**: join-поле `registrations` на сторінці події → «Додати новий». +- **Документація**: цей розділ і [менеджерська категорія «Події»](/admin/docs/manager/podii/stvorennia-podii). + +## Тести + +| Файл | Що покриває | +| --- | --- | +| `tests/int/events.int.spec.ts` | валідація публікації (адреса / посилання / `endDate > startDate`), видимість чернеток, `meetingLink` лише для адміна, заборона create/update не-адмінам, join `registrations` (адмін бачить, анонім — ні), каскад видалення (реєстрації, коментарі, лайки), форматери часу | +| `tests/int/event-enrollments.int.spec.ts` | дублікат, чернетка, завершена, ongoing, місткість + override адміна, **гонка за останнє місце**, привʼязка `user`, матриця read, owner-delete, заборона owner-update, ручний запис адміном і незмінність `user`/`event` | +| `tests/e2e/events.e2e.spec.ts` | заголовки uk/en, сторінка події (час за Києвом, SEO, JSON-LD, коментарі), запрошення увійти, `.ics` | +| `tests/e2e/admin.e2e.spec.ts` | дашборд подій, розділи «Події» / «Реєстрації», панель адміністратора на сайті | + +## Повʼязані статті + +- [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting) — ліміт `event-enroll-create`. +- [Comments та likes](/admin/docs/technical/biznes-logika/komentari-laiky) — спільна логіка взаємодії. +- [Міграції](/admin/docs/technical/infrastruktura/mihratsii) — схема подій і preview-база. diff --git a/docs/admin-panel/technical/04-autentyfikatsiya/03-roli-i-dostup.md b/docs/admin-panel/technical/04-autentyfikatsiya/03-roli-i-dostup.md index 5397379..2505eda 100644 --- a/docs/admin-panel/technical/04-autentyfikatsiya/03-roli-i-dostup.md +++ b/docs/admin-panel/technical/04-autentyfikatsiya/03-roli-i-dostup.md @@ -70,7 +70,7 @@ const adminOrOwn: Access = ({ req: { user } }) => { } ``` -- `adminOrOwn` — `Enrollments.ts`, `Likes.ts`, `QuizAttempts.ts` (адмін бачить усе, інші — лише свої записи, анонім — нічого); +- `adminOrOwn` — `Enrollments.ts`, `EventEnrollments.ts`, `Likes.ts`, `QuizAttempts.ts` (адмін бачить усе, інші — лише свої записи, анонім — нічого); - `adminOrAuthor` — `Comments.ts`, те саме з фільтром `{ author: { equals: user.id } }`. Обидва повертають **query-фільтр**, а не boolean — Payload вшиває його в запит, тож «чужі» документи для власника просто не існують (у списках, лічильниках і по прямому id). @@ -118,13 +118,15 @@ const adminOrOwn: Access = ({ req: { user } }) => { | `media`, `course-files`, `categories`, `course-categories` | admin | anyone | admin | admin | | `users` | admin | *(дефолт: admin-or-self)* | *(дефолт: admin-or-self, self лише `name`)* | admin | | `enrollments` | authenticated | adminOrOwn | **admin** | admin | +| `events` | admin | authenticatedOrPublished (поле `meetingLink` — лише **admin**) | admin | admin | +| `event-enrollments` | authenticated | adminOrOwn | admin (поля `user`/`event` не змінюються ніколи) | **adminOrOwn** (власник скасовує реєстрацію) | | `quiz-attempts` | **admin** | adminOrOwn | admin | admin | | `xp-events` | admin | admin | admin | admin | | `comments` | authenticated | anyone | admin | adminOrAuthor | | `likes` | authenticated | anyone | **`() => false`** (ніхто) | adminOrOwn | | Глобали `header`/`footer` | — | public | admin | — | -Ключові рішення: `enrollments.update` — admin-only, бо прогрес пишуть server actions через Local API (відкритий owner-update дозволив би підробити `completedSteps`/`quizPassed`); `quiz-attempts.create` — admin-only, бо оцінювання серверне (відкритий create = підроблені бали); `likes.update` заборонений усім — лайк або існує, або ні. Записи, що їх створює `authenticated`, захищені хуками: колекції примусово ставлять `user`/`author = req.user.id` для не-адмінів. +Ключові рішення: `enrollments.update` — admin-only, бо прогрес пишуть server actions через Local API (відкритий owner-update дозволив би підробити `completedSteps`/`quizPassed`); `quiz-attempts.create` — admin-only, бо оцінювання серверне (відкритий create = підроблені бали); `likes.update` заборонений усім — лайк або існує, або ні. `event-enrollments` дозволяє власнику видалення, бо там немає прогресу, який можна підробити (як у `likes`), а `events.meetingLink` закритий field access-ом `admin`, бо зареєструватись може будь-хто. Записи, що їх створює `authenticated`, захищені хуками: колекції примусово ставлять `user`/`author = req.user.id` для не-адмінів. ### Дві додаткові поверхні diff --git a/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md b/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md index 34cac07..c075968 100644 --- a/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md +++ b/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md @@ -129,6 +129,17 @@ Payload має вбудований механізм `prodMigrations` (мігр Історична примітка: раніше Build Command у дашборді Vercel був перевизначений на `payload migrate && pnpm build` — він ганяв міграції без гейта в кожному оточенні і тінив би `vercel-build`. Перевизначення знято; поведінка білда тепер повністю контролюється репозиторієм. +## Приклади з подій і підводні камені + +Функціонал подій пішов двома міграціями, на яких добре видно типові випадки: + +- `20260815_120000_events` — нові таблиці, enum-и, блокові таблиці сторінок і колонки в `*_rels`. Імʼя складеного індексу (`user_event_idx`) береться **з того, що згенерував push**, а не з ваших очікувань — звіряйте з БД сесійного бранча. +- `20260928_120000_event_interactions` — додавання значення `events` у enum-и `enum_comments_target_collection` та `enum_likes_target_collection` через `ALTER TYPE … ADD VALUE IF NOT EXISTS`. `down()` свідомо **нічого не видаляє**: прибрати значення з enum-а неможливо без перестворення типу, а це стерло б контент. + +:::warning Preview-база не отримує міграцій +Preview-деплой не запускає міграції, а його база — довгоживучий Neon-бранч `preview` — сама не оновлюється. Тому PR зі змінами схеми може успішно зібратись, але падати на `relation "…" does not exist` під час збірки (запити в `generateStaticParams`) або на `invalid input value for enum …` у рантаймі — наприклад, коментарі під подією не завантажувались, доки в enum preview-бази не зʼявилось значення `events`. Перед перевіркою preview застосуйте схему до цього бранча вручну (той самий ідемпотентний SQL) або перестворіть бранч від актуального `dev`. Продакшн такої проблеми не має — міграції виконуються під час його збірки. +::: + ## Чеклист перед відкриттям PR зі схемною зміною 1. Міграція в `src/migrations/` створена, відредагована до вашої зміни, ідемпотентна, з `down()`. diff --git a/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md b/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md index 03b4432..ce33e50 100644 --- a/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md +++ b/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md @@ -3,7 +3,7 @@ title: Пошук — синхронізація локалей description: Чому plugin-search породжує «привидні» картки в мультилокальному сетапі, як їх лікують backfill-плагін і read-side hack, і як безпечно реіндексувати. --- -Повнотекстовий пошук побудований на `@payloadcms/plugin-search`: плагін тримає окрему колекцію `search`, куди afterChange-хуком синхронізує документи чотирьох колекцій — `searchIndexedCollections = ['posts', 'courses', 'course-categories', 'pages']` (`src/search/localeSync.ts`). Ця стаття — розбір головної болячки плагіна в мультилокальному проєкті і трьох шарів обходу, які тут напрацьовані. +Повнотекстовий пошук побудований на `@payloadcms/plugin-search`: плагін тримає окрему колекцію `search`, куди afterChange-хуком синхронізує документи пʼяти колекцій — `searchIndexedCollections = ['posts', 'courses', 'course-categories', 'pages', 'events']` (`src/search/localeSync.ts`). Ця стаття — розбір головної болячки плагіна в мультилокальному проєкті і трьох шарів обходу, які тут напрацьовані. ## Структура search-рядка diff --git a/docs/admin-panel/technical/06-rozrobka/02-testuvannia.md b/docs/admin-panel/technical/06-rozrobka/02-testuvannia.md index 37b7a2e..51c4b6a 100644 --- a/docs/admin-panel/technical/06-rozrobka/02-testuvannia.md +++ b/docs/admin-panel/technical/06-rozrobka/02-testuvannia.md @@ -20,7 +20,7 @@ pnpm test:int Тести працюють проти бази з `DATABASE_URL` — тобто локально проти **вашого сесійного Neon-бранча**, тієї самої, що й dev-сервер. -Поточний набір у `tests/int/` покриває: access control (`access-control`, `user-default-role`), доменну логіку курсів (`course-completion`, `complete-step`, `quiz-attempts`, `quiz-answer-validation`, `courses`, `enrollments`, `courseJsonImport`), каскадні видалення (`courses-cascade-delete`, `users-cascade-delete`), взаємодію (`comments`, `likes`), інфраструктурні механізми (`rate-limit`, `search-locale-sync`, `certificate-token`, `cyrillicSlugify`, `media-block`, `admin-docs`, `api`). Новій фічі — новий `*.int.spec.ts` поруч. +Поточний набір у `tests/int/` покриває: access control (`access-control`, `user-default-role`), доменну логіку курсів і подій (`events`, `event-enrollments` — зокрема гонка за останнє місце, доступ до `meetingLink`, join-поле реєстрацій) (`course-completion`, `complete-step`, `quiz-attempts`, `quiz-answer-validation`, `courses`, `enrollments`, `courseJsonImport`), каскадні видалення (`courses-cascade-delete`, `users-cascade-delete`), взаємодію (`comments`, `likes`), інфраструктурні механізми (`rate-limit`, `search-locale-sync`, `certificate-token`, `cyrillicSlugify`, `media-block`, `admin-docs`, `api`). Новій фічі — новий `*.int.spec.ts` поруч. ### vitest.setup.ts і заборона DATABASE_URL у .env.local @@ -49,7 +49,7 @@ pnpm test:e2e:snapshots # перезапис візуальних базлай - сід/клінап тестових користувачів — `tests/helpers/seedUser.ts`; - CI ставить лише chromium: `pnpm exec playwright install chromium --with-deps`. -Сьютні файли в `tests/e2e/`: `admin.e2e.spec.ts` (адмінка), `frontend.e2e.spec.ts`, `registration.e2e.spec.ts` (OTP-флоу), `smoke-locale-content.e2e.spec.ts` (+ директорія `*-snapshots` з візуальними базлайнами). +Сьютні файли в `tests/e2e/`: `admin.e2e.spec.ts` (адмінка), `frontend.e2e.spec.ts`, `registration.e2e.spec.ts` (OTP-флоу), `events.e2e.spec.ts` (каталог і сторінка події, `.ics`), `comments-resilience.e2e.spec.ts` (скелетони коментарів не висять, коли server action падає), `smoke-locale-content.e2e.spec.ts` (+ директорія `*-snapshots` з візуальними базлайнами). ### Візуальні снапшоти diff --git a/docs/admin-panel/technical/06-rozrobka/03-tsykl-rozrobky-fichi.md b/docs/admin-panel/technical/06-rozrobka/03-tsykl-rozrobky-fichi.md index 5c24901..0c3c4e2 100644 --- a/docs/admin-panel/technical/06-rozrobka/03-tsykl-rozrobky-fichi.md +++ b/docs/admin-panel/technical/06-rozrobka/03-tsykl-rozrobky-fichi.md @@ -33,6 +33,10 @@ pnpm generate:types # регенерує src/payload-types.ts — коміти Dev-сервер на старті сам синхронізує базу через drizzle push — міграція для локальної роботи не потрібна. +:::tip Нова колекція, з якою щодня працюють адміністратори +Окрім самої колекції не забудьте про адмін-поверхні: лічильник у `quickStats` та швидку дію в `quickActions` (`src/components/BeforeDashboard/index.tsx`), за потреби — пункт у `quickLinks` панелі `src/components/AdminBar/index.tsx`, індексацію в пошуку (`searchIndexedCollections`) і статті в обох треках цієї документації. +::: + ### 4. Міграція вручну — в тому ж PR Залізне правило: **схемна зміна без міграції в тому ж PR не мерджиться.** `pnpm payload migrate:create ` генерує забруднений diff — відредагуйте його до рівно вашої зміни і зробіть ідемпотентним (`IF NOT EXISTS`). Повний розбір: [Push vs міграції](/admin/docs/technical/infrastruktura/mihratsii). diff --git a/src/collections/EventEnrollments.ts b/src/collections/EventEnrollments.ts index c9dc7bd..6b6d002 100644 --- a/src/collections/EventEnrollments.ts +++ b/src/collections/EventEnrollments.ts @@ -64,9 +64,8 @@ export const EventEnrollments: CollectionConfig = { required: true, label: 'Користувач', index: true, - admin: { - readOnly: true, - }, + // Chosen once when an admin registers someone by hand; never changeable afterwards. + access: { update: () => false }, }, { name: 'event', @@ -75,9 +74,7 @@ export const EventEnrollments: CollectionConfig = { required: true, label: 'Подія', index: true, - admin: { - readOnly: true, - }, + access: { update: () => false }, }, { name: 'enrolledAt', diff --git a/src/collections/Events.ts b/src/collections/Events.ts index 4938b1b..7214e5b 100644 --- a/src/collections/Events.ts +++ b/src/collections/Events.ts @@ -221,6 +221,19 @@ export const Events: CollectionConfig = { description: 'Залиште порожнім, якщо кількість учасників не обмежена', }, }, + { + name: 'registrations', + type: 'join', + collection: 'event-enrollments', + on: 'event', + label: 'Реєстрації', + defaultLimit: 50, + defaultSort: '-enrolledAt', + admin: { + defaultColumns: ['user', 'enrolledAt'], + description: 'Хто зареєструвався. Кнопкою «Додати новий» можна записати учасника вручну.', + }, + }, { name: 'publishedAt', type: 'date', diff --git a/src/components/AdminBar/index.tsx b/src/components/AdminBar/index.tsx index 7f6b7ac..bc0dc48 100644 --- a/src/components/AdminBar/index.tsx +++ b/src/components/AdminBar/index.tsx @@ -1,6 +1,15 @@ 'use client' -import { GraduationCap, LayoutDashboard, LogOut, Newspaper, PanelsTopLeft, Plus, Users } from 'lucide-react' +import { + CalendarDays, + CalendarRange, + GraduationCap, + LayoutDashboard, + LogOut, + Newspaper, + PanelsTopLeft, + Users, +} from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import React, { useEffect, useState } from 'react' @@ -15,10 +24,12 @@ import './index.scss' const baseClass = 'admin-bar' const quickLinks = [ - { create: true, icon: GraduationCap, label: 'Курси', slug: 'courses' }, - { create: true, icon: Newspaper, label: 'Публікації', slug: 'posts' }, - { create: true, icon: PanelsTopLeft, label: 'Сторінки', slug: 'pages' }, - { create: false, icon: Users, label: 'Користувачі', slug: 'users' }, + { href: '/admin/collections/courses', icon: GraduationCap, label: 'Курси' }, + { href: '/admin/collections/events', icon: CalendarDays, label: 'Події' }, + { href: '/admin/globals/home-calendar', icon: CalendarRange, label: 'Календар змін' }, + { href: '/admin/collections/posts', icon: Newspaper, label: 'Публікації' }, + { href: '/admin/collections/pages', icon: PanelsTopLeft, label: 'Сторінки' }, + { href: '/admin/collections/users', icon: Users, label: 'Користувачі' }, ] as const type AdminBarProps = { @@ -78,7 +89,10 @@ export const AdminBar: React.FC = ({ adminBarProps }) => { } return ( -
+
= ({ adminBarProps }) => { Адмін-панель
diff --git a/src/components/BeforeDashboard/index.tsx b/src/components/BeforeDashboard/index.tsx index 99c03b9..0e59164 100644 --- a/src/components/BeforeDashboard/index.tsx +++ b/src/components/BeforeDashboard/index.tsx @@ -10,11 +10,14 @@ const quickStats = [ { slug: 'users', label: 'Користувачі' }, { slug: 'posts', label: 'Публікації' }, { slug: 'enrollments', label: 'Записи на курси' }, + { slug: 'events', label: 'Події' }, + { slug: 'event-enrollments', label: 'Реєстрації на події' }, { slug: 'comments', label: 'Коментарі' }, ] as const const quickActions = [ { href: '/admin/collections/courses/create', label: 'Новий курс' }, + { href: '/admin/collections/events/create', label: 'Нова подія' }, { href: '/admin/collections/posts/create', label: 'Нова публікація' }, { href: '/admin/collections/pages/create', label: 'Нова сторінка' }, { href: '/admin/collections/media', label: 'Медіатека' }, diff --git a/src/payload-types.ts b/src/payload-types.ts index 2ec2f6b..4be92b1 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -105,6 +105,9 @@ export interface Config { account: 'accounts'; session: 'sessions'; }; + events: { + registrations: 'event-enrollments'; + }; 'payload-folders': { documentsAndFolders: 'payload-folders' | 'media'; }; @@ -999,11 +1002,31 @@ export interface Event { * Залиште порожнім, якщо кількість учасників не обмежена */ capacity?: number | null; + /** + * Хто зареєструвався. Кнопкою «Додати новий» можна записати учасника вручну. + */ + registrations?: { + docs?: (number | EventEnrollment)[]; + hasNextPage?: boolean; + totalDocs?: number; + }; publishedAt?: string | null; updatedAt: string; createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "event-enrollments". + */ +export interface EventEnrollment { + id: number; + user: number | User; + event: number | Event; + enrolledAt?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "FormBlock". @@ -1230,18 +1253,6 @@ export interface Enrollment { updatedAt: string; createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "event-enrollments". - */ -export interface EventEnrollment { - id: number; - user: number | User; - event: number | Event; - enrolledAt?: string | null; - updatedAt: string; - createdAt: string; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "comments". @@ -2309,6 +2320,7 @@ export interface EventsSelect { mapLink?: T; meetingLink?: T; capacity?: T; + registrations?: T; publishedAt?: T; updatedAt?: T; createdAt?: T; diff --git a/tests/e2e/admin.e2e.spec.ts b/tests/e2e/admin.e2e.spec.ts index 9e72193..ae166d1 100644 --- a/tests/e2e/admin.e2e.spec.ts +++ b/tests/e2e/admin.e2e.spec.ts @@ -20,6 +20,32 @@ test.describe('Admin Panel', () => { await expect(page.getByTestId('before-dashboard-root')).toBeVisible() }) + test('dashboard covers events', async () => { + await page.goto('/admin') + const dashboard = page.getByTestId('before-dashboard-root') + await expect(dashboard.getByRole('link', { name: /Реєстрації на події/ })).toBeVisible() + await expect(dashboard.locator('a[href="/admin/collections/events"]')).toBeVisible() + await expect(dashboard.locator('a[href="/admin/collections/events/create"]')).toBeVisible() + }) + + test('can open the events and event registrations lists', async () => { + await page.goto('/admin/collections/events') + await expect(page).toHaveURL(/\/admin\/collections\/events(?:\?.*)?$/) + await expect(page.getByRole('heading', { name: 'Події' }).first()).toBeVisible() + + await page.goto('/admin/collections/event-enrollments') + await expect(page).toHaveURL(/\/admin\/collections\/event-enrollments(?:\?.*)?$/) + }) + + test('admin bar on the site links to events and the calendar, without create buttons', async () => { + await page.goto('/') + const bar = page.getByTestId('admin-bar') + await expect(bar).toBeVisible({ timeout: 30000 }) + await expect(bar.locator('a[href="/admin/collections/events"]')).toBeVisible() + await expect(bar.locator('a[href="/admin/globals/home-calendar"]')).toBeVisible() + await expect(bar.locator('a[href$="/create"]')).toHaveCount(0) + }) + test('can navigate to list view', async () => { await page.goto('/admin/collections/users') await expect(page).toHaveURL(/\/admin\/collections\/users(?:\?.*)?$/) diff --git a/tests/int/admin-docs.int.spec.ts b/tests/int/admin-docs.int.spec.ts index f1212e8..de5bf04 100644 --- a/tests/int/admin-docs.int.spec.ts +++ b/tests/int/admin-docs.int.spec.ts @@ -128,4 +128,19 @@ describe('admin-docs loader (real content)', () => { expect(category.label).not.toMatch(/^\d+-/) } }) + + it('documents events in both tracks', () => { + const managerArticles = [ + ['podii', 'stvorennia-podii'], + ['podii', 'reiestratsii-na-podii'], + ['podii', 'podii-na-saiti'], + ] + for (const parts of managerArticles) { + expect(findArticle('manager', parts), parts.join('/')).toBeDefined() + } + expect(findArticle('technical', ['model-danykh', 'podii'])).toBeDefined() + expect(findArticle('technical', ['biznes-logika', 'podii'])).toBeDefined() + + expect(getNavTree('manager').map((category) => category.label)).toContain('Події') + }) }) diff --git a/tests/int/event-enrollments.int.spec.ts b/tests/int/event-enrollments.int.spec.ts index dd61e51..c24399e 100644 --- a/tests/int/event-enrollments.int.spec.ts +++ b/tests/int/event-enrollments.int.spec.ts @@ -260,6 +260,29 @@ describe('EventEnrollments', () => { expect(remaining.totalDocs).toBe(0) }) + it('lets an admin register someone by hand but never re-point a registration', async () => { + const created = await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + user: adminUser, + overrideAccess: false, + }) + expect(created.id).toBeDefined() + + const other = await createEvent(minimalEventData('Other Event')) + const updated = await payload.update({ + collection: 'event-enrollments', + id: created.id, + data: { user: otherUser.id, event: other.id }, + user: adminUser, + overrideAccess: false, + }) + const userId = typeof updated.user === 'object' ? updated.user.id : updated.user + const eventId = typeof updated.event === 'object' ? updated.event.id : updated.event + expect(userId).toBe(user.id) + expect(eventId).toBe(event.id) + }) + it('progress-free rows are not owner-updatable', async () => { const enrollment = await payload.create({ collection: 'event-enrollments', diff --git a/tests/int/events.int.spec.ts b/tests/int/events.int.spec.ts index f60d9a9..7cc214b 100644 --- a/tests/int/events.int.spec.ts +++ b/tests/int/events.int.spec.ts @@ -101,7 +101,7 @@ describe('Events', () => { createdEventIds.splice(createdEventIds.indexOf(event.id), 1) const [remainingComments, remainingEventLikes, remainingCommentLikes] = await Promise.all([ - payload.count({ collection: 'comments', where: { targetId: { equals: event.id } } }), + payload.count({ collection: 'comments', where: { and: [{ targetCollection: { equals: 'events' } }, { targetId: { equals: event.id } }] } }), payload.count({ collection: 'likes', where: { and: [{ targetCollection: { equals: 'events' } }, { targetId: { equals: event.id } }] } }), payload.count({ collection: 'likes', where: { and: [{ targetCollection: { equals: 'comments' } }, { targetId: { equals: comment.id } }] } }), ]) @@ -189,6 +189,50 @@ describe('Events', () => { expect(adminResult.docs[0].meetingLink).toBe('https://us02web.zoom.us/j/1234567890') }) + it('lists registrations on the event for admins and never exposes them to the public', async () => { + const event = await createEvent(minimalEventData('Registrations Join Event')) + await payload.create({ + collection: 'event-enrollments', + data: { user: regularUser.id, event: event.id }, + }) + + const asAdmin = await payload.findByID({ + collection: 'events', + id: event.id, + depth: 1, + user: adminUser, + overrideAccess: false, + }) + expect(asAdmin.registrations?.docs).toHaveLength(1) + + const asAnon = await payload.find({ + collection: 'events', + where: { id: { equals: event.id } }, + depth: 1, + overrideAccess: false, + }) + expect(asAnon.totalDocs).toBe(1) + expect(asAnon.docs[0].registrations?.docs ?? []).toHaveLength(0) + + const asLearner = await payload.findByID({ + collection: 'events', + id: event.id, + depth: 1, + user: regularUser, + overrideAccess: false, + }) + const visible = (asLearner.registrations?.docs ?? []) as Array<{ user: unknown }> + for (const registration of visible) { + const owner = typeof registration.user === 'object' ? (registration.user as User).id : registration.user + expect(owner).toBe(regularUser.id) + } + + await payload.delete({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + }) + }) + it('rejects create/update from non-admin users', async () => { await expect( payload.create({