From cace3f9150625b831a3dcb16ba076e824045df9e Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Sat, 15 Aug 2026 01:43:22 +0300 Subject: [PATCH 1/3] feat: two-track admin documentation (manager + technical) at /admin/docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom Payload admin view (/admin/docs, exact:false) wrapped in DefaultTemplate, with auth redirect, track switcher, breadcrumbs, prev/next pager - Content: 59 Ukrainian articles in docs/admin-panel/{manager,technical} covering every collection, hook, access rule, and business-logic sharp edge - Renderer: marked + transliterated heading ids, :::callouts, scroll-wrapped tables, external-link handling (src/lib/admin-docs) - UX: client-side search (session-gated index API, Cmd+K), TOC scrollspy with reading progress, collapsible category nav, client-side cross-link navigation - Nav: 'Документація' section with two entry links via afterNavLinks - outputFileTracingIncludes so md files reach Vercel serverless bundles - Tests: renderer unit cases + real-content checks incl. cross-link resolvability --- .../admin-panel/manager/01-osnovy/01-vstup.md | 85 +++ .../01-osnovy/02-vkhid-ta-oblikovi-zapysy.md | 82 +++ .../manager/01-osnovy/03-ohliad-adminky.md | 103 +++ .../manager/01-osnovy/_category.json | 4 + .../manager/02-kursy/01-stvorennia-kursu.md | 95 +++ .../manager/02-kursy/02-kroky-kursu.md | 79 ++ .../manager/02-kursy/03-test-kursu.md | 83 +++ .../manager/02-kursy/04-import-json.md | 80 ++ .../manager/02-kursy/05-katehorii-ta-faily.md | 79 ++ .../manager/02-kursy/06-zapysy-uchasnykiv.md | 86 +++ .../manager/02-kursy/07-sertyfikaty.md | 86 +++ .../08-redahuvannia-opublikovanoho.md | 94 +++ .../manager/02-kursy/_category.json | 4 + .../manager/03-kontent/01-publikatsii.md | 106 +++ .../manager/03-kontent/02-storinky.md | 103 +++ .../03-kontent/03-chernetky-i-publikatsiia.md | 109 +++ docs/admin-panel/manager/03-kontent/04-seo.md | 93 +++ .../manager/03-kontent/05-lokalizatsiia.md | 95 +++ .../manager/03-kontent/06-media.md | 97 +++ .../manager/03-kontent/07-perenapravlennia.md | 95 +++ .../manager/03-kontent/08-formy.md | 106 +++ .../manager/03-kontent/_category.json | 4 + .../manager/04-spilnota/01-korystuvachi.md | 100 +++ .../04-spilnota/02-komentari-i-laiky.md | 96 +++ .../manager/04-spilnota/03-xp-i-reitynh.md | 103 +++ .../manager/04-spilnota/_category.json | 4 + .../manager/05-servisy/01-poshuk.md | 90 +++ .../manager/05-servisy/02-hlobalni-bloky.md | 98 +++ .../manager/05-servisy/03-chasti-pytannia.md | 103 +++ .../manager/05-servisy/_category.json | 4 + .../technical/01-arkhitektura/01-ohliad.md | 170 +++++ .../01-arkhitektura/02-payload-config.md | 243 ++++++ .../03-marshruty-i-middleware.md | 207 ++++++ .../01-arkhitektura/04-admin-kastomizatsii.md | 188 +++++ .../technical/01-arkhitektura/_category.json | 4 + .../technical/02-model-danykh/01-ohliad.md | 147 ++++ .../technical/02-model-danykh/02-courses.md | 186 +++++ .../02-model-danykh/03-enrollments.md | 147 ++++ .../04-quiz-attempts-xp-events.md | 145 ++++ .../02-model-danykh/05-users-i-auth.md | 155 ++++ .../02-model-danykh/06-posts-pages-media.md | 158 ++++ .../02-model-danykh/07-comments-likes.md | 149 ++++ .../technical/02-model-danykh/08-hlobaly.md | 150 ++++ .../02-model-danykh/09-plahinni-kolektsii.md | 135 ++++ .../technical/02-model-danykh/_category.json | 4 + .../03-biznes-logika/01-zavershennia-kursu.md | 151 ++++ .../technical/03-biznes-logika/02-kvizy.md | 153 ++++ .../technical/03-biznes-logika/03-xp.md | 152 ++++ .../03-biznes-logika/04-sertyfikaty.md | 146 ++++ .../03-biznes-logika/05-komentari-laiky.md | 148 ++++ .../03-biznes-logika/06-rate-limiting.md | 152 ++++ .../technical/03-biznes-logika/_category.json | 4 + .../04-autentyfikatsiya/01-better-auth.md | 144 ++++ .../02-reiestratsiia-otp.md | 149 ++++ .../04-autentyfikatsiya/03-roli-i-dostup.md | 142 ++++ .../04-autentyfikatsiya/04-dev-login-i-sid.md | 138 ++++ .../05-avatary-i-profil.md | 136 ++++ .../04-autentyfikatsiya/_category.json | 4 + .../05-infrastruktura/01-baza-danykh.md | 148 ++++ .../05-infrastruktura/02-mihratsii.md | 155 ++++ .../technical/05-infrastruktura/03-deploi.md | 143 ++++ .../05-infrastruktura/04-media-blob.md | 139 ++++ .../05-poshuk-synkhronizatsiia.md | 139 ++++ .../technical/05-infrastruktura/06-email.md | 134 ++++ .../05-infrastruktura/_category.json | 4 + .../06-rozrobka/01-lokalne-seredovyshche.md | 144 ++++ .../technical/06-rozrobka/02-testuvannia.md | 132 ++++ .../06-rozrobka/03-tsykl-rozrobky-fichi.md | 148 ++++ .../06-rozrobka/04-tsia-dokumentatsiia.md | 142 ++++ .../technical/06-rozrobka/_category.json | 4 + next.config.js | 8 + package.json | 1 + pnpm-lock.yaml | 10 + src/app/(payload)/admin/importMap.js | 4 + src/app/api/admin-docs/search-index/route.ts | 15 + .../admin/Docs/DocsContent.client.tsx | 33 + .../admin/Docs/DocsNavLinks/index.scss | 39 + .../admin/Docs/DocsNavLinks/index.tsx | 58 ++ .../admin/Docs/DocsSidebar.client.tsx | 241 ++++++ src/components/admin/Docs/DocsToc.client.tsx | 92 +++ .../admin/Docs/DocsView/ArticleView.tsx | 70 ++ .../admin/Docs/DocsView/DocsHome.tsx | 45 ++ .../admin/Docs/DocsView/DocsShell.tsx | 29 + .../admin/Docs/DocsView/NotFoundView.tsx | 12 + .../admin/Docs/DocsView/TrackHome.tsx | 56 ++ src/components/admin/Docs/DocsView/index.scss | 695 ++++++++++++++++++ src/components/admin/Docs/DocsView/index.tsx | 59 ++ src/lib/admin-docs/loader.ts | 218 ++++++ src/lib/admin-docs/markdown.ts | 152 ++++ src/lib/admin-docs/types.ts | 77 ++ src/payload.config.ts | 8 + tests/int/admin-docs.int.spec.ts | 131 ++++ 92 files changed, 9658 insertions(+) create mode 100644 docs/admin-panel/manager/01-osnovy/01-vstup.md create mode 100644 docs/admin-panel/manager/01-osnovy/02-vkhid-ta-oblikovi-zapysy.md create mode 100644 docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md create mode 100644 docs/admin-panel/manager/01-osnovy/_category.json create mode 100644 docs/admin-panel/manager/02-kursy/01-stvorennia-kursu.md create mode 100644 docs/admin-panel/manager/02-kursy/02-kroky-kursu.md create mode 100644 docs/admin-panel/manager/02-kursy/03-test-kursu.md create mode 100644 docs/admin-panel/manager/02-kursy/04-import-json.md create mode 100644 docs/admin-panel/manager/02-kursy/05-katehorii-ta-faily.md create mode 100644 docs/admin-panel/manager/02-kursy/06-zapysy-uchasnykiv.md create mode 100644 docs/admin-panel/manager/02-kursy/07-sertyfikaty.md create mode 100644 docs/admin-panel/manager/02-kursy/08-redahuvannia-opublikovanoho.md create mode 100644 docs/admin-panel/manager/02-kursy/_category.json create mode 100644 docs/admin-panel/manager/03-kontent/01-publikatsii.md create mode 100644 docs/admin-panel/manager/03-kontent/02-storinky.md create mode 100644 docs/admin-panel/manager/03-kontent/03-chernetky-i-publikatsiia.md create mode 100644 docs/admin-panel/manager/03-kontent/04-seo.md create mode 100644 docs/admin-panel/manager/03-kontent/05-lokalizatsiia.md create mode 100644 docs/admin-panel/manager/03-kontent/06-media.md create mode 100644 docs/admin-panel/manager/03-kontent/07-perenapravlennia.md create mode 100644 docs/admin-panel/manager/03-kontent/08-formy.md create mode 100644 docs/admin-panel/manager/03-kontent/_category.json create mode 100644 docs/admin-panel/manager/04-spilnota/01-korystuvachi.md create mode 100644 docs/admin-panel/manager/04-spilnota/02-komentari-i-laiky.md create mode 100644 docs/admin-panel/manager/04-spilnota/03-xp-i-reitynh.md create mode 100644 docs/admin-panel/manager/04-spilnota/_category.json create mode 100644 docs/admin-panel/manager/05-servisy/01-poshuk.md create mode 100644 docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md create mode 100644 docs/admin-panel/manager/05-servisy/03-chasti-pytannia.md create mode 100644 docs/admin-panel/manager/05-servisy/_category.json create mode 100644 docs/admin-panel/technical/01-arkhitektura/01-ohliad.md create mode 100644 docs/admin-panel/technical/01-arkhitektura/02-payload-config.md create mode 100644 docs/admin-panel/technical/01-arkhitektura/03-marshruty-i-middleware.md create mode 100644 docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md create mode 100644 docs/admin-panel/technical/01-arkhitektura/_category.json create mode 100644 docs/admin-panel/technical/02-model-danykh/01-ohliad.md create mode 100644 docs/admin-panel/technical/02-model-danykh/02-courses.md create mode 100644 docs/admin-panel/technical/02-model-danykh/03-enrollments.md create mode 100644 docs/admin-panel/technical/02-model-danykh/04-quiz-attempts-xp-events.md create mode 100644 docs/admin-panel/technical/02-model-danykh/05-users-i-auth.md create mode 100644 docs/admin-panel/technical/02-model-danykh/06-posts-pages-media.md create mode 100644 docs/admin-panel/technical/02-model-danykh/07-comments-likes.md create mode 100644 docs/admin-panel/technical/02-model-danykh/08-hlobaly.md create mode 100644 docs/admin-panel/technical/02-model-danykh/09-plahinni-kolektsii.md create mode 100644 docs/admin-panel/technical/02-model-danykh/_category.json create mode 100644 docs/admin-panel/technical/03-biznes-logika/01-zavershennia-kursu.md create mode 100644 docs/admin-panel/technical/03-biznes-logika/02-kvizy.md create mode 100644 docs/admin-panel/technical/03-biznes-logika/03-xp.md create mode 100644 docs/admin-panel/technical/03-biznes-logika/04-sertyfikaty.md create mode 100644 docs/admin-panel/technical/03-biznes-logika/05-komentari-laiky.md create mode 100644 docs/admin-panel/technical/03-biznes-logika/06-rate-limiting.md create mode 100644 docs/admin-panel/technical/03-biznes-logika/_category.json create mode 100644 docs/admin-panel/technical/04-autentyfikatsiya/01-better-auth.md create mode 100644 docs/admin-panel/technical/04-autentyfikatsiya/02-reiestratsiia-otp.md create mode 100644 docs/admin-panel/technical/04-autentyfikatsiya/03-roli-i-dostup.md create mode 100644 docs/admin-panel/technical/04-autentyfikatsiya/04-dev-login-i-sid.md create mode 100644 docs/admin-panel/technical/04-autentyfikatsiya/05-avatary-i-profil.md create mode 100644 docs/admin-panel/technical/04-autentyfikatsiya/_category.json create mode 100644 docs/admin-panel/technical/05-infrastruktura/01-baza-danykh.md create mode 100644 docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md create mode 100644 docs/admin-panel/technical/05-infrastruktura/03-deploi.md create mode 100644 docs/admin-panel/technical/05-infrastruktura/04-media-blob.md create mode 100644 docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md create mode 100644 docs/admin-panel/technical/05-infrastruktura/06-email.md create mode 100644 docs/admin-panel/technical/05-infrastruktura/_category.json create mode 100644 docs/admin-panel/technical/06-rozrobka/01-lokalne-seredovyshche.md create mode 100644 docs/admin-panel/technical/06-rozrobka/02-testuvannia.md create mode 100644 docs/admin-panel/technical/06-rozrobka/03-tsykl-rozrobky-fichi.md create mode 100644 docs/admin-panel/technical/06-rozrobka/04-tsia-dokumentatsiia.md create mode 100644 docs/admin-panel/technical/06-rozrobka/_category.json create mode 100644 src/app/api/admin-docs/search-index/route.ts create mode 100644 src/components/admin/Docs/DocsContent.client.tsx create mode 100644 src/components/admin/Docs/DocsNavLinks/index.scss create mode 100644 src/components/admin/Docs/DocsNavLinks/index.tsx create mode 100644 src/components/admin/Docs/DocsSidebar.client.tsx create mode 100644 src/components/admin/Docs/DocsToc.client.tsx create mode 100644 src/components/admin/Docs/DocsView/ArticleView.tsx create mode 100644 src/components/admin/Docs/DocsView/DocsHome.tsx create mode 100644 src/components/admin/Docs/DocsView/DocsShell.tsx create mode 100644 src/components/admin/Docs/DocsView/NotFoundView.tsx create mode 100644 src/components/admin/Docs/DocsView/TrackHome.tsx create mode 100644 src/components/admin/Docs/DocsView/index.scss create mode 100644 src/components/admin/Docs/DocsView/index.tsx create mode 100644 src/lib/admin-docs/loader.ts create mode 100644 src/lib/admin-docs/markdown.ts create mode 100644 src/lib/admin-docs/types.ts create mode 100644 tests/int/admin-docs.int.spec.ts diff --git a/docs/admin-panel/manager/01-osnovy/01-vstup.md b/docs/admin-panel/manager/01-osnovy/01-vstup.md new file mode 100644 index 0000000..b5bbff0 --- /dev/null +++ b/docs/admin-panel/manager/01-osnovy/01-vstup.md @@ -0,0 +1,85 @@ +--- +title: Вступ +description: Що таке платформа «Залізна Зміна», з чого вона складається і як користуватись цією документацією +--- + +Вітаємо в документації адміністративної панелі навчальної платформи «Залізна Зміна». Цей розділ допоможе вам швидко зорієнтуватися: що взагалі є на платформі, хто нею користується і де шукати відповіді на конкретні питання. + +## Що таке платформа + +«Залізна Зміна» — це навчальна платформа, на якій учасники проходять курси, складають тести й отримують сертифікати. Усім вмістом ви керуєте через адмін-панель за адресою `/admin`. + +Основні складові платформи: + +| Складова | Що це | +| --- | --- | +| **Курси** | Навчальні матеріали, розбиті на кроки: текстові уроки, відео з YouTube, файли (PDF, презентації). Учасник проходить кроки по черзі. | +| **Тести** | До курсу можна додати тест із питаннями та варіантами відповідей. Тест може бути умовою завершення курсу. | +| **Сертифікати** | Коли учасник завершує курс (усі кроки + тест, якщо він є), він може завантажити PDF-сертифікат із QR-кодом для перевірки справжності. | +| **XP та рейтинг** | За кожен пройдений крок і складений тест учасник отримує бали досвіду (XP). З них будується рейтинг учасників на сайті. | +| **Публікації** | Блог платформи: новини, статті, анонси. | +| **Сторінки** | Довільні сторінки сайту (наприклад, «Про нас»), які збираються з готових блоків. | +| **Коментарі та лайки** | Учасники можуть коментувати курси й публікації, відповідати одне одному та ставити лайки. | + +## Хто користується платформою + +На платформі є дві ролі: + +| Роль | Хто це | Що може | +| --- | --- | --- | +| **Адміністратор** (`admin`) | Ви та ваші колеги, що керують контентом | Повний доступ до адмін-панелі: створення курсів, публікацій, сторінок, керування користувачами та коментарями | +| **Учасник** (`learner`) | Усі, хто зареєструвався на сайті | Бачить лише сайт: записується на курси, проходить кроки, складає тести, коментує, отримує сертифікати. В адмін-панель зайти не може | + +Кожен новий користувач, що реєструється на сайті, автоматично стає **Учасником**. Адміністратором можна стати лише за запрошенням — як це зробити, описано в статті [Вхід та облікові записи](/admin/docs/manager/osnovy/vkhid-ta-oblikovi-zapysy). + +## Що вміє адмін-панель + +Через адмін-панель ви можете: + +- **Створювати та редагувати курси**: кроки, тести, обкладинки, категорії — див. розділ [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu). +- **Імпортувати вміст курсу з JSON**, згенерованого AI-асистентом (ChatGPT, Claude) — див. [Імпорт кроків і тесту з JSON](/admin/docs/manager/kursy/import-json). +- **Переглядати прогрес учасників**: хто на які курси записаний, скільки кроків пройшов, як склав тест — див. [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv). +- **Вести блог**: публікації з категоріями, зображеннями та SEO-налаштуваннями. +- **Керувати сторінками сайту**, меню (хедер і футер) та календарем змін на головній. +- **Модерувати коментарі**: редагувати або видаляти небажані. +- **Запрошувати нових адміністраторів** і переглядати список користувачів. +- **Керувати медіафайлами**: зображення для курсів і публікацій, файли курсів. + +Уміст платформи ведеться двома мовами — українською (основна) та англійською. Як працює перемикач мов, описано в статті [Огляд адмін-панелі](/admin/docs/manager/osnovy/ohliad-adminky). + +## Як користуватись цією документацією + +Документація складається з **двох розділів**: + +### Менеджерський розділ (ви зараз тут) + +Написаний для контент-менеджерів. Без технічних деталей і коду — лише покрокові інструкції, пояснення наслідків дій і відповіді на питання «що буде, якщо…». Починається зі сторінки [Вступ](/admin/docs/manager/osnovy/vstup). + +### Технічний розділ + +Призначений для розробників: архітектура, модель даних, бізнес-логіка, точні назви полів і файлів коду. Знаходиться за адресою `/admin/docs/technical`. Якщо у статті менеджерського розділу ви бачите посилання на кшталт «технічні деталі — тут», воно веде саме туди. Читати технічний розділ необовʼязково, але він корисний, коли треба зрозуміти, чому система поводиться саме так. + +:::tip Порада +Якщо ви шукаєте відповідь на конкретне питання («як додати тест до курсу?»), не читайте все підряд — скористайтеся пошуком. +::: + +### Пошук по документації + +Натисніть **⌘K** (на Mac) або **Ctrl+K** (на Windows) у будь-якому місці документації — відкриється вікно пошуку по всіх статтях обох розділів. Почніть вводити запит, і результати зʼявляться одразу. + +### Зміст статті + +Праворуч від тексту кожної статті показується **зміст** — список її заголовків. Клікніть на пункт, щоб перейти одразу до потрібного розділу. На вузьких екранах зміст може ховатися. + +## З чого почати + +Рекомендований порядок знайомства для нового менеджера: + +1. [Вхід та облікові записи](/admin/docs/manager/osnovy/vkhid-ta-oblikovi-zapysy) — як увійти і як влаштовані ролі. +2. [Огляд адмін-панелі](/admin/docs/manager/osnovy/ohliad-adminky) — що де знаходиться. +3. [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu) — головний робочий процес платформи. +4. [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho) — **обовʼязково** прочитайте перед тим, як змінювати курс, на який уже записані учасники. + +:::warning Найважливіше правило +Зміни в опублікованому курсі впливають на прогрес учасників, сертифікати та XP. Перш ніж видаляти кроки, вимикати тест або видаляти курс — прочитайте статтю [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho). +::: diff --git a/docs/admin-panel/manager/01-osnovy/02-vkhid-ta-oblikovi-zapysy.md b/docs/admin-panel/manager/01-osnovy/02-vkhid-ta-oblikovi-zapysy.md new file mode 100644 index 0000000..b3bdc52 --- /dev/null +++ b/docs/admin-panel/manager/01-osnovy/02-vkhid-ta-oblikovi-zapysy.md @@ -0,0 +1,82 @@ +--- +title: Вхід та облікові записи +description: Як увійти в адмін-панель, як запросити нового адміністратора і чим відрізняються ролі +--- + +Ця стаття пояснює, як потрапити в адмін-панель, як влаштовані облікові записи та ролі, і як додати до команди нового адміністратора. + +## Вхід в адмін-панель + +Адмін-панель знаходиться за адресою `/admin`. Якщо ви не авторизовані, вас зустріне сторінка входу. + +### Вхід за email і паролем + +1. Введіть свій **email** і **пароль**. +2. Натисніть кнопку входу. + +Якщо пароль забули — на сторінці входу на сайті є відновлення пароля: на email надійде код підтвердження, що діє 5 хвилин. + +### Вхід через Google + +Кнопка «продовжте через Google» доступна **лише на робочому (продакшн) сайті**. На тестових і локальних версіях платформи її немає — там працює лише вхід за email і паролем. + +:::info Google і адмін-доступ +Вхід через Google сам по собі не робить вас адміністратором. Роль привʼязана до облікового запису, а не до способу входу: якщо ваш акаунт має роль «Адміністратор», ви потрапите в адмінку будь-яким способом входу. +::: + +### Тривалість сесії + +Сесія діє **7 днів**. Якщо ви користуєтесь панеллю регулярно, сесія автоматично продовжується — виходити й заходити щотижня не доведеться. Після 7 днів повної неактивності потрібно буде увійти знову. + +## Ролі: Адміністратор і Учасник + +Кожен користувач має роль, яка визначає його можливості: + +| | **Адміністратор** | **Учасник** | +| --- | --- | --- | +| Вхід в адмін-панель `/admin` | Так | Ні | +| Створення і редагування курсів, публікацій, сторінок | Так | Ні | +| Перегляд усіх записів на курси і спроб тестів | Так | Лише своїх | +| Керування користувачами | Так | Ні | +| Редагування і видалення будь-яких коментарів | Так | Лише видалення власних | +| Проходження курсів, тести, сертифікати, коментарі на сайті | Так | Так | + +Кожен, хто самостійно реєструється на сайті, отримує роль **Учасник**. Роль **Адміністратор** видається лише через запрошення (див. нижче). + +:::danger Адміністратор — це повний доступ +Адміністратор може редагувати й видаляти будь-який контент платформи, включно з курсами, користувачами та записами учасників. Запрошуйте в адміністратори лише тих, кому довіряєте керування всією платформою. +::: + +## Як запросити нового адміністратора + +1. В адмін-панелі відкрийте розділ **Користувачі** (група «Автентифікація» в меню ліворуч). +2. Над списком користувачів знайдіть **кнопку запрошення** і натисніть її. +3. У вікні запрошення вкажіть **email** майбутнього адміністратора і виберіть роль. +4. Далі є два способи передати запрошення: + - **Згенерувати посилання** — скопіюйте його і передайте людині будь-яким зручним каналом (месенджер тощо). + - **Надіслати лист** — людина отримає email із темою **«Запрошення до панелі адміністратора — Залізна Зміна»** з кнопкою-посиланням. +5. Перейшовши за посиланням, людина створює обліковий запис і одразу отримує вибрану роль. + +:::warning Якщо лист не надходить +Надсилання листів працює лише тоді, коли на платформі налаштований поштовий сервіс. Якщо лист не вдалося надіслати, ви побачите повідомлення про помилку — у такому разі скористайтеся варіантом із посиланням. +::: + +### Чому не можна просто «підняти» роль існуючому користувачу + +Список користувачів у розділі **Користувачі** показує всіх — і адміністраторів, і учасників. Редагування чужих облікових записів навмисно обмежене з міркувань безпеки: стандартний шлях надати комусь адмін-доступ — саме запрошення. + +## Що бачать учасники + +Учасники не мають доступу до `/admin` взагалі — при спробі зайти вони не пройдуть далі сторінки входу. На самому сайті учасник бачить: + +- каталог опублікованих курсів і публікацій; +- власний профіль, прогрес, сертифікати (`/certificates`); +- рейтинг учасників (XP); +- коментарі та лайки. + +Чернетки курсів, публікацій і сторінок на сайті для неавторизованих відвідувачів не показуються — див. подробиці про чернетки у статті [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu). + +## Повʼязані статті + +- [Огляд адмін-панелі](/admin/docs/manager/osnovy/ohliad-adminky) — що знаходиться в меню після входу. +- Технічні деталі автентифікації (сесії, коди підтвердження, захист від зловживань) — у технічному розділі: [Користувачі та автентифікація](/admin/docs/technical/model-danykh/users-i-auth). diff --git a/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md b/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md new file mode 100644 index 0000000..def54dd --- /dev/null +++ b/docs/admin-panel/manager/01-osnovy/03-ohliad-adminky.md @@ -0,0 +1,103 @@ +--- +title: Огляд адмін-панелі +description: Дашборд, групи навігації, перемикач мови контенту та інші елементи інтерфейсу адмінки +--- + +Після входу в `/admin` ви потрапляєте на головну сторінку панелі — дашборд. Ця стаття допоможе зорієнтуватися в інтерфейсі. + +## Дашборд + +Головна сторінка адмінки вітає вас на імʼя і містить: + +### Швидкі дії + +Чотири кнопки для найчастіших операцій: + +- **Новий курс** +- **Нова публікація** +- **Нова сторінка** +- **Медіатека** + +### Лічильники + +Під швидкими діями — поточна статистика платформи: кількість **курсів**, **користувачів**, **публікацій**, **записів на курси** та **коментарів**. Це швидкий спосіб оцінити масштаб без переходу в окремі розділи. + +## Меню навігації + +Ліворуч — меню з усіма розділами панелі. Розділи згруповані за призначенням. + +### Група «Курси» + +Усе, що стосується навчання: + +| Розділ | Що всередині | Стаття | +| --- | --- | --- | +| **Курси** | Самі курси: кроки, тести, обкладинки | [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu) | +| **Категорії курсів** | Рубрики каталогу курсів | [Категорії курсів і файли](/admin/docs/manager/kursy/katehorii-ta-faily) | +| **Файли курсів** | PDF і презентації для файлових кроків | [Категорії курсів і файли](/admin/docs/manager/kursy/katehorii-ta-faily) | +| **Записи на курси** | Хто на що записаний і прогрес | [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv) | +| **Спроби тестів** | Історія складання тестів | [Тест курсу](/admin/docs/manager/kursy/test-kursu) | +| **Події XP** | Журнал нарахування балів досвіду | — довідковий журнал, редагувати не потрібно | + +### Група «Взаємодія» + +| Розділ | Що всередині | +| --- | --- | +| **Коментарі** | Усі коментарі до курсів і публікацій. Тут можна відредагувати або видалити небажаний коментар | +| **Лайки** | Записи про вподобання. Довідковий розділ | + +:::info Коментарі публікуються одразу +Попередньої модерації немає — коментар учасника зʼявляється на сайті миттєво. Розділ «Коментарі» — ваш інструмент реагування постфактум. +::: + +### Група «Автентифікація» + +| Розділ | Що всередині | +| --- | --- | +| **Користувачі** | Список усіх облікових записів; тут же кнопка запрошення адміністратора — див. [Вхід та облікові записи](/admin/docs/manager/osnovy/vkhid-ta-oblikovi-zapysy) | +| **Сесії, Акаунти, Верифікації, Запрошення** | Службові розділи системи входу. У щоденній роботі вони не потрібні | + +### Окремі розділи (поза групами) + +| Розділ | Що всередині | +| --- | --- | +| **Сторінки** | Сторінки сайту, які збираються з блоків (герой, контент, форми) | +| **Публікації** | Блог: статті та новини | +| **Медіа** | Зображення сайту: обкладинки, ілюстрації. Підтримує теки для впорядкування | +| **Категорії** | Рубрики для публікацій (не плутати з категоріями курсів) | +| **Перенаправлення** | Переадресації зі старих адрес сторінок на нові | +| **Форми** | Конструктор форм (наприклад, форма зворотного звʼязку) та надіслані відповіді | +| **Пошук** | Службовий індекс пошуку по сайту. Редагувати вручну не потрібно | + +Також у меню є глобальні елементи сайту: **Хедер сайту** і **Футер сайту** (пункти меню вгорі та внизу сайту) і **Календар змін** (секція «Найближчі зміни» на головній сторінці). + +### Документація внизу меню + +У нижній частині меню навігації є посилання на цю документацію — з будь-якого місця адмінки ви за один клік потрапите до статей. + +## Перемикач мови контенту + +Вміст платформи ведеться **двома мовами: українською (uk, основна) та англійською (en)**. Коли ви відкриваєте курс, публікацію чи сторінку, вгорі форми є перемикач локалі — він визначає, **яку мовну версію полів ви зараз редагуєте**. + +Як це працює: + +- Поля з позначкою локалізації (назва, опис, вміст кроків тощо) мають окреме значення для кожної мови. +- Перемкнули на **en** — бачите і редагуєте англійські значення. Українські при цьому не змінюються. +- Якщо англійська версія поля порожня, на сайті англомовному відвідувачу показується українська (автоматична підстановка). + +:::warning Перевіряйте, в якій локалі ви працюєте +Найчастіша помилка — ввести український текст, стоячи в локалі en. Текст збережеться як «англійська версія», а українська залишиться старою. Перед редагуванням гляньте на перемикач локалі. +::: + +:::tip Мова самої адмінки +Інтерфейс адмін-панелі завжди українською — перемикач локалі стосується лише вмісту, який ви редагуєте. +::: + +## Автозбереження і кнопки публікації + +У курсів, публікацій і сторінок праворуч угорі є кнопки **«Зберегти чернетку»** та **«Опублікувати»**, а зміни в чернетці зберігаються автоматично. Детально про чернетки, версії та планову публікацію — у статті [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu). + +## Повʼязані статті + +- [Вступ](/admin/docs/manager/osnovy/vstup) — загальна картина платформи. +- Технічний огляд усіх розділів даних — [Модель даних: огляд](/admin/docs/technical/model-danykh/ohliad). diff --git a/docs/admin-panel/manager/01-osnovy/_category.json b/docs/admin-panel/manager/01-osnovy/_category.json new file mode 100644 index 0000000..580bd20 --- /dev/null +++ b/docs/admin-panel/manager/01-osnovy/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Основи роботи", + "description": "Що таке платформа, як увійти в адмінку та як вона влаштована." +} diff --git a/docs/admin-panel/manager/02-kursy/01-stvorennia-kursu.md b/docs/admin-panel/manager/02-kursy/01-stvorennia-kursu.md new file mode 100644 index 0000000..58c5fc9 --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/01-stvorennia-kursu.md @@ -0,0 +1,95 @@ +--- +title: Створення курсу +description: Покрокова інструкція створення курсу — від назви до публікації, чернетки, версії та планова публікація +--- + +Курс — головна одиниця контенту платформи. Ця стаття проведе вас від порожньої форми до опублікованого курсу. + +## Створення нового курсу + +1. У меню ліворуч відкрийте **Курси** (група «Курси») і натисніть **«Створити»** — або скористайтесь швидкою дією «Новий курс» на дашборді. +2. Заповніть основні поля (див. нижче). +3. Додайте хоча б один крок — без кроків курс опублікувати неможливо. +4. Натисніть **«Опублікувати»**, коли курс готовий, або залиште його чернеткою. + +## Основні поля курсу + +### Назва + +Поле `title` — назва курсу, яку бачать учасники в каталозі та на сторінці курсу. Поле локалізоване: заповніть українську версію, а за потреби перемкніть локаль на en і додайте англійську (див. [Огляд адмін-панелі](/admin/docs/manager/osnovy/ohliad-adminky)). + +### Slug (адреса курсу) + +Поле `slug` — частина адреси сторінки курсу на сайті: курс зі slug `bazova-taktychna-medytsyna` буде доступний за адресою `/courses/bazova-taktychna-medytsyna`. + +- Slug **генерується автоматично з назви**: кирилиця транслітерується латинкою («Базова тактична медицина» → `bazova-taktychna-medytsyna`). +- Slug має бути **унікальним** — два курси з однаковою адресою неможливі. + +:::info Чому slug інколи порожній у чернетці +Система автоматично зберігає чернетку ще до того, як ви ввели назву. Поки назви немає — slug лишається порожнім, і це нормально: він згенерується, щойно зʼявиться назва. Порожній slug у щойно створеній чернетці — не помилка. +::: + +:::warning Не змінюйте slug опублікованого курсу без потреби +Зміна slug змінює адресу сторінки — усі старі посилання на курс (у месенджерах, соцмережах, закладках) перестануть працювати. Подробиці — у статті [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho). +::: + +### Опис + +Поле `description` — короткий текст про курс, показується в каталозі та на сторінці курсу. Локалізоване. + +### Обкладинка + +Поле «Обкладинка» (`heroImage`) — зображення курсу в каталозі та на його сторінці. Виберіть наявне зображення з розділу **Медіа** або завантажте нове прямо з форми. + +### Категорія + +Поле `category` — рубрика каталогу, до якої належить курс. Категорії створюються окремо — див. [Категорії курсів і файли](/admin/docs/manager/kursy/katehorii-ta-faily). + +## Кроки і тест + +- **Кроки** — сам навчальний матеріал: текстові уроки, відео, файли. Це окрема велика тема — див. [Кроки курсу](/admin/docs/manager/kursy/kroky-kursu). +- **Тест** — необовʼязковий блок питань наприкінці курсу — див. [Тест курсу](/admin/docs/manager/kursy/test-kursu). +- Кроки й тест можна не набирати вручну, а **імпортувати з JSON**, згенерованого AI — див. [Імпорт кроків і тесту з JSON](/admin/docs/manager/kursy/import-json). + +:::warning Публікація неможлива без кроків +Курс без жодного кроку можна зберегти лише як чернетку. Кнопка «Опублікувати» видасть помилку, поки не додано хоча б один крок. +::: + +## Чернетка чи публікація + +Курс завжди перебуває в одному з двох станів: + +| Стан | Хто бачить на сайті | Коли використовувати | +| --- | --- | --- | +| **Чернетка** | Ніхто з відвідувачів; курсу немає в каталозі | Поки курс у роботі | +| **Опубліковано** | Усі відвідувачі сайту | Коли курс готовий приймати учасників | + +Важливі нюанси: + +- Після публікації можна продовжувати редагувати: нові зміни накопичуються як **чернетка поверх опублікованої версії** і потрапляють на сайт лише після повторного натискання «Опублікувати». +- Опублікований курс можна **зняти з публікації** — він зникне з сайту, але записи учасників при цьому збережуться. + +### Автозбереження + +Поки ви редагуєте курс, чернетка **зберігається автоматично кожні 10 секунд**. Якщо закриєте вкладку або зникне інтернет — втратите щонайбільше кілька останніх секунд роботи. Автозбереження ніколи не публікує зміни — на сайт вони потрапляють лише через кнопку «Опублікувати». + +### Планова публікація + +Курс можна опублікувати не одразу, а в призначений час: у меню поруч із кнопкою публікації виберіть планування, вкажіть дату й час — система опублікує курс автоматично. Так само можна запланувати зняття з публікації. + +### Версії + +Система зберігає до **50 останніх версій** кожного курсу. У вкладці версій можна переглянути історію змін і **відкотитися** до будь-якої збереженої версії, якщо щось пішло не так. Коли версій стає більше за 50, найстаріші видаляються автоматично. + +## Що після публікації + +- Курс зʼявляється в каталозі й у пошуку по сайту; учасники можуть натиснути «Записатись» — див. [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv). +- Зміни на сторінках сайту зʼявляються з невеликою затримкою (кеш, до 5 хвилин). +- Перш ніж редагувати курс, на який уже записані люди, — прочитайте [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho). + +## Повʼязані статті + +- [Кроки курсу](/admin/docs/manager/kursy/kroky-kursu) +- [Тест курсу](/admin/docs/manager/kursy/test-kursu) +- [Сертифікати](/admin/docs/manager/kursy/sertyfikaty) +- Технічні деталі колекції курсів — [Курси (технічний розділ)](/admin/docs/technical/model-danykh/courses) diff --git a/docs/admin-panel/manager/02-kursy/02-kroky-kursu.md b/docs/admin-panel/manager/02-kursy/02-kroky-kursu.md new file mode 100644 index 0000000..7a4906d --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/02-kroky-kursu.md @@ -0,0 +1,79 @@ +--- +title: Кроки курсу +description: Три типи кроків — текстовий, відео та файловий; тривалість, локалізація і порядок проходження +--- + +Кроки — це навчальний матеріал курсу. Учасник проходить їх один за одним і позначає завершеними; коли всі кроки пройдені (і складено тест, якщо він увімкнений), курс вважається завершеним. + +## Три типи кроків + +У полі кроків натисніть «Додати» і виберіть тип блока: + +| Тип | Для чого | Головне поле | +| --- | --- | --- | +| **Текстовий крок** | Урок у вигляді форматованого тексту | Вміст (rich text) | +| **Відео крок** | Відео з YouTube | Посилання на YouTube | +| **Файловий крок** | PDF або презентація для перегляду/завантаження | Файл із розділу «Файли курсів» | + +### Текстовий крок + +- **Назва** — заголовок кроку (обовʼязкова). +- **Вміст** — повноцінний текстовий редактор: заголовки, жирний/курсив, списки, цитати, посилання, зображення. Обовʼязкове поле. +- **Тривалість** — див. нижче. + +### Відео крок + +- **Назва**, **опис** — заголовок і короткий супровідний текст. +- **Посилання на YouTube** — обовʼязкове. Поле приймає лише коректні адреси YouTube-відео; якщо вставити щось інше, побачите помилку **«Введіть коректне YouTube посилання»**. Підійдуть звичайні посилання виду `https://www.youtube.com/watch?v=…` або короткі `https://youtu.be/…`. +- **Тривалість**. + +:::tip Відео має бути доступне на YouTube +Платформа вбудовує плеєр YouTube. Якщо відео на YouTube приватне або видалене, учасники побачать помилку плеєра — перевіряйте, що відео відкрите (публічне або «за посиланням»). +::: + +### Файловий крок + +- **Назва**, **опис**. +- **Файл** — обовʼязкове поле; вибирається з розділу **Файли курсів**. Підтримуються лише **PDF і презентації PowerPoint (PPT, PPTX)**. +- **Тривалість**. + +:::info Спершу завантажте файл +Файл потрібно спочатку завантажити в розділ **Файли курсів**, і лише потім вибрати його у кроці. Як це зробити — у статті [Категорії курсів і файли](/admin/docs/manager/kursy/katehorii-ta-faily). +::: + +## Тривалість кроку + +Кожен крок має поле тривалості — орієнтовний час проходження **у хвилинах, від 1 до 600**. Тривалість показується учасникам і допомагає їм планувати час. Значення поза межами 1–600 система не прийме. + +## Порядок кроків + +**Порядок кроків у формі — це порядок проходження курсу.** Учасник бачить кроки саме в тій послідовності, в якій вони стоять у редакторі. Кроки можна перетягувати мишею, щоб змінити порядок — це безпечно і не впливає на прогрес учасників (зараховані кроки лишаються зарахованими). + +## Локалізація кроків + +Назви, описи і вміст кроків — локалізовані поля: у них окремі значення для української та англійської версій. Перемикач локалі вгорі форми визначає, яку мову ви редагуєте (див. [Огляд адмін-панелі](/admin/docs/manager/osnovy/ohliad-adminky)). + +- Сама **структура кроків спільна** для обох мов: набір кроків, їх типи, порядок, YouTube-посилання і файли однакові — перекладаються лише тексти. +- Якщо англійський переклад не заповнений, англомовний відвідувач побачить український текст. + +## Швидке наповнення через AI + +Замість ручного набору кроки можна імпортувати одним блоком JSON, який згенерує ChatGPT або Claude за готовим промптом — див. [Імпорт кроків і тесту з JSON](/admin/docs/manager/kursy/import-json). + +## Зміна кроків у опублікованому курсі + +:::warning Це впливає на прогрес учасників +Додавання і особливо **видалення кроків у курсі, на який уже записані учасники, має серйозні наслідки**: видалення кроку може автоматично перевести частину записів у статус «Завершено» і дати право на сертифікат. Обовʼязково прочитайте статтю [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho) перед такими змінами. +::: + +Коротко: + +- **Видалили крок** → усім, кому цей крок був останнім незавершеним, курс автоматично зарахується. +- **Додали крок** → ті, хто вже завершив курс, залишаються завершеними; ті, хто в процесі, побачать новий крок як непройдений. + +## Повʼязані статті + +- [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu) — назва, slug, публікація. +- [Тест курсу](/admin/docs/manager/kursy/test-kursu) — питання і прохідний бал. +- [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv) — як виглядає прогрес по кроках. +- Технічні деталі логіки завершення — [Завершення курсу (технічний розділ)](/admin/docs/technical/biznes-logika/zavershennia-kursu). diff --git a/docs/admin-panel/manager/02-kursy/03-test-kursu.md b/docs/admin-panel/manager/02-kursy/03-test-kursu.md new file mode 100644 index 0000000..e612b1f --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/03-test-kursu.md @@ -0,0 +1,83 @@ +--- +title: Тест курсу +description: Як увімкнути тест, налаштувати питання, прохідний бал і як система оцінює відповіді учасників +--- + +До кожного курсу можна додати тест (квіз). Якщо тест увімкнений, він стає **обовʼязковою умовою завершення курсу**: сертифікат учасник отримає лише після проходження всіх кроків **і** успішного складання тесту. + +## Увімкнення тесту + +У формі курсу знайдіть групу полів тесту і поставте прапорець **увімкнення** (`enabled`). Після цього зʼявляться решта полів тесту. Поки прапорець знятий — тесту в курсі немає, і завершення залежить лише від кроків. + +## Налаштування тесту + +### Назва та опис + +Заголовок і супровідний текст, які учасник бачить на сторінці тесту. Локалізовані поля. + +### Прохідний бал + +Поле `passingScore` — мінімальний відсоток правильних відповідей для заліку, від 0 до 100. **Типове значення — 70**: учасник має правильно відповісти щонайменше на 70% питань. + +:::info Прохідний бал 0 +Якщо поставити 0, тест складе будь-хто, навіть відповівши неправильно на всі питання. Використовуйте 0 лише якщо тест суто ознайомчий. +::: + +## Питання і відповіді + +- Тест повинен мати **щонайменше 1 питання**. +- Кожне питання повинно мати **щонайменше 2 варіанти відповіді**, і **хоча б один** із них має бути позначений як правильний — інакше система покаже помилку **«Позначте щонайменше одну правильну відповідь»** і не дасть зберегти. +- Тексти питань і відповідей — локалізовані. + +### Кілька правильних відповідей + +Питання може мати декілька правильних варіантів. Тоді учасник бачить його як питання з множинним вибором і **мусить вибрати точно всі правильні варіанти — і жодного неправильного**: + +| Учасник вибрав | Результат | +| --- | --- | +| Усі правильні, жодного зайвого | Зараховано | +| Лише частину правильних | **Не зараховано** (часткового заліку немає) | +| Усі правильні + один неправильний | **Не зараховано** | +| Нічого не вибрав у питанні | **Не зараховано** — пропущене питання рахується як помилка | + +:::warning Часткового заліку немає +Питання оцінюється за принципом «все або нічого». Якщо у питанні 3 правильні відповіді, а учасник вибрав 2 — питання не зараховується взагалі. Враховуйте це, коли робите питання з багатьма правильними варіантами: вони суттєво складніші. +::: + +## Перемішування + +Під час кожної спроби **порядок питань і порядок відповідей у кожному питанні перемішуються випадково**. Двоє учасників (або один учасник у двох спробах) побачать тест у різному порядку — це ускладнює механічне запамʼятовування «правильна відповідь — третя». + +## Перескладання + +- Кількість спроб **не обмежена** — учасник може перескладати тест скільки завгодно. +- Єдине обмеження — захист від зловживань: не більше **30 спроб на годину** на одного учасника. Після перевищення система тимчасово відповідає «Забагато спроб. Спробуйте пізніше». +- Між спробами немає обовʼязкової паузи. + +### «Тест складено» не скасовується + +Щойно учасник хоч раз склав тест, позначка «тест складено» закріплюється **назавжди**: + +- Якщо після успішного складання учасник перескладає тест «на кращий бал» і провалює спробу — залік **не скасовується**, сертифікат залишається чинним. +- У записі учасника зберігається **найкращий бал** з усіх спроб і загальна кількість спроб — див. [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv). + +## Де подивитись результати + +- Розділ **Спроби тестів** (група «Курси» в меню) — кожна спроба окремим записом: бал, складено чи ні, кількість правильних відповідей, номер спроби. +- Розділ **Записи на курси** — підсумок по учаснику: чи складено тест, найкращий бал, кількість спроб. + +Записи спроб створюються системою автоматично і захищені від редагування — підробити чи «домалювати» результат через адмінку неможливо. + +## Зміна тесту в опублікованому курсі + +:::warning Вимкнення тесту зараховує курси +Якщо ви **вимкнете тест** у курсі, де учасники вже пройшли всі кроки, але не склали тест — їхні записи автоматично перейдуть у «Завершено» з правом на сертифікат. І навпаки: **увімкнення** тесту в курсі не забирає завершення у тих, хто вже завершив. Подробиці — у статті [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho). +::: + +Редагування самих питань (виправлення формулювань, заміна варіантів) не впливає на вже зараховані результати — позначка «складено» лишається. + +## Повʼязані статті + +- [Імпорт кроків і тесту з JSON](/admin/docs/manager/kursy/import-json) — швидке створення тесту через AI. +- [Сертифікати](/admin/docs/manager/kursy/sertyfikaty) — як тест впливає на видачу сертифіката. +- Технічні деталі оцінювання — [Квізи (технічний розділ)](/admin/docs/technical/biznes-logika/kvizy). diff --git a/docs/admin-panel/manager/02-kursy/04-import-json.md b/docs/admin-panel/manager/02-kursy/04-import-json.md new file mode 100644 index 0000000..8dea911 --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/04-import-json.md @@ -0,0 +1,80 @@ +--- +title: Імпорт кроків і тесту з JSON +description: Як швидко наповнити курс через AI — скопіювати промпт, отримати JSON від ChatGPT чи Claude і вставити його в редактор +--- + +Замість того щоб набирати кроки і питання тесту вручну, можна доручити чорнову роботу AI-асистенту (ChatGPT, Claude тощо). Редактор курсу має вбудовані панелі імпорту, які перетворюють відповідь AI у готові кроки або тест. + +## Де знайти панелі імпорту + +У формі курсу є **дві окремі панелі імпорту**: + +- панель **імпорту кроків** — над полем кроків; +- панель **імпорту тесту** — у групі полів тесту. + +Кожна панель працює однаково: кнопка копіювання промпту + поле для вставлення JSON. + +## Покроково + +1. **Скопіюйте промпт.** У панелі імпорту натисніть кнопку копіювання промпту — в буфер обміну потрапить готова інструкція для AI, яка описує потрібний формат відповіді. +2. **Вставте промпт у AI-чат** (ChatGPT, Claude) і допишіть, про що курс: тема, аудиторія, скільки кроків чи питань, якою мовою. +3. **Скопіюйте відповідь AI** — це буде блок JSON (структурований текст у фігурних дужках). +4. **Вставте JSON у поле імпорту** в адмінці й підтвердіть імпорт. +5. Перевірте результат: кроки або тест зʼявляться у формі як звичайні поля — їх можна редагувати, переставляти, доповнювати. +6. Збережіть курс (чернетка збережеться і автоматично). + +:::tip Просіть AI одразу українською +Промпт можна доповнити побажаннями: «10 кроків, кожен на 5–10 хвилин, українською мовою, для підлітків». Що конкретніше опишете курс — то менше правок потім. +::: + +## Що підтримує імпорт + +### Імпорт кроків + +Підтримуються **всі три типи кроків** (див. [Кроки курсу](/admin/docs/manager/kursy/kroky-kursu)): + +- текстові кроки — назва, вміст, тривалість; +- відео кроки — назва, опис, YouTube-посилання (перевіряється так само, як при ручному вводі); +- файлові кроки — але файл усе одно доведеться вибрати вручну після імпорту, бо AI не має доступу до ваших файлів. + +Тривалість кроків має вкладатися у звичайні межі 1–600 хвилин. + +### Імпорт тесту + +Імпортується весь тест: назва, опис, прохідний бал, питання з варіантами відповідей і позначками правильних. Діють ті самі правила, що й при ручному створенні: щонайменше 1 питання, щонайменше 2 відповіді на питання, хоча б одна правильна (див. [Тест курсу](/admin/docs/manager/kursy/test-kursu)). + +## Обмеження + +### Лише простий текст + +Вміст текстових кроків імпортується як **простий текст без форматування**: порожній рядок у тексті стає новим абзацом, але **markdown-розмітка (зірочки, ґратки, списки з дефісів) не перетворюється** на жирний шрифт, заголовки чи списки — вона залишиться в тексті як є, символами. + +:::warning Попросіть AI не форматувати текст +Додайте до запиту: «текст кроків — простим текстом, без markdown». Якщо AI усе ж наставив зірочок і ґраток, після імпорту доформатуйте текст вручну в редакторі — або приберіть зайві символи. +::: + +### Імпорт замінює вміст поля + +**Імпорт не додає до наявного, а повністю замінює**: імпорт кроків замінює всі кроки курсу, імпорт тесту — весь тест. Якщо у курсі вже були кроки, після імпорту їх не стане — залишаться лише імпортовані. + +:::danger Не імпортуйте в готовий курс без потреби +Якщо ви вже вручну доопрацювали кроки, повторний імпорт зітре ваші правки. Використовуйте імпорт для первинного наповнення, а далі редагуйте вручну. Якщо все ж потрібно переімпортувати — памʼятайте, що попередній стан можна повернути через вкладку версій (див. [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu)). +::: + +### Помилки показуються українською + +Якщо JSON битий або не відповідає вимогам (наприклад, некоректне YouTube-посилання, питання без правильної відповіді, тривалість поза межами), панель покаже зрозуміле повідомлення про помилку українською мовою і **нічого не змінить** у курсі. Виправте відповідь AI (найпростіше — показати AI текст помилки) і вставте знову. + +## Типовий робочий процес + +1. Створіть курс, заповніть назву й опис — [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu). +2. Імпортуйте кроки з JSON, перегляньте і поправте тексти. +3. Для файлових кроків завантажте файли у **Файли курсів** і привʼяжіть їх — [Категорії курсів і файли](/admin/docs/manager/kursy/katehorii-ta-faily). +4. Імпортуйте тест, перевірте питання і прохідний бал. +5. Опублікуйте курс. + +## Повʼязані статті + +- [Кроки курсу](/admin/docs/manager/kursy/kroky-kursu) +- [Тест курсу](/admin/docs/manager/kursy/test-kursu) +- Технічний опис формату JSON і валідації — [Курси (технічний розділ)](/admin/docs/technical/model-danykh/courses) diff --git a/docs/admin-panel/manager/02-kursy/05-katehorii-ta-faily.md b/docs/admin-panel/manager/02-kursy/05-katehorii-ta-faily.md new file mode 100644 index 0000000..96a1c09 --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/05-katehorii-ta-faily.md @@ -0,0 +1,79 @@ +--- +title: Категорії курсів і файли +description: Як створювати рубрики каталогу курсів і завантажувати файли для файлових кроків +--- + +Два допоміжні розділи групи «Курси»: **Категорії курсів** — рубрики каталогу, і **Файли курсів** — сховище PDF і презентацій для файлових кроків. + +## Категорії курсів + +Категорії впорядковують каталог: кожен курс можна віднести до однієї категорії (поле `category` у формі курсу — див. [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu)). + +### Поля категорії + +| Поле | Опис | +| --- | --- | +| **Назва** (`title`) | Назва рубрики, локалізована (окремі версії для uk та en) | +| **Опис** (`description`) | Короткий текст про рубрику, локалізований | +| **Зображення** (`image`) | Ілюстрація категорії з розділу Медіа | +| **Slug** | Частина адреси сторінки категорії, генерується з назви автоматично (кирилиця транслітерується) | + +### Де категорії показуються на сайті + +- У **каталозі курсів** — як фільтри/рубрики, за якими відвідувач звужує список. +- Кожна категорія має **власну сторінку** виду `/courses/category/` зі списком її курсів. +- Категорії індексуються **пошуком по сайту** — відвідувач може знайти категорію через пошук. +- Назва категорії видна на картці курсу. + +:::tip Спочатку категорії — потім курси +Зручно створити основні рубрики до масового наповнення курсами: тоді поле «Категорія» заповнюється одразу при створенні курсу, і каталог від початку виглядає впорядковано. +::: + +### Видалення категорії + +Видалення категорії не видаляє її курси — вони просто залишаться без рубрики. Але сторінка категорії зникне, і старі посилання на неї перестануть працювати. + +## Файли курсів + +Розділ **Файли курсів** — сховище документів, які використовуються у **файлових кроках** курсів (див. [Кроки курсу](/admin/docs/manager/kursy/kroky-kursu)). + +### Які файли можна завантажити + +Лише два типи: + +- **PDF** (`.pdf`) +- **Презентації PowerPoint** (`.ppt`, `.pptx`) + +Інші формати (Word, Excel, зображення, відео) розділ не прийме. Зображення для обкладинок і статей завантажуються в окремий розділ **Медіа** — не плутайте ці два сховища. + +### Поля файлу + +| Поле | Опис | +| --- | --- | +| Сам файл | Завантажується з компʼютера | +| **Назва** (`title`) | Необовʼязкова, локалізована — людська назва документа, яку зручно показувати учасникам замість технічного імені файлу | + +### Порядок роботи: спершу файл, потім крок + +1. Відкрийте **Файли курсів** → «Створити», завантажте PDF або презентацію, за бажанням заповніть назву. +2. Поверніться до курсу, додайте **Файловий крок** і у полі файлу виберіть щойно завантажений документ. + +:::info Чому саме так +Файловий крок не вміє завантажувати файл «на льоту» з довільного місця — він лише посилається на документ, що вже лежить у Файлах курсів. Тому файл має існувати в сховищі до створення кроку. +::: + +### Оновлення файлу + +Якщо потрібно замінити документ новою версією (виправили помилку в презентації), відкрийте запис файлу в **Файлах курсів** і завантажте нову версію в ньому ж. Усі кроки, що посилаються на цей файл, автоматично показуватимуть оновлений документ — нічого міняти в курсах не треба. + +### Видалення файлу + +:::warning Не видаляйте файли, які використовуються у кроках +Якщо видалити файл, на який посилається файловий крок опублікованого курсу, учасники не зможуть відкрити матеріал цього кроку. Перш ніж видаляти — переконайтеся, що файл не привʼязаний до жодного кроку, або замініть його у відповідних кроках. +::: + +## Повʼязані статті + +- [Кроки курсу](/admin/docs/manager/kursy/kroky-kursu) — як влаштований файловий крок. +- [Створення курсу](/admin/docs/manager/kursy/stvorennia-kursu) — де вибирається категорія. +- Технічні деталі зберігання файлів — [Публікації, сторінки та медіа (технічний розділ)](/admin/docs/technical/model-danykh/posts-pages-media). diff --git a/docs/admin-panel/manager/02-kursy/06-zapysy-uchasnykiv.md b/docs/admin-panel/manager/02-kursy/06-zapysy-uchasnykiv.md new file mode 100644 index 0000000..3532026 --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/06-zapysy-uchasnykiv.md @@ -0,0 +1,86 @@ +--- +title: Записи учасників +description: Як влаштовані записи на курси — статуси, прогрес по кроках, результати тестів і чому їх не можна редагувати вручну +--- + +Розділ **Записи на курси** (група «Курси») показує, хто на які курси записаний і як далеко просунувся. Це головний інструмент спостереження за навчанням. + +## Звідки беруться записи + +Записи створюють **самі учасники**: коли учасник натискає кнопку **«Записатись»** на сторінці курсу, система створює запис. Адміністратор нових записів зазвичай не створює — розділ призначений для перегляду. + +Правила: + +- **Один запис на пару «користувач + курс»** — двічі записатися на той самий курс неможливо; при повторній спробі учасник побачить «Ви вже записані на цей курс». +- Записатися можна лише будучи авторизованим на сайті. + +## Статуси запису + +| Статус | Що означає | +| --- | --- | +| **Записаний** | Учасник записався, але ще не завершив жодного кроку | +| **В процесі** | Завершено хоча б один крок | +| **Завершено** | Пройдені всі кроки курсу і, якщо тест увімкнений, тест складено. Дає право на сертифікат | + +Статус рухається лише вперед: із «Завершено» запис сам по собі не повертається назад. Єдиний виняток, коли завершення зʼявляється «стрибком», — автоматичне зарахування після спрощення курсу (див. [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho)). + +## Поля запису + +| Поле | Опис | +| --- | --- | +| `user` | Хто записався | +| `course` | На який курс | +| `status` | Статус (див. вище) | +| `enrolledAt` | Дата запису | +| `completedAt` | Дата завершення (видно лише у завершених) | +| `completedSteps` | Прогрес: перелік кроків, які учасник позначив завершеними | +| `quizPassed` | Чи складено тест курсу | +| `bestQuizScore` | Найкращий бал з усіх спроб тесту (0–100) | +| `quizAttempts` | Скільки разів учасник складав тест | + +### Прогрес — це список кроків + +Поле `completedSteps` — технічний список ідентифікаторів завершених кроків. Курс вважається завершеним, коли **кожен поточний крок курсу** є в цьому списку (а не коли «кількість збігається») — тому перестановка кроків місцями безпечна, а видалені кроки не ламають підрахунок. + +### Поля тесту + +Три поля — `quizPassed`, `bestQuizScore`, `quizAttempts` — підсумовують результати тесту. Деталі кожної окремої спроби (відповіді, бал, номер спроби) лежать у розділі **Спроби тестів**. Про правила оцінювання — [Тест курсу](/admin/docs/manager/kursy/test-kursu). + +## Чому поля лише для читання + +Усі поля запису в адмінці **закриті від редагування** — навіть для адміністратора. Прогрес веде виключно сама система: коли учасник завершує крок чи складає тест, запис оновлюється автоматично. + +:::info Це захист чесності сертифікатів і XP +Статус «Завершено» — це право на сертифікат, а завершені кроки і складені тести — це XP у рейтингу. Якби прогрес можна було редагувати вручну, будь-яка правка (навіть випадкова) означала б «намальований» сертифікат або зайві бали в рейтингу. Тому шлях до завершення один — реальне проходження курсу учасником. +::: + +Що адміністратор **може**: + +- переглядати всі записи, фільтрувати за курсом, користувачем, статусом; +- **видалити** запис — учасник втратить прогрес у курсі і зможе записатися заново з нуля. Разом із записом перестане підтверджуватись і виданий сертифікат (див. [Сертифікати](/admin/docs/manager/kursy/sertyfikaty)). + +Що адміністратор **не може**: + +- вручну позначити крок пройденим чи перевести запис у «Завершено»; +- «зняти» позначку про складений тест; +- змінити бал. + +## Типові питання + +### Учасник каже, що пройшов усе, але статус не «Завершено» + +Найчастіша причина — у курсі увімкнений тест, а учасник його ще не склав. Перевірте поле `quizPassed` і кількість спроб. Друга можлива причина — до курсу додали новий крок після того, як учасник пройшов старі: тепер у нього є непройдений крок. + +### Чи можна записати учасника на курс вручну? + +Штатного сценарію немає — записуються учасники самі, це одна кнопка на сторінці курсу. Просто попросіть учасника натиснути «Записатись». + +### Скільки людей записано на курс? + +Відкрийте розділ **Записи на курси** і відфільтруйте за курсом — кількість записів буде видна у списку. + +## Повʼязані статті + +- [Сертифікати](/admin/docs/manager/kursy/sertyfikaty) — що дає статус «Завершено». +- [Тест курсу](/admin/docs/manager/kursy/test-kursu) — як формуються поля тесту. +- Технічні деталі — [Записи на курси (технічний розділ)](/admin/docs/technical/model-danykh/enrollments) і [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu). diff --git a/docs/admin-panel/manager/02-kursy/07-sertyfikaty.md b/docs/admin-panel/manager/02-kursy/07-sertyfikaty.md new file mode 100644 index 0000000..6c819a0 --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/07-sertyfikaty.md @@ -0,0 +1,86 @@ +--- +title: Сертифікати +description: Коли видається сертифікат, як учасник його завантажує і як працює перевірка справжності через QR-код +--- + +Сертифікат — PDF-документ, який підтверджує, що учасник завершив курс. Сертифікати видаються повністю автоматично: окремого розділу «Сертифікати» в адмінці немає, і жодних дій від менеджера не потрібно. + +## Коли видається сертифікат + +Право на сертифікат зʼявляється, щойно запис учасника на курс переходить у статус **«Завершено»**. Це відбувається, коли: + +1. учасник пройшов **усі кроки** курсу, **і** +2. якщо у курсі увімкнений тест — **склав тест** (набрав прохідний бал). + +Інших умов немає. Докладно про статуси — [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv), про тест — [Тест курсу](/admin/docs/manager/kursy/test-kursu). + +:::warning Курс без кроків і тесту не видасть сертифікат нікому +Порожній курс (без жодного кроку і з вимкненим тестом) ніколи не вважається завершеним — сертифікат за нього отримати неможливо. +::: + +## Як учасник отримує сертифікат + +Учасник сам завантажує PDF у будь-який момент після завершення курсу: + +- на **сторінці курсу** — зʼявляється кнопка завантаження сертифіката; +- на сторінці **`/certificates`** — список усіх завершених курсів учасника з кнопками завантаження. + +Сертифікат генерується у момент завантаження — його можна завантажувати повторно скільки завгодно разів. У PDF зазначені: + +- імʼя учасника (з його профілю); +- назва курсу; +- дата завершення; +- унікальний код сертифіката виду **CERT-…**; +- **QR-код** для перевірки справжності. + +:::tip Імʼя в сертифікаті +У сертифікат підставляється імʼя з профілю учасника. Якщо учасник хоче інше написання — йому треба змінити імʼя у своєму профілі й завантажити сертифікат заново. +::: + +## Перевірка справжності + +Кожен сертифікат можна перевірити — наприклад, роботодавець чи викладач може переконатися, що документ не підроблений: + +- **QR-код** на сертифікаті веде на сторінку перевірки платформи; +- або вручну: на сторінці **`/verify`** можна ввести код із сертифіката. + +Сторінка перевірки показує: справжній сертифікат чи ні, кому виданий і за який курс. + +### Коли перевірка перестає працювати + +Перевірка підтверджує сертифікат доти, доки запис учасника на курс існує і має статус «Завершено». Перевірка покаже «недійсний» лише у двох випадках: + +1. **запис на курс видалили** (наприклад, адміністратор видалив запис або самого користувача чи курс); +2. **запис перестав бути завершеним** — на практиці це можливо лише через видалення/пересоздання запису, бо система ніколи сама не знижує статус. + +Сам PDF-файл при цьому нікуди не зникає — але його перевірка більше не підтверджуватиметься. + +:::danger Видалення = недійсні сертифікати +Видалення курсу видаляє всі його записи, а отже робить недійсними всі видані за нього сертифікати. Перш ніж видаляти курс або запис — усвідомте цей наслідок. Подробиці — [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho). +::: + +## Сертифікат не зникає, якщо курс ускладнили + +Якщо після того, як учасник завершив курс, ви **додали нові кроки або увімкнули тест** — його завершення і сертифікат **залишаються чинними**. Система ніколи не «забирає» вже зароблене завершення через підвищення вимог. Це свідоме рішення: сертифікат підтверджує проходження курсу в тому вигляді, в якому він був на момент завершення. + +І навпаки: якщо ви **спростили** курс (видалили крок, вимкнули тест), система автоматично зарахує курс тим, хто тепер відповідає вимогам, — і вони одразу отримають право на сертифікат. + +## Часті питання + +### Чи може адміністратор видати сертифікат вручну? + +Ні. Сертифікат — прямий наслідок статусу «Завершено», а цей статус виставляє лише система за реальний прогрес. Саме тому поля записів закриті від редагування — див. [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv). + +### Учасник завершив курс, але не бачить кнопку сертифіката + +Перевірте його запис у розділі **Записи на курси**: статус має бути «Завершено». Якщо статус «В процесі» — найімовірніше, не складено тест або залишився непройдений крок. + +### Чи приходить учаснику лист із сертифікатом? + +Ні, листи про завершення курсу не надсилаються — учасник завантажує сертифікат сам зі сторінки курсу або `/certificates`. + +## Повʼязані статті + +- [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv) +- [Редагування опублікованого курсу](/admin/docs/manager/kursy/redahuvannia-opublikovanoho) +- Технічні деталі генерації PDF і кодів перевірки — [Сертифікати (технічний розділ)](/admin/docs/technical/biznes-logika/sertyfikaty) diff --git a/docs/admin-panel/manager/02-kursy/08-redahuvannia-opublikovanoho.md b/docs/admin-panel/manager/02-kursy/08-redahuvannia-opublikovanoho.md new file mode 100644 index 0000000..deda39b --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/08-redahuvannia-opublikovanoho.md @@ -0,0 +1,94 @@ +--- +title: Редагування опублікованого курсу +description: Наслідки змін у курсі з учасниками — автоматичні зарахування, видалення курсу, зміна slug і затримка кешу +--- + +Поки курс — чернетка, редагуйте його як завгодно. Але щойно курс опубліковано і на нього записалися люди, **кожна структурна зміна впливає на їхній прогрес, сертифікати та XP**. Ця стаття — про те, що саме станеться. + +## Головний принцип: система зараховує, але ніколи не забирає + +Коли ви публікуєте зміни в курсі, система автоматично переглядає всі незавершені записи учасників і перевіряє: чи відповідає хтось із них вимогам **нового** курсу? + +- **Спрощення курсу** (видалили крок, вимкнули тест) → записи, які тепер відповідають вимогам, автоматично переходять у **«Завершено»** з датою завершення і правом на сертифікат. +- **Ускладнення курсу** (додали крок, увімкнули тест) → вже завершені записи **залишаються завершеними**, сертифікати чинні. Система ніколи не знижує статус. + +Це працює лише для опублікованих змін: правки, збережені як чернетка, ні на що не впливають, поки ви не натиснете «Опублікувати». + +## Видалення кроку + +:::warning Може миттєво «дозавершити» курс багатьом учасникам +Приклад: у курсі 10 кроків, учасник пройшов 9 і закинув. Ви видаляєте той самий десятий крок — у момент публікації запис учасника автоматично стає «Завершено», і він може завантажити сертифікат. +::: + +Що варто зробити перед видаленням кроку: + +1. Відкрийте **Записи на курси**, відфільтруйте за курсом і оцініть, скільки людей «в процесі». +2. Усвідомте, що частина з них може завершити курс автоматично. +3. Якщо це небажано — подумайте, чи не краще відредагувати вміст кроку замість видалення. + +Прогрес по видаленому кроку просто перестає враховуватись; тим, хто його пройшов, нічого «мінусується» не буде (зокрема XP за пройдені кроки не забирається). + +## Додавання кроку + +- Учасники **«в процесі»** побачать новий крок як непройдений — їхній шлях до завершення подовжиться. +- Учасники зі статусом **«Завершено»** такими й залишаться, сертифікати чинні — навіть якщо вони ніколи не пройдуть новий крок. + +Тому додавати кроки в живий курс безпечно з погляду сертифікатів, але враховуйте нерівність: ранні випускники завершили «коротшу» версію курсу. + +## Вимкнення та увімкнення тесту + +- **Вимкнули тест** → усі, хто пройшов усі кроки, але не склав тест, автоматично стають «Завершено» з правом на сертифікат. +- **Увімкнули тест** → уже завершені записи не чіпаються; ті, хто «в процесі», муситимуть скласти тест. + +Подробиці про сам тест — [Тест курсу](/admin/docs/manager/kursy/test-kursu). + +## Видалення курсу + +:::danger Безповоротна втрата всіх даних курсу +Видалення курсу **назавжди** видаляє: + +- усі **записи учасників** на курс (весь їхній прогрес); +- усі **спроби тестів** по курсу; +- усі **XP-події** курсу — зароблені за курс бали зникнуть із рейтингу учасників; +- усі **коментарі та лайки** курсу. + +Усі видані за курс сертифікати перестануть підтверджуватись при перевірці (див. [Сертифікати](/admin/docs/manager/kursy/sertyfikaty)). Відновити це неможливо. +::: + +Перед видаленням адмінка показує **модальне вікно підтвердження з кількістю** повʼязаних записів (записи, спроби, коментарі тощо) — уважно прочитайте цифри перед підтвердженням. + +:::tip Альтернатива — зняти з публікації +Якщо курс просто застарів, **зніміть його з публікації** замість видалення: він зникне з сайту, але прогрес, XP і сертифікати учасників збережуться. Видалення — лише для курсів, створених помилково або тестових. +::: + +## Зміна slug (адреси курсу) + +Slug — частина адреси сторінки курсу. Якщо змінити slug опублікованого курсу: + +- курс отримає **нову адресу**, стара перестане працювати; +- зламаються всі поширені раніше посилання: у месенджерах, соцмережах, email-розсилках, закладках учасників; +- пошуковики деякий час вестимуть на неробочу адресу. + +Якщо зміна slug справді потрібна (наприклад, у назві була груба помилка), можна створити **Перенаправлення** зі старої адреси на нову у відповідному розділі адмінки. + +## Затримка появи змін на сайті + +Сторінки сайту кешуються заради швидкості. Після публікації змін оновлення зʼявляються на сайті **не миттєво, а протягом кількох хвилин (до 5)**. Якщо ви опублікували правку і не бачите її на сайті — зачекайте кілька хвилин і оновіть сторінку, це нормально. + +## Памʼятка перед зміною живого курсу + +| Дія | Наслідок для учасників | +| --- | --- | +| Редагування текстів, назв, обкладинки | Безпечно, прогрес не змінюється | +| Перестановка кроків місцями | Безпечно | +| Додавання кроку / увімкнення тесту | Незавершеним додається робота; завершені не постраждають | +| Видалення кроку / вимкнення тесту | Частина записів може автоматично стати «Завершено» + сертифікати | +| Зміна slug | Ламаються старі посилання | +| Зняття з публікації | Курс зникає з сайту, дані учасників зберігаються | +| **Видалення курсу** | **Безповоротно зникають записи, спроби, XP, коментарі, лайки; сертифікати стають недійсними** | + +## Повʼязані статті + +- [Записи учасників](/admin/docs/manager/kursy/zapysy-uchasnykiv) +- [Сертифікати](/admin/docs/manager/kursy/sertyfikaty) +- Технічний механізм автоматичних зарахувань — [Завершення курсу (технічний розділ)](/admin/docs/technical/biznes-logika/zavershennia-kursu); нарахування балів — [XP](/admin/docs/technical/biznes-logika/xp) diff --git a/docs/admin-panel/manager/02-kursy/_category.json b/docs/admin-panel/manager/02-kursy/_category.json new file mode 100644 index 0000000..cfdb0b3 --- /dev/null +++ b/docs/admin-panel/manager/02-kursy/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Курси", + "description": "Створення та супровід курсів: кроки, тести, імпорт, записи учасників і сертифікати." +} diff --git a/docs/admin-panel/manager/03-kontent/01-publikatsii.md b/docs/admin-panel/manager/03-kontent/01-publikatsii.md new file mode 100644 index 0000000..210225b --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/01-publikatsii.md @@ -0,0 +1,106 @@ +--- +title: Публікації +description: Як створювати й редагувати публікації — новини та статті блогу платформи +--- + +Публікації — це новини й статті, які зʼявляються на сайті в розділі «Публікації» (`/posts`). Кожна публікація має власну сторінку з адресою виду `/posts/`. + +Знайти їх в адмінці: бічне меню → **Публікації**. + +## Створення публікації + +1. Натисни **Створити** у списку публікацій (або скористайся швидкою дією на головній сторінці адмінки). +2. Заповни поля — див. таблицю нижче. +3. Публікація зберігається як **чернетка** автоматично; на сайті вона зʼявиться лише після натискання **Опублікувати**. + +:::tip Автозбереження +Публікації зберігаються автоматично кожні **2 секунди** — вводиш текст і нічого не втрачаєш. Але памʼятай: автозбереження оновлює лише чернетку, а не опубліковану версію. Детальніше — у статті [Чернетки, версії та попередній перегляд](/admin/docs/manager/kontent/chernetky-i-publikatsiia). +::: + +## Поля публікації + +| Поле | Де | Призначення | +| --- | --- | --- | +| Заголовок (`title`) | Вкладка «Контент» | Назва публікації. З неї автоматично генерується slug (адреса сторінки). | +| Hero-зображення (`heroImage`) | Вкладка «Контент» | Велике зображення вгорі публікації та на її картці в списках. Обирається з [Медіатеки](/admin/docs/manager/kontent/media). | +| Контент (`content`) | Вкладка «Контент» | Основний текст публікації (rich text, див. нижче). | +| SEO | Вкладка «SEO» | Мета-заголовок, опис і зображення для пошуковиків і соцмереж — див. [SEO](/admin/docs/manager/kontent/seo). | +| Категорії (`categories`) | Бічна панель | Одна чи кілька категорій публікації. | +| Автори (`authors`) | Бічна панель | Користувачі-автори; їхні імена показуються на сторінці публікації. | +| Повʼязані публікації (`relatedPosts`) | Бічна панель | Інші публікації, які показуються в блоці «Читайте також». Поточну публікацію обрати не можна — список автоматично її виключає. | +| Дата публікації (`publishedAt`) | Бічна панель | Дата й час публікації. Якщо залишити порожньою, підставиться момент першої публікації. | +| Slug | Бічна панель | Адреса сторінки. Генерується із заголовка автоматично (кирилиця транслітерується), можна відредагувати вручну. Має бути унікальним. | + +## Редактор контенту + +Основний текст — це rich text редактор із заголовками (h1–h4), жирним/курсивом, списками, цитатами, посиланнями (на сторінки й публікації або зовнішні URL) і вставкою зображень. + +Крім звичайного тексту, в контент можна вставляти **блоки**: + +| Блок | Що робить | +| --- | --- | +| **Банер** | Виділена кольорова вставка для важливої примітки чи попередження. | +| **Код** | Блок програмного коду з підсвіткою. | +| **Медіа-блок** | Зображення з медіатеки всередині тексту. | +| **Архів** | Автоматична сітка карток: останні публікації чи курси (з фільтром за категоріями) або вручну вибрані документи. | + +## Категорії + +Категорії публікацій живуть в окремій колекції **Категорії**. Вони підтримують **вкладеність**: у категорії може бути батьківська категорія (`parent`), тож можна будувати дерево на кшталт «Новини → Табори». Категорія має лише назву та slug. + +:::info +Вкладеність є **лише у категорій публікацій**. Сторінки сайту вкладеності не мають — див. [Сторінки](/admin/docs/manager/kontent/storinky). +::: + +## Автори + +У полі «Автори» можна вибрати одного чи кількох користувачів. На сайті відображаються лише їхні **імена** — платформа зберігає окрему копію імен, тому зміна імені користувача підхопиться після наступного збереження публікації. + +## Публікація і планування + +- **Опублікувати** — публікація одразу зʼявляється на сайті, а її сторінка й списки оновлюються автоматично. +- **Планова публікація** — можна вказати дату й час, коли чернетка опублікується сама (кнопка планування поруч із «Опублікувати»). +- **Зняти з публікації** — публікація зникає з сайту, стара адреса перестає працювати. + +## Попередній перегляд + +Публікації підтримують **live preview**: прямо в адмінці видно, як сторінка виглядатиме на сайті, у трьох розмірах екрана (мобільний, планшет, компʼютер). Зміни в полях відображаються в передперегляді ще до публікації. + +:::warning Чернетки не повністю приватні +Чернетку не видно анонімним відвідувачам, але **будь-який залогінений користувач** платформи технічно може прочитати її через API. Не тримай у чернетках нічого чутливого. Детальніше: [Чернетки, версії та попередній перегляд](/admin/docs/manager/kontent/chernetky-i-publikatsiia). +::: + +## Швидкий старт: опублікувати новину + +1. Головна сторінка адмінки → швидка дія «Нова публікація» (або **Публікації** → **Створити**). +2. Введи заголовок — slug згенерується сам. +3. Встав текст у редактор контенту. Розбий довгий матеріал підзаголовками (h2/h3) — так легше читати. +4. Додай hero-зображення з медіатеки (якщо потрібного ще немає — завантаж прямо з вікна вибору). +5. У бічній панелі вибери категорії та автора. +6. Перейди на вкладку **SEO**: згенеруй мета-заголовок кнопкою, напиши опис, вибери зображення. +7. Переглянь результат у live preview. +8. Натисни **Опублікувати** — або запланував публікацію на потрібний час. + +## Редагування опублікованої публікації + +Відкрий публікацію, внеси правки і натисни **Опублікувати** ще раз. Поки цього не зробиш, на сайті висітиме стара версія, а правки житимуть у чернетці — це захищає від показу недописаного тексту. + +Якщо правки треба відкинути — відкрий **Версії** та відновись до останньої опублікованої версії. + +## Видалення публікації + +Перед видаленням зваж наслідки: + +- адреса `/posts/` почне віддавати 404 — усі зовнішні посилання на неї зламаються; краще заздалегідь налаштувати [перенаправлення](/admin/docs/manager/kontent/perenapravlennia); +- коментарі й лайки цієї публікації **не** видаляються автоматично — вони просто перестануть бути видимими на сайті; +- публікація зникне з пошуку та sitemap. + +Якщо матеріал просто застарів — часто краще **зняти з публікації**, ніж видаляти: історія версій і можливість повернути залишаться. + +## Що ще варто знати + +- Після збереження опублікованої публікації сайт оновлюється сам; у найгіршому випадку старий кеш сторінки публікації живе до 10 хвилин — див. [Часті питання](/admin/docs/manager/servisy/chasti-pytannia). +- Публікації індексуються пошуком по сайту — див. [Пошук на сайті](/admin/docs/manager/servisy/poshuk). +- Якщо змінюєш slug опублікованої публікації, стара адреса почне віддавати 404 — налаштуй [перенаправлення](/admin/docs/manager/kontent/perenapravlennia). +- Заголовок, контент і hero-зображення локалізуються: у публікації є українська та англійська версії — див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). +- Під публікаціями користувачі можуть лишати коментарі та лайки — див. [Коментарі та лайки](/admin/docs/manager/spilnota/komentari-i-laiky). diff --git a/docs/admin-panel/manager/03-kontent/02-storinky.md b/docs/admin-panel/manager/03-kontent/02-storinky.md new file mode 100644 index 0000000..2b67093 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/02-storinky.md @@ -0,0 +1,103 @@ +--- +title: Сторінки +description: Конструктор сторінок сайту — секція «Герой», блоки контенту, slug і SEO +--- + +Сторінки — це довільні сторінки сайту, які збираються з готових блоків: головна, «Про нас», лендінги тощо. Знайти їх в адмінці: бічне меню → **Сторінки**. + +Кожна сторінка має адресу виду `/`. Сторінка зі slug **`home`** — особлива: це **головна сторінка** сайту, вона відкривається за адресою `/`. + +## Структура сторінки + +Редактор сторінки поділений на три вкладки: + +| Вкладка | Що містить | +| --- | --- | +| **Герой** | Верхня «шапка» сторінки: тип, текст, кнопки, зображення. | +| **Контент** | Конструктор із блоків — основне тіло сторінки. | +| **SEO** | Мета-дані для пошуковиків — див. [SEO](/admin/docs/manager/kontent/seo). | + +Плюс бічна панель: **Дата публікації** та **Slug** (генерується з назви автоматично, можна виправити вручну; має бути унікальним). + +## Секція «Герой» + +Поле «Тип» визначає вигляд шапки: + +| Тип | Вигляд | Зображення | +| --- | --- | --- | +| **Немає** | Шапки немає — сторінка починається одразу з контенту. | — | +| **Високий вплив** | Великий повноекранний герой. | Обовʼязкове | +| **Середній вплив** | Помірна шапка із зображенням. | Обовʼязкове | +| **Низький вплив** (за замовчуванням) | Компактна текстова шапка. | Не використовується | + +Крім типу, герой має: + +- **текст** (rich text із заголовками h1–h4); +- **до 2 кнопок-посилань** — на внутрішню сторінку/публікацію або зовнішній URL; +- **зображення** з [Медіатеки](/admin/docs/manager/kontent/media) — лише для типів «Високий вплив» і «Середній вплив». + +## Конструктор «Контент» + +Тіло сторінки складається з блоків, які можна додавати в будь-якій кількості й міняти місцями перетягуванням: + +| Блок | Що робить | +| --- | --- | +| **Заклик до дії** (CTA) | Виділена секція з текстом і кнопками — «запишись», «дізнайся більше». | +| **Контент** | Текстова секція з колонками (від однієї до кількох, різної ширини), у кожній — свій rich text і, за потреби, посилання. | +| **Медіа-блок** | Зображення з медіатеки на всю ширину секції. | +| **Архів** | Автоматична сітка карток. Джерело: колекція (публікації, курси або категорії курсів, із фільтром за категоріями та лімітом, за замовчуванням 10) або вибрані вручну документи. | +| **Блок форми** | Вставляє форму, створену в конструкторі форм — див. [Форми](/admin/docs/manager/kontent/formy). | + +:::tip +«Герой» і «Контент» локалізуються — англійська версія сторінки може мати власний набір блоків. Порожня англійська версія показує український вміст. Див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). +::: + +## Адреси сторінок — без вкладеності + +Сторінки **не мають ієрархії**: усі адреси плоскі, з одного сегмента — `/about`, `/contacts`, `/camp-2026`. Зробити сторінку «дитиною» іншої (наприклад `/about/team`) **неможливо**. + +:::info +Вкладеність на платформі є лише у **категорій публікацій**, не у сторінок. Якщо потрібна логічна структура — використовуй назви на кшталт `about-team` і посилання між сторінками. +::: + +## Покроково: нова посадкова сторінка + +1. **Сторінки** → **Створити**, введи назву — slug згенерується сам (перевір, що він охайний, і виправ за потреби). +2. Вкладка **Герой**: обери тип «Високий вплив», напиши заголовок і підзаголовок у тексті героя, додай 1–2 кнопки (наприклад, на форму запису), вибери яскраве зображення. +3. Вкладка **Контент**: додай блоки згори вниз — «Контент» із розповіддю, «Медіа-блок» із фото, «Архів» з останніми публікаціями, наприкінці «Заклик до дії» або «Блок форми». +4. Вкладка **SEO**: мета-заголовок, опис, зображення — див. [SEO](/admin/docs/manager/kontent/seo). +5. Перевір у live preview всі три розміри екрана — особливо мобільний. +6. **Опублікувати**. +7. Додай сторінку в меню сайту, якщо потрібно — див. [Глобальні блоки сайту](/admin/docs/manager/servisy/hlobalni-bloky). + +## Порядок блоків + +Блоки рендеряться на сторінці саме в тому порядку, в якому стоять у конструкторі. Перетягуй їх за «ручку» зліва. Кожен блок можна тимчасово згорнути, продублювати або видалити через меню блока (три крапки). + +## Видалення сторінки + +- Адреса `/` почне віддавати 404 — якщо на сторінку вели посилання, налаштуй [перенаправлення](/admin/docs/manager/kontent/perenapravlennia) на наступницю. +- Перевір хедер і футер: якщо сторінка стояла в меню як «внутрішній документ», пункт меню перестане працювати — прибери або перенаправ його. +- Сторінка зникне з пошуку по сайту та з sitemap. + +Якщо сторінка сезонна (акція, набір на зміну) — краще **зняти з публікації**: наступного сезону відредагуєш і опублікуєш знову. + +## Чернетки, публікація, перегляд + +- Сторінка автоматично зберігається як чернетка кожні **10 секунд**. +- На сайті видно лише **опубліковану** версію; кнопка **Опублікувати** застосовує зміни. +- Підтримується **планова публікація** (публікація у вказаний час) і **live preview** у трьох розмірах екрана. +- Історія зберігає до 50 версій кожної сторінки. + +Подробиці — у статті [Чернетки, версії та попередній перегляд](/admin/docs/manager/kontent/chernetky-i-publikatsiia). + +## Що ще варто знати + +- Після публікації сторінка сайту оновлюється автоматично (для головної — і секція курсів, і календар). +- Сторінки потрапляють у [пошук по сайту](/admin/docs/manager/servisy/poshuk) та в sitemap для пошуковиків. +- Якщо змінюєш slug опублікованої сторінки — стара адреса ламається; додай [перенаправлення](/admin/docs/manager/kontent/perenapravlennia). +- Меню сайту (хедер і футер) редагуються окремо — див. [Глобальні блоки сайту](/admin/docs/manager/servisy/hlobalni-bloky). + +:::warning Не видаляй сторінку home +Сторінка зі slug `home` — це головна сторінка сайту. Без неї головна перестане відкриватися. +::: diff --git a/docs/admin-panel/manager/03-kontent/03-chernetky-i-publikatsiia.md b/docs/admin-panel/manager/03-kontent/03-chernetky-i-publikatsiia.md new file mode 100644 index 0000000..6a0cb71 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/03-chernetky-i-publikatsiia.md @@ -0,0 +1,109 @@ +--- +title: Чернетки, версії та попередній перегляд +description: Як працюють статуси чернетка/опубліковано, автозбереження, історія версій і live preview +--- + +Сторінки, публікації та курси працюють за схемою «чернетка → опубліковано». Це дозволяє готувати матеріал скільки завгодно довго, не показуючи його відвідувачам, і безпечно редагувати вже опубліковане. + +## Два статуси + +| Статус | Що означає | +| --- | --- | +| **Чернетка** | Документ існує тільки в адмінці. Відвідувачі сайту його не бачать. | +| **Опубліковано** | Документ видно на сайті. | + +Важливий нюанс: після публікації можна далі редагувати документ — зміни накопичуються **в новій чернетці**, а на сайті залишається остання опублікована версія. Щоб зміни зʼявилися на сайті, натисни **Опублікувати** ще раз. + +Кнопка **Зняти з публікації** повертає документ у чернетки — він зникає з сайту, а його адреса перестає працювати. + +## Автозбереження + +Поки редагуєш, система сама зберігає чернетку. Нічого натискати не потрібно: + +| Колекція | Інтервал автозбереження | +| --- | --- | +| Публікації | кожні **2 секунди** | +| Сторінки | кожні **10 секунд** | +| Курси | кожні **10 секунд** | + +Автозбереження оновлює **лише чернетку** — випадково «зламати» опубліковану сторінку недописаним текстом неможливо. + +## Історія версій + +Кожне збереження (і кожна публікація) створює версію. Для документа зберігається до **50 останніх версій**. + +- Переглянути історію: кнопка **Версії** у редакторі документа. +- Можна порівняти версії між собою та **відновити** будь-яку стару — вона стане поточною чернеткою. + +:::tip +Якщо щось випадково зіпсували чи видалили великий шматок тексту — не панікуй, відкрий «Версії» та відновись до потрібного стану. +::: + +## Хто працює за схемою «чернетка → опубліковано» + +| Колекція | Чернетки | Автозбереження | Планова публікація | Live preview | +| --- | --- | --- | --- | --- | +| Сторінки | так | 10 с | так | так | +| Публікації | так | 2 с | так | так | +| Курси | так | 10 с | так | **ні** | +| Медіа, категорії, форми, глобальні блоки | ні — збереження одразу «живе» | — | — | — | + +:::warning Без чернеток — без страховки +Усе, що не має чернеток (медіатека, категорії, форми, хедер/футер/календар), змінюється на сайті **одразу після збереження**. Редагуй такі речі уважніше. +::: + +## Планова публікація + +Біля кнопки «Опублікувати» є опція запланувати публікацію на конкретну дату й час. У призначений момент чернетка опублікується автоматично — зручно для анонсів «рівно о 9:00». + +Так само можна запланувати **зняття з публікації**. + +## Попередній перегляд (live preview) + +**Сторінки** та **публікації** мають вбудований попередній перегляд: у редакторі відкривається жива версія сторінки, яка оновлюється в міру редагування — ще до публікації. + +Доступні три розміри екрана: + +| Пресет | Розмір | +| --- | --- | +| Мобільний | 375 × 667 | +| Планшет | 768 × 1024 | +| Компʼютер | 1440 × 900 | + +:::info Курси — без live preview +У курсів попереднього перегляду немає. Щоб перевірити, як виглядає курс, опублікуй його та відкрий на сайті (або тримай тестовий курс-чернетку і публікуй його на тестовій платформі). Як перевіряти курс перед запуском — див. [Тест курсу](/admin/docs/manager/kursy/test-kursu). +::: + +## Хто бачить чернетки + +:::warning Чернетки не є секретними +Анонімні відвідувачі чернеток не бачать. Але **будь-який залогінений користувач платформи** (звичайний учасник, не лише адміністратор) технічно може прочитати вміст чернеток сторінок, публікацій і курсів через API сайту. + +Тому: **не тримай у чернетках чутливої інформації** — паролів, персональних даних, неанонсованих цін, внутрішніх домовленостей. Чернетка — це «ще не на вітрині», а не «під замком». +::: + +## Типові сценарії + +### Підготувати статтю заздалегідь + +1. Створи публікацію, наповни її — автозбереження працює саме. +2. Переглянь через live preview. +3. Запланув публікацію на потрібну дату або опублікуй вручну. + +### Виправити помилку на опублікованій сторінці + +1. Відкрий документ, внеси правку. +2. Натисни **Опублікувати** — без цього на сайті лишиться стара версія. + +### Повернути як було + +1. Відкрий **Версії**. +2. Знайди версію до злощасної правки, відновись до неї. +3. Опублікуй. + +## Повʼязані статті + +- [Публікації](/admin/docs/manager/kontent/publikatsii) +- [Сторінки](/admin/docs/manager/kontent/storinky) +- [SEO](/admin/docs/manager/kontent/seo) +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) — зокрема «Зберіг зміни, а на сайті по-старому» diff --git a/docs/admin-panel/manager/03-kontent/04-seo.md b/docs/admin-panel/manager/03-kontent/04-seo.md new file mode 100644 index 0000000..39fb453 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/04-seo.md @@ -0,0 +1,93 @@ +--- +title: SEO +description: Вкладка SEO у сторінок і публікацій — мета-заголовок, опис, зображення та як сайт видно пошуковикам +--- + +У сторінок і публікацій є вкладка **SEO**. Вона визначає, як документ виглядає в результатах Google та в превʼю посилань у соцмережах і месенджерах. + +## Поля вкладки SEO + +| Поле | Призначення | +| --- | --- | +| **Мета-заголовок** | Заголовок у вкладці браузера, в результатах пошуку і в превʼю посилання. | +| **Мета-опис** | Короткий опис (1–2 речення) під заголовком у результатах пошуку. | +| **Мета-зображення** | Картинка в превʼю посилання (Telegram, Facebook тощо). Обирається з [Медіатеки](/admin/docs/manager/kontent/media). | +| **Превʼю** | Наочний приклад, як виглядатиме сніпет у результатах пошуку — заповнюється сам на основі полів вище. | + +## Автогенерація заголовка + +Біля мета-заголовка є кнопка автогенерації: вона підставляє назву документа у форматі + +``` +Назва документа | Залізна Зміна +``` + +Можна лишити згенерований варіант або написати власний. Рекомендація — до ~60 символів, інакше Google обріже заголовок. + +:::tip Мінімальний робочий набір +Якщо часу мало: згенеруй мета-заголовок кнопкою, напиши мета-опис в одне-два речення своїми словами і вибери зображення. Цього достатньо для акуратного вигляду в пошуку та месенджерах. +::: + +## Мета-зображення + +Для превʼю посилань використовується стандарт Open Graph. Завантажене в медіатеку зображення **автоматично** отримує варіант розміру **1200 × 630** (стандарт og) — саме він підставляється в превʼю. Окремо різати картинку під соцмережі не потрібно. + +Якщо зображення обрізається невдало — постав у медіатеці **фокусну точку** на головному обʼєкті, і кадрування центруватиметься на ній. Див. [Медіатека](/admin/docs/manager/kontent/media). + +## Що працює автоматично + +Цим займатися не потрібно — просто знай, що воно є: + +- **Sitemap** (карта сайту для пошуковиків) оновлюється сама: опубліковані сторінки й публікації потрапляють туди автоматично, зняті з публікації — зникають. +- У sitemap потрапляють **лише українські адреси** (без `/en`-версій) — англійські сторінки пошуковики знаходять за посиланнями. +- Розділ `/admin` (адмінка) **закритий від індексації** через `robots.txt` — службові сторінки в Google не потраплять. +- Чернетки на сайті не існують, тож і в пошуковики не потрапляють. + +## Поради щодо заповнення + +### Мета-опис + +- 1–2 речення, до ~160 символів. +- Пиши для людини: що вона отримає, відкривши сторінку. +- Не дублюй заголовок — опис показується поруч із ним. + +### Коли SEO-поля порожні + +Сторінка все одно працюватиме, але превʼю посилання буде бідним: без опису та картинки месенджери покажуть лише голий заголовок або взагалі нічого. Для головної сторінки, лендингів і важливих публікацій **завжди** заповнюй усі три поля. + +### Англійська версія + +SEO-поля належать до вмісту документа, тож для англійської локалі їх можна заповнити окремо (перемкнувши локаль у редакторі). Порожні англійські поля показують український текст — див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). + +## Як перевірити превʼю посилання + +1. Опублікуй документ. +2. Встав його адресу в «Збережені повідомлення» в Telegram (або в чат із собою) — месенджер збудує превʼю. +3. Перевір: заголовок, опис, зображення без дивних обрізань. + +:::info Месенджери кешують превʼю +Telegram і Facebook запамʼятовують превʼю посилання. Якщо після виправлення SEO-полів у чаті досі стара картинка — це кеш месенджера, а не помилка сайту. Превʼю оновиться згодом само; у Facebook можна форсувати оновлення через їхній інструмент Sharing Debugger. +::: + +## Рекомендовані обсяги + +| Поле | Рекомендація | Що буде при перевищенні | +| --- | --- | --- | +| Мета-заголовок | до ~60 символів | Google обріже трикрапкою | +| Мета-опис | до ~160 символів | обріжеться в сніпеті | +| Мета-зображення | горизонтальне, від 1200 px завширшки | замалі картинки виглядатимуть розмито | + +## Чеклист перед публікацією важливої сторінки + +- [ ] Мета-заголовок згенеровано або написано вручну +- [ ] Мета-опис — живе речення, не обрубок тексту +- [ ] Мета-зображення вибрано, фокусна точка стоїть на головному обʼєкті +- [ ] Превʼю-сніпет у вкладці SEO виглядає охайно +- [ ] Для англійської локалі поля теж заповнені (якщо ведете англійську версію) + +## Повʼязані статті + +- [Публікації](/admin/docs/manager/kontent/publikatsii) +- [Сторінки](/admin/docs/manager/kontent/storinky) +- [Медіатека](/admin/docs/manager/kontent/media) +- [Пошук на сайті](/admin/docs/manager/servisy/poshuk) — внутрішній пошук платформи, це окремий механізм, не Google diff --git a/docs/admin-panel/manager/03-kontent/05-lokalizatsiia.md b/docs/admin-panel/manager/03-kontent/05-lokalizatsiia.md new file mode 100644 index 0000000..83b6f64 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/05-lokalizatsiia.md @@ -0,0 +1,95 @@ +--- +title: Локалізація +description: Дві мови контенту — українська та англійська: перемикач локалі, що локалізується і як працює fallback +--- + +Сайт має дві мови контенту: + +| Мова | Адреси | Роль | +| --- | --- | --- | +| **Українська** | без префікса: `/courses`, `/posts/...` | Основна мова. Завжди має бути заповнена. | +| **Англійська** | з префіксом `/en`: `/en/courses` | Додаткова. Заповнюється за бажанням. | + +## Перемикач локалі в адмінці + +У редакторі документа вгорі є перемикач локалі (Українська / English). Він визначає, **яку мовну версію полів ти зараз редагуєш**: + +1. Заповни документ українською (локаль за замовчуванням). +2. Перемкнись на English. +3. Локалізовані поля стануть порожніми (або покажуть англійські значення, якщо вже заповнені) — введи переклад. +4. Збережи/опублікуй. Публікація одна на документ — вона стосується обох мов одразу. + +Нелокалізовані поля (наприклад slug, категорія курсу, дата публікації) спільні для обох мов — їх видно однаковими в будь-якій локалі. + +## Що локалізується + +| Де | Локалізовані поля | +| --- | --- | +| Публікації | заголовок, hero-зображення, контент, SEO-поля | +| Сторінки | назва, вся секція «Герой», увесь конструктор «Контент», SEO-поля | +| Курси | назва, опис, вміст кроків (назви, тексти, описи), тексти тесту (питання, відповіді, назва й опис тесту) | +| Медіатека | alt-текст, підпис | +| Файли курсів | назва | +| Категорії (публікацій і курсів) | назва (у категорій курсів — і опис) | +| Хедер і футер | пункти меню | +| Календар змін | **усі поля** | + +## Що НЕ локалізується + +Ці речі спільні для обох мов — окремої англійської версії у них немає: + +| Що | Чому це важливо | +| --- | --- | +| Slug (адреса) | адреса одна: `/posts/tabir` і `/en/posts/tabir` | +| Дата публікації, статус | публікація одна на обидві мови | +| Звʼязки: категорії, автори, повʼязані публікації, обкладинка курсу | вибираються один раз | +| Структура тесту курсу (кількість питань, правильні відповіді, прохідний бал) | перекладаються лише тексти питань і відповідей | +| Записи на курси, коментарі, лайки, XP | дані користувачів не мають мовних версій | + +## Fallback: порожнє англійське поле показує українську + +Якщо англійська версія поля не заповнена, на `/en`-сторінці автоматично показується **український текст**. Сайт ніколи не показує порожнечу. + +Наслідки: + +- Можна спокійно вести лише українську версію — англійська просто дублюватиме її. +- Якщо на англійській сторінці «стирчить» українське речення — значить саме це поле не перекладене. Знайди його, перемкнувши локаль в адмінці на English. + +:::warning Календар змін — заповнюй обидві локалі +Глобальний блок «Календар змін» на головній сторінці слід зберігати **в обох локалях** — інакше англійська головна показуватиме українські назви змін. Про це нагадує й підказка в самому блоці. Див. [Глобальні блоки сайту](/admin/docs/manager/servisy/hlobalni-bloky). +::: + +## Пошук і локалі + +Пошук по сайту індексує назви документів для обох мов автоматично, але **опис і зображення** в результатах пошуку зберігаються лише для тієї локалі, в якій документ востаннє зберігали. Деталі та що з цим робити — у статті [Пошук на сайті](/admin/docs/manager/servisy/poshuk). + +## Мова адмінки + +Інтерфейс адмін-панелі **завжди українською** — незалежно від мови браузера та від того, яку локаль контенту ти редагуєш. Перемикач локалі змінює лише мовну версію вмісту, а не мову інтерфейсу. + +## Типовий процес перекладу публікації + +1. Створи й доведи до ладу українську версію. +2. Опублікуй її. +3. Перемкни локаль на English, переклади заголовок, контент і SEO-поля. +4. Натисни **Опублікувати** ще раз — англійська версія зʼявиться на `/en/posts/`. + +:::tip +Slug у документа один на обидві мови — англійська версія живе за тією самою адресою з префіксом `/en`. Окремий «англійський slug» створювати не потрібно. +::: + +## Типові помилки + +| Симптом | Причина | Що зробити | +| --- | --- | --- | +| На `/en` українське речення посеред англійського тексту | саме це поле не перекладене | перемкнись в English, знайди порожнє поле, заповни | +| Переклав, але на сайті не видно | після перекладу не натиснуто «Опублікувати» | опублікуй ще раз | +| «Зник» англійський текст під час редагування | редагуєш не ту локаль | глянь на перемикач локалі вгорі редактора | +| Пошук англійською показує «голі» картки | документ збережений лише в українській локалі | збережи документ і в англійській локалі; див. [Пошук на сайті](/admin/docs/manager/servisy/poshuk) | + +## Повʼязані статті + +- [Публікації](/admin/docs/manager/kontent/publikatsii) +- [Сторінки](/admin/docs/manager/kontent/storinky) +- [SEO](/admin/docs/manager/kontent/seo) +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) — «Англійська сторінка показує український текст» diff --git a/docs/admin-panel/manager/03-kontent/06-media.md b/docs/admin-panel/manager/03-kontent/06-media.md new file mode 100644 index 0000000..c1d2e21 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/06-media.md @@ -0,0 +1,97 @@ +--- +title: Медіатека +description: Завантаження зображень, папки, alt-тексти, фокусна точка, автоматичні розміри та зберігання файлів +--- + +**Медіа** в бічному меню — це спільна бібліотека зображень сайту. Звідси беруться hero-зображення публікацій, обкладинки курсів, картинки в блоках сторінок і мета-зображення для SEO. + +## Завантаження + +1. Відкрий **Медіа** → **Створити** (або перетягни файл у вікно). +2. Заповни **alt-текст** — короткий опис зображення. +3. Збережи. + +Одне зображення можна використовувати в багатьох місцях — не завантажуй те саме двічі. + +## Папки + +Медіатека підтримує **папки**: файли можна групувати (наприклад «Курси», «Блог», «Зміна 2026») і переносити між папками. Папки — лише для порядку в адмінці, на адреси файлів і на сайт вони не впливають. + +## Поля зображення + +| Поле | Призначення | +| --- | --- | +| **Alt-текст** (`alt`) | Опис зображення для незрячих користувачів і пошуковиків. Локалізується (українська/англійська) — див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). | +| **Підпис** (`caption`) | Текст під зображенням там, де сайт його показує. Теж локалізується. | + +:::tip Пиши alt-тексти +Alt — це і доступність, і SEO. Одне речення: що зображено. «Діти збирають намет на галявині», а не «IMG_20260815». +::: + +## Фокусна точка + +У редакторі зображення можна пересунути **фокусну точку** — маркер найважливішого місця кадру (обличчя, логотип, центр дії). + +Навіщо: частина автоматичних розмірів **обрізає** кадр під фіксовані пропорції (квадрат, банер 1200×630). Кадрування центрується на фокусній точці — тож головний обʼєкт не «відріжеться». Якщо в превʼю посилань чи на картках зображення обрізане невдало — постав фокусну точку й збережи. + +## Автоматичні розміри + +З кожного завантаженого зображення система сама генерує набір розмірів — сайт підставляє оптимальний під кожен екран: + +| Варіант | Розмір | +| --- | --- | +| thumbnail | 300 px завширшки | +| square | 500 × 500 (обрізка) | +| small | 600 px | +| medium | 900 px | +| large | 1400 px | +| xlarge | 1920 px | +| og | 1200 × 630 (обрізка, для превʼю посилань) | + +Тому завантажуй **оригінал у хорошій якості** (від ~1920 px завширшки для hero-зображень) — зменшенням займеться платформа. Власноруч стискати до 300 px не треба. + +## Де зберігаються файли + +Файли лежать у хмарному сховищі **Vercel Blob** і роздаються через CDN — швидко в будь-якій точці світу. В адмінці це непомітно: просто завантажуєш і використовуєш. + +:::warning Усі файли публічні за посиланням +Будь-який файл медіатеки (і будь-який файл курсу) доступний **кожному, хто має його URL** — без входу на сайт. Посилання довгі й невгадувані, але це не захист: не завантажуй у медіатеку документи з персональними даними, паролями, фінансовою інформацією тощо. +::: + +## Поради щодо якості + +- **Формат**: для фотографій — JPEG або WebP; для логотипів і графіки з прозорістю — PNG. +- **Розмір оригіналу**: для hero-зображень і обкладинок — від 1920 px завширшки; для ілюстрацій у тексті досить 1200–1400 px. +- **Вага**: кілька мегабайтів — нормально, система сама згенерує легкі варіанти; але 20-мегабайтові скани краще стиснути перед завантаженням. +- **Імена файлів**: давай осмислені імена латиницею до завантаження (`camp-2026-opening.jpg`, а не `IMG_0231.jpg`) — імʼя стає частиною URL і допомагає шукати файл у бібліотеці. + +## Alt-текст чи підпис? + +| | Alt-текст | Підпис | +| --- | --- | --- | +| Хто бачить | скрінрідери, пошуковики; показується, якщо зображення не завантажилось | усі відвідувачі — текст під зображенням | +| Що писати | нейтральний опис того, що в кадрі | коментар, підводку, авторство фото | +| Обовʼязковість | бажано завжди | лише коли є що сказати | + +## Видалення зображень + +Перед видаленням переконайся, що зображення ніде не використовується: сторінки й публікації, які на нього посилаються, залишаться з «дірками». Якщо сумніваєшся — краще не видаляти. + +## Файли курсів — окрема бібліотека + +Крім медіатеки, є колекція **Файли курсів** (група «Курси» в меню). Це навчальні матеріали, які прикріплюються до файлових кроків курсів: + +| | Медіа | Файли курсів | +| --- | --- | --- | +| Типи файлів | зображення | **PDF, PPT, PPTX** | +| Використання | сторінки, публікації, обкладинки | файлові кроки курсів | +| Поля | alt, підпис | назва (локалізується) | +| Розміри-варіанти | генеруються | не генеруються | + +Файли курсів так само зберігаються у Vercel Blob і так само **публічні за посиланням**. + +## Повʼязані статті + +- [SEO](/admin/docs/manager/kontent/seo) — як зображення потрапляє в превʼю посилань +- [Публікації](/admin/docs/manager/kontent/publikatsii) та [Сторінки](/admin/docs/manager/kontent/storinky) — де використовуються зображення +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) diff --git a/docs/admin-panel/manager/03-kontent/07-perenapravlennia.md b/docs/admin-panel/manager/03-kontent/07-perenapravlennia.md new file mode 100644 index 0000000..7e65b77 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/07-perenapravlennia.md @@ -0,0 +1,95 @@ +--- +title: Перенаправлення +description: Як налаштувати редіректи зі старих адрес на нові — і коли вони насправді спрацьовують +--- + +**Перенаправлення** в бічному меню — це правила виду «стара адреса → нова». Коли відвідувач (або пошуковик) відкриває стару адресу, сайт автоматично переводить його на нову замість сторінки 404. + +## Коли потрібні перенаправлення + +- Змінили **slug** опублікованої сторінки чи публікації — стара адреса зламалась, а на неї ведуть посилання з розсилок, соцмереж і закладок. +- Видалили сторінку, але хочете вести людей на її наступницю. +- Обʼєднали дві публікації в одну. + +## Як створити + +1. **Перенаправлення** → **Створити**. +2. **Звідки** (`from`) — стара адреса. Це шлях, що починається з `/`, наприклад `/posts/stara-nazva`. +3. **Куди** (`to`) — одне з двох: + - **внутрішній документ** — обери сторінку чи публікацію зі списку (найкращий варіант: якщо в неї знову зміниться slug, редірект не зламається); + - **власний URL** — довільна адреса, зокрема зовнішня. +4. Збережи. + +:::warning Адреса «Звідки» має збігатися точно +Правило спрацьовує при **точному** збігу шляху. `/posts/stara-nazva` не перехопить ані `/posts/stara-nazva/`, ані `/posts/stara-nazva?utm=...` як інший шлях (параметри після `?` не заважають, а от зайвий слеш — так). Вводь шлях рівно так, як він виглядав на сайті, без домену. +::: + +## Коли перенаправлення спрацює + +Поруч із полем «Звідки» є службова підказка: «Після зміни цього поля сайт потрібно перебудувати». **Це не відповідає дійсності для нашої платформи** — підказка вшита в стандартний модуль і не редагується. + +Насправді список перенаправлень кешується й **автоматично скидається одразу після збереження** правила. Тобто: + +- нове чи змінене правило починає діяти **практично миттєво** — жодних «перебудов» замовляти не треба; +- якщо здається, що не працює — спершу перевір точність шляху в полі «Звідки», а потім онови сторінку без кешу браузера (Ctrl+Shift+R / Cmd+Shift+R). + +Перенаправлення застосовується тоді, коли за старою адресою **немає живої сторінки**: якщо документ із таким slug досі опублікований, показуватиметься він, а не редірект. + +## Типовий кейс: зміна slug + +1. Скопіюй поточну адресу публікації, напр. `/posts/tabir-2025`. +2. Зміни slug у публікації на новий, опублікуй. +3. Створи перенаправлення: «Звідки» = `/posts/tabir-2025`, «Куди» = ця сама публікація (внутрішній документ). +4. Перевір: відкрий стару адресу — має перекинути на нову. + +:::tip +Роби це одразу після зміни slug, поки стара адреса ще «гаряча» в пошуковиках і месенджерах. +::: + +## Перевірка після створення + +1. Відкрий стару адресу в **режимі інкогніто** (щоб кеш браузера не заважав). +2. Переконайся, що потрапив на нову адресу. +3. Якщо не спрацювало: + - звір шлях у полі «Звідки» символ у символ зі старою адресою (без домену, з початковим `/`, без зайвого слеша в кінці); + - переконайся, що за старою адресою немає опублікованого документа — живі сторінки мають пріоритет над редіректом; + - онови сторінку без кешу (Ctrl+Shift+R / Cmd+Shift+R). + +## Типові сценарії + +| Ситуація | «Звідки» | «Куди» | +| --- | --- | --- | +| Змінили slug публікації | стара адреса `/posts/<старий-slug>` | внутрішній документ — та сама публікація | +| Видалили сторінку акції | `/` | внутрішній документ — головна чи сторінка-наступниця | +| Обʼєднали дві статті | адреса поглинутої статті | внутрішній документ — обʼєднана стаття | +| Розділ переїхав на зовнішній сервіс | стара внутрішня адреса | власний URL — `https://...` | + +## Видалення правила + +Правило можна видалити будь-коли — стара адреса знову почне віддавати 404. Видаляй лише тоді, коли впевнений, що на стару адресу вже ніхто не ходить (минули місяці, пошуковики переіндексувалися). Тримати старе правило нічого не коштує — за сумніву краще лишити. + +## Обмеження + +- Перенаправлення працюють для **сторінок сайту** (адрес, які відкриває браузер), а не для файлів медіатеки. +- Ланцюжки правил (`A → B`, `B → C`) технічно спрацюють, але браузер пройде два стрибки — краще одразу вести `A → C`. +- Стеж, щоб не створити коло (`A → B`, `B → A`) — сторінка перестане відкриватись. + +## Міні-FAQ + +### Чи можна перенаправити на іншу мову? + +Так — «Куди» з власним URL приймає будь-яку адресу, зокрема `/en/...`. Але зазвичай це не потрібно: мовні версії живуть за тією самою адресою з префіксом `/en` і без нього. + +### Скільки правил можна створити? + +Обмежень немає. Але тримай список охайним: підписуй собі логіку в назвах документів «Куди», а правила, що дублюють одне одного, — видаляй. + +### Чи впливають перенаправлення на Google? + +Так, і позитивно: пошуковик, прийшовши за старою адресою, отримує перенаправлення і переносить «вагу» сторінки на нову адресу замість того, щоб бачити 404. + +## Повʼязані статті + +- [Публікації](/admin/docs/manager/kontent/publikatsii) і [Сторінки](/admin/docs/manager/kontent/storinky) — де живе slug +- [SEO](/admin/docs/manager/kontent/seo) — чому биті адреси шкодять видачі +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) diff --git a/docs/admin-panel/manager/03-kontent/08-formy.md b/docs/admin-panel/manager/03-kontent/08-formy.md new file mode 100644 index 0000000..d4491bf --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/08-formy.md @@ -0,0 +1,106 @@ +--- +title: Форми +description: Конструктор форм — поля, повідомлення підтвердження, листи-сповіщення та перегляд відповідей +--- + +**Форми** в бічному меню — це конструктор анкет і форм зворотного звʼязку. Форму збираєш із полів, а потім вставляєш на будь-яку сторінку через блок «Блок форми». + +## Створення форми + +1. **Форми** → **Створити**. +2. Дай формі **назву** (видно лише в адмінці). +3. Додай **поля форми** (див. типи нижче). +4. Заповни **текст кнопки надсилання** (наприклад «Надіслати заявку»). +5. Налаштуй, що станеться після надсилання, і, за потреби, листи. +6. Збережи. + +## Типи полів + +| Тип | Для чого | +| --- | --- | +| **Текстове поле** | Короткий текст в один рядок: імʼя, місто. | +| **Багаторядкове поле** | Довгий текст: коментар, питання. | +| **Email** | Електронна адреса (з перевіркою формату). | +| **Число** | Числове значення: вік, кількість учасників. | +| **Випадний список** | Вибір одного варіанта зі списку (варіанти задаєш сам: мітка + значення). | +| **Прапорець** | Так/ні: «погоджуюсь з умовами». | +| **Країна** / **Штат** | Готові списки країн/штатів (рідко потрібні). | +| **Повідомлення** | Не поле для вводу, а текстова вставка між полями — пояснення чи роздільник. | + +У кожного поля є: **Імʼя поля** (латиницею, без пробілів — технічний ідентифікатор), **Мітка** (підпис, який бачить людина), **Обовʼязкове**, **Ширина поля (%)** (щоб ставити два поля в ряд), у деяких — **Значення за замовчуванням** і **Підказка (placeholder)**. + +:::info Платежів немає +Конструктор не приймає оплат — жодного поля «платіж» на платформі не ввімкнено. Форми збирають лише дані. +::: + +## Після надсилання: підтвердження + +Поле **Тип підтвердження**: + +- **Повідомлення** — на місці форми показується текст, який ти напишеш у «Повідомлення підтвердження» (з форматуванням і заголовками). +- **Перенаправлення** — користувача перекидає на вказану сторінку (наприклад окрему сторінку «Дякуємо»). + +## Листи-сповіщення + +У секції **Листи** можна налаштувати автоматичні email після кожного надсилання форми — собі, колегам чи самому відправнику. + +Поля листа: **Кому**, **Копія (CC)**, **Прихована копія (BCC)**, **Відповідати на (Reply-To)**, **Від кого**, **Тема**, **Текст листа**. Кілька адрес розділяй комами. + +### Плейсхолдери — підстановка відповідей у лист + +У темі та тексті листа можна підставляти дані з форми: + +| Плейсхолдер | Що підставить | +| --- | --- | +| `{{імʼяПоля}}` | Значення конкретного поля, напр. `{{email}}` чи `{{firstName}}` — використовуй **Імʼя поля** (латиницею), не мітку. | +| `{{*}}` | Усі відповіді списком. | +| `{{*:table}}` | Усі відповіді акуратною таблицею в листі. | + +:::tip +Щоб лист-підтвердження летів самому відправнику, постав у «Кому» плейсхолдер його email-поля, напр. `{{email}}`. +::: + +Якщо листи не надходять — див. [Часті питання](/admin/docs/manager/servisy/chasti-pytannia), пункт «Форма не надсилає листи». + +## Приклад: анкета запису на подію + +1. Створи форму «Запис на відкриття зміни». +2. Поля: + - Текстове поле, імʼя `firstName`, мітка «Імʼя», обовʼязкове, ширина 50%; + - Текстове поле, імʼя `lastName`, мітка «Прізвище», обовʼязкове, ширина 50%; + - Email, імʼя `email`, мітка «Email», обовʼязкове; + - Число, імʼя `participants`, мітка «Скільки людей прийде»; + - Багаторядкове поле, імʼя `question`, мітка «Ваше питання» (необовʼязкове). +3. Текст кнопки: «Записатися». +4. Тип підтвердження: «Повідомлення» — «Дякуємо! Ми надішлемо деталі на вашу пошту.» +5. Лист організаторам: «Кому» — своя адреса, «Тема» — `Нова заявка: {{firstName}} {{lastName}}`, «Текст листа» — `{{*:table}}`. +6. Лист учаснику: «Кому» — `{{email}}`, тема й текст із деталями події. +7. Встав форму на сторінку події через **Блок форми** й опублікуй. + +## Поради щодо полів + +- **Імʼя поля** задавай латиницею без пробілів (`firstName`, `phone`) — саме воно використовується в плейсхолдерах і в колонках заявок. Після того як форма вже зібрала заявки, імена полів краще не міняти. +- Не роби всі поля обовʼязковими — що коротша форма, то більше заявок. +- Поле «Повідомлення» стане в пригоді, щоб пояснити, навіщо збираєте дані, або розділити форму на смислові частини. +- Через «Ширина поля (%)» став компактні поля парами: 50% + 50% в один рядок. + +## Перегляд заявок + +Усі надіслані форми зберігаються в колекції **Відповіді форм** (у меню поруч із «Форми»). Кожен запис — одна заявка: яка форма й перелік «поле → значення». Заявки нікуди не зникають, навіть якщо лист не надіслався — тож періодично заглядай сюди. + +## Як розмістити форму на сайті + +Форма сама по собі ніде не показується. Щоб вона зʼявилась на сторінці: + +1. Відкрий (або створи) сторінку — див. [Сторінки](/admin/docs/manager/kontent/storinky). +2. У вкладці «Контент» додай блок **Блок форми**. +3. Вибери свою форму зі списку, за бажанням увімкни вступний текст. +4. Опублікуй сторінку. + +Одну форму можна вставляти на кілька сторінок — відповіді зберуться в одному місці. + +## Повʼязані статті + +- [Сторінки](/admin/docs/manager/kontent/storinky) — блок форми в конструкторі +- [Глобальні блоки сайту](/admin/docs/manager/servisy/hlobalni-bloky) — «Календар змін» посилається на зовнішні анкети, це інший механізм +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) diff --git a/docs/admin-panel/manager/03-kontent/_category.json b/docs/admin-panel/manager/03-kontent/_category.json new file mode 100644 index 0000000..d134b65 --- /dev/null +++ b/docs/admin-panel/manager/03-kontent/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Контент сайту", + "description": "Публікації, сторінки, медіа, SEO, локалізація, перенаправлення та форми." +} diff --git a/docs/admin-panel/manager/04-spilnota/01-korystuvachi.md b/docs/admin-panel/manager/04-spilnota/01-korystuvachi.md new file mode 100644 index 0000000..c7629b9 --- /dev/null +++ b/docs/admin-panel/manager/04-spilnota/01-korystuvachi.md @@ -0,0 +1,100 @@ +--- +title: Користувачі +description: Список користувачів, ролі, поля профілю, аватари, запрошення адміністраторів і наслідки видалення +--- + +Колекція **Користувачі** (група «Автентифікація» в меню) — це всі акаунти платформи: і учасники, які проходять курси, і адміністратори. + +## Список користувачів + +У списку видно **імʼя** та **email**. Користувачі реєструються самостійно (email із кодом підтвердження або Google) — вручну створювати акаунти зазвичай не потрібно. + +## Ролі + +| Роль | Що дає | +| --- | --- | +| **Учасник** | Звичайний користувач: проходить курси, коментує, ставить лайки, отримує сертифікати. Роль за замовчуванням для всіх нових реєстрацій. | +| **Адміністратор** | Повний доступ до адмін-панелі: контент, курси, користувачі, налаштування. | + +В одного користувача може бути **кілька ролей одночасно** — поле «Роль» дозволяє обрати більше однієї. Адміністратор, який сам проходить курси, — нормальна ситуація. + +:::danger Роль «Адміністратор» — це повний доступ +Адміністратор може редагувати й видаляти будь-що на платформі, включно з іншими користувачами. Видавай цю роль лише людям, яким довіряєш повністю. +::: + +## Поля профілю + +| Поле | Обмеження | Хто заповнює | +| --- | --- | --- | +| Імʼя | обовʼязкове | користувач при реєстрації, далі — сам у профілі | +| Email | обовʼязковий | при реєстрації | +| Email підтверджено | — | ставиться автоматично після коду підтвердження | +| Зображення (аватар) | до **5 МБ**; JPEG, PNG, WebP або GIF | користувач у налаштуваннях профілю на сайті | +| Про мене | до **500 символів** | користувач у профілі | +| Соцмережі | до **8 посилань** | користувач у профілі | +| Приховати коментарі в профілі | так/ні | користувач у профілі | + +Соцмережі: Instagram, Facebook, Telegram, YouTube, TikTok, LinkedIn, X або власний сайт — для кожного пункту обирається платформа і вводиться посилання. + +**«Приховати коментарі в профілі»** — учасник може прибрати блок «Останні коментарі» зі своєї публічної сторінки профілю. Самі коментарі під публікаціями й курсами при цьому залишаються — див. [Коментарі та лайки](/admin/docs/manager/spilnota/komentari-i-laiky). + +## Що користувач може змінити сам + +- **Через сайт** (налаштування профілю): аватар, «Про мене», соцмережі, приховування коментарів, пароль. +- **Через адмінку/API**: звичайний учасник може змінити у своєму акаунті лише **власне імʼя** — все інше для нього закрито. Роль собі підвищити неможливо. + +Редагувати чужі акаунти може лише адміністратор. + +## Запрошення адміністраторів + +Нового адміністратора не «підвищують» вручну наосліп — для цього є запрошення: + +1. Відкрий список **Користувачі** — над ним є кнопка запрошення. +2. Обери роль і згенеруй **посилання-запрошення**, або одразу надішли лист (тема: «Запрошення до панелі адміністратора — Залізна Зміна»). +3. Людина переходить за посиланням, реєструється — і отримує роль адміністратора автоматично. + +Видані запрошення видно в колекції **Запрошення адміністраторів** (група «Автентифікація»). + +Якщо людина вже має акаунт учасника, роль можна додати й напряму: відкрий її картку і додай «Адміністратор» у полі «Роль». + +## Як знайти користувача + +У списку **Користувачі** працює пошук по колекції — шукай за імʼям чи email. Із картки користувача видно його акаунт; повʼязані дані шукай в інших колекціях за фільтром по користувачу: + +| Що подивитися | Де | +| --- | --- | +| Записи на курси та прогрес | **Записи на курси** (група «Курси») | +| Спроби тестів і бали | **Спроби тестів** | +| Коментарі | **Коментарі** (група «Взаємодія») | +| Активні сесії (пристрої, IP) | **Сесії** (група «Автентифікація») | + +## Паролі та вхід + +- Адміністратор **не бачить** паролів користувачів і не має задавати їх вручну. +- Якщо користувач забув пароль — він сам скидає його на сторінці входу через «Забули пароль»: на пошту приходить код підтвердження, дійсний **5 хвилин**. +- Користувачі, що ввійшли через Google, пароля можуть не мати взагалі — це нормально. +- Примусово розлогінити користувача можна, видаливши його записи в колекції **Сесії**. + +## Видалення користувача + +:::danger Видалення безповоротне і тягне за собою всі дані +Разом із користувачем **назавжди** видаляються: + +- його записи на курси (увесь прогрес і статус «Завершено»); +- спроби тестів; +- уся історія XP (зникає і з періодних рейтингів — див. [XP та рейтинг](/admin/docs/manager/spilnota/xp-i-reitynh)); +- його лайки; +- його коментарі. + +Раніше завантажені сертифікати (PDF-файли в людини на диску) не зникнуть, але перевірка сертифіката за QR-кодом перестане підтверджуватись. Відновити нічого не можна. Видаляй акаунт лише коли впевнений — наприклад, на прохання самої людини. +::: + +## Службові колекції поруч + +У групі «Автентифікація» є ще **Сесії**, **Облікові записи** та **Верифікації** — технічні дані входу (активні сесії, привʼязки Google, коди підтвердження). Їх не потрібно редагувати; єдиний практичний сценарій — видалити сесію користувача, щоб примусово його розлогінити. + +## Повʼязані статті + +- [Коментарі та лайки](/admin/docs/manager/spilnota/komentari-i-laiky) +- [XP та рейтинг](/admin/docs/manager/spilnota/xp-i-reitynh) +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) — зокрема «Завантаження аватара не працює» 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 new file mode 100644 index 0000000..a0902ea --- /dev/null +++ b/docs/admin-panel/manager/04-spilnota/02-komentari-i-laiky.md @@ -0,0 +1,96 @@ +--- +title: Коментарі та лайки +description: Як працюють коментарі й лайки, які є запобіжники та як модерувати +--- + +Залогінені користувачі можуть коментувати й лайкати контент платформи. В адмінці ці дані живуть у групі «Взаємодія»: колекції **Коментарі** та **Лайки**. + +## Де можна коментувати й лайкати + +| Ціль | Коментарі | Лайки | +| --- | --- | --- | +| Публікації | так | так | +| Курси (сторінка курсу) | так | так | +| Коментарі інших | відповіді | так | + +## Коментарі публікуються одразу + +:::warning Премодерації немає +Коментар зʼявляється на сайті **миттєво**, без попереднього схвалення. Ніхто не переглядає його перед публікацією — модерація можлива лише **постфактум**. +::: + +Вбудовані запобіжники проти зловживань: + +- ліміт довжини: до **2000 символів** на коментар; +- ліміт частоти: не більше **10 коментарів за хвилину** від одного користувача; +- коментувати можуть **лише залогінені** користувачі — анонімних коментарів немає; +- коментарі можливі лише під **опублікованим** контентом. + +## Відповіді + +Під коментарем можна лишити відповідь. Гілка має **один рівень**: відповідають на кореневий коментар, окремих «відповідей на відповіді» з глибшою вкладеністю немає. + +## Модерація + +| Дія | Хто може | Як | +| --- | --- | --- | +| **Відредагувати** текст коментаря | лише адміністратор | відкрити коментар у колекції «Коментарі» й змінити поле тексту | +| **Видалити** коментар | адміністратор або сам автор | на сайті кнопкою біля коментаря, або в адмінці | + +При видаленні кореневого коментаря разом із ним видаляються **його відповіді та всі лайки** цих коментарів — окремо чистити нічого не треба. + +:::tip Як знайти проблемний коментар в адмінці +У колекції «Коментарі» список показує текст коментаря. Використовуй пошук по колекції або сортування за датою створення — найсвіжіші згори. +::: + +Редагування коментаря — інструмент делікатний: текст залишиться підписаним автором. Для явно неприйнятного вмісту чесніше видалити коментар повністю. + +## Лайки + +- Один користувач може лайкнути конкретну публікацію, курс чи коментар **лише раз**; повторне натискання знімає лайк. +- Лайки видно всім (лічильники на сайті); список записів — у колекції «Лайки» в адмінці. +- Редагувати запис лайка не можна нікому (навіть адміністратору) — його можна лише видалити. + +Лічильники лайків і коментарів на сайті кешуються, тож можуть відставати від реальності на кілька хвилин — це нормально. + +## Хто що бачить + +- **Читати** коментарі та бачити лічильники лайків можуть усі відвідувачі, включно з незалогіненими. +- **Писати** коментарі та ставити лайки — лише залогінені користувачі. +- В адмінці колекції «Коментарі» та «Лайки» бачить лише адміністратор. + +## Ліміти частоти + +Щоб окремий користувач не міг заспамити сайт, діють автоматичні ліміти: + +| Дія | Ліміт | +| --- | --- | +| Коментарі | 10 за хвилину | +| Лайки | 60 за хвилину | + +Користувач, який перевищив ліміт, отримує повідомлення «Забагато запитів. Спробуйте пізніше.» — і за хвилину може продовжити. На адміністраторів ліміти не поширюються. + +## Покроково: видалити коментар з адмінки + +1. Відкрий **Коментарі** (група «Взаємодія»). +2. Відсортуй за датою створення або знайди потрібний текст пошуком по колекції. +3. Відкрий коментар, переконайся, що це саме він (текст, автор, під чим лишений — поля цілі й документа). +4. Видали. Його відповіді та лайки зникнуть разом із ним. + +## Коментарі в профілі користувача + +На публічній сторінці профілю показуються останні коментарі користувача. Сам користувач може **приховати** цей блок перемикачем «Приховати коментарі в профілі» у своїх налаштуваннях — коментарі під публікаціями й курсами при цьому нікуди не зникають. Див. [Користувачі](/admin/docs/manager/spilnota/korystuvachi). + +## Що робити з небажаним дописувачем + +1. Видали проблемні коментарі (в адмінці або на сайті). +2. Постійного порушника можна розлогінити, видаливши його **Сесії** (група «Автентифікація»). +3. Крайній захід — видалення акаунта, але памʼятай: воно безповоротно стирає і його прогрес у курсах — див. [Користувачі](/admin/docs/manager/spilnota/korystuvachi). + +Окремої функції «бан» на платформі немає. + +## Повʼязані статті + +- [Користувачі](/admin/docs/manager/spilnota/korystuvachi) +- [XP та рейтинг](/admin/docs/manager/spilnota/xp-i-reitynh) — лайки та коментарі на XP не впливають +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) diff --git a/docs/admin-panel/manager/04-spilnota/03-xp-i-reitynh.md b/docs/admin-panel/manager/04-spilnota/03-xp-i-reitynh.md new file mode 100644 index 0000000..889c3a8 --- /dev/null +++ b/docs/admin-panel/manager/04-spilnota/03-xp-i-reitynh.md @@ -0,0 +1,103 @@ +--- +title: XP та рейтинг +description: Як нараховуються бали досвіду, як працюють рівні та лідерборд і чому XP не можна виправити вручну +--- + +XP (бали досвіду) — ігрова механіка платформи: учасники отримують бали за навчання, ростуть у рівнях і змагаються в лідерборді. + +## За що нараховується XP + +| Дія | XP | Скільки разів | +| --- | --- | --- | +| Завершення кроку курсу | **30 XP** | один раз за кожен крок | +| Перше успішне складання тесту курсу | **100 XP** | один раз на курс | + +Тобто максимум XP за курс = кількість кроків × 30, плюс 100, якщо в курсі є тест. + +Нюанси: + +- Повторне проходження того самого кроку XP не додає. +- За тест бали дає лише **перша успішна** спроба; наступні перескладання (навіть із кращим балом) XP не змінюють. +- Коментарі, лайки та сам факт запису на курс XP не дають. + +### Приклад розрахунку + +Курс із 5 кроків і тестом: + +| Дія учасника | Нараховано | +| --- | --- | +| Пройшов 5 кроків | 5 × 30 = 150 XP | +| Склав тест з першої спроби | +100 XP | +| Перездав тест на кращий бал | +0 XP | +| **Разом за курс** | **250 XP** | + +## Де учасник бачить свій XP + +- **Профіль** — загальний XP, поточний рівень і прогрес до наступного. +- **Лідерборд** — позиція серед інших (якщо потрапляє в топ-50). +- Після проходження кроку чи тесту цифра оновлюється з невеликою затримкою — див. нижче. + +## Рівні + +Рівень росте з накопиченим XP: + +- **Рівень 1** — потрібно **300 XP**; +- кожен наступний рівень вимагає на **100 XP більше**, ніж попередній: ще 400 XP до рівня 2, ще 500 — до рівня 3, і так далі. + +Прогрес до наступного рівня учасник бачить у своєму профілі. + +## Лідерборд + +Сторінка рейтингу показує **топ-50** учасників у кількох вимірах: + +| Вкладка | Що рахує | +| --- | --- | +| **За весь час** | увесь накопичений XP учасника | +| **День / Тиждень / Місяць** | XP, зароблений за останні 1 / 7 / 30 днів | + +Учасники з нульовим XP у рейтинг не потрапляють. + +## Оновлення з затримкою + +:::info Затримка до 5 хвилин — це нормально +Лідерборд і особистий лічильник XP кешуються. Щойно зароблені бали можуть зʼявитися в рейтингу і в шапці профілю з затримкою **до 5 хвилин**. Якщо учасник скаржиться «пройшов крок, а XP не додались» — попроси зачекати кілька хвилин і оновити сторінку. +::: + +## XP не редагується вручну + +На платформі **немає** механізму нарахувати, зняти чи «виправити» XP руками: + +- в адмінці колекція «Події XP» (група «Курси») — технічний журнал, всі його поля лише для читання; +- єдиний спосіб отримати XP — реально пройти кроки й скласти тест; +- єдиний спосіб втратити — видалення курсу, запису на курс чи акаунта. + +Якщо здається, що комусь нараховано неправильно, — звернись до розробників, не намагайся правити записи в адмінці. + +## Що впливає на XP руйнівно + +:::warning Видалення курсу стирає повʼязану XP-історію +Коли адміністратор видаляє курс, разом із ним видаляються записи учасників на цей курс і журнал XP-подій по ньому. Наслідки: + +- зароблений на курсі XP зникає із загального рахунку учасників; +- історія цих балів зникає з періодних рейтингів (день/тиждень/місяць). + +Те саме стосується видалення користувача — вся його XP-історія зникає з рейтингів. Тому видалення курсу з живими учасниками — крайній захід; краще просто **зняти курс із публікації**. +::: + +## XP і зміни в курсі + +Редагування курсу впливає на XP так: + +- **Додали крок** — учасники, які вже завершили курс, свій статус «Завершено» і сертифікат зберігають; новий крок вони можуть пройти й отримати за нього 30 XP. +- **Видалили крок** — уже нарахований за нього XP залишається в учасників. +- **Провалене перескладання тесту** нічого не забирає: складений тест і його 100 XP — назавжди. + +## Звідки береться цифра «За весь час» + +Загальний XP учасника завжди обчислюється з його реального прогресу в курсах (пройдені кроки + складені тести). Періодні рейтинги ведуться за окремим журналом подій. У рідкісних випадках (технічний збій при записі в журнал) цифри «за весь час» і «за місяць» можуть трохи розходитися — довіряй цифрі «за весь час». + +## Повʼязані статті + +- [Тест курсу](/admin/docs/manager/kursy/test-kursu) — коли тест вважається складеним +- [Користувачі](/admin/docs/manager/spilnota/korystuvachi) — наслідки видалення акаунта +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) — «Хочу скасувати чиїсь XP» diff --git a/docs/admin-panel/manager/04-spilnota/_category.json b/docs/admin-panel/manager/04-spilnota/_category.json new file mode 100644 index 0000000..22d769e --- /dev/null +++ b/docs/admin-panel/manager/04-spilnota/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Спільнота", + "description": "Користувачі, коментарі, лайки та система XP." +} diff --git a/docs/admin-panel/manager/05-servisy/01-poshuk.md b/docs/admin-panel/manager/05-servisy/01-poshuk.md new file mode 100644 index 0000000..598a20a --- /dev/null +++ b/docs/admin-panel/manager/05-servisy/01-poshuk.md @@ -0,0 +1,90 @@ +--- +title: Пошук на сайті +description: Що індексує внутрішній пошук, як оновлюється індекс і чому документ може не знаходитись +--- + +На сайті є власний пошук (сторінка `/search`). Це **внутрішній** пошук платформи — він не повʼязаний із Google і працює за власним індексом. + +## Що індексується + +| Колекція | Потрапляє в пошук | +| --- | --- | +| Публікації | так | +| Курси | так | +| Категорії курсів | так | +| Сторінки | так | + +Усе інше (коментарі, користувачі, файли медіатеки) у пошук по сайту не потрапляє. + +## Як виглядає пошук для відвідувача + +- Сторінка пошуку показує до **12 результатів** без пагінації — якщо збігів більше, зайві просто не показуються. Тому влучні назви й SEO-описи важливі: вони і є те, по чому шукається. +- Результат — картка з назвою, описом і зображенням документа. +- Пошук працює для обох мов: на `/en/search` шукається англійська версія індексу. + +## Як оновлюється індекс + +**Автоматично.** Щоразу, коли документ зберігається чи публікується, його запис у пошуковому індексі створюється або оновлюється сам. Окремої кнопки «проіндексувати» натискати не потрібно. + +Пошук шукає за назвою, SEO-заголовком, SEO-описом і slug документа; у результатах показуються назва, опис і зображення (із SEO-полів). + +## Колекція «Результати пошуку» в адмінці + +У меню адмінки є колекція **Результати пошуку** — це і є технічний індекс. Записи в ній створюються й оновлюються автоматично. + +:::warning Не редагуй індекс вручну +Не змінюй і не створюй записи в «Результатах пошуку» руками: при наступному збереженні документа твої правки буде перезаписано, а «осиротілі» ручні записи засмічуватимуть видачу. Єдине розумне застосування цієї колекції — подивитись, чи є документ в індексі. +::: + +## Чому документ не знаходиться + +### 1. Він у чернетках + +Пошук показує лише опубліковане. Чернетки й зняті з публікації документи з пошуку зникають. Перевір статус документа — див. [Чернетки, версії та попередній перегляд](/admin/docs/manager/kontent/chernetky-i-publikatsiia). + +### 2. Особливості локалей + +Документ зберігався лише в одній локалі (наприклад, тільки українською): + +- **Назва** в індексі синхронізується для обох мов автоматично — за назвою документ знайдеться і в українському, і в англійському пошуку; +- а от **опис і зображення** картки результату зберігаються лише для тієї локалі, в якій документ востаннє зберігали. В іншій локалі картка може бути «голою» або підтягнути дані з другої мови. + +Практичний рецепт: якщо картка результату виглядає неповно в одній із мов — відкрий документ, перемкнись у цю локаль і збережи його ще раз. Див. також [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). + +### 3. Пошуковий запит не збігається + +Пошук шукає збіг за початком/фрагментом тексту в назві, SEO-полях і slug. Синонімів і виправлення одруків немає: «табір» не знайде «зміна». Якщо важливо, щоб документ знаходився за певним словом, — додай це слово в назву або SEO-опис. + +## Як «підштовхнути» документ в індексі + +Якщо конкретний документ у видачі виглядає застаріло (стара назва, старий опис, немає зображення): + +1. Відкрий документ в адмінці. +2. За потреби онови його SEO-поля — саме з них будується картка результату. +3. Натисни **Опублікувати** (для опублікованих — повторно). Збереження пересинхронізує запис в індексі. +4. Якщо ведеш обидві мови — повтори збереження в англійській локалі, щоб оновились і її опис із зображенням. + +Це вирішує 90% точкових проблем із видачею без повного реіндексу. + +## Повний реіндекс + +Якщо індекс явно «розʼїхався» з реальністю (наприклад, у видачі висять видалені документи, а масове перезбереження не допомагає) — потрібен повний реіндекс. + +:::info Реіндекс запускає техпідтримка +Кнопки реіндексу в адмінці немає. Повне перебудування індексу запускається через захищений секретом службовий ендпоінт — звернись до технічної підтримки/розробників. Технічні деталі: [Пошук — синхронізація індексу](/admin/docs/technical/infrastruktura/poshuk-synkhronizatsiia). +::: + +## Швидка діагностика + +| Симптом | Перше, що перевірити | +| --- | --- | +| Документа немає у видачі | Чи опублікований? Чи є запис у «Результатах пошуку»? | +| Картка без опису/зображення | Заповнені SEO-поля? Збережений у цій локалі? | +| У видачі «привид» видаленого документа | Потрібен повний реіндекс — до техпідтримки. | +| Не знаходиться за очевидним словом | Чи є це слово в назві або SEO-полях документа? | + +## Повʼязані статті + +- [SEO](/admin/docs/manager/kontent/seo) — поля, з яких будується картка результату +- [Локалізація](/admin/docs/manager/kontent/lokalizatsiia) +- [Часті питання](/admin/docs/manager/servisy/chasti-pytannia) diff --git a/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md b/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md new file mode 100644 index 0000000..1a283cf --- /dev/null +++ b/docs/admin-panel/manager/05-servisy/02-hlobalni-bloky.md @@ -0,0 +1,98 @@ +--- +title: Глобальні блоки сайту +description: Хедер, футер і «Календар змін» на головній — наскрізні елементи, які редагуються в одному місці +--- + +Глобальні блоки — це елементи, які видно на всьому сайті (або на головній) і які редагуються один раз в одному місці. В адмінці вони показані внизу бічного меню, окремо від колекцій: **Хедер сайту**, **Футер сайту**, **Календар змін**. + +## Хедер сайту + +Верхнє меню на всіх сторінках. Складається з пунктів меню — до **6 пунктів**. + +Кожен пункт: + +| Налаштування | Варіанти | +| --- | --- | +| Тип посилання | **внутрішній документ** (сторінка або публікація — обирається зі списку) або **власний URL** (будь-яка адреса, зокрема зовнішня) | +| Мітка | текст пункту меню; локалізується — заповни українську та англійську версії | +| Відкривати в новій вкладці | так/ні | + +:::tip +Для внутрішніх розділів обирай саме «внутрішній документ», а не вручну введений URL: якщо у сторінки зміниться slug, пункт меню не зламається. +::: + +## Футер сайту + +Нижнє меню сайту. Влаштоване так само, як хедер: до 6 пунктів, внутрішні або зовнішні посилання, локалізовані мітки. + +## Календар змін + +Секція «Найближчі зміни» на головній сторінці: список майбутніх таборових змін із кнопкою запису. + +Поля секції: + +| Поле | Призначення | +| --- | --- | +| **Надзаголовок** | дрібний текст над заголовком секції | +| **Заголовок** | назва секції | +| **Опис** | абзац під заголовком | +| **Зміни** | список подій (див. нижче) | +| **Текст кнопки** | напис на головній кнопці секції | + +Кожна подія у списку «Зміни»: + +| Поле | Приклад | +| --- | --- | +| Місяць (скорочено) | «ВЕР» | +| Рік | «2026» | +| Дати | «1–7 вересня 2026» | +| Назва зміни | «Осіння зміна "Залізна воля"» | +| Опис | короткий текст про зміну | +| Посилання на анкету | URL зовнішньої форми запису (обовʼязкове) | + +:::info Кнопка веде на анкету першої зміни +Головна кнопка секції відкриває анкету **першої події у списку**. Тримай найближчу актуальну зміну першою; минулі — видаляй або перетягуй униз. +::: + +:::warning Заповнюй обидві локалі +Усі поля календаря локалізовані. Якщо заповнити лише українську версію, англійська головна (`/en`) показуватиме українські тексти. Перемкни локаль на English у редакторі й заповни поля ще раз. Див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). +::: + +## Покроково: додати пункт меню + +1. Відкрий **Хедер сайту** (внизу бічного меню). +2. У списку пунктів натисни «Додати». +3. Обери тип: «внутрішній документ» → вибери сторінку чи публікацію, або «власний URL» → встав адресу. +4. Введи мітку українською. +5. Перемкни локаль на English і введи англійську мітку. +6. Збережи — меню на сайті оновиться одразу. + +Якщо пунктів уже 6 — новий не додасться: спершу звільни місце, прибравши найменш важливий. + +## Покроково: додати нову зміну в календар + +1. Відкрий **Календар змін**. +2. У списку «Зміни» додай подію: місяць (скорочено, напр. «ВЕР»), рік, дати («1–7 вересня 2026»), назву, опис і посилання на анкету. +3. Перетягни подію на правильне місце: **перша в списку** — та, на чию анкету веде головна кнопка секції. +4. Перемкни локаль на English і заповни ті самі поля англійською. +5. Збережи. Головна сторінка оновиться одразу. + +Коли зміна минула — видали її зі списку (в обох локалях це один запис — видаляється разом). + +## Хто може редагувати + +Глобальні блоки редагуються в адмін-панелі, куди мають доступ лише користувачі з роллю «Адміністратор». Вміст блоків публічний — це і є меню та календар, які бачать усі відвідувачі сайту. + +## Коли зміни зʼявляться на сайті + +Одразу. Після збереження будь-якого з трьох глобальних блоків кеш сайту скидається автоматично — «перебудови» чи очікування не потрібні. Якщо в браузері досі стара версія, онови сторінку без кешу (Ctrl+Shift+R / Cmd+Shift+R). + +## У глобальних блоків немає чернеток + +На відміну від сторінок і публікацій, глобальні блоки не мають статусу «чернетка»: **збереження одразу змінює сайт**. Редагуй уважно — попереднього перегляду для них теж немає. + +## Повʼязані статті + +- [Сторінки](/admin/docs/manager/kontent/storinky) — вміст самої головної сторінки (сторінка `home`) +- [Форми](/admin/docs/manager/kontent/formy) — власні форми платформи; календар натомість посилається на зовнішні анкети +- [Локалізація](/admin/docs/manager/kontent/lokalizatsiia) diff --git a/docs/admin-panel/manager/05-servisy/03-chasti-pytannia.md b/docs/admin-panel/manager/05-servisy/03-chasti-pytannia.md new file mode 100644 index 0000000..6448b71 --- /dev/null +++ b/docs/admin-panel/manager/05-servisy/03-chasti-pytannia.md @@ -0,0 +1,103 @@ +--- +title: Часті питання +description: Швидкі відповіді на типові запитання контент-менеджера — від кешу до сертифікатів +--- + +Зібрані відповіді на питання, які виникають найчастіше. Кожна відповідь коротка; за деталями — посилання на повні статті. + +Швидка навігація за симптомом: + +| Симптом | Питання нижче | +| --- | --- | +| Зміни не видно на сайті | «Зберіг зміни, а на сайті по-старому» | +| Немає сертифіката | «Учасник каже, що не може отримати сертифікат» | +| Документ не шукається | «Пошук не знаходить документ» | +| Курс не публікується | «Не можу опублікувати курс» | +| Українська на `/en` | «Англійська сторінка показує український текст» | +| Немає листів із форми | «Форма не надсилає листи» | +| Аватар не вантажиться | «Завантаження аватара не працює» | +| Проблеми з XP | «Хочу скасувати (або дорахувати) чиїсь XP» | + +## «Зберіг зміни, а на сайті по-старому» + +Перевір по черзі: + +1. **Ти опублікував, а не лише зберіг?** Редагування опублікованого документа накопичується в чернетці — на сайт зміни потраплять тільки після повторного натискання **Опублікувати**. Див. [Чернетки, версії та попередній перегляд](/admin/docs/manager/kontent/chernetky-i-publikatsiia). +2. **Кеш сайту.** Зазвичай сторінки оновлюються одразу після публікації, але в найгіршому разі стара версія може жити: для курсів і головної — до **5 хвилин**, для публікацій — до **10 хвилин**. Зачекай і онови сторінку. +3. **Кеш браузера.** Онови сторінку без кешу: Ctrl+Shift+R (Windows) або Cmd+Shift+R (Mac). + +Глобальні блоки (хедер, футер, календар) оновлюються миттєво після збереження. + +## «Учасник каже, що не може отримати сертифікат» + +Сертифікат видається лише тоді, коли запис учасника на курс має статус **«Завершено»**. Перевір: + +1. Відкрий **Записи на курси** (група «Курси»), знайди запис цього учасника на цей курс. +2. Якщо статус «В процесі» — учасник пройшов **не всі кроки**, або (якщо в курсі ввімкнено тест) **ще не склав тест**. Для завершення потрібне і те, і те. +3. Курс має бути опублікований — на знятому з публікації курсі учасник не зможе ані дійти до кроків, ані завантажити сертифікат. + +:::warning +Курс без жодного кроку і без тесту **ніколи** не вважається завершеним — сертифікат за нього отримати неможливо. +::: + +## «Пошук не знаходить документ» + +Три найчастіші причини: документ у чернетках; документ збережений лише в одній локалі; шукане слово відсутнє в назві та SEO-полях. Повний розбір із діагностикою — у статті [Пошук на сайті](/admin/docs/manager/servisy/poshuk). + +## «Не можу опублікувати курс» + +Дві типові причини: + +1. **У курсі немає жодного кроку.** Чернетка без кроків зберігається, але публікація вимагає щонайменше один крок. +2. **Помилка в тесті.** Якщо тест увімкнено: у ньому має бути хоча б одне питання, у кожного питання — щонайменше дві відповіді, і серед них позначена **хоча б одна правильна**. Інакше зʼявиться помилка «Позначте щонайменше одну правильну відповідь.» + +Перед публікацією проглянь усі питання тесту — див. [Тест курсу](/admin/docs/manager/kursy/test-kursu). + +## «Англійська сторінка показує український текст» + +Це нормальна поведінка: порожні англійські поля автоматично показують український вміст. Щоб зʼявився переклад — відкрий документ, перемкни локаль на **English**, заповни поля й опублікуй ще раз. Див. [Локалізація](/admin/docs/manager/kontent/lokalizatsiia). + +## «Форма не надсилає листи» + +1. Відкрий форму → секція **Листи**. Якщо там порожньо — листи й не мали надходити: додай лист із полями «Кому», «Тема», «Текст листа». +2. Перевір адресу в «Кому» (кілька адрес — через кому) і плейсхолдери: `{{імʼяПоля}}` має використовувати **імʼя поля латиницею**, не мітку. +3. Загляни в папку «Спам». +4. Самі заявки при цьому не губляться — вони завжди є в колекції **Відповіді форм**. + +Якщо все налаштовано, а листів немає — проблема на боці сервісу розсилки, звернись до техпідтримки. Див. [Форми](/admin/docs/manager/kontent/formy). + +## «Завантаження аватара не працює» + +Найчастіша причина — **файл більший за 5 МБ**: попроси учасника стиснути фото або вибрати менше. Підтримувані формати: JPEG, PNG, WebP, GIF. Див. [Користувачі](/admin/docs/manager/spilnota/korystuvachi). + +## «Хочу скасувати (або дорахувати) чиїсь XP» + +Вручну — **неможливо**. XP нараховується лише за реальні дії (кроки й тести), механізму ручного коригування в адмінці немає, а колекція «Події XP» — технічний журнал лише для читання. Якщо є підозра на помилку в нарахуванні — звернись до розробників. Див. [XP та рейтинг](/admin/docs/manager/spilnota/xp-i-reitynh). + +## «Пройшов крок, а XP в рейтингу не додались» + +Лідерборд і лічильник XP кешуються — нові бали зʼявляються з затримкою **до 5 хвилин**. Це нормально; попроси учасника оновити сторінку трохи згодом. Див. [XP та рейтинг](/admin/docs/manager/spilnota/xp-i-reitynh). + +## «У меню сайту не додається сьомий пункт» + +Це обмеження, а не збій: хедер і футер вміщують до **6 пунктів** кожен. Звільни місце — прибери найменш важливий пункт. Див. [Глобальні блоки сайту](/admin/docs/manager/servisy/hlobalni-bloky). + +## «Користувачу не приходить код підтвердження з пошти» + +1. Перевірте папку «Спам». +2. Код дійсний лише **5 хвилин** і має **3 спроби вводу** — якщо час вийшов чи спроби вичерпані, треба запросити новий код. +3. Запити кодів обмежені за частотою (кілька на 5 хвилин на одну адресу) — після кількох поспіль запитів варто трохи зачекати. +4. Якщо листи не приходять нікому взагалі — проблема із сервісом розсилки, звернись до техпідтримки. + +## «Під публікацією зʼявився образливий коментар» + +Коментарі публікуються без премодерації, тож таке можливо. Видали коментар в адмінці (**Коментарі**, група «Взаємодія») або кнопкою на сайті — разом із ним зникнуть відповіді та лайки. Порядок дій і що робити з системним порушником — у статті [Коментарі та лайки](/admin/docs/manager/spilnota/komentari-i-laiky). + +## «Видалив курс — учасники скаржаться, що зник прогрес» + +Так і працює видалення: разом із курсом безповоротно видаляються записи учасників, спроби тестів і XP-історія по ньому. Повернути дані неможливо. Надалі для «архівації» курсу використовуй **зняття з публікації**, а не видалення. Див. [XP та рейтинг](/admin/docs/manager/spilnota/xp-i-reitynh). + +## Не знайшов відповіді? + +- Розділи менеджерської документації: [Публікації](/admin/docs/manager/kontent/publikatsii), [Сторінки](/admin/docs/manager/kontent/storinky), [Медіатека](/admin/docs/manager/kontent/media), [Користувачі](/admin/docs/manager/spilnota/korystuvachi), [Пошук](/admin/docs/manager/servisy/poshuk), [Глобальні блоки](/admin/docs/manager/servisy/hlobalni-bloky). +- Технічні питання (інфраструктура, збої, реіндекс) — до розробників; для них є окремий технічний трек документації. diff --git a/docs/admin-panel/manager/05-servisy/_category.json b/docs/admin-panel/manager/05-servisy/_category.json new file mode 100644 index 0000000..a1479a2 --- /dev/null +++ b/docs/admin-panel/manager/05-servisy/_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 new file mode 100644 index 0000000..b7bb26c --- /dev/null +++ b/docs/admin-panel/technical/01-arkhitektura/01-ohliad.md @@ -0,0 +1,170 @@ +--- +title: Огляд архітектури +description: Стек технологій, структура src/ і потік даних — з чого складається платформа і як її частини звʼязані +--- + +## Стек + +| Шар | Технологія | Версія | +| --- | --- | --- | +| Фреймворк | Next.js (App Router, React 19, Turbopack) | 15.4.11 | +| CMS / бекенд | Payload CMS | 3.87.0 | +| Інтеграція auth | `payload-auth` (Better Auth) | ^1.9.4 (better-auth ^1.4.19) | +| База даних | PostgreSQL (Neon serverless), `@payloadcms/db-postgres` (Drizzle) | — | +| Сховище файлів | Vercel Blob (`@payloadcms/storage-vercel-blob`) | — | +| Email | Resend (`@payloadcms/email-resend`) | — | +| UI | Tailwind CSS v4, Radix UI, Lucide | — | +| Пакетний менеджер | pnpm | — | +| Хостинг | Vercel, регіон `fra1` | — | + +Next.js і Payload працюють в одному процесі: адмін-панель Payload — це звичайні +маршрути App Router у route group `(payload)`, а фронтенд викликає Payload через +Local API без жодного HTTP. + +## Структура `src/` + +``` +src/ +├── app/ +│ ├── (frontend)/ # Публічний сайт +│ │ ├── [locale]/ # Усі сторінки: uk (без префікса) / en +│ │ ├── (sitemaps)/ # pages-sitemap.xml, posts-sitemap.xml +│ │ └── next/ # /next/preview, /next/exit-preview (draft mode) +│ ├── (payload)/ # Адмінка (/admin) + REST (/api/[...slug]) + GraphQL +│ └── api/ # Власні API-маршрути: auth, dev-login, +│ # reindex-search, courses/[id]/completions, admin-docs +├── collections/ # Конфіги колекцій Payload (13 проєктних) +├── Header/ Footer/ HomeCalendar/ # Глобали (конфіг + RowLabel + revalidate-хук) +├── components/ # React-компоненти (admin/ і фронтенд) +├── hooks/ # Спільні Payload-хуки (rateLimitCreate, +│ # syncCourseCompletions, revalidateCourse, …) +├── access/ # Access-функції: admin, anyone, authenticated, +│ # authenticatedOrPublished +├── actions/ # Server actions: commentsAndLikes, xp, accountSettings +├── blocks/ heros/ fields/ # Блоки макета, hero-конфіг, defaultLexical, link +├── search/ # beforeSync, fieldOverrides, localeSync (плагін) +├── plugins/ # plugins/index.ts (порядок!), ukrainianAdmin +├── lib/ # auth/, email/, rate-limit.ts, payload.ts, courses/ +├── utilities/ # courseCompletion, xp, leaderboard, certificateToken, +│ # cyrillicSlugify, courseJsonImport, i18n, … +├── migrations/ # SQL-міграції для CI та production +├── middleware.ts # Локалі + захист приватних маршрутів +└── payload.config.ts # Головний конфіг Payload +``` + +Детальніше: + +- Конфіг і плагіни — [payload.config.ts і плагіни](/admin/docs/technical/arkhitektura/payload-config) +- Маршрути й ISR — [Маршрути та middleware](/admin/docs/technical/arkhitektura/marshruty-i-middleware) +- Колекції — [Огляд моделі даних](/admin/docs/technical/model-danykh/ohliad) + +## Потік даних + +### Читання: RSC → Local API + +Публічні сторінки — серверні компоненти (RSC), які читають дані напряму через +Local API: + +```ts +const payload = await getPayload({ config }) +const result = await payload.find({ + collection: 'courses', + where: { _status: { equals: 'published' } }, + draft: false, +}) +``` + +Жодного `fetch` до власного REST API з серверного коду немає — Local API працює +в тому ж процесі й транзакції. Більшість публічних сторінок при цьому +кешуються через ISR (`export const revalidate = 300/600`), тож запит до БД +виконується не на кожен перегляд. + +:::warning Local API обходить access control +`payload.find()` без опцій виконується з правами адміністратора. Якщо +передаєте `user`, завжди додавайте `overrideAccess: false` — інакше запит +матиме права адміна, хоч і «від імені» користувача. +::: + +### Мутації: клієнт → server actions → Local API + +Увесь запис користувацького прогресу йде через server actions: + +| Server action | Файл | Що робить | +| --- | --- | --- | +| `enrollInCourse`, `completeStep`, `submitQuizAttempt`, `getEnrollment`, `getQuizAttempts`, `getMyCourseStatuses` | `src/app/(frontend)/[locale]/courses/actions.ts` | Запис на курс, прогрес, оцінювання тесту | +| `addComment`, `deleteComment`, `toggleLike`, `getComments` | `src/actions/commentsAndLikes.ts` | Коментарі та лайки | +| `getMyXp` | `src/actions/xp.ts` | Сумарний XP користувача | +| `updateAvatar`, `removeAvatar`, `updateAbout`, `updateHideProfileComments`, `updateSocialLinks`, `setInitialPassword` | `src/actions/accountSettings.ts` | Налаштування профілю | + +Server action сам перевіряє сесію (Better Auth), валідує вхід, застосовує rate +limit і лише потім пише через Local API. + +### REST — лише для адмінки та зовнішніх клієнтів + +REST (`/api/[...slug]` у route group `(payload)`) і GraphQL використовуються: + +- адмін-панеллю Payload (вона працює тільки через REST); +- MCP-клієнтами (`@payloadcms/plugin-mcp` з ключами `payload-mcp-api-keys`); +- кастомними admin-компонентами (наприклад, `CourseDeleteConfirmation` рахує + повʼязані записи через `/api/enrollments?...&limit=0`). + +## Ключовий принцип: прогрес пишеться тільки server actions + +Це — головне архітектурне рішення платформи, і на ньому тримається access +control кількох колекцій: + +- `enrollments.update` — **тільки адмін**. Користувач не може через REST + «домалювати» собі `completedSteps`, `status: 'completed'` чи `quizPassed` — + саме з цих полів виводяться сертифікати та XP. +- `quiz-attempts.create` — **тільки адмін**. Оцінювання тесту відбувається на + сервері у `submitQuizAttempt`; відкритий REST-create дозволив би підробляти + результати. +- `xp-events` — усі 4 операції тільки адмін; записи створює сервер. + +Server action виконує перевірки самостійно (сесія → власність enrollment → +належність кроку курсу) і пише через Local API, де адмінський дефолт доступу — +це фіча, а не діра: легітимний шлях запису один, і він серверний. + +:::danger Не відкривайте update/create цих колекцій +Якщо колись здасться зручним дозволити власнику оновлювати свій enrollment +через REST — це відкриє підробку завершень курсів і сертифікатів. Правильний +шлях — новий server action з перевірками. +::: + +Деталі бізнес-правил: [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu), +[Квізи](/admin/docs/technical/biznes-logika/kvizy), [XP](/admin/docs/technical/biznes-logika/xp). + +## Автентифікація + +Better Auth (сесійні cookie, Google OAuth, email+OTP) інтегрований у Payload +плагіном `payload-auth`: колекція `users` — спільна для сайту й адмінки, ролі +`admin`/`learner` лежать у полі `users.role` (hasMany select). Вхід в адмінку +дозволено лише ролі `admin` (`adminRoles: ['admin']` у +`src/lib/auth/options.ts`). + +Сесію на сервері читають хелпери `getSession` / `requireSession` +(`src/lib/auth/*`), а `src/middleware.ts` перекриває приватні маршрути ще до +рендера. Подробиці: [Better Auth](/admin/docs/technical/autentyfikatsiya/better-auth), +[Ролі і доступ](/admin/docs/technical/autentyfikatsiya/roli-i-dostup). + +## Кешування та інвалідація + +Три рівні: + +1. **ISR** — сторінки з `export const revalidate` (300 с курси/головна/лідерборд, + 600 с пости). +2. **`unstable_cache` з тегами** — лідерборд, лічильники лайків/коментарів, + глобали, redirects, документи по slug. +3. **Клієнтський кеш XP** — `sessionStorage` (`src/utilities/myXpCache.ts`, TTL 5 хв). + +Хуки колекцій (`revalidateCourse`, `revalidatePage`, `revalidatePost`) і server +actions бустять шляхи й теги одразу після мутацій — повна таблиця тегів у +[Маршрути та middleware](/admin/docs/technical/arkhitektura/marshruty-i-middleware). + +## База даних і міграції + +Dev-сесії працюють через Drizzle push (`push: !process.env.CI`) на власній +Neon-гілці; CI та production застосовують файлові міграції з `src/migrations/` +(23 міграції станом на 2026-07). Кожна зміна схеми їде в PR разом із +міграцією. Деталі: [База даних](/admin/docs/technical/infrastruktura/baza-danykh) +і [Міграції](/admin/docs/technical/infrastruktura/mihratsii). diff --git a/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md b/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md new file mode 100644 index 0000000..9a2316c --- /dev/null +++ b/docs/admin-panel/technical/01-arkhitektura/02-payload-config.md @@ -0,0 +1,243 @@ +--- +title: payload.config.ts і плагіни +description: Розбір головного конфігу Payload — БД, локалізація, email, jobs — і десяти плагінів, порядок яких критичний +--- + +Головний конфіг — `src/payload.config.ts`. Він збирає 13 колекцій, 3 глобали, +масив плагінів із `src/plugins/index.ts` і налаштування, описані нижче. + +## Секції конфігу + +### db: Postgres + push + +```ts +db: postgresAdapter({ + pool: { + connectionString: normalizeDatabaseURL(process.env.DATABASE_URL || ''), + }, + push: !process.env.CI, +}), +``` + +- `push: !process.env.CI` — на dev-сервері Drizzle сам синхронізує схему при + старті; в CI та production схему змінюють лише файлові міграції + (див. [Міграції](/admin/docs/technical/infrastruktura/mihratsii)). +- `normalizeDatabaseURL` замінює `sslmode=prefer|require|verify-ca` на + `verify-full`: `pg-connection-string` сьогодні трактує їх як аліаси + `verify-full`, але попереджає, що pg v9 це змінить — тож значення зафіксовано + явно. Neon віддає публічно довірений сертифікат, тому `verify-full` працює. +- Перед `buildConfig` конфіг логує hostname із `DATABASE_URL` — швидка перевірка, + на яку Neon-гілку дивиться сервер. + +### localization: контент uk + en + +```ts +localization: { + locales: [ + { label: { uk: 'Українська', en: 'Ukrainian' }, code: 'uk' }, + { label: { uk: 'Англійська', en: 'English' }, code: 'en' }, + ], + defaultLocale: 'uk', + fallback: true, +}, +``` + +`fallback: true` — порожнє en-значення на фронтенді підставляється з uk. +Зворотного фолбека немає: документ, збережений лише в en, для uk-запитів +порожній (це корінь проблеми з пошуком, див. +[Пошук і синхронізація](/admin/docs/technical/infrastruktura/poshuk-synkhronizatsiia)). + +### i18n: адмінка лише українською + +```ts +i18n: { + fallbackLanguage: 'uk', + supportedLanguages: { uk }, + translations: { uk: { /* … */ } }, +}, +``` + +`supportedLanguages` містить **тільки** `uk` — навмисно. Якщо додати `en`, +Payload ≥3.79 матчить регіональні `Accept-Language` теги (`en-US` → `en`), і +кожен браузер з англійською локаллю отримує англійську адмінку. Кастомні +переклади поверх стандартних: + +- `general.creatingNewLabel`, `general.payloadSettings`; +- увесь namespace `plugin-redirects` — плагін redirects не має uk-перекладів, + і з `fallbackLanguage: 'uk'` без цих рядків в UI протікали сирі ключі + `plugin-redirects:*`. + +### editor: defaultLexical + +`editor: defaultLexical` (`src/fields/defaultLexical.ts`) — базовий набір для +всіх richText-полів без власного editor: Paragraph, Bold/Italic/Underline/ +Strikethrough, Align, Indent, списки, Blockquote, HR, Upload, обидва тулбари та +`LinkFeature({ enabledCollections: ['pages', 'posts'] })`. Колекції з +розширеними потребами (контент постів, caption медіа) задають власний +`lexicalEditor` поверх. + +### email: Resend, умовно + +```ts +email: process.env.RESEND_API_KEY + ? resendAdapter({ + defaultFromAddress: process.env.EMAIL_FROM || 'onboarding@resend.dev', + defaultFromName: 'Learning Platform', + apiKey: process.env.RESEND_API_KEY, + }) + : undefined, +``` + +Без `RESEND_API_KEY` адаптер не підключається: `payload.sendEmail` падає у +консольний фолбек — зручно локально, але кнопка «Надіслати запрошення» в +адмінці реального листа не відправить. Перелік усіх листів — +[Email](/admin/docs/technical/infrastruktura/email). + +### jobs: доступ через CRON_SECRET + +```ts +jobs: { + access: { + run: ({ req }) => { + if (req.user) return true + const secret = process.env.CRON_SECRET + if (!secret) return false + return req.headers.get('authorization') === `Bearer ${secret}` + }, + }, + tasks: [], +}, +``` + +Черга задач (використовується `schedulePublish` для відкладених публікацій) +запускається або залогіненим користувачем, або зовнішнім кроном з +`Authorization: Bearer $CRON_SECRET`. Власних `tasks` немає. + +### cors, serverURL, secret + +- `serverURL: getServerSideURL()` (`src/utilities/getURL.ts`); +- `cors`: `getServerSideURL()` + `https://${VERCEL_URL}` (порожні відкидаються); +- `secret: process.env.PAYLOAD_SECRET` — ним же підписуються сертифікатні + токени (`src/utilities/certificateToken.ts`); +- `sharp` — обробка зображень; `typescript.outputFile` → `src/payload-types.ts` + (регенерація: `pnpm generate:types`). + +Секція `admin` (компоненти, meta, livePreview) розібрана окремо в +[Кастомізаціях адмін-панелі](/admin/docs/technical/arkhitektura/admin-kastomizatsii). + +## Плагіни: порядок критичний + +`src/plugins/index.ts` експортує масив із 10 плагінів. Payload застосовує їх +послідовно, кожен мутує конфіг, тому позиція має значення: **betterAuth +першим** (створює `users`, на якого посилаються інші), **searchLocaleSync +одразу після searchPlugin** (його хук має бігти після синка плагіна), +**ukrainianAdmin останнім** (перекладає ярлики колекцій, які створили всі +попередні). + +### 1. betterAuthPlugin + +`payload-auth/better-auth`, опції — `src/lib/auth/options.ts`. +`hidePluginCollections: true`. Розширює `users` (поля `name`, `email`, +`emailVerified`, `image`, `role`) і створює колекції `sessions`, `accounts`, +`verifications`, `rateLimit`, `admin-invitations`. Ключові опції: +`adminRoles: ['admin']`, `allowedFields: ['name']`, +`collectionOverrides` перетворює дефолт ролі на масив `['learner']` (інакше +Drizzle мовчки дропав нон-array дефолт hasMany-select і юзери лишалися без +ролі). `adminInvitations.sendInviteEmail` шле лист через `payload.sendEmail`. +Мусить бути першим — усі relationship на `users` та access-функції залежать від +готової auth-колекції. + +### 2. vercelBlobStorage (умовний) + +Підключається лише коли задано `BLOB_READ_WRITE_TOKEN`, інакше файли пишуться +на локальний диск. Обслуговує `media` і `course-files`, обидві з +`disablePayloadAccessControl: true` — це безпечно лише тому, що обидві колекції +мають `read: anyone`; віддача йде прямо з Blob CDN без serverless-виклику. +Див. [Медіа і Blob](/admin/docs/technical/infrastruktura/media-blob). + +### 3. mcpPlugin + +Створює auth-колекцію `payload-mcp-api-keys` і виставляє колекції для +MCP-клієнтів: `posts`/`pages`/`categories`/`courses`/`course-categories`/ +`comments` — повний CRUD; `media` — `{find, update}` без create/delete; +`likes` — `{find, create, delete}` без update; глобали `header`/`footer`. +Деталі — [Колекції плагінів](/admin/docs/technical/model-danykh/plahinni-kolektsii). + +### 4. redirectsPlugin + +Для `pages` і `posts`. Override поля `from` додає опис «Після зміни цього поля +сайт потрібно перебудувати.», afterChange-хук `revalidateRedirects` +(`src/hooks/revalidateRedirects.ts`) бустить тег `redirects`. + +### 5. nestedDocsPlugin + +**Лише** `collections: ['categories']` — додає їм `parent` і `breadcrumbs`. +Сторінки (`pages`) пласкі, одно-сегментні URL; вкладеності `/parent/child` +немає, це поширена помилка. + +### 6. seoPlugin + +`generateTitle` → `«{title} | Залізна Зміна»`, `generateURL` → +`${serverURL}/${slug}`. Поля плагіна не додаються автоматично — вони вручну +розкладені по SEO-табах у Pages/Posts (`MetaTitleField`, `MetaImageField`, +`MetaDescriptionField`, `OverviewField`, `PreviewField`). + +### 7. formBuilderPlugin + +`fields: { payment: false }`. Override `confirmationMessage` дає йому lexical з +`FixedToolbarFeature` + заголовками h1–h4. Створює `forms` і +`form-submissions`. + +### 8. searchPlugin + +```ts +searchPlugin({ + collections: searchIndexedCollections, // posts, courses, course-categories, pages + beforeSync: beforeSyncWithSearch, // src/search/beforeSync.ts + searchOverrides: { fields: ({ defaultFields }) => [...defaultFields, ...searchFields] }, +}), +``` + +Створює колекцію `search`; `searchFields` (`src/search/fieldOverrides.ts`) +додають `slug`, `collectionType`, групу `meta`, масив `categories`. + +### 9. searchLocaleSync + +Кастомний плагін `src/search/localeSync.ts`. Додає хук +`backfillSearchTitleLocales` у `afterChange` чотирьох індексованих колекцій: +plugin-search пише локалізовані поля лише для `req.locale`, і документ, +збережений з en-локалі адмінки, був би невидимим для uk-пошуку. Плагін +дописує `title` для решти локалей. **Мусить стояти одразу після +searchPlugin** — хук зареєструється після синк-хука плагіна і побачить уже +створений search-рядок. + +### 10. ukrainianAdmin + +`src/plugins/ukrainianAdmin.ts`, **завжди останній**. Сторонні плагіни +(payload-auth, form-builder, search, MCP) хардкодять англійські ярлики без +API перекладу, тож цей плагін проходить фінальний конфіг і переписує labels, +descriptions, groups та options перелічених колекцій (redirects, forms з усіма +блоками полів, form-submissions, search, users, sessions, accounts, +verifications, admin-invitations, payload-mcp-api-keys), перекладає MCP-таби +(Tools/Resources/Prompts) і підміняє компонент кнопки запрошення на +`@/components/admin/InviteUserButton`. Якщо поставити його не останнім, +колекції зареєстровані пізнішими плагінами лишаться англійськими. + +## Що створює кожен плагін + +| Плагін | Колекції/поля | +| --- | --- | +| betterAuthPlugin | `users` (розширення), `sessions`, `accounts`, `verifications`, `rateLimit`, `admin-invitations` | +| vercelBlobStorage | — (стораджі для `media`, `course-files`) | +| mcpPlugin | `payload-mcp-api-keys` | +| redirectsPlugin | `redirects` | +| nestedDocsPlugin | поля `parent`/`breadcrumbs` у `categories` | +| seoPlugin | поля meta (розкладені вручну) | +| formBuilderPlugin | `forms`, `form-submissions` | +| searchPlugin | `search` | +| searchLocaleSync | хук `backfillSearchTitleLocales` | +| ukrainianAdmin | — (лише переклади) | + +Плюс службові колекції самого Payload: `payload-kv`, `payload-jobs`, +`payload-folders`, `payload-locked-documents`, `payload-preferences`, +`payload-migrations`. diff --git a/docs/admin-panel/technical/01-arkhitektura/03-marshruty-i-middleware.md b/docs/admin-panel/technical/01-arkhitektura/03-marshruty-i-middleware.md new file mode 100644 index 0000000..aa8e14c --- /dev/null +++ b/docs/admin-panel/technical/01-arkhitektura/03-marshruty-i-middleware.md @@ -0,0 +1,207 @@ +--- +title: Маршрути та middleware +description: Повна карта App Router-маршрутів, логіка src/middleware.ts, ISR-часи, cache tags і generateStaticParams +--- + +## src/middleware.ts + +Middleware робить дві речі: нормалізує локаль у URL і перекриває приватні +маршрути до рендера. Повний код: + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { getSessionCookie } from 'better-auth/cookies' + +const defaultLocale = 'uk' +const locales = ['uk', 'en'] + +const protectedPrefixes = ['/profile', '/certificates'] +const protectedPatterns = [/^\/courses\/[^/]+\/steps/] + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + if ( + pathname.startsWith('/admin') || + pathname.startsWith('/api') || + pathname.startsWith('/_next') || + pathname.startsWith('/next') || + pathname.includes('.') || + pathname.includes('-sitemap') + ) { + return NextResponse.next() + } + + const pathnameLocale = locales.find( + (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`, + ) + + if (pathnameLocale === defaultLocale) { + const newPathname = pathname.replace(`/${defaultLocale}`, '') || '/' + return NextResponse.redirect(new URL(newPathname, request.url)) + } + + const cleanPath = pathnameLocale + ? pathname.replace(`/${pathnameLocale}`, '') || '/' + : pathname + + const isProtected = + protectedPrefixes.some((p) => cleanPath.startsWith(p)) || + protectedPatterns.some((p) => p.test(cleanPath)) + + if (isProtected) { + const sessionCookie = getSessionCookie(request) + if (!sessionCookie) { + const locale = pathnameLocale || defaultLocale + const loginPath = locale === defaultLocale ? '/login' : `/${locale}/login` + const redirectParam = encodeURIComponent(pathname) + return NextResponse.redirect( + new URL(`${loginPath}?redirect=${redirectParam}`, request.url), + ) + } + } + + if (pathnameLocale) { + const requestHeaders = new Headers(request.headers) + requestHeaders.set('x-locale', pathnameLocale) + return NextResponse.next({ request: { headers: requestHeaders } }) + } + + const url = request.nextUrl.clone() + url.pathname = `/${defaultLocale}${pathname}` + const requestHeaders = new Headers(request.headers) + requestHeaders.set('x-locale', defaultLocale) + return NextResponse.rewrite(url, { request: { headers: requestHeaders } }) +} + +export const config = { + matcher: ['/((?!_next|admin|api|favicon|media/|.*\\..*).*)'], +} +``` + +### Правила по порядку + +1. **Bypass**: `/admin`, `/api`, `/_next`, `/next` (preview-роути), будь-який + шлях із крапкою (статичні файли), шляхи з `-sitemap` — пропускаються без + обробки. +2. **uk без префікса**: `/uk/...` → 307-redirect на шлях без префікса. Канонічні + українські URL не мають `/uk`. +3. **en passthrough**: `/en/...` проходить як є, з заголовком `x-locale: en`. +4. **Захист**: для `cleanPath` (шлях без локалі), що починається з `/profile`, + `/certificates` або матчить `^/courses/[^/]+/steps`, перевіряється наявність + session cookie Better Auth (`getSessionCookie` — лише наявність, без + валідації). Немає cookie → redirect на `/login?redirect=<оригінальний шлях>` + (з локальним префіксом для en). +5. **Rewrite**: шлях без локалі переписується на `/uk${pathname}` — App Router + завжди бачить сегмент `[locale]`, але користувач префікса не бачить. + +:::warning Quiz і certificate — поза matcher-ом +`/courses/:slug/quiz` і `/courses/:slug/certificate` middleware НЕ захищає — +вони мають self-guard: quiz-сторінка сама редіректить неавтентифікованих на +логін, certificate-route повертає 401/403/404. Ще нюанс: `/certificate` +містить крапку-вільний шлях, але посилання на завантаження сертифіката +генеруються **без** locale-префікса й покладаються на rewrite middleware. +::: + +## Карта маршрутів App Router + +### Frontend — `src/app/(frontend)/[locale]/` + +| Маршрут | Файл | Захист | Рендеринг | +| --- | --- | --- | --- | +| `/` | `page.tsx` | — | ISR 300 | +| `/[slug]` | `[slug]/page.tsx` (CMS-сторінки `pages`) | — | статика + draft mode | +| `/courses` | `courses/page.tsx` | — | ISR 300 | +| `/courses/category/[slug]` | `courses/category/[slug]/page.tsx` | — | ISR 300 | +| `/courses/[slug]` | `courses/[slug]/page.tsx` | — | ISR 300 | +| `/courses/[slug]/steps/[stepIndex]` | `.../steps/[stepIndex]/page.tsx` | middleware | per-request | +| `/courses/[slug]/quiz` | `.../quiz/page.tsx` | self-guard | per-request | +| `/courses/[slug]/certificate` | `.../certificate/route.ts` (GET, PDF) | self-guard (401/403/404) | per-request, `no-store` | +| `/posts`, `/posts/page/[n]` | `posts/page.tsx`, `posts/page/[pageNumber]/page.tsx` | — | ISR 600 | +| `/posts/[slug]` | `posts/[slug]/page.tsx` | — | ISR 600 | +| `/leaderboard` | `leaderboard/page.tsx` | — | ISR 300 | +| `/search` | `search/page.tsx` | — | per-request | +| `/profile`, `/profile/settings` | `profile/…` | middleware | per-request | +| `/certificates` | `certificates/page.tsx` | middleware + `requireSession` | per-request | +| `/users/[id]` | `users/[id]/page.tsx` (публічний профіль) | — | per-request | +| `/login`, `/register`, `/forgot-password` | відповідні `page.tsx` | — | — | +| `/verify`, `/verify/[token]` | перевірка сертифіката | — | per-request | + +### Службові frontend-маршрути + +| Маршрут | Файл | Призначення | +| --- | --- | --- | +| `/next/preview` | `(frontend)/next/preview/route.ts` | Вхід у draft mode (перевіряє `previewSecret === PREVIEW_SECRET`, `payload.auth()`) | +| `/next/exit-preview` | `(frontend)/next/exit-preview/route.ts` | Вихід із draft mode | +| `/pages-sitemap.xml`, `/posts-sitemap.xml` | `(frontend)/(sitemaps)/…/route.ts` | Динамічні sitemap-и (published only, uk-only, кеш по тегах) | + +### API — `src/app/api/` + +| Маршрут | Метод | Призначення | +| --- | --- | --- | +| `/api/auth/[...all]` | * | Better Auth (sign-in, sign-up, OAuth, session, …) | +| `/api/auth/verify-registration` | POST | OTP-гейт реєстрації (`send-otp` / `verify-otp`) | +| `/api/dev-login` | GET | Дев-логін адміном (жорстко вимкнений на Vercel), див. [Dev-login і сід](/admin/docs/technical/autentyfikatsiya/dev-login-i-sid) | +| `/api/reindex-search` | POST | Повний реіндекс пошуку (заголовок `x-reindex-secret === CRON_SECRET`) | +| `/api/courses/[id]/completions` | GET | Публічна проєкція завершень курсу (`s-maxage=60`, swr 300) | +| `/api/admin-docs/search-index` | GET | Пошуковий індекс цієї документації | + +### Payload — `src/app/(payload)/` + +| Маршрут | Призначення | +| --- | --- | +| `/admin/[[...segments]]` | Адмін-панель (включно з кастомним view `/admin/docs`) | +| `/api/[...slug]` | Payload REST API | +| `/api/graphql`, `/api/graphql-playground` | GraphQL | + +## ISR і generateStaticParams + +### Часи revalidate + +| Значення | Маршрути | +| --- | --- | +| 300 с | `/`, `/courses`, `/courses/[slug]`, `/courses/category/[slug]`, `/leaderboard` | +| 600 с | `/posts`, `/posts/page/[n]`, `/posts/[slug]` | +| per-request | степи, квіз, сертифікат, профіль, пошук, verify, публічні профілі | + +`getCatalogData` (`src/lib/courses/getCatalogData.ts`) свідомо session-free: +ISR-кеш спільний для всіх, а прогрес конкретного користувача дофетчується +клієнтськи (`useMyCourseStatuses` → server action `getMyCourseStatuses`). +Не додавайте `getSession` чи `useSearchParams` у компоненти публічних +сторінок — це вимикає ISR. + +### generateStaticParams + +Пребілд на `next build`: `pages` (всі не-home slugs × 2 локалі), `courses` +(published × 2), `/leaderboard` (× 2), плюс списки постів. Решта сторінок +генерується на першому запиті. + +## Cache tags: повна таблиця + +| Тег | Хто бустить | Хто споживає | +| --- | --- | --- | +| `pages-sitemap` | `revalidatePage` / `revalidateDelete` (Pages) | `pages-sitemap.xml` | +| `posts-sitemap` | `revalidatePost` / `revalidateDelete` (Posts) | `posts-sitemap.xml` | +| `redirects` | `revalidateRedirects` (afterChange redirects) | `getRedirects` (`src/utilities/getRedirects.ts`) | +| `global_header` | `revalidateHeader` | `getCachedGlobal` (`src/utilities/getGlobals.ts`) | +| `global_footer` | `revalidateFooter` | те саме | +| `global_home-calendar` | `revalidateHomeCalendar` | те саме | +| `xp-leaderboard` | `logXpEvent` після успішного запису XP (`courses/actions.ts`) | лідерборд-запити в `src/utilities/leaderboard.ts` (revalidate 300) | +| `course-enrollment-stats` | `revalidateCoursePages` (server action) | `getCachedCourseCompletions` (revalidate 60) | +| `likes-counts-` | `revalidateCounts` у `src/actions/commentsAndLikes.ts` | `src/utilities/contentCounts.ts` (revalidate 120) | +| `comments-counts-` | те саме | те саме | +| `pages_`, `posts_` | — (лише часовий фолбек) | `getDocument` (`src/utilities/getDocument.ts`, тег `${collection}_${slug}`) | + +Нюанс: `revalidateCounts` пропускає `targetCollection === 'comments'` — лайки +коментарів рахуються без кешу в `getComments`. + +### revalidatePath у хуках курсів + +`revalidateCourse` (`src/hooks/revalidateCourse.ts`) бустить для обох префіксів +`''` та `/en`: `/courses/`, `/courses`, +`/courses/category/[slug]` (як page-паттерн) і головну. При rename/unpublish +бустить і старий slug. Усе загорнуто у `try/catch`: відкладена публікація +(`schedulePublish`) виконується поза request-контекстом, де `revalidatePath` +кидає — пропущений буст компенсується часовим вікном ISR, тож збереження не +падає. Server action `revalidateCoursePages` додатково бустить +`steps/[stepIndex]`, `quiz` і тег `course-enrollment-stats`. diff --git a/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md b/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md new file mode 100644 index 0000000..c6e2058 --- /dev/null +++ b/docs/admin-panel/technical/01-arkhitektura/04-admin-kastomizatsii.md @@ -0,0 +1,188 @@ +--- +title: Кастомізації адмін-панелі +description: Кастомні admin-компоненти, importmap-воркфлоу, JSON-імпорт курсів, безпечне видалення, запрошення користувачів +--- + +## admin.components у payload.config.ts + +```ts +admin: { + components: { + beforeLogin: ['@/components/BeforeLogin'], + beforeDashboard: ['@/components/BeforeDashboard'], + afterNavLinks: ['@/components/admin/Docs/DocsNavLinks'], + graphics: { + Icon: '@/components/admin/graphics/Icon', + Logo: '@/components/admin/graphics/Logo', + }, + views: { + docs: { + Component: '@/components/admin/Docs/DocsView', + exact: false, + path: '/docs', + }, + }, + }, + // … +} +``` + +| Слот | Компонент | Що робить | +| --- | --- | --- | +| `beforeLogin` | `src/components/BeforeLogin` | Українське привітання над формою входу | +| `beforeDashboard` | `src/components/BeforeDashboard` | Лого, привітання по імені, 4 quick actions (новий курс / публікація / сторінка, медіатека) і лічильники courses/users/posts/enrollments/comments | +| `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/...`) | + +Компоненти документації: `DocsView/` (DocsShell, DocsHome, TrackHome, +ArticleView, NotFoundView) + клієнтські `DocsSidebar.client.tsx` / +`DocsToc.client.tsx`; пошуковий індекс віддає `/api/admin-docs/search-index`. +Як влаштований рендерер — [Ця документація](/admin/docs/technical/rozrobka/tsia-dokumentatsiia). + +## Importmap: обовʼязковий крок + +Адмінка Payload — клієнтський бандл, який не може динамічно резолвити рядкові +шляхи компонентів. Тому всі вони збираються в +`src/app/(payload)/admin/importMap.js`. + +**Після додавання чи перейменування будь-якого admin-компонента:** + +```bash +pnpm generate:importmap +``` + +Без цього кроку адмінка падає з «component not found in import map». Файл +генерований — не редагуйте вручну. + +## CourseJsonImport + +`src/components/admin/CourseJsonImport/index.tsx` — ui-поле, вбудоване у форму +курсу **двічі** з різними `clientProps`: + +- `stepsJsonImport` (`clientProps: { target: 'steps' }`) — над полем кроків; +- `quiz.quizJsonImport` (`clientProps: { target: 'quiz' }`) — у групі тесту. + +Призначення: контент курсу генерує LLM, редактор вставляє отриманий JSON. + +### Механіка + +1. Кнопка «Скопіювати промпт для AI» кладе в буфер `STEPS_PROMPT` або + `QUIZ_PROMPT` (з `src/utilities/courseJsonImport.ts`) — з фолбеком на + `document.execCommand('copy')` для не-secure контекстів. +2. Вставлений JSON парситься наживо через `parseStepsJson` / `parseQuizJson` + (той самий файл, 523 рядки). Парсер **ліберальний до форми** (обгортка чи + голий масив, аліаси полів, plain text замість Lexical — `textToLexical` + перетворює порожній рядок на новий параграф, markdown не підтримується) і + **строгий до валідності** (крок без title, некоректний `YOUTUBE_URL_REGEX`, + питання без правильної відповіді, `duration` > 600). Помилки — українською, + показуються списком (перші 10). +3. «Додати за допомогою JSON» — append до наявних рядків; «Замінити…» — з + модальним підтвердженням, якщо є що втрачати. Імпорт квіза примусово + ставить `quiz.enabled: true`. +4. Застосування йде через `reset(nextData)` форми (єдиний надійний спосіб + додати richText/blocks-рядки) + `setModified(true)`, щоб кнопка збереження + лишилась активною. + +Валідація на цьому етапі свідомо дублює валідацію колекції: помилка тут — це +читабельне повідомлення, помилка на save — field error на три рівні вглиб форми. + +## CourseDeleteConfirmation + +`src/components/admin/CourseDeleteConfirmation/index.tsx` — ui-поле в сайдбарі +курсу (`deleteConfirmation`). Стандартне видалення Payload не показує масштаб +каскаду, тому компонент: + +1. Через REST рахує повʼязані записи (`limit=0`, лише `totalDocs`): + `enrollments`, `quiz-attempts`, `comments` і `likes` з + `targetCollection=courses`. +2. Якщо щось є — показує попередження зі списком кількостей. +3. Кнопка «Видалити курс» відкриває модалку з підсумком «буде безповоротно + видалено: N записів, M спроб…» і робить `DELETE /api/courses/{id}`, після + успіху — redirect на список курсів. + +Сам каскад виконує `beforeDelete`-хук колекції (див. +[Колекція courses](/admin/docs/technical/model-danykh/courses)) — компонент +лише робить його видимим для адміністратора. + +## InviteUserButton + +`src/components/admin/InviteUserButton/index.tsx`. payload-auth ставить власну +кнопку `AdminInviteButton` як `Description` колекції users; плагін +`ukrainianAdmin` (останній у ланцюжку) підміняє її шлях на цей компонент. + +- Рендериться лише на `/admin/collections/users` (перевірка `pathname`). +- Модалка: вибір ролі (ярлики «Адміністратор»/«Учасник»), email. +- Використовує ендпоінти payload-auth на колекції users: + `POST /api/users/generate-invite-url` (потрібен повний обʼєкт `{ role }`, не + рядок) і `POST /api/users/send-invite`. +- «Скопіювати посилання» працює без email; «Надіслати» шле лист через + `sendInviteEmail` → `payload.sendEmail` (без Resend-адаптера ендпоінт + віддасть 500 — див. [Email](/admin/docs/technical/infrastruktura/email)). + +Токен запрошення дозволяє реєстрацію в обхід OTP-гейта: +[Реєстрація і OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp). + +## RowLabel × 3 + +Масиви з однотипними рядками отримують читабельні ярлики рядків замість +«Item 1»: + +| Файл | Де використовується | +| --- | --- | +| `src/Header/RowLabel.tsx` | `header.navItems` | +| `src/Footer/RowLabel.tsx` | `footer.navItems` | +| `src/HomeCalendar/RowLabel.tsx` | `home-calendar.events` | + +Підключаються через `admin.components.RowLabel` відповідного array-поля, +наприклад `'@/Header/RowLabel#RowLabel'`. + +## custom.scss: хак «або продовжте через» + +`src/app/(payload)/custom.scss`. payload-auth хардкодить англійський розділювач +«Or login with» / «Or sign up with» на екранах входу і реєстрації без API +перекладу. Обхід — чистий CSS: + +```scss +.login-form-methods__divider span { + font-size: 0; + + &::after { + content: 'або продовжте через'; + font-size: 12px; + } +} +``` + +Оригінальний текст стискається до нуля, псевдоелемент малює нейтральну +українську фразу, що покриває обидва екрани. + +## admin.meta і livePreview + +```ts +meta: { + titleSuffix: '— Залізна Зміна', + description: 'Панель адміністратора навчальної платформи «Залізна Зміна»', + icons: [ + { type: 'image/png', rel: 'icon', url: '/favicon-192.png' }, + { rel: 'apple-touch-icon', url: '/apple-touch-icon.png' }, + ], + openGraph: { images: [{ url: '/og-image.webp', width: 1200, height: 630 }], /* … */ }, +}, +``` + +`livePreview.breakpoints`: Mobile 375×667, Tablet 768×1024, Desktop 1440×900. +Live preview підключений у `pages` і `posts` через `generatePreviewPath`; +**курси live preview не мають** — їхні сторінки збираються з багатьох джерел і +переглядаються через звичайний publish. + +## Навігаційні групи + +Колекції згруповано через `admin.group`: + +- **Курси**: courses, course-categories, course-files, enrollments, + quiz-attempts, xp-events; +- **Взаємодія**: comments, likes; +- **Автентифікація**: users, sessions, accounts, verifications, + admin-invitations (групу auth-колекціям простaвляє `ukrainianAdmin`); +- поза групами: Pages, Posts, Media, Categories. diff --git a/docs/admin-panel/technical/01-arkhitektura/_category.json b/docs/admin-panel/technical/01-arkhitektura/_category.json new file mode 100644 index 0000000..99ac582 --- /dev/null +++ b/docs/admin-panel/technical/01-arkhitektura/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Архітектура", + "description": "Стек, структура проєкту, конфігурація Payload і маршрутизація." +} diff --git a/docs/admin-panel/technical/02-model-danykh/01-ohliad.md b/docs/admin-panel/technical/02-model-danykh/01-ohliad.md new file mode 100644 index 0000000..b979398 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/01-ohliad.md @@ -0,0 +1,147 @@ +--- +title: Огляд моделі даних +description: Усі колекції платформи, схема звʼязків між ними та повна карта ручних каскадів видалення +--- + +## Проєктні колекції (13) + +Оголошені в `src/collections/` і зареєстровані в `src/payload.config.ts`: + +| Колекція | Файл | Призначення | +| --- | --- | --- | +| `users` | `Users/index.ts` | Auth-колекція (розширюється payload-auth): профіль, ролі | +| `courses` | `Courses.ts` | Курси: кроки-блоки, квіз, drafts/versions | +| `course-categories` | `CourseCategories.ts` | Таксономія курсів | +| `course-files` | `CourseFiles.ts` | Upload: PDF/PPT(X) для файлових кроків | +| `enrollments` | `Enrollments.ts` | Запис користувача на курс + увесь прогрес | +| `quiz-attempts` | `QuizAttempts.ts` | Спроби фінального тесту (оцінені сервером) | +| `xp-events` | `XpEvents.ts` | Append-only лог нарахувань XP (періодні лідерборди) | +| `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` | Лайки постів/курсів/коментарів | + +## Колекції плагінів + +| Колекція | Плагін | Призначення | +| --- | --- | --- | +| `sessions` | payload-auth | Активні сесії Better Auth (токен, IP, user agent) | +| `accounts` | payload-auth | Облікові записи провайдерів (credential-хеш пароля, Google) | +| `verifications` | payload-auth | OTP та інші верифікаційні записи | +| `rateLimit` | payload-auth | Спільна таблиця fixed-window rate limit (`src/lib/rate-limit.ts` теж пише сюди) | +| `admin-invitations` | payload-auth | Токени запрошень адміністраторів | +| `redirects` | plugin-redirects | Перенаправлення для pages/posts | +| `forms` | plugin-form-builder | Конструктор форм | +| `form-submissions` | plugin-form-builder | Відповіді форм | +| `search` | plugin-search | Пошуковий індекс (posts, courses, course-categories, pages) | +| `payload-mcp-api-keys` | plugin-mcp | API-ключі MCP-клієнтів | + +Службові колекції Payload: `payload-kv`, `payload-jobs` (черга +schedulePublish), `payload-folders` (папки медіа), `payload-locked-documents`, +`payload-preferences`, `payload-migrations`. Деталі плагінних колекцій — +[Колекції плагінів](/admin/docs/technical/model-danykh/plahinni-kolektsii). + +## Схема звʼязків + +Mermaid рендерер не підтримує, тому — таблиця всіх relations: + +| Звідки | Поле | Куди | Тип | +| --- | --- | --- | --- | +| `enrollments` | `user` | `users` | rel, required, unique разом із `course` | +| `enrollments` | `course` | `courses` | rel, required | +| `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** | +| `likes` | `user` | `users` | rel, required | +| `likes` | `targetCollection` + `targetId` | `posts` \| `courses` \| `comments` | **поліморфний, без FK** | +| `courses` | `category` | `course-categories` | rel | +| `courses` | `heroImage` | `media` | upload | +| `courses` | `steps[].file` (fileStep) | `course-files` | upload, required | +| `posts` | `authors` | `users` | rel hasMany | +| `posts` | `categories` | `categories` | rel hasMany | +| `posts` | `heroImage`, `meta.image` | `media` | upload | +| `posts` | `relatedPosts` | `posts` | rel hasMany | +| `pages` | `hero.media`, `meta.image` | `media` | upload | +| `categories` | `parent` | `categories` | rel (nestedDocs) | +| `course-categories` | `image` | `media` | upload | +| `sessions` / `accounts` | `user` | `users` | rel (payload-auth) | +| `search` | `doc` | індексовані колекції | поліморфний rel плагіна | + +Словами: **`users` і `courses` — два центри графа.** Навколо users обертаються +прогрес (enrollments, quiz-attempts, xp-events), взаємодія (comments, likes) і +auth-колекції; навколо courses — той самий прогрес плюс таксономія і файли. +`comments`/`likes` цілляться в контент не через relationship, а через пару +`targetCollection` + `targetId` — цілісність цих посилань тримають server +actions, не БД (див. +[comments та likes](/admin/docs/technical/model-danykh/comments-likes)). + +## Каскади видалення: чому вручну + +Drizzle генерує для relationship-полів FK з `ON DELETE SET NULL`. Але колонки +`user_id` / `course_id` / `author_id` у прогрес-колекціях оголошені +`NOT NULL` — тож при видаленні користувача або курсу Postgres спробував би +поставити NULL у NOT NULL колонку і **впав би на рівні БД**. Тому обидві +«центральні» колекції мають ручні `beforeDelete`-хуки, які спершу зачищають +залежні рядки (кожен `payload.delete` отримує `req` — увесь каскад в одній +транзакції). + +### Повна карта каскадів + +**`users.beforeDelete`** (`src/collections/Users/index.ts`) — 5 колекцій, у +цьому порядку: + +1. `xp-events` де `user = id` +2. `quiz-attempts` де `user = id` +3. `enrollments` де `user = id` +4. `likes` де `user = id` +5. `comments` де `author = id` + +**`courses.beforeDelete`** (`src/collections/Courses.ts`) — 5 запитів: + +1. `xp-events` де `course = id` +2. `quiz-attempts` де `course = id` +3. `enrollments` де `course = id` +4. `comments` де `targetCollection = 'courses'` і `targetId = id` +5. `likes` де `targetCollection = 'courses'` і `targetId = id` + +**`deleteComment`** (server action, не хук) — каскад одного коментаря: лайки +коментаря → прямі відповіді (`parent = commentId`, **один рівень** — «онуки» +осиротіють) → сам коментар. + +:::warning Наслідки каскадів +- Видалення курсу або користувача стирає їхні `xp-events` — історичні дані + періодних лідербордів за цей внесок зникають (сумарний XP і так деривується + з enrollments, тож теж зникає). +- Видалення поста НЕ каскадить: коментарі/лайки з `targetCollection='posts'` + лишаються сиротами (клієнтський код це терпить, але рядки висять). +- В адмінці масштаб каскаду курсу показує компонент `CourseDeleteConfirmation` + (див. [Кастомізації адмін-панелі](/admin/docs/technical/arkhitektura/admin-kastomizatsii)). +::: + +## Спільні патерни колекцій + +- **Access-функції** з `src/access/`: `admin`, `anyone`, `authenticated`, + `authenticatedOrPublished` + інлайнові `adminOrOwn` (enrollments, likes, + quiz-attempts) і `adminOrAuthor` (comments). +- **`lockDocuments: false`** — всюди, де задано: блокування документів + вимкнено свідомо (сольна адмін-команда). +- **Drafts/versions** лише у контентних колекцій: `pages`, `posts`, `courses` + (autosave 10000/2000/10000 мс, `schedulePublish`, `maxPerDoc: 50`). +- **Слаги** — core `slugField({ slugify: cyrillicSlugify })`: транслітерація + кирилиці, `undefined` замість `''` (щоб autosave-чернетки не билися об + unique-індекс). +- **Rate limit на create** — фабрика `rateLimitCreate` + (`src/hooks/rateLimitCreate.ts`) у enrollments, comments, likes + (див. [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting)). + +Детальні статті: [courses](/admin/docs/technical/model-danykh/courses), +[enrollments](/admin/docs/technical/model-danykh/enrollments), +[quiz-attempts та xp-events](/admin/docs/technical/model-danykh/quiz-attempts-xp-events), +[users і auth](/admin/docs/technical/model-danykh/users-i-auth), +[posts, pages, media](/admin/docs/technical/model-danykh/posts-pages-media), +[comments та likes](/admin/docs/technical/model-danykh/comments-likes), +[Глобали](/admin/docs/technical/model-danykh/hlobaly). diff --git a/docs/admin-panel/technical/02-model-danykh/02-courses.md b/docs/admin-panel/technical/02-model-danykh/02-courses.md new file mode 100644 index 0000000..22a54af --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/02-courses.md @@ -0,0 +1,186 @@ +--- +title: Колекція courses +description: Поля курсу, три блоки кроків, quiz-група, три валідаційні воркараунди та хуки життєвого циклу +--- + +Файл: `src/collections/Courses.ts`. Ярлики «Курс»/«Курси», група «Курси», +`useAsTitle: 'title'`, колонки списку `[title, category, _status, createdAt]`, +`lockDocuments: false`. + +## Access + +| Операція | Правило | +| --- | --- | +| create / update / delete | `admin` | +| read | `authenticatedOrPublished` — анонім бачить лише `_status: published`; **будь-який** залогінений (у т.ч. learner) бачить і чернетки через API | + +## Поля + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `title` | text | required, localized | +| `slug` (+ `generateSlug`) | core `slugField` | `slugify: cyrillicSlugify`, unique | +| `description` | textarea | localized | +| `heroImage` | upload → `media` | «Обкладинка» | +| `category` | rel → `course-categories` | — | +| `stepsJsonImport` | ui | `CourseJsonImport` з `clientProps: { target: 'steps' }` | +| `steps` | blocks | **required: true, minRows: 1** — 3 типи блоків | +| `quiz` | group | див. нижче | +| `publishedAt` | date | sidebar; field-hook ставить `new Date()` при першій публікації | +| `deleteConfirmation` | ui | sidebar, `CourseDeleteConfirmation` | + +### Блоки кроків + +Спільне поле `duration` (number, 1–600, «Тривалість (хв)»). + +| Блок | Ярлик | Поля | +| --- | --- | --- | +| `richTextStep` | Текстовий крок | `title` (req, localized), `content` richText (req, localized), `duration` | +| `youtubeVideoStep` | Відео крок | `title` (req, localized), `description` (localized), `youtubeUrl` (req, валідація `YOUTUBE_URL_REGEX` з `src/utilities/courseJsonImport.ts` → «Введіть коректне YouTube посилання»), `duration` | +| `fileStep` | Файловий крок | `title` (req, localized), `description` (localized), `file` upload → `course-files` (req), `duration` | + +**Id блока кроку — це одиниця прогресу**: `enrollments.completedSteps` зберігає +масив цих id, а `getStepIds`/`isCourseComplete` +(`src/utilities/courseCompletion.ts`) порівнюють саме їх. Видалення кроку і +створення «такого самого» заново — це новий id, прогрес по старому не +зарахується. + +### Група quiz + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `enabled` | checkbox | default `false` | +| `quizJsonImport` | ui | `CourseJsonImport`, `target: 'quiz'` | +| `title`, `description` | text / textarea | localized, `condition: quiz.enabled === true` | +| `passingScore` | number | 0–100, **default 70**, видиме лише при enabled | +| `questions` | array | minRows 1, кастомний validate (нижче), видиме лише при enabled | +| `questions[].question` | text | required, localized | +| `questions[].answers` | array | **required, minRows 2**, кастомний validate (нижче) | +| `answers[].text` | text | required, localized | +| `answers[].isCorrect` | checkbox | default `false` | + +## Три валідаційні воркараунди + +Усі три обходять одну й ту саму поведінку Payload: **`minRows` взагалі не +перевіряється на порожньому опційному масиві**. Порожній масив + не-required +поле = валідація мовчки пропущена. + +### 1. `steps.required: true` + +```ts +// required is what actually blocks publishing a course with zero steps: +// Payload skips minRows validation entirely when the array is empty and +// the field is not required. +required: true, +minRows: 1, +``` + +Саме `required`, а не `minRows`, блокує публікацію курсу без кроків. Чернетки +при цьому зберігаються порожніми — на draft/autosave-збереженнях валідація +пропускається, тож робочий процес адмінки не ламається. + +### 2. `questions.validate`: required лише коли quiz увімкнено + +Безумовний `required: true` (як у steps) зламав би курси **без** квіза — тому +воркараунд повторно запускає стокову валідацію масиву, вмикаючи `required` +лише коли `enabled === true`: + +```ts +validate: (value, options) => { + const quizEnabled = + (options.siblingData as { enabled?: boolean } | null | undefined)?.enabled === true + return validations.array(value, { ...options, required: quizEnabled }) +}, +``` + +Без цього увімкнений квіз публікувався б із нулем питань. + +### 3. `answers.validate`: ≥1 правильна відповідь + +Питання без жодної `isCorrect` — без відповіді: кожна спроба отримує на ньому +нуль, і курс стає незавершуваним. Ніщо інше цього не ловить, тому validate +спершу проганяє стокову перевірку довжини (тут `required: true` безумовний — +питання завжди потребує відповідей), а потім рядки: + +```ts +validate: (value, options) => { + const lengthResult = validations.array(value, options) + if (lengthResult !== true) return lengthResult + + const quizEnabled = + (options.data as { quiz?: { enabled?: boolean } } | undefined)?.quiz?.enabled === true + if (!quizEnabled || !Array.isArray(value)) return true + + const hasCorrect = value.some( + (answer) => (answer as { isCorrect?: boolean } | null)?.isCorrect === true, + ) + if (!hasCorrect) return 'Позначте щонайменше одну правильну відповідь.' + return true +}, +``` + +Перевірка `isCorrect` свідомо пропускається при вимкненому квізі — «застарілі» +питання не мають блокувати збереження курсу без тесту. + +## Versions / autosave / schedulePublish + +```ts +versions: { + drafts: { autosave: { interval: 10000 }, schedulePublish: true }, + maxPerDoc: 50, +}, +``` + +Autosave кожні 10 с (не ставте <2000 мс — старі версії Payload мали баг зі +stale-модалкою на власному autosave), відкладена публікація через +`payload-jobs`, до 50 версій на документ. **Live preview у курсів немає** — на +відміну від pages/posts. + +## Хуки + +### afterChange: `syncCourseCompletions` + `revalidateCourse` + +`syncCourseCompletions` (`src/hooks/syncCourseCompletions.ts`) — редагування +курсу змінює визначення «завершено», тож хук промоутить enrollments, які +задовольняють нову форму (видалили крок/квіз → хтось міг «доїхати»). Працює +лише на `_status === 'published'`; short-circuit по сигнатурі +`${quiz.enabled === true}|${stepIds.join(',')}` — якщо склад кроків і квіз не +змінились, жодних запитів. **Promote-only**: завершення ніколи не знімається, +зароблений сертифікат переживає пізніше додавання квіза. Повна логіка — +[Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu). + +`revalidateCourse` (`src/hooks/revalidateCourse.ts`) бустить ISR-кеш обох +локалей (`/courses/`, `/courses`, категорії, головна) і старий slug при +rename/unpublish. Увесь блок revalidatePath — у `try/catch`: відкладена +публікація (schedulePublish) виконується поза request-контекстом, де +`revalidatePath` кидає виняток; пропущений буст компенсує 300-секундне вікно +ISR, а збереження курсу не має падати через кеш. + +### beforeDelete: ручний каскад + +`xp_events.course_id` (та інші) — `NOT NULL` колонки з FK `ON DELETE SET +NULL`, тож без попереднього зачищення delete падає на рівні БД. Хук видаляє по +черзі: `xp-events`, `quiz-attempts`, `enrollments`, `comments` +(`targetCollection='courses'`), `likes` (те саме) — усі з `req` для +атомарності. Карта каскадів — в +[Огляді моделі даних](/admin/docs/technical/model-danykh/ohliad). + +### afterDelete / плагінні + +`revalidateCourseDelete` — той самий буст шляхів. `backfillSearchTitleLocales` +(доданий плагіном `searchLocaleSync`) — дозаповнення локалей пошукового +індексу. + +## Slug: чому `undefined`, а не `''` + +`cyrillicSlugify` (`src/utilities/cyrillicSlugify.ts`) обгортає `slugify` з +`{ lower: true, strict: true, locale: 'uk' }` і повертає: + +```ts +return slug || undefined +``` + +Генератор слага Payload ставить `slug = ''` при створенні autosave-чернетки ще +до вводу назви. Postgres вважає кілька `''` порушенням unique-індексу +(`valueMustBeUnique`), а кілька `NULL` — ні. Повернення `undefined` (→ NULL у +БД) дозволяє мати скільки завгодно свіжостворених чернеток одночасно. diff --git a/docs/admin-panel/technical/02-model-danykh/03-enrollments.md b/docs/admin-panel/technical/02-model-danykh/03-enrollments.md new file mode 100644 index 0000000..2846235 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/03-enrollments.md @@ -0,0 +1,147 @@ +--- +title: Колекція enrollments +description: Запис на курс і весь прогрес користувача — поля, unique-індекс, access-філософія та хуки +--- + +Файл: `src/collections/Enrollments.ts`. Ярлики «Запис на курс»/«Записи на +курси», група «Курси», колонки `[user, course, status, enrolledAt]`, +`lockDocuments: false`. + +Це найважливіша колекція бізнес-логіки: з неї деривуються сумарний XP, +сертифікати та статуси в каталозі. + +## Unique-індекс + +```ts +indexes: [{ fields: ['user', 'course'], unique: true }], +``` + +Один enrollment на пару користувач×курс — гарантія БД, а не лише +duplicate-перевірки в хуку (перевірка дає читабельний 409, індекс страхує від +гонок). + +## Поля + +Усі поля мають `admin.readOnly: true`. + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `user` | rel → `users` | required, index | +| `course` | rel → `courses` | required, index | +| `completedSteps` | json | default `[]`; опис в адмінці: «Масив ID завершених блоків кроків» | +| `status` | select | default `enrolled`; options: `enrolled` («Записаний»), `in_progress` («В процесі»), `completed` («Завершено») | +| `enrolledAt` | date | sidebar | +| `completedAt` | date | sidebar, видиме лише при `status === 'completed'` | +| `quizPassed` | checkbox | default `false` | +| `bestQuizScore` | number | 0–100 | +| `quizAttempts` | number | default `0` | + +### Філософія: усе readOnly + +Прогрес пишуть **лише server actions** через Local API +(`enrollInCourse`, `completeStep`, `submitQuizAttempt` у +`src/app/(frontend)/[locale]/courses/actions.ts`) та хук +`syncCourseCompletions`. Адмінка показує стан, але не редагує його: readOnly +прибирає спокусу «поправити руками» дані, з яких виводяться сертифікати. Якщо +дані треба виправити — робіть це усвідомлено через Local API/скрипт, а не через +форму. + +`completedSteps` — json-масив **id блоків кроків** (рядки). Server action +додає id по одному з dedupe через `includes`; читачі завжди роблять +`Array.isArray`-guard. Порівняння завершеності — по конкретних id, не по +кількості: застарілі id видалених кроків не завершать курс достроково. + +## Access + +| Операція | Правило | Обґрунтування | +| --- | --- | --- | +| create | `authenticated` | Записатися може будь-який залогінений (хуки привʼязують його до себе) | +| read | `adminOrOwn` | Адмін бачить усі; користувач — лише свої (`user = req.user.id`); анонім — нічого | +| update | **`admin`** | Ключове рішення: поля прогресу — основа сертифікатів і XP. Усі легітимні записи йдуть через server actions (Local API, який має адмін-права); update власником через REST дав би лише підробку `completedSteps`/`status`/`quizPassed` | +| delete | `admin` | — | + +Це «server actions або ніяк» — той самий принцип, що в `quiz-attempts.create` +(див. [Огляд архітектури](/admin/docs/technical/arkhitektura/ohliad)). + +## Хуки + +### beforeValidate[0]: rate limit + +```ts +rateLimitCreate({ prefix: 'enroll-create', windowSeconds: 600, max: 30 }) +``` + +30 створень за 10 хвилин на користувача. Дублікати й так відкидаються, але +кожна спроба коштує lookup-ів і ревалідації сторінок — ліміт зрізає скриптові +enroll-цикли. Адміни і записи без user id (сідинг) проходять без ліміту. +Перевищення → `APIError` «Забагато запитів. Спробуйте пізніше.» зі статусом +429. Деталі механізму — [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting). + +### beforeValidate[1]: binding + дублікат 409 + +```ts +if (operation === 'create' && data && req.user && + !('role' in req.user && req.user.role?.includes('admin'))) { + data.user = req.user.id +} +``` + +Не-адмінський API-запит може записати **лише себе** — `data.user` +перезаписується до перевірки дубліката, тож підставлений чужий id не обійде +її. Local API-виклики без `req.user` (server actions) передають id явно. + +Далі — пошук наявного enrollment по парі user×course; знайдено → +`APIError('Ви вже записані на цей курс', 409)`. + +### beforeChange: дефолти create + +```ts +if (operation === 'create') { + data.enrolledAt = new Date().toISOString() + data.completedSteps = [] + data.status = 'enrolled' +} +``` + +Клієнт не може створити enrollment «одразу завершеним» — стартові значення +примусові. + +## Життєвий цикл статусів + +``` +enrolled ──► in_progress ──► completed +``` + +Переходів «назад» немає. + +| Перехід | Хто виконує | +| --- | --- | +| → `enrolled` | `beforeChange` при create (+`enrolledAt`, порожній `completedSteps`) | +| → `in_progress` | `completeStep` після першого зарахованого кроку | +| → `completed` | `completeStep` або `submitQuizAttempt`, коли `isCourseComplete()` каже true (+`completedAt`); або `syncCourseCompletions` після редагування курсу | + +Правило завершеності одне — `isCourseComplete()` у +`src/utilities/courseCompletion.ts`: усі поточні step id ∈ `completedSteps` +**і** (якщо квіз увімкнено) `quizPassed === true`. Не редеривуйте його. Курс +без кроків і без квіза не завершується ніколи. Сертифікати гейтяться лише на +`status === 'completed'` і успадковують квіз-вимогу звідти. Подробиці: +[Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu), +[Сертифікати](/admin/docs/technical/biznes-logika/sertyfikaty). + +`quizPassed` — «липкий»: ставиться true на першій успішній спробі й ніколи не +скидається (пізніший провал ретейку не чіпає ні його, ні сертифікат). +`bestQuizScore` оновлюється лише вгору, `quizAttempts` інкрементується на +кожну спробу — усе в `submitQuizAttempt` +(див. [Квізи](/admin/docs/technical/biznes-logika/kvizy)). + +## Повʼязані server actions + +| Action | Гарди | +| --- | --- | +| `enrollInCourse` | сесія; ідемпотентний (наявний запис → success без create); 429 → «Забагато запитів. Спробуйте пізніше.» | +| `completeStep(enrollmentId, stepBlockId, courseId)` | сесія → enrollment існує → `enrollment.user === session.user` → **`enrollment.course === courseId`** (інакше можна закривати кроки короткого курсу проти чужого enrollment) → уже completed / крок уже зарахований → early success (без подвійного XP) → `stepBlockId ∈ getStepIds(course)` інакше «Крок не знайдено» | +| `getMyCourseStatuses` | клієнтський дофетч статусів для ISR-каталогу (`{completed[], inProgress[]}`, ≤1000 enrollments) | + +Каскади: enrollment видаляється разом із користувачем або курсом +(`beforeDelete`-хуки відповідних колекцій, див. +[Огляд моделі даних](/admin/docs/technical/model-danykh/ohliad)). diff --git a/docs/admin-panel/technical/02-model-danykh/04-quiz-attempts-xp-events.md b/docs/admin-panel/technical/02-model-danykh/04-quiz-attempts-xp-events.md new file mode 100644 index 0000000..3db0eb8 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/04-quiz-attempts-xp-events.md @@ -0,0 +1,145 @@ +--- +title: quiz-attempts та xp-events +description: Дві службові колекції прогресу — журнал спроб тесту з server-side оцінюванням і append-only лог XP +--- + +Обидві колекції — «журнали», які наповнює виключно сервер. Користувач їх у +кращому разі читає (quiz-attempts — свої), в адмінці всі поля readOnly. + +## quiz-attempts + +Файл: `src/collections/QuizAttempts.ts`. Ярлики «Спроба тесту»/«Спроби +тестів», група «Курси», колонки `[user, course, score, passed, createdAt]`, +`timestamps: true`, `lockDocuments: false`. + +### Поля + +Усі з `admin.readOnly: true`. + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `user` | rel → `users` | required, index | +| `course` | rel → `courses` | required, index | +| `score` | number | required, 0–100, «Результат (%)» | +| `passed` | checkbox | «Складено» | +| `totalQuestions` | number | required | +| `correctAnswers` | number | required | +| `answers` | json | `gradedAnswers`: `{questionIndex, selectedAnswerIndices, correct}[]` | +| `attemptNumber` | number | required, ставиться хуком | + +### Access: чому create — admin-only + +```ts +access: { + create: admin, + read: adminOrOwn, + update: admin, + delete: admin, +}, +``` + +Оцінювання відбувається **на сервері**: server action `submitQuizAttempt` +(`src/app/(frontend)/[locale]/courses/actions.ts`) сам звіряє відповіді з +правильними (exact-set match по конфігу курсу), рахує `score` і лише тоді +створює документ через Local API (де діють адмін-права). Відкритий REST-create +дозволив би будь-кому надіслати `score: 100, passed: true` і зробити собі +сертифікат. `read: adminOrOwn` — користувач бачить лише власні спроби +(історію на сторінці квіза віддає `getQuizAttempts`, sort `-createdAt`, +limit 100). + +Правила оцінювання, rate limit `quiz-submit` (30/год) і необмежені ретейки — +у [Квізах](/admin/docs/technical/biznes-logika/kvizy). + +### beforeValidate: attemptNumber + +```ts +beforeValidate: [ + async ({ data, operation, req }) => { + if (operation === 'create' && data?.user && data?.course) { + const existing = await req.payload.count({ + collection: 'quiz-attempts', + where: { and: [{ user: { equals: data.user } }, { course: { equals: data.course } }] }, + req, + }) + data.attemptNumber = existing.totalDocs + 1 + } + return data + }, +], +``` + +Rationale вибору фази: `attemptNumber` — **required**, тож він мусить існувати +до запуску валідації. `beforeValidate` (а не `beforeChange`) гарантує це і +звільняє викликача від обчислення номера — `submitQuizAttempt` передає що +завгодно, хук перезапише коректним `count + 1`. + +## xp-events + +Файл: `src/collections/XpEvents.ts`. Ярлики «Подія XP»/«Події XP», група +«Курси», колонки `[user, course, kind, amount, createdAt]`, +`timestamps: true`. + +### Поля + +Усі readOnly в адмінці. + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `user` | rel → `users` | required, index | +| `course` | rel → `courses` | required, index | +| `kind` | select | required; `step` («Крок») \| `quiz` («Тест») | +| `amount` | number | required (фактично `STEP_XP = 30` або `QUIZ_XP = 100`) | + +### Access: адмін × 4 + +```ts +access: { create: admin, read: admin, update: admin, delete: admin }, +``` + +Навіть read закритий: фронтенд ніколи не читає цю колекцію напряму — сумарний +XP користувача деривується з enrollments, а періодні лідерборди рахує +серверний SQL. Пише сюди лише `logXpEvent` через Local API. + +### Призначення: append-only лог для періодних лідербордів + +XP існує у **двох представленнях**, і це принципово: + +1. **Сумарний XP деривується з enrollments**: + `Σ completedSteps.length × 30 + (quizPassed ? 100 : 0)` — так рахують + `getMyXp` (`src/actions/xp.ts`), all-time лідерборд (raw SQL у + `src/utilities/leaderboard.ts`) і сторінка профілю. Джерело правди — + enrollment, лог не потрібен. +2. **`xp-events` — лише для періодів**: enrollment знає, *які* кроки пройдені, + але не *коли*. Лідерборди «за день/тиждень/місяць» (1/7/30 днів) сумують + `amount` з `xp_events` за вікно. + +Записи створює `logXpEvent` (`courses/actions.ts`): `completeStep` → +`{kind: 'step', amount: 30}` на кожен уперше зарахований крок; +`submitQuizAttempt` → `{kind: 'quiz', amount: 100}` **лише на першій** +успішній спробі (`passed && !enrollmentDoc.quizPassed`). + +### Best-effort запис + +```ts +try { + await payload.create({ collection: 'xp-events', data: { /* … */ } }) + revalidateTag('xp-leaderboard') +} catch (err) { + // лог не має завалити мутацію прогресу +} +``` + +Провал запису логовується і **ковтається**: втратити рядок періодного +лідерборду прийнятніше, ніж відкотити зарахований крок. Наслідок — `xp_events` +може дрейфувати від деривованого сумарного XP; це відома і прийнята +властивість. + +:::warning Каскади стирають історію +`beforeDelete` у `users` і `courses` видаляє й `xp-events` — періодні +лідерборди втрачають внесок видаленого курсу/користувача заднім числом. +Період покривається лише з моменту міграції `20260724_140000_xp_events`. +::: + +Формули рівнів (`levelSpan`, `levelForXp`), кеш `xp-leaderboard` +(revalidate 300) і клієнтський `myXpCache` — у статті +[XP](/admin/docs/technical/biznes-logika/xp). diff --git a/docs/admin-panel/technical/02-model-danykh/05-users-i-auth.md b/docs/admin-panel/technical/02-model-danykh/05-users-i-auth.md new file mode 100644 index 0000000..c4a4876 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/05-users-i-auth.md @@ -0,0 +1,155 @@ +--- +title: users та auth-колекції +description: Гібридна колекція users (проєктні + плагінні поля), критичне рішення щодо access і призначення пʼяти колекцій payload-auth +--- + +## users + +Файл: `src/collections/Users/index.ts`. Це auth-колекція: проєктний конфіг +задає профільні поля й каскади, а плагін `payload-auth` (перший у +`src/plugins/index.ts`) домішує auth-поля, ендпоінти та дефолти доступу. +Ярлики «Користувач»/«Користувачі», `useAsTitle: 'name'`, колонки +`[name, email]`, група «Автентифікація» (проставляє `ukrainianAdmin`), +`lockDocuments: false`. + +### Проєктні поля + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `about` | textarea | maxLength **500**, «Про мене» | +| `hideProfileComments` | checkbox | default `false` — ховає коментарі в публічному профілі | +| `socialLinks` | array | maxRows **8**; рядок: `platform` select (required: instagram, facebook, telegram, youtube, tiktok, linkedin, x, website) + `url` text (required) | + +Редагуються вони через server actions `updateAbout`, +`updateHideProfileComments`, `updateSocialLinks` +(`src/actions/accountSettings.ts`) — не через REST. + +### Плагінні поля + +| Поле | Тип | Нюанси | +| --- | --- | --- | +| `name` | text | required | +| `email` | text | required, unique | +| `emailVerified` | boolean | ставиться OTP-гейтом реєстрації | +| `image` | **text (URL-рядок!)** | не upload-relationship: снапшот URL з Blob CDN або googleusercontent. Історичний нюанс: снапшоти зроблені до PR#67 вказували на `/api/media/file/...` і 404-лять після `disablePayloadAccessControl` | +| `role` | select **hasMany** | `['admin' \| 'learner']`; дефолт перешейплений у масив `['learner']` через `collectionOverrides` (Drizzle мовчки дропає нон-array дефолт hasMany-select — без фікса юзери створювались із порожньою роллю; бекфіл: міграція `20260729_100000_backfill_user_roles`) | +| `account`, `session` | join | звʼязки на accounts/sessions | + +### КРИТИЧНО: read/update навмисно НЕ задані + +```ts +access: { + admin: admin, + create: admin, + delete: admin, + // read/update are intentionally NOT set. payload-auth spreads this object + // over its own defaults … +}, +``` + +payload-auth **розгортає проєктний access поверх власних дефолтів**. Його +дефолти для `read`/`update` — admin-or-self, причому self-update не-адміна +обмежений `allowedFields: ['name']` (`src/lib/auth/options.ts`). Якщо задати +тут власний `update` (навіть «еквівалентний» admin-or-self), обмеження +`allowedFields` буде втрачено — і користувач зможе через +`PATCH /api/users/:id` дописати собі `role: ['admin']`. Це вже було знайдено +і виправлено в аудиті; **не додавайте read/update у цю колекцію.** + +Ролі й перевірки доступу загалом — +[Ролі і доступ](/admin/docs/technical/autentyfikatsiya/roli-i-dostup). + +### beforeDelete: каскад на 5 колекцій + +Таблиці прогресу оголошують `user_id`/`author_id` **NOT NULL**, а FK — +`ON DELETE SET NULL`, тож без ручного зачищення delete користувача падає на +рівні БД. Хук видаляє (усе з `req`, одна транзакція): + +1. `xp-events` (`user = id`) +2. `quiz-attempts` (`user = id`) +3. `enrollments` (`user = id`) +4. `likes` (`user = id`) +5. `comments` (`author = id`) + +Разом із користувачем зникає весь його прогрес, сертифікатна підстава і внесок +у лідерборди. + +### Кастомний компонент + +`admin.components.Description` колекції підмінено (через `ukrainianAdmin`) на +`@/components/admin/InviteUserButton` — кнопка запрошення з українськими +ролями. Див. [Кастомізації адмін-панелі](/admin/docs/technical/arkhitektura/admin-kastomizatsii). + +## Auth-колекції payload-auth + +Усі створює `betterAuthPlugin` (`hidePluginCollections: true` — але вони +видимі в групі «Автентифікація» з перекладами від `ukrainianAdmin`). + +### sessions + +Активні сесії користувачів. Поля: `user`, `token` (унікальний токен сесії), +`expiresAt`, `ipAddress`, `userAgent`, `impersonatedBy`. Параметри життя +сесії — в `src/lib/auth/options.ts`: `expiresIn` 7 днів, rolling `updateAge` +1 день, cookie cache 5 хв (через нього server-side оновлення користувача +стають видимі з запізненням — див. +[Better Auth](/admin/docs/technical/autentyfikatsiya/better-auth)). + +### accounts + +Облікові записи у провайдерів: для email+пароль — рядок `providerId: +'credential'` з полем `password` (хеш); для Google — токени OAuth +(`accessToken`, `refreshToken`, `idToken`, `scope`). Один користувач може мати +кілька акаунтів (trustedProviders: google, email-password — автолінкування). +Наявність credential-акаунта — умова відмови `setInitialPassword` +(`HAS_PASSWORD`). + +### verifications + +Верифікаційні записи: OTP підтвердження email +(identifier `email-verification-otp-`, value `"otp:attempts"`), +скидання пароля тощо. OTP: 6 цифр, TTL 300 с, до 3 спроб — увесь флоу в +[Реєстрація і OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp). + +### rateLimit + +Таблиця fixed-window лічильників. Її ділять **два** споживачі: вбудований rate +limit Better Auth для `/api/auth/*` (window 60 с, max 60, per-path +перевизначення) і власний `src/lib/rate-limit.ts` (атомарний +`INSERT ... ON CONFLICT DO UPDATE`) для enroll/comment/like/quiz/OTP-лімітів. +Таблиця створена міграцією `20260724_190000`. Повна таблиця лімітів — +[Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting). + +### admin-invitations + +Токени запрошень: `role`, `token`, `url`. Створюються кнопкою InviteUserButton +(ендпоінти `generate-invite-url` / `send-invite` на users). Валідний токен у +`databaseHooks.user.create.before` пропускає реєстрацію повз OTP-гейт і +одразу ставить роль із запрошення. Лист шле `sendInviteEmail` через +`payload.sendEmail` (HTML — `src/lib/email/admin-invite.ts`). + +## Звідки береться доступ до адмінки + +Ключові опції users у `src/lib/auth/options.ts`: + +```ts +users: { + slug: 'users', + adminRoles: ['admin'], + defaultRole: 'learner', + defaultAdminRole: 'admin', + roles: ['learner', 'admin'], + allowedFields: ['name'], + collectionOverrides: /* дефолт ролі → ['learner'] */, +}, +``` + +`adminRoles: ['admin']` — тільки користувачі з `admin` у `role` проходять в +`/admin` (перевіряє і `access.admin: admin` колекції). `defaultAdminRole` +застосовується при створенні першого адміністратора. Учасники (`learner`) на +`/admin` не потрапляють, але це не скасовує їхніх REST-прав у інших +колекціях — памʼятайте, що `authenticatedOrPublished` показує залогіненим і +чернетки контенту. + +Серверні хелпери сесії (`src/lib/auth/*`): `getSession` (React `cache` поверх +`betterAuth.api.getSession`), `requireSession` (redirect на +`/login?redirect=…`), `getMeUser`. У server actions завжди починайте з +перевірки сесії — middleware покриває не всі маршрути. diff --git a/docs/admin-panel/technical/02-model-danykh/06-posts-pages-media.md b/docs/admin-panel/technical/02-model-danykh/06-posts-pages-media.md new file mode 100644 index 0000000..e7fda2b --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/06-posts-pages-media.md @@ -0,0 +1,158 @@ +--- +title: posts, pages, media +description: Контентні колекції — схеми, SEO-таби, денормалізовані автори, revalidate-хуки та конфіг завантажень +--- + +## posts + +Файл: `src/collections/Posts/index.ts`. Ярлики «Публікація»/«Публікації», +`useAsTitle: 'title'`, live preview + preview через `generatePreviewPath`, +`defaultPopulate: {title, slug, categories, meta.image, meta.description}`. + +### Поля + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `title` | text | required, localized | +| `heroImage` | upload → `media` | localized | +| `content` | richText | required, localized; lexical з h1–h4, `BlocksFeature([Banner, Code, MediaBlock, Archive])`, обидва тулбари, HR | +| `relatedPosts` | rel → `posts` hasMany | sidebar; `filterOptions` виключає сам документ | +| `categories` | rel → `categories` hasMany | sidebar | +| `meta` (таб SEO) | group | `OverviewField`, `MetaTitleField` (hasGenerateFn), `MetaImageField` → media, `MetaDescriptionField`, `PreviewField` | +| `publishedAt` | date | sidebar, `dayAndTime`; field-hook ставить now при першій публікації | +| `authors` | rel → `users` hasMany | sidebar | +| `populatedAuthors` | array `{id, name}` | `access.update: () => false`, `admin.disabled`, readOnly | +| `slug` | slugField | `cyrillicSlugify` | + +Access: create/update/delete `admin`; read `authenticatedOrPublished`. +Versions: drafts + autosave **2000 мс** (найагресивніший у проєкті), +schedulePublish, maxPerDoc 50. + +### populatedAuthors: чому денормалізовано + +Колекція `users` закрита на читання (admin-or-self), тож анонімний фронтенд не +може populate-нути `authors` — depth-запит поверне порожньо. Хук `afterRead` +`populateAuthors` (`src/collections/Posts/hooks/populateAuthors.ts`) на +кожному читанні дофетчує документи авторів **через Local API** (обходячи +access) і переписує `populatedAuthors` проєкцією `{id, name}` — рівно тим, що +можна показати публічно. Поле недоступне для запису (`update: () => false`) і +сховане з форми (`admin.disabled`) — це кеш, а не дані. + +### revalidatePost: нюанс з previousDoc + +`src/collections/Posts/hooks/revalidatePost.ts` бустить `/posts/{slug}` + тег +`posts-sitemap` при публікації і старий шлях при unpublish. Зверніть увагу: + +```ts +if (previousDoc._status === 'published' && doc._status !== 'published') { +``` + +`previousDoc` тут розіменовується **без guard-а** (на відміну від +`revalidateCourse`, де `previousDoc?._status`). У стандартному +afterChange-флоу Payload завжди передає previousDoc, тож на практиці не +падає, — але якщо викликатимете update у нетиповому контексті або +скопіюєте цей хук для нової колекції, додайте `?.`. Хук шанує +`req.context.disableRevalidate`. + +## pages + +Файл: `src/collections/Pages/index.ts`. Ярлики «Сторінка»/«Сторінки», +`defaultPopulate: {title, slug}`, live preview + preview. + +### Поля + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `title` | text | required, localized | +| таб «Герой» | group `hero` | з `src/heros/config.ts`: `type` select (`none`/`highImpact`/`mediumImpact`/`lowImpact`, default `lowImpact`, required), `richText` (h1–h4), `links` (linkGroup, maxRows 2), `media` upload → media (required лише для high/mediumImpact) | +| таб «Контент» | blocks `layout` | required, localized; блоки: `cta`, `content`, `mediaBlock`, `archive`, `formBlock` (з `src/blocks/*`) | +| таб «SEO» | group `meta` | ті самі 5 полів seo-плагіна, що й у posts | +| `publishedAt` | date | sidebar + auto-set hook | +| `slug` | slugField | `cyrillicSlugify`, unique | + +Access: як у posts. Versions: drafts + autosave 10000 мс, schedulePublish, +maxPerDoc 50. + +Хуки: afterChange `revalidatePage` — slug `home` бустить `/`, інший — +`/{slug}`, плюс тег `pages-sitemap`; при unpublish бустить старий шлях; +шанує `disableRevalidate`. afterDelete `revalidateDelete`. Обом контентним +колекціям плагін `searchLocaleSync` дописує `backfillSearchTitleLocales`. + +:::info Сторінки пласкі +nestedDocsPlugin підключений **лише до categories** — у pages немає +parent/child і URL завжди одно-сегментний. Ілюзія «/батько/дитина» — поширена +помилка. +::: + +## media + +Файл: `src/collections/Media.ts`. Ярлик «Медіа», **`folders: true`** +(payload-folders — папки в медіатеці), `lockDocuments: false`. + +Поля: `alt` text (localized, **не** required), `caption` richText (localized, +lexical з тулбарами). + +Access: create/update/delete `admin`; read `anyone` — саме це робить безпечним +`disablePayloadAccessControl: true` у Vercel Blob (роздача з CDN без +serverless-виклику; URL-и unguessable, але не автентифіковані). + +### upload + +```ts +upload: { + staticDir: path.resolve(dirname, '../../public/media'), + adminThumbnail: 'thumbnail', + focalPoint: true, + imageSizes: [ /* … */ ], +}, +``` + +`staticDir: public/media` — діє лише без `BLOB_READ_WRITE_TOKEN` (локальний +дев без Blob). `focalPoint: true` — редактор задає фокусну точку кропів. + +| imageSize | Розміри | +| --- | --- | +| `thumbnail` | 300w | +| `square` | 500×500 | +| `small` | 600w | +| `medium` | 900w | +| `large` | 1400w | +| `xlarge` | 1920w | +| `og` | 1200×630, crop center | + +Роздача, redirects зі старих `/api/media/file/...` URL-ів і бекфіл БД — +[Медіа і Blob](/admin/docs/technical/infrastruktura/media-blob). + +## categories + +`src/collections/Categories.ts`. «Категорія»/«Категорії»: `title` (required, +localized) + slugField. Access: cud `admin`, read `anyone`. Єдина колекція з +nestedDocsPlugin — отримує `parent` і `breadcrumbs`. + +## course-categories + +`src/collections/CourseCategories.ts`. «Категорія курсів»: `title` (required, +localized), `description` textarea (localized), `image` upload → media, +slugField. Access: cud `admin`, read `anyone`. Група «Курси». Індексується +пошуком (+ `backfillSearchTitleLocales`). + +## course-files + +`src/collections/CourseFiles.ts`. «Файл курсу»/«Файли курсів», група «Курси». +Єдине поле — `title` (text, localized, опційне). Access: cud `admin`, read +`anyone`. + +```ts +upload: { + staticDir: path.resolve(dirname, '../../public/course-files'), + mimeTypes: [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.ms-powerpoint', + ], +}, +``` + +Тільки PDF, PPTX і PPT; без imageSizes (це не зображення). Використовується +виключно блоком `fileStep` курсів +(див. [Колекція 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 new file mode 100644 index 0000000..c8884fb --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/07-comments-likes.md @@ -0,0 +1,149 @@ +--- +title: comments та likes +description: Колекції взаємодії — поліморфний таргетинг без FK, unique-індекси, binding-хуки та rate limits +--- + +Обидві колекції — у групі «Взаємодія», обидві з `lockDocuments: false` і +`timestamps: true`. Спільна риса: ціль задається парою +`targetCollection` + `targetId`, а не relationship. + +## comments + +Файл: `src/collections/Comments.ts`. Ярлики «Коментар»/«Коментарі», +`useAsTitle: 'body'`, колонки `[body, author, targetCollection, createdAt]`. + +### Поля + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `body` | textarea | required, **maxLength 2000** | +| `author` | rel → `users` | required, index, readOnly в адмінці | +| `targetCollection` | select | required, index; options: `posts` («Публікації»), `courses` («Курси») | +| `targetId` | number | required, index | +| `parent` | rel → `comments` | index — тред-відповіді | + +### Access + +| Операція | Правило | Обґрунтування | +| --- | --- | --- | +| create | `authenticated` | будь-який залогінений | +| read | `anyone` | коментарі публічні, **модерації/апрувів немає взагалі** | +| update | інлайн: лише admin | **автори НЕ редагують власні коментарі** — свідоме рішення: немає «edited»-історії, немає підміни змісту після відповідей; користувачу доступне лише видалення | +| delete | `adminOrAuthor` | автор або адмін | + +### Хуки + +- `beforeValidate`: `rateLimitCreate({ prefix: 'comment-create', + userField: 'author', windowSeconds: 60, max: 10 })` — коментарі — єдина + необмежена за кількістю користувацька колекція (лайки й enrollments + дедуплікуються), тож 10/хв на автора проти спам-флуду. +- `beforeChange`: не-адмінський create примусово отримує + `data.author = req.user.id` — клієнтський `author` дозволив би імперсонацію. + Local API-виклики без user (server actions) передають автора явно. + +## likes + +Файл: `src/collections/Likes.ts`. Ярлики «Лайк»/«Лайки», без `useAsTitle`, +колонки `[user, targetCollection, targetId, createdAt]`. + +### Поля + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `user` | rel → `users` | required, index | +| `targetCollection` | select | required, index; options: `posts`, `courses`, **`comments`** (лайкати можна й коментарі — на відміну від самих comments) | +| `targetId` | number | required, index | + +### Unique-індекс + +```ts +indexes: [{ fields: ['user', 'targetCollection', 'targetId'], unique: true }], +``` + +Один лайк на користувача на ціль — гарантія БД поверх duplicate-перевірки. + +### Access + +| Операція | Правило | +| --- | --- | +| create | `authenticated` | +| read | `anyone` | +| update | **`() => false`** — ніхто, навіть адмін: лайк або існує, або ні; «редагувати» його безглуздо, дозволений update лише відкрив би перевішування лайків на інші цілі | +| delete | `adminOrOwn` — анлайк собі, адмін — будь-кому | + +### Хуки + +- `beforeValidate[0]`: `rateLimitCreate({ prefix: 'like-create', + windowSeconds: 60, max: 60 })` — дублікати й так відкинуться, але кожен + цикл like/unlike коштує запис + ревалідацію кешу. +- `beforeValidate[1]`: binding (`data.user = req.user.id` для не-адмінів, до + duplicate-перевірки) + пошук наявного лайка → `APIError('Already liked', 409)`. + +## Поліморфізм без FK: цілісність на server actions + +`targetCollection` + `targetId` — не relationship, тож **БД не перевіряє, що +ціль існує**, і не каскадить від неї. Цілісність тримають server actions +(`src/actions/commentsAndLikes.ts`): + +- `addComment`: сесія → `body` trim, непорожній, ≤2000 (`INVALID_BODY`) → + **ціль існує і `_status: published`** (`INVALID_TARGET`) → `parent` існує і + належить тому самому таргету → rate limit (429 → `RATE_LIMITED`). +- `toggleLike`: видаляє наявний лайк або створює новий, потім recount + (`liked = totalDocs === 0` після delete). +- `deleteComment`: автор або адмін (`FORBIDDEN`); каскад: лайки коментаря → + прямі відповіді (`parent = commentId`) → сам коментар. Каскад **одного + рівня** — відповіді на відповіді осиротіють. +- `getComments`: sort `createdAt` asc, limit 500, depth 1; лайки всіх + коментарів одним bulk-запитом (`targetId in ids`, limit 10000) → + `likesCount` + `userLiked`; видалений автор → `{id: 0, name: ''}`. + +Зворотні каскади від контенту: `courses.beforeDelete` зачищає +comments/likes з `targetCollection='courses'`; `users.beforeDelete` — усі +коментарі/лайки користувача. **Пости каскаду не мають** — їхні +коментарі/лайки після видалення поста лишаються сиротами (див. +[Огляд моделі даних](/admin/docs/technical/model-danykh/ohliad)). + +## Кеші лічильників + +Публічні лічильники на картках — `src/utilities/contentCounts.ts`: raw SQL +`GROUP BY`, `unstable_cache` revalidate 120 с, теги `likes-counts-` / +`comments-counts-`, які бустить `revalidateCounts` після мутацій +(**окрім** `targetCollection='comments'` — лайки коментарів рахуються без +кешу в `getComments`). Лічильники display-only і можуть відставати — не +використовуйте їх у бізнес-логіці. + +## Рендер-ланцюжок на фронтенді + +``` +InteractionSection (RSC) +└── InteractionClient + ├── LikeButton + └── CommentsSection + ├── CommentForm + └── CommentItem (рекурсивно для parent-тредів) +``` + +Секція підключена на сторінці курсу (overview) і на сторінках постів. У +публічному профілі (`/users/[id]`) останні коментарі користувача рендерить +`ProfileLatestComments` — якщо той не увімкнув `users.hideProfileComments`. + +## Модерація: її немає + +Grep по `approved|moderat` не знаходить нічого — коментарі публікуються +одразу, без черги апрувів, і читаються анонімами (`read: anyone`). Захисні +шари, які це компенсують: + +1. rate limit створення (10/хв на автора); +2. `maxLength: 2000` на тіло; +3. перевірка server action-ом, що ціль published; +4. адмін може редагувати й видаляти будь-який коментар з адмінки + (група «Взаємодія», `useAsTitle: body` — список читабельний); +5. автор може видалити свій. + +Якщо колись знадобиться premoderation — додавайте окреме поле статусу і +фільтр у `getComments`, а не перекручуйте access. + +Поведінкові деталі й відомі гострі кути (сирітство «онуків» при каскаді, +відставання кешованих лічильників) — +[Коментарі та лайки](/admin/docs/technical/biznes-logika/komentari-laiky); +механізм лімітів — [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting). diff --git a/docs/admin-panel/technical/02-model-danykh/08-hlobaly.md b/docs/admin-panel/technical/02-model-danykh/08-hlobaly.md new file mode 100644 index 0000000..a181ff6 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/08-hlobaly.md @@ -0,0 +1,150 @@ +--- +title: Глобали +description: header, footer і home-calendar — поля, revalidate-теги та відома діра в update-доступі календаря +--- + +У проєкті три глобали, зареєстровані в `payload.config.ts`: +`globals: [Header, Footer, HomeCalendar]`. Кожен живе у власній директорії +разом із RowLabel-компонентом і revalidate-хуком. + +## header + +Файл: `src/Header/config.ts`. Ярлик «Хедер сайту». + +### Поля + +Єдине поле — `navItems`: + +| Атрибут | Значення | +| --- | --- | +| Тип | array, localized | +| maxRows | **6** | +| RowLabel | `@/Header/RowLabel#RowLabel` (показує label посилання замість «Item N») | +| initCollapsed | true | + +Кожен рядок — одне `link({ appearances: false })` поле +(`src/fields/link.ts`): + +- `type` radio: `reference` (внутрішній документ) / `custom` (URL); +- `reference` rel → `pages` \| `posts` (при type=reference); +- `url` text (при type=custom); +- `newTab` checkbox; +- `label` text — required, localized. + +### Access і хук + +```ts +access: { + read: () => true, + update: admin, +}, +hooks: { afterChange: [revalidateHeader] }, +``` + +`revalidateHeader` (`src/Header/hooks/revalidateHeader.ts`) → +`revalidateTag('global_header')`. Фронтенд читає глобал через `getCachedGlobal` +(`src/utilities/getGlobals.ts`, `unstable_cache` з тегом `global_`), тож +зміна навігації підхоплюється одразу після збереження. + +## footer + +Файл: `src/Footer/config.ts`. Ярлик «Футер сайту». Конфіг **ідентичний** +header-у: те саме `navItems` (localized, maxRows 6, той самий link-філд, +власний `@/Footer/RowLabel#RowLabel`), той самий access (read public, update +admin). Хук `revalidateFooter` → тег `global_footer`. + +## home-calendar + +Файл: `src/HomeCalendar/config.ts`. Ярлик «Календар змін» — секція «Найближчі +зміни» на головній сторінці. `admin.description` попереджає редактора: +зберігайте **обидві** локалізації, інакше англійська версія показуватиме +український текст. + +### Поля + +Усі поля localized, а `defaultValue` кожного підтягується з +`getHomeContent(locale)` (`src/components/Home/content.ts`) — тобто порожній +глобал рендерить той самий текст, що зашитий у код головної: + +| Поле | Тип | Атрибути | +| --- | --- | --- | +| `tag` | text | «Надзаголовок» | +| `title` | text | «Заголовок» | +| `description` | textarea | «Опис» | +| `events` | array | «Зміни», RowLabel `@/HomeCalendar/RowLabel#RowLabel`, initCollapsed | +| `events[].month` + `events[].year` | text + text | row 50/50; місяць скорочено, напр. «ВЕР»; обидва required | +| `events[].range` | text | required, напр. «1–7 вересня 2026» | +| `events[].title` | text | required | +| `events[].description` | textarea | — | +| `events[].formUrl` | text | **required** — посилання на анкету | +| `cta` | text | «Текст кнопки»; опис: «Кнопка веде на анкету першої зміни у списку.» | + +### ⚠️ Update-доступ не заданий — відоме обмеження + +```ts +access: { + read: () => true, +}, +``` + +На відміну від header/footer, тут задано **лише** `read`. Для незаданих +операцій глобала Payload застосовує дефолт — **будь-який автентифікований +користувач**. Тобто формально кожен залогінений learner може через REST +(`POST /api/globals/home-calendar`) переписати календар на головній сторінці. + +:::warning Відома діра, задокументовано свідомо +Ризик обмежений (дефейс однієї секції головної, без даних користувачів), але +реальний. Правильний фікс — один рядок: `update: admin` за прикладом +`src/Header/config.ts`. Якщо додаєте новий глобал — **завжди** задавайте +`update` явно; цей випадок показує, як легко його загубити. +::: + +### Хук + +`revalidateHomeCalendar` (`src/HomeCalendar/hooks/revalidateHomeCalendar.ts`) +→ `revalidateTag('global_home-calendar')`. + +## Зведення revalidate-тегів + +| Глобал | Тег | Хто читає | +| --- | --- | --- | +| `header` | `global_header` | `getCachedGlobal('header')` у layout | +| `footer` | `global_footer` | `getCachedGlobal('footer')` у layout | +| `home-calendar` | `global_home-calendar` | головна сторінка | + +Тег формується як `global_${slug}` і в хуках, і в `getGlobals.ts` — при +додаванні нового глобала дотримуйтесь цієї конвенції, інакше кеш не +інвалідується. Загальна карта тегів — +[Маршрути та middleware](/admin/docs/technical/arkhitektura/marshruty-i-middleware). + +## Як фронтенд читає глобали + +`src/utilities/getGlobals.ts`: + +```ts +const getGlobal = unstable_cache( + async () => payload.findGlobal({ slug, depth }), + [slug], + { tags: [`global_${slug}`] }, +) +``` + +Кеш безстроковий і інвалідується **лише тегом** — тому afterChange-хук +обовʼязковий для кожного глобала. Layout читає header/footer на кожен рендер, +але фактичний запит до БД відбувається тільки після збереження в адмінці. + +## Відмінності від колекцій + +- Глобали **не мають versions/drafts** у цьому проєкті — збереження одразу + live (після ревалідації тега). Помилка редактора на головній видима + негайно; історії версій, куди можна відкотитись, немає. +- Локалізовані поля зберігаються по локалях одного документа: перемикач + локалі вгорі форми змінює, яку мову ви редагуєте. Для `home-calendar` це + критично — заповнюйте en окремо (fallback підставить uk, але це виглядає як + «недороблена» англійська версія). + +## MCP + +Глобали `header` і `footer` доступні MCP-клієнтам з увімкненим доступом (див. +[Колекції плагінів](/admin/docs/technical/model-danykh/plahinni-kolektsii)); +`home-calendar` в MCP не експонований. 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 new file mode 100644 index 0000000..8632e46 --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/09-plahinni-kolektsii.md @@ -0,0 +1,135 @@ +--- +title: Колекції плагінів +description: search, redirects, forms, MCP-ключі та службові payload-* колекції — що зберігають і як конфігуруються +--- + +Українські ярлики й описи всім цим колекціям проставляє плагін `ukrainianAdmin` +(останній у `src/plugins/index.ts`) — самі плагіни перекладу не підтримують. + +## search + +Створюється `searchPlugin` для чотирьох колекцій +(`searchIndexedCollections` у `src/search/localeSync.ts`): `posts`, `courses`, +`course-categories`, `pages`. Один документ джерела → один search-рядок, +який плагін створює/оновлює в afterChange (тільки published; чернетки +видаляються з індексу). + +### Поля + +Стандартні поля плагіна: `title`, `priority`, `doc` (поліморфний rel на +джерело). Кастомних `defaultPriorities` проєкт не задає — пріоритет +залишається дефолтним. Додаткові поля з `src/search/fieldOverrides.ts` +(усі readOnly в адмінці): + +| Поле | Тип | Призначення | +| --- | --- | --- | +| `slug` | text, index | посилання на сторінку результату | +| `collectionType` | text, index | тип джерела (`posts`/`courses`/…) — фронтенд будує URL і бейдж | +| `meta` | group: `title`, `description`, `image` → media | превʼю результату | +| `categories` | array: `relationTo`, `categoryID`, `title` | категорії джерела | + +### Наповнення + +`beforeSync` (`src/search/beforeSync.ts`) мапить документ у ці поля: +courses → meta з title/description/heroImage + категорія через `findByID` +(`disableErrors`, select title; відсутня → `console.error` + порожній масив); +pages → `meta.title = meta?.title || title`; posts (default) — spread SEO-meta ++ категорії по одній. + +**Проблема локалей** і її обхід (`backfillSearchTitleLocales` — бекфіл лише +`title`; `meta.*` і `categories` лишаються одномовними), read-side хак з +`fallbackLocale` + dedupe і реіндекс через `POST /api/reindex-search` +(`x-reindex-secret === CRON_SECRET`) — окрема стаття: +[Пошук і синхронізація](/admin/docs/technical/infrastruktura/poshuk-synkhronizatsiia). + +## redirects + +Створюється `redirectsPlugin({ collections: ['pages', 'posts'] })`. + +| Поле | Опис | +| --- | --- | +| `from` | стара URL-адреса; override додає опис «Після зміни цього поля сайт потрібно перебудувати.» | +| `to.type` | `reference` \| `custom` | +| `to.reference` | rel → pages \| posts | +| `to.url` | власна адреса | + +Хук afterChange `revalidateRedirects` (`src/hooks/revalidateRedirects.ts`) → +`revalidateTag('redirects')`; фронтенд читає всі редіректи через кешований +`getRedirects` і матчить **точним порівнянням рядків** `redirect.from === url` +у компоненті `PayloadRedirects` (без wildcard-ів). i18n-нюанс: namespace +`plugin-redirects` перекладений вручну в `payload.config.ts`, бо плагін не +має uk-рядків. + +## forms / form-submissions + +Створюються `formBuilderPlugin({ fields: { payment: false } })` — платіжні +поля вимкнені. + +### forms + +Конструктор форм: `title`, масив блоків `fields` (checkbox, country, email, +message, number, select, state, text, textarea — усі перекладені +`ukrainianAdmin` разом із пропсами name/label/required/width/…), +`submitButtonLabel`, `confirmationType` (message/redirect), +`confirmationMessage` (lexical з FixedToolbar + h1–h4 через override), +`redirect`, масив `emails`. + +`emails` — листи після сабміту: emailTo/cc/bcc/replyTo/emailFrom/subject/ +message з плейсхолдерами `{{fieldName}}`, `{{*}}` (усі дані) та `{{*:table}}` +(HTML-таблиця). Надсилання йде через `payload.sendEmail` — без Resend-ключа +лише консольний фолбек (див. [Email](/admin/docs/technical/infrastruktura/email)). + +### form-submissions + +Відповіді: `form` (rel → forms) + `submissionData` (пари field/value). +Створюються block-компонентом `formBlock` на сторінках. + +## payload-mcp-api-keys + +Створюється `mcpPlugin`. API-ключ = документ колекції: `user`, `label`, +`description`, сам ключ + пер-ключові тогли доступу (таби Tools / Resources / +Prompts, перекладені як Інструменти / Ресурси / Промпти). + +Конфіг плагіна (`src/plugins/index.ts`) визначає стелю можливого: + +| Колекція | Доступ MCP | +| --- | --- | +| `posts`, `pages`, `categories`, `courses`, `course-categories`, `comments` | повний CRUD (`enabled: true`) | +| `media` | `{find: true, create: false, update: true, delete: false}` — оновити alt/caption можна, залити чи видалити файл — ні | +| `likes` | `{find: true, create: true, update: false, delete: true}` — дзеркалить власний access колекції (update заборонений усім) | +| глобали `header`, `footer` | enabled | + +Прогрес-колекції (`enrollments`, `quiz-attempts`, `xp-events`), `users` та +auth-колекції в MCP **не** експоновані — навмисно: MCP-клієнт (AI-інструмент) +може вести контент, але не може торкатися прогресу, сертифікатної підстави чи +акаунтів. Кожна експонована колекція має `description` у конфігу плагіна — +це підказка для LLM, оновлюйте її при зміні схеми. + +### Життєвий цикл search-рядка + +- publish документа → плагін створює/оновлює рядок (afterChange), потім + `backfillSearchTitleLocales` дозаповнює title інших локалей; +- unpublish/чернетка → плагін видаляє рядок з індексу; +- delete джерела → рядок видаляється; +- ручне редагування search-рядків в адмінці можливе, але буде перетерте + наступним збереженням джерела — колекція фактично derived-only. + +## Auth-колекції payload-auth + +`sessions`, `accounts`, `verifications`, `rateLimit`, `admin-invitations` — +детально в [users та auth-колекціях](/admin/docs/technical/model-danykh/users-i-auth). + +## Службові payload-* + +| Колекція | Призначення | +| --- | --- | +| `payload-kv` | key-value сховище ядра | +| `payload-jobs` | черга задач — сюди падають відкладені публікації `schedulePublish`; запуск гейтиться `jobs.access.run` (юзер або `Bearer CRON_SECRET`) | +| `payload-folders` | папки медіатеки (`folders: true` у media) | +| `payload-locked-documents` | блокування редагування; фактично порожня — усі проєктні колекції мають `lockDocuments: false` | +| `payload-preferences` | персональні налаштування адмін-UI (колонки, згорнуті секції) | +| `payload-migrations` | бухгалтерія застосованих міграцій. Обережно: на dev-гілках Drizzle push записує сюди `batch=-1`, через що `payload migrate` на такій БД зависає на інтерактивному промпті — **не запускайте міграції на dev-гілках** (див. [Міграції](/admin/docs/technical/infrastruktura/mihratsii)) | + +Ці колекції не зʼявляються в навігації адмінки і не потребують супроводу — +але їхні таблиці існують у БД, тож памʼятайте про них при ручних SQL-операціях +і в дампах. diff --git a/docs/admin-panel/technical/02-model-danykh/_category.json b/docs/admin-panel/technical/02-model-danykh/_category.json new file mode 100644 index 0000000..07ae2fc --- /dev/null +++ b/docs/admin-panel/technical/02-model-danykh/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Модель даних", + "description": "Усі колекції та глобали: поля, доступ, хуки, індекси." +} diff --git a/docs/admin-panel/technical/03-biznes-logika/01-zavershennia-kursu.md b/docs/admin-panel/technical/03-biznes-logika/01-zavershennia-kursu.md new file mode 100644 index 0000000..ebc2e28 --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/01-zavershennia-kursu.md @@ -0,0 +1,151 @@ +--- +title: Завершення курсу +description: Єдине правило завершення в isCourseComplete, життєвий цикл enrollment, guards у completeStep і promote-only синхронізація syncCourseCompletions +--- + +## Єдине джерело правди: `isCourseComplete()` + +Правило «курс завершено» живе в одному місці — `src/utilities/courseCompletion.ts`. Усі споживачі (server actions, хук `syncCourseCompletions`, гейт сертифіката — опосередковано через `status`) викликають `isCourseComplete()` замість того, щоб виводити правило самостійно. Це принципово: якщо колись зміниться визначення завершення, зміна відбудеться в одній функції. + +```ts +import type { Course } from '@/payload-types' + +type CourseShape = Pick + +export const getStepIds = (course: Pick): string[] => + (course.steps ?? []).map((step) => step.id).filter((id): id is string => Boolean(id)) + +export function isCourseComplete({ + course, + completedSteps, + quizPassed, +}: { + course: CourseShape + completedSteps: string[] + quizPassed?: boolean | null +}): boolean { + const stepIds = getStepIds(course) + const quizEnabled = course.quiz?.enabled === true + + if (stepIds.length === 0 && !quizEnabled) return false + if (!stepIds.every((id) => completedSteps.includes(id))) return false + + return !quizEnabled || quizPassed === true +} +``` + +Словами: enrollment досягає завершення тоді й лише тоді, коли **кожен поточний** id кроку курсу є в `completedSteps`, **і** — якщо на курсі увімкнено тест (`quiz.enabled === true`) — `quizPassed === true`. + +### Порівняння по block id, не по кількості + +`completedSteps` — це `json`-масив **id блоків** кроків (Payload генерує стабільний `id` для кожного блока в масиві `steps`). Порівняння йде через `stepIds.every((id) => completedSteps.includes(id))`, а не через `completedSteps.length >= stepIds.length`. + +Причина: у `completedSteps` можуть залишатися **застарілі id видалених кроків**. Якби порівнювали лічильники, юзер, що пройшов 3 кроки зі старої версії курсу, «завершив» би нову версію з 3 інших кроків, не відкривши жодного з них. Порівняння по id гарантує, що зараховуються лише актуальні кроки. + +### Edge cases + +| Конфігурація курсу | Результат | +| --- | --- | +| 0 кроків, тест вимкнено | **Ніколи** не завершується (`return false` явно) — і сертифіката не буде | +| 0 кроків, тест увімкнено | Завершення визначає лише `quizPassed` (`every` на порожньому масиві — `true`) | +| Кроки є, тест вимкнено | Всі stepIds у `completedSteps` | +| Кроки є, тест увімкнено | Всі stepIds + `quizPassed === true` | + +:::warning +Чернетка курсу може існувати з 0 кроків (у Courses `steps` має `required: true`, що блокує лише **публікацію** порожнього курсу). Але якщо опублікований курс якимось чином опиниться без кроків і без тесту — жоден учасник його не завершить. Це навмисний запобіжник, а не помилка. +::: + +### Хто викликає `isCourseComplete()` + +| Споживач | Файл | Момент | +| --- | --- | --- | +| `completeStep` | `src/app/(frontend)/[locale]/courses/actions.ts` | після додавання кроку — вирішує `in_progress` vs `completed` | +| `submitQuizAttempt` | там само | після оцінювання — з `quizPassed: passed \|\| enrollmentDoc.quizPassed` | +| `syncCourseCompletions` | `src/hooks/syncCourseCompletions.ts` | після публікації зміненого курсу — для кожного незавершеного enrollment | + +Сертифікатний роут і сторінка верифікації функцію **не** викликають — вони читають лише `status === 'completed'`, тобто результат, а не правило. + +## Запис на курс: `enrollInCourse` + +Перед завершенням курс треба почати. `enrollInCourse(courseId)` у тому ж `actions.ts`: + +- без сесії → `«Необхідно увійти в акаунт»`; +- **ідемпотентний**: якщо enrollment (user × course) вже існує — `{ success: true, enrollment }` без create; дубль на рівні БД додатково блокує унікальний індекс (user, course) і хук колекції з APIError 409 `«Ви вже записані на цей курс»`; +- `payload.create` може кинути 429 з хука `rateLimitCreate` (`enroll-create`, 30/600 с) → `«Забагато запитів. Спробуйте пізніше.»`; +- хуки колекції на create: не-адміну примусово ставиться `data.user = req.user.id` (не можна записати когось іншого), `beforeChange` ініціалізує `enrolledAt = now`, `completedSteps = []`, `status = 'enrolled'`; +- наприкінці — `revalidateCoursePages(course.slug)`. + +## Життєвий цикл enrollment + +Статуси: `enrolled` → `in_progress` → `completed`. **Переходів «вниз» не існує ніде в коді** — жоден шлях не демоутить enrollment. + +| Статус | Хто ставить | +| --- | --- | +| `enrolled` | `beforeChange`-хук колекції `enrollments` при create (разом з `enrolledAt = now`, `completedSteps = []`) | +| `in_progress` | `completeStep`, коли після додавання кроку курс ще не завершено | +| `completed` | `completeStep` / `submitQuizAttempt` / `syncCourseCompletions` — усі три додатково пишуть `completedAt` | + +Поля enrollment описано в [Колекція enrollments](/admin/docs/technical/model-danykh/enrollments); тут важливо, що всі вони `admin.readOnly`, а `update` на колекції — admin-only: прогрес пишеться виключно server actions через Local API (відкритий REST-update дозволив би підробку завершень). + +## `completeStep`: п'ять guards + +`completeStep(enrollmentId, stepBlockId, courseId)` у `src/app/(frontend)/[locale]/courses/actions.ts` — єдиний шлях позначити крок пройденим. Перед записом виконуються перевірки, кожна з rationale: + +1. **Сесія**: `getSession()`; без неї — `«Необхідно увійти в акаунт»`. Анонім не має до чого писати. +2. **Enrollment існує**: `payload.findByID({ collection: 'enrollments', id: enrollmentId })`; інакше `«Запис не знайдено»`. +3. **Власник**: `String(enrollmentUserId) !== String(session.user.id)` → `«Немає доступу»`. Клієнт передає `enrollmentId` — без цієї перевірки можна було б писати в чужі enrollments. +4. **Курс enrollment = курс клієнта**: найтонший guard. Джерело правди — `enrollment.course`, а не `courseId` з клієнта: + + ```ts + const enrollmentCourseId = + typeof enrollment.course === 'object' ? enrollment.course.id : enrollment.course + if (Number(enrollmentCourseId) !== Number(courseId)) { + return { success: false, error: 'Немає доступу' } + } + ``` + + Якби сервер довіряв клієнтському `courseId`, юзер міг би «закривати» кроки короткого курсу проти enrollment іншого (довгого) курсу: id кроків беруться з курсу за `courseId`, а пишуться в enrollment. Далі по коду курс фетчиться саме по `enrollmentCourseId`. +5. **Ідемпотентність**: якщо `enrollment.status === 'completed'` — early return `success: true` без запису (і без подвійного XP); якщо `completedSteps.includes(stepBlockId)` — так само. + +Після guards: `stepBlockId` мусить бути в `getStepIds(course)` (інакше `«Крок не знайдено»` — відсікає вигадані та видалені id). Потім атомарно за змістом: append id у `completedSteps`, статус `completed` або `in_progress` за результатом `isCourseComplete()` (з поточним `quizPassed` — на курсі з тестом останній крок не є фінішем, промоут робить `submitQuizAttempt`), `completedAt` лише при завершенні, `logXpEvent` (+30 XP, див. [Система XP](/admin/docs/technical/biznes-logika/xp)) і ревалідація сторінок курсу. + +## `syncCourseCompletions`: редагування курсу змінює визначення завершення + +Хук `afterChange` колекції `courses` — `src/hooks/syncCourseCompletions.ts`: + +```ts +export const syncCourseCompletions: CollectionAfterChangeHook = async ({ + doc, + previousDoc, + req, +}) => { /* ... */ } +``` + +Механіка: + +- Працює лише коли `doc._status === 'published'` — чернетки не змінюють, що означає «завершено». +- **Short-circuit по сигнатурі**: `completionSignature(course)` = `` `${course.quiz?.enabled === true}|${getStepIds(course).join(',')}` ``. Якщо сигнатури `previousDoc` і `doc` збігаються (правили лише тексти, назви, SEO) — хук виходить одразу, без запиту enrollments. +- Інакше — `find` усіх enrollments курсу зі `status != completed` (`depth: 0`, `pagination: false`, з передачею `req` для транзакційності), і кожен, що тепер задовольняє `isCourseComplete()`, отримує `{ status: 'completed', completedAt }`. +- `completedAt` — **один спільний ISO timestamp** на весь прогін (`new Date().toISOString()` обчислюється до циклу): всі промоутнуті одним редагуванням отримують однакову дату, що чесно відображає причину завершення. + +### Promote-only, ніколи demote + +Хук шукає лише незавершені enrollments і лише підвищує їх. Сценарії: + +- **Видалили крок або вимкнули тест** → планка знизилась → учасники, що вже виконали решту, автоматично стають `completed`. +- **Додали крок або увімкнули тест** → планка піднялась → уже завершені enrollments **не чіпаються**. Сертифікат, виданий раніше, лишається валідним — і PDF, і сторінка верифікації гейтяться саме на `status === 'completed'` (див. [Сертифікати](/admin/docs/technical/biznes-logika/sertyfikaty)). + +:::info Чому не демоутити +Сертифікат — це факт про минуле («завершив курс у такому вигляді»), а не жива відповідність поточній програмі. Демоушн зробив би видані PDF брехнею заднім числом і зламав би QR-верифікацію вже надрукованих сертифікатів. +::: + +## Зв'язок із сертифікатами + +Сертифікат **не перевіряє** тест чи кроки окремо — route гейтиться на єдиній умові: існує enrollment користувача на цей курс зі `status === 'completed'`. Отже вимога тесту успадковується через `isCourseComplete()`, а не дублюється. Єдиний спосіб «відкликати» сертифікат — щоб enrollment покинув статус `completed`, чого код ніколи не робить автоматично (лише адмін вручну). Деталі токенів і PDF — [Сертифікати](/admin/docs/technical/biznes-logika/sertyfikaty), менеджерський погляд — [Сертифікати](/admin/docs/manager/kursy/sertyfikaty). + +## Пов'язане + +- Тести й оцінювання: [Тести: оцінювання та спроби](/admin/docs/technical/biznes-logika/kvizy) +- Нарахування XP за кроки: [Система XP](/admin/docs/technical/biznes-logika/xp) +- Схема колекції: [Колекція enrollments](/admin/docs/technical/model-danykh/enrollments) +- Ліміт на створення enrollments (30/10 хв): [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting) diff --git a/docs/admin-panel/technical/03-biznes-logika/02-kvizy.md b/docs/admin-panel/technical/03-biznes-logika/02-kvizy.md new file mode 100644 index 0000000..31293e4 --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/02-kvizy.md @@ -0,0 +1,153 @@ +--- +title: "Тести: оцінювання та спроби" +description: submitQuizAttempt покроково, exact-set grading без часткового заліку, нумерація спроб, клієнтська QuizForm і гейтинг сторінки тесту +--- + +## Де що лежить + +- Server action `submitQuizAttempt` — `src/app/(frontend)/[locale]/courses/actions.ts`. Це **єдиний** користувацький шлях створення спроби: `quiz-attempts.create` у REST — admin-only, інакше можна було б підробити результат (див. [quiz-attempts та xp-events](/admin/docs/technical/model-danykh/quiz-attempts-xp-events)). +- Конфіг тесту — група `quiz` у колекції `courses`: `enabled` (default `false`), `passingScore` 0–100 (default **70**), `questions` (minRows 1, required лише коли `enabled`), у питанні `answers` (minRows 2, щонайменше одна `isCorrect`). Деталі — [Колекція courses](/admin/docs/technical/model-danykh/courses). +- Клієнт — `src/components/Courses/QuizForm.tsx`, сторінка — `src/app/(frontend)/[locale]/courses/[slug]/quiz/page.tsx`. + +## `submitQuizAttempt`: 11 кроків + +Сигнатура: `submitQuizAttempt(courseId, answers: Array<{ questionId: string; selectedAnswerIds: string[] }>)`. + +1. **Сесія.** `getSession()`; без неї — `«Необхідно увійти в акаунт»`. +2. **Rate limit.** `checkRateLimit` з ключем `quiz-submit:${session.user.id}`, вікно 3600 с, max **30** → `«Забагато спроб. Спробуйте пізніше.»`. 30/год ніколи не зачепить людину, що перескладає тест, — лише скриптовані потоки (спроби — необмежені рядки в БД + серверне оцінювання). Див. [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting). +3. **Enrollment.** `find` по user × course, `totalDocs === 0` → `«Ви не записані на цей курс»`. +4. **Тест увімкнено.** Курс фетчиться `findByID` (`depth: 0`); `!course.quiz?.enabled` → `«Тест не активовано для цього курсу»`. +5. **Оцінювання** (exact-set match): + + ```ts + const selectedSet = new Set(submittedAnswer.selectedAnswerIds) + const correctSet = new Set(correctAnswerIds) + + const isCorrect = + selectedSet.size === correctSet.size && + [...selectedSet].every((id) => correctSet.has(id)) + ``` + + Матчинг питання — по `q.id` (`questions.findIndex`); відповіді з невідомим `questionId` **мовчки пропускаються** (`continue`). Часткового заліку немає: на мультивибірковому питанні треба вибрати **точно** множину правильних — зайва або пропущена відповідь обнуляє питання. Пропущене питання (клієнт такого не шле, але API дозволяє) = неправильне, бо `correctCount` за нього не інкрементується, а `totalQuestions` рахується з курсу. У `gradedAnswers` пишуться `{ questionIndex, selectedAnswerIndices, correct }` — індекси, не id, щоб знімок був читабельним навіть після редагування курсу. +6. **Score.** `score = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0`. +7. **Passed.** `passed = score >= (course.quiz.passingScore ?? 70)`. Наслідок: `passingScore: 0` означає, що проходить будь-яка спроба, включно з 0%. +8. **Створення спроби.** `payload.create({ collection: 'quiz-attempts', ... , attemptNumber: 0 })` — нуль лише задовольняє required-поле; реальне значення перезаписує хук колекції (нижче). +9. **Оновлення enrollment** одним `update`: + - `quizAttempts: currentAttempts + 1` — завжди; + - `bestQuizScore` — лише якщо `score > currentBest` (строго більше); + - `quizPassed: true` — лише якщо `passed`; **ніколи не скидається** — пізніший провал не чіпає поле (липкий прапорець; сертифікат не відкликається ретейком); + - `status: 'completed'` + `completedAt` — лише якщо `nowComplete`: статус ще не `completed` і `isCourseComplete({ course, completedSteps, quizPassed: passed || enrollmentDoc.quizPassed })` тепер істинний. +10. **XP рівно раз.** `if (passed && !enrollmentDoc.quizPassed)` — тобто на **першій** успішній спробі (перевірка по стану enrollment *до* оновлення) — `logXpEvent(..., { kind: 'quiz', amount: QUIZ_XP })` (+100). Повторні успішні спроби XP не дають. +11. **Ревалідація.** `revalidateCoursePages(course.slug)`: `revalidatePath` для `/courses/`, `steps/[stepIndex]`, `quiz` у обох локальних префіксах (`''`, `'/en'`) + `revalidateTag('course-enrollment-stats')`. + +Повертається `{ success: true, attempt: { score, passed, correctAnswers, totalQuestions, attemptNumber } }`. + +### Форма даних + +Вхід від клієнта і збережений знімок відповіді навмисно різні: + +```ts +// вхід (id-центричний — стабільний проти shuffle на клієнті) +answers: Array<{ questionId: string; selectedAnswerIds: string[] }> + +// збережений знімок у quiz-attempts.answers (індекс-центричний — читабельний в адмінці) +gradedAnswers: Array<{ questionIndex: number; selectedAnswerIndices: number[]; correct: boolean }> +``` + +`selectedAnswerIndices` будується мапінгом id → індекс у поточному порядку відповідей курсу; невідомі id відфільтровуються (`filter((i) => i !== -1)`). + +### Помилки та edge cases + +| Умова | Результат | +| --- | --- | +| Немає сесії | `«Необхідно увійти в акаунт»` | +| > 30 сабмітів/год | `«Забагато спроб. Спробуйте пізніше.»` | +| Немає enrollment | `«Ви не записані на цей курс»` | +| `quiz.enabled !== true` | `«Тест не активовано для цього курсу»` | +| `totalQuestions === 0` | `score = 0`; `passed = 0 >= passingScore` — при дефолтних 70 провал | +| `passingScore = 0` | будь-яка спроба passed, навіть 0% | +| Невідомий `questionId` | мовчки ігнорується (не зараховується ні туди, ні туди) | +| Відповідь не надіслано на питання | питання неправильне (correctCount не росте) | + +## Валідація конфігу тесту в адмінці + +Правила на колекції `courses`, які гарантують, що оцінювачу є що оцінювати: + +- поля `title`/`description`/`passingScore`/`questions` групи `quiz` видимі лише коли `enabled`; +- `questions` — `minRows: 1`, але кастомний validate робить їх required **лише коли** `enabled` (курс без тесту зберігається без питань); +- `answers` у питанні — required, `minRows: 2`, кастомний validate вимагає ≥1 `isCorrect` з повідомленням `«Позначте щонайменше одну правильну відповідь.»`; +- `steps` курсу — `required: true`: саме це блокує публікацію курсу без кроків (Payload пропускає minRows на порожньому опційному масиві; чернетки зберігаються порожніми). + +## `attemptNumber`: хук колекції + +У `src/collections/QuizAttempts.ts` — `beforeValidate`-хук (саме `beforeValidate`, а не `beforeChange`, щоб required-поле існувало **до** валідації): + +```ts +if (operation === 'create' && data?.user && data?.course) { + const existing = await req.payload.count({ + collection: 'quiz-attempts', + where: { and: [{ user: { equals: data.user } }, { course: { equals: data.course } }] }, + req, + }) + data.attemptNumber = existing.totalDocs + 1 +} +``` + +Нумерація per user × course; викликачам не треба її рахувати. + +## Клієнт: `QuizForm.tsx` + +- **Shuffle.** І питання, і відповіді всередині кожного питання перемішуються Fisher–Yates на кожну спробу: + + ```ts + const shuffledQuestions = useMemo(() => { + void seed + return fisherYatesShuffle(questions).map((q) => ({ + ...q, + answers: fisherYatesShuffle(q.answers ?? []), + })) + }, [questions, seed]) + ``` + + `seed` ініціалізується `Date.now()`; кнопка «Спробувати ще» (`handleTryAgain`) скидає вибір і робить `setSeed(Date.now())` — новий порядок на кожну спробу. +- **Витік мультивибору.** Тип інпута визначає `hasMultipleCorrect(question)`: чекбокси, коли правильних відповідей > 1, інакше радіо. Для цього сторінка передає `isCorrect` у клієнтські пропси — тобто правильні відповіді **присутні в HTML/JS-пейлоаді сторінки тесту**. Сам факт «тут чекбокси» вже підказує, що правильних кілька. Це усвідомлений компроміс (тест — навчальний інструмент, не екзамен із проктором); чесність результату все одно гарантує лише серверне оцінювання. +- **Submit-блокування.** Кнопка `disabled`, поки не `allAnswered` — кожне питання має ≥1 вибрану відповідь (а також під час `isPending`). Тому «пропущене питання» — суто API-шлях, не UI. +- Після успіху — `clearMyXpCache()` (скидає sessionStorage-кеш XP, див. [Система XP](/admin/docs/technical/biznes-logika/xp)) і рендер `QuizResults`. + +Сторінка також показує бейджі: прохідний бал, кількість питань, використані спроби (або «№1»), нагороду `+100 XP` (`QUIZ_XP`). + +## Гейтинг сторінки тесту + +`/courses/[slug]/quiz` **не входить** у matcher захищених шляхів middleware (`src/middleware.ts` захищає `/profile`, `/certificates`, `^/courses/[^/]+/steps`) — сторінка захищає себе сама, послідовно: + +1. Немає сесії → `redirect('/login?redirect=')` (з локальним префіксом). +2. Курс не знайдено серед published → `notFound()`. +3. `!course.quiz?.enabled` → redirect на сторінку курсу. +4. Немає enrollment (`getEnrollment(course.id)`) → redirect на сторінку курсу. +5. Є незавершений крок → redirect на **перший незавершений**: `steps.findIndex((step) => !completedSteps.includes(step.id ?? ''))`, редірект на `steps/${firstIncompleteIndex + 1}` (індекси кроків у URL — 1-based). Для enrollment зі `status === 'completed'` перевірка пропускається (`firstIncompleteIndex = -1`), тож завершені учасники завжди можуть відкрити тест. + +Тест відкривається лише після всіх кроків — «відстаючих» ведуть до місця зупинки, а не мовчки викидають на сторінку курсу. + +## Ретейки + +Кількість спроб **не обмежена** — ні cooldown, ні max-attempts; єдине стримування — rate limit 30/год. Історія: `getQuizAttempts(courseId)` повертає спроби юзера по курсу, `sort: '-createdAt'`, `limit: 100`; рендериться в `QuizAttemptHistory` під формою (з лінком на сертифікат для passed-спроб). + +### Два лічильники спроб + +Не плутати: + +- `quiz-attempts.attemptNumber` — порядковий номер конкретної спроби, рахує хук колекції по фактичних записах; +- `enrollments.quizAttempts` — денормалізований лічильник на enrollment, інкрементується в `submitQuizAttempt`. + +Вони можуть розійтися, якщо адмін видалить записи спроб (лічильник на enrollment не перераховується) — це нормально, лічильник enrollment відображає «скільки разів складав», а не «скільки записів існує». + +:::tip Наслідки для контенту +Оскільки exact-set match не дає часткового заліку, мультивибіркові питання значно «дорожчі» за одиночні. Якщо `passingScore` високий, а питань мало — одне мультивибіркове питання може коштувати проходження. Автору курсу варто це враховувати. +::: + +## Пов'язане + +- Що означає «завершено» і липкість `quizPassed`: [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu) +- +100 XP за перший passing: [Система XP](/admin/docs/technical/biznes-logika/xp) +- Схеми `quiz-attempts` і `xp-events`: [quiz-attempts та xp-events](/admin/docs/technical/model-danykh/quiz-attempts-xp-events) +- Конфіг групи `quiz` на курсі: [Колекція courses](/admin/docs/technical/model-danykh/courses) diff --git a/docs/admin-panel/technical/03-biznes-logika/03-xp.md b/docs/admin-panel/technical/03-biznes-logika/03-xp.md new file mode 100644 index 0000000..7371cf0 --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/03-xp.md @@ -0,0 +1,152 @@ +--- +title: Система XP +description: Дуалізм derived-XP з enrollments та append-only логу xp-events, формули рівнів, лідерборди з кешами і відомий дрейф best-effort логування +--- + +## Два представлення одного XP + +XP існує у двох формах, і плутати їх не можна: + +1. **Сумарний (derived) XP** — ніде не зберігається, щоразу **деривується** з enrollments: `Σ completedSteps.length * 30 + (quizPassed ? 100 : 0)` по всіх enrollments користувача. Так рахують `getMyXp` (`src/actions/xp.ts`), all-time лідерборд і сторінка профілю. Джерело правди — прогрес, тому XP неможливо «розсинхронізувати» з реальними завершеннями. +2. **Лог `xp-events`** — append-only колекція `{ user, course, kind: 'step' | 'quiz', amount }` з timestamps. Існує **лише** заради періодних лідербордів: enrollment не знає, *коли* був пройдений кожен крок, а «XP за тиждень» без часових міток не порахувати. + +Схема колекції — [quiz-attempts та xp-events](/admin/docs/technical/model-danykh/quiz-attempts-xp-events) (усі 4 операції admin-only; фронт її не читає й не пише напряму). + +## Формули — `src/utilities/xp.ts` + +```ts +export const STEP_XP = 30 +export const QUIZ_XP = 100 + +export const courseXp = (stepsCount: number, hasQuiz: boolean): number => + stepsCount * STEP_XP + (hasQuiz ? QUIZ_XP : 0) + +const levelSpan = (level: number): number => 300 + (level - 1) * 100 + +export const levelForXp = (xp: number): { level: number; intoLevel: number; span: number } => { + let level = 1 + let rest = Math.max(0, xp) + while (rest >= levelSpan(level)) { + rest -= levelSpan(level) + level += 1 + } + return { level, intoLevel: rest, span: levelSpan(level) } +} + +export const formatXp = (xp: number): string => `${xp.toLocaleString('uk-UA')} XP` +``` + +- Крок = **30 XP**, пройдений тест = **100 XP** (раз на курс). +- Рівні: span рівня n = `300 + (n-1)*100` — L1 потребує 300, L2 — 400, L3 — 500 і т.д. `levelForXp` віднімає span-и послідовно; від'ємний вхід клампиться до 0. +- `courseXp` використовується в UI для бейджа «скільки XP дає курс». + +## `getMyXp` — derived XP поточного користувача + +`src/actions/xp.ts` показує деривацію в чистому вигляді: + +```ts +const [{ docs }, userDoc] = await Promise.all([ + payload.find({ + collection: 'enrollments', + where: { user: { equals: Number(session.user.id) } }, + limit: 1000, + depth: 0, + select: { completedSteps: true, quizPassed: true }, + }), + payload.findByID({ collection: 'users', id: ..., select: { image: true }, disableErrors: true }), +]) + +let steps = 0, quizzes = 0 +for (const enrollment of docs) { + steps += Array.isArray(enrollment.completedSteps) ? enrollment.completedSteps.length : 0 + if (enrollment.quizPassed) quizzes += 1 +} +const xp = steps * STEP_XP + quizzes * QUIZ_XP +``` + +Повертає `MyXp = { xp, level, intoLevel, span, image }` або `null` без сесії. `Array.isArray`-guard — стандартний патерн усіх читачів `completedSteps` (json-поле нічого не гарантує). `limit: 1000` — практична стеля кількості enrollments одного користувача. + +## `logXpEvent`: best-effort за дизайном + +У `src/app/(frontend)/[locale]/courses/actions.ts`: + +```ts +async function logXpEvent(payload, data) { + try { + await payload.create({ collection: 'xp-events', data }) + revalidateTag('xp-leaderboard') + } catch (err) { + payload.logger.error({ err }, 'xp-events: failed to log XP award') + } +} +``` + +Виклики: `completeStep` — `{ kind: 'step', amount: 30 }` на кожен **новий** крок (дублікати відсікає early-return по `completedSteps.includes`); `submitQuizAttempt` — `{ kind: 'quiz', amount: 100 }` лише на першій успішній спробі (`passed && !enrollmentDoc.quizPassed`). + +**Rationale try/catch:** сумарний XP деривується з enrollments, тож провал запису в лог не має завалити мутацію, яка цей XP заробила. Крок уже записано в `completedSteps` — падати через журнал було б гірше, ніж втратити рядок журналу. + +**Ціна:** `xp_events` може **дрейфувати** від derived-значень — пропущений рядок означає, що періодний лідерборд недорахує ці 30/100 XP, тоді як all-time і профіль покажуть їх коректно. Дрейф лише в один бік (лог ≤ факт) і лише в періодних вибірках. + +## Лідерборди — `src/utilities/leaderboard.ts` + +`TOP_N = 50`; періоди `PERIOD_DAYS = { day: 1, week: 7, month: 30 }`. + +### All-time: raw SQL по enrollments + +```sql +SELECT e.user_id, u.name, u.image, + SUM( + CASE WHEN jsonb_typeof(e.completed_steps) = 'array' + THEN jsonb_array_length(e.completed_steps) ELSE 0 END * 30 + + CASE WHEN e.quiz_passed THEN 100 ELSE 0 END + ) AS xp +FROM enrollments e +JOIN users u ON u.id = e.user_id +GROUP BY e.user_id, u.name, u.image +HAVING SUM(...) > 0 +ORDER BY xp DESC, e.user_id ASC +LIMIT 50 +``` + +Зверніть увагу на guard `jsonb_typeof(...) = 'array'` — `completed_steps` це `json`-колонка, і `jsonb_array_length` на не-масиві кинув би помилку; рядки з зіпсованим значенням просто дають 0. `HAVING > 0` прибирає нульових, `user_id ASC` — стабільний tie-break. Виконується через `runRows` (`src/utilities/contentCounts.ts`). + +### Period: SUM з `xp_events` + +```sql +SELECT x.user_id, u.name, u.image, SUM(x.amount) AS xp +FROM xp_events x +JOIN users u ON u.id = x.user_id +WHERE x.created_at >= NOW() - make_interval(days => N) +GROUP BY ... ORDER BY xp DESC, x.user_id ASC LIMIT 50 +``` + +:::info Період покриває лише еру логу +`xp-events` з'явилися міграцією `20260724_140000_xp_events` — активність до неї в періодні лідерборди не потрапляє (а в all-time потрапляє, бо той рахує з enrollments). +::: + +### Кешування + +Обидва загорнуті в `unstable_cache` з `revalidate: 300` і тегом `xp-leaderboard` (ключі `xp-leaderboard-all` / `xp-leaderboard-`). Успішний `logXpEvent` робить `revalidateTag('xp-leaderboard')`, тож табло оновлюється швидше за 5 хв після реальної події. Поруч — `getCachedCourseCompletions(courseId)`: останні 24 завершення курсу, `revalidate: 60`, тег `course-enrollment-stats`. + +## Клієнтський кеш: `myXpCache` + +`src/utilities/myXpCache.ts` — sessionStorage-кеш результату `getMyXp` (ключ `myXp:v1`, TTL **5 хв**, прив'язка до `userId`, `try/catch` навколо кожної операції — приватний режим/квота не мають ламати сторінку). `QuizForm` і шлях завершення кроку викликають `clearMyXpCache()` після успіху, щоб хедер показав свіжий XP одразу. `getMyXp` сам по собі: enrollments юзера `limit: 1000` + `select: { completedSteps, quizPassed }`, плюс `users.image` для аватарки. + +## Хто яке представлення читає + +| Споживач | Джерело | Чому | +| --- | --- | --- | +| Хедер / профіль (`getMyXp`) | derived з enrollments | завжди точний, дешевий для одного юзера | +| All-time лідерборд | derived (raw SQL по enrollments) | точний для всіх часів | +| Періодні лідерборди (день/тиждень/місяць) | `xp_events` SUM | лише лог знає *коли* | +| Бейдж «+N XP» на курсі (`courseXp`) | формула від кроків/тесту | не залежить від юзера | + +## Каскадні видалення ламають історію періодів + +`beforeDelete`-хуки users і courses **hard-delete** пов'язані `xp-events` (FK у БД — ON DELETE SET NULL проти NOT NULL колонок, тож без каскаду видалення просто б падало). Наслідок: видалення курсу чи користувача стирає їхній внесок з історії періодних лідербордів заднім числом. All-time від видалення курсу теж змінюється — але це «чесно», бо зникають і enrollments. Якщо колись знадобиться незмінна історія — лог доведеться від'єднати від FK. + +## Пов'язане + +- Хто і коли нараховує: [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu), [Тести: оцінювання та спроби](/admin/docs/technical/biznes-logika/kvizy) +- Схема `xp-events`: [quiz-attempts та xp-events](/admin/docs/technical/model-danykh/quiz-attempts-xp-events) +- ISR і кеш-теги в цілому: [Огляд архітектури](/admin/docs/technical/arkhitektura/ohliad) diff --git a/docs/admin-panel/technical/03-biznes-logika/04-sertyfikaty.md b/docs/admin-panel/technical/03-biznes-logika/04-sertyfikaty.md new file mode 100644 index 0000000..fd2e7cb --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/04-sertyfikaty.md @@ -0,0 +1,146 @@ +--- +title: Сертифікати +description: HMAC-токени, гейтинг PDF-роуту, пайплайн @react-pdf/renderer з QR-кодом та сторінки верифікації — включно з відомими недоліками +--- + +## Огляд + +Сертифікат — це PDF, який генерується **на льоту** при кожному завантаженні (нічого не зберігається) і несе QR-код на публічну сторінку верифікації. Компоненти: + +| Файл | Роль | +| --- | --- | +| `src/utilities/certificateToken.ts` | Генерація/перевірка HMAC-токена | +| `src/app/(frontend)/[locale]/courses/[slug]/certificate/route.ts` | GET-роут, що віддає PDF | +| `.../certificate/pdf.tsx` | Рендер PDF через `@react-pdf/renderer` | +| `.../certificate/certificate-bg.ts` | Фонове зображення як data-URI | +| `src/app/(frontend)/[locale]/verify/page.tsx` + `[token]/page.tsx` | Сторінки верифікації | + +Менеджерський опис — [Сертифікати](/admin/docs/manager/kursy/sertyfikaty). + +## Токен: `generateCertificateToken` / `verifyCertificateToken` + +Формат: `base64url("enrollmentId:userId:courseId") ~ hex(HMAC-SHA256(secret, "certificate-v1:e:u:c"))`, розділювач — перший `~`. + +```ts +export function generateCertificateToken(enrollmentId, userId, courseId): string { + const payload = `certificate-v1:${enrollmentId}:${userId}:${courseId}` + const signature = createHmac('sha256', getSecret()).update(payload).digest('hex') + const data = Buffer.from(`${enrollmentId}:${userId}:${courseId}`).toString('base64url') + return `${data}~${signature}` +} +``` + +Секрет — `process.env.PAYLOAD_SECRET` (без нього — throw). Зверніть увагу: у HMAC підписується рядок **з префіксом версії** `certificate-v1:`, а в base64url-частину префікс не входить — токен коротший, а стара версія формату ніколи не зверифікується новим кодом як своя. + +`verifyCertificateToken` — послідовність відмов: + +```ts +const sepIndex = token.indexOf('~') // split по ПЕРШОМУ ~ +if (sepIndex === -1) return { valid: false } // 1. немає розділювача +// 2. битий base64url → catch → invalid +// 3. decoded.split(':').length !== 3 → invalid +// 4. будь-який id: !Number.isFinite(n) || n <= 0 → invalid +// 5. signaturePart !== expectedSignature → invalid +return { valid: true, enrollmentId, userId, courseId } +``` + +Валідний токен повертає розібрані id — верифікація підпису відбувається **до** будь-якого запиту в БД, тож сторінка `/verify/[token]` не дає безкоштовного oracle для перебору enrollment id. + +### Відомі недоліки (свідомі компроміси) + +:::warning +- **Не constant-time порівняння**: `if (signaturePart !== expectedSignature)` — звичайний `!==`, а не `crypto.timingSafeEqual`. Теоретично відкриває timing-атаку на підпис; практична експлуатованість через мережеві шуми низька, але при доопрацюванні перше, що варто замінити. +- **Stateless і безстрокові**: токен не має expiry і не зберігається в БД — його неможливо відкликати як токен. Ротація `PAYLOAD_SECRET` інвалідує **всі** видані QR одразу. +- **Єдиний механізм відклику** — стан enrollment: сторінка верифікації перевіряє `status === 'completed'` наживо, тож переведення enrollment з `completed` (вручну адміном) або його видалення робить токен «недійсним» фактично, хоча підпис лишається валідним. +::: + +## Гейтинг роуту + +`GET /[locale]/courses/[slug]/certificate` (роут **не** покритий middleware-matcher'ом — self-guard): + +| Крок | Перевірка | Відповідь | +| --- | --- | --- | +| 1 | `getSession()` відсутня/фейл | **401** `Unauthorized` | +| 2 | курс по slug серед `_status: 'published'` (з локаллю, `depth: 0`) не знайдено | **404** `Course not found` | +| 3 | enrollment `user × course × status: 'completed'` не знайдено | **403** `No completed enrollment found` | + +PDF завжди генерується для **поточного** користувача сесії — у роуті немає параметра «чий сертифікат», тож скачати чужий неможливо в принципі. + +Роут **не** перевіряє тест чи кроки окремо — вимоги успадковуються через `status` (див. [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu)). Далі: + +- `userName = userRecord.name || session.user.name || session.user.email || 'Unknown'`; +- `completedAt = enrollment.completedAt ?? enrollment.updatedAt`, формат `toLocaleDateString('uk-UA' | 'en-US', { year: 'numeric', month: 'long', day: 'numeric' })`; +- відповідь: `Content-Type: application/pdf`, `Content-Disposition: attachment; filename="certificate-.pdf"`, `Cache-Control: no-store`. + +### Деривація CERT-ID + +```ts +const certId = token.slice(0, token.indexOf('~')).replace(/[^a-zA-Z0-9]/g, '').slice(0, 12).toUpperCase() +// на PDF і сторінці верифікації: `CERT-${certId}` +``` + +Тобто CERT-ID — це перші 12 алфанумериків **data-частини** токена (не підпису), uppercase. Він детермінований: той самий enrollment завжди дає той самий номер, і сторінка верифікації обчислює його з токена так само — числа збігаються без жодного збереження. + +## PDF-пайплайн — `pdf.tsx` + +- **Рендерер**: `@react-pdf/renderer`, `renderToBuffer()`. +- **Сторінка**: фіксовані `595.5 × 419.25 pt` (альбомний A5-подібний макет, розміри дзеркалять мокап). +- **Шрифт**: PT Serif (400 + 700) реєструється з **jsDelivr у рантаймі**: + + ```ts + Font.register({ + family: 'PT Serif', + fonts: [{ src: 'https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/ptserif/PT_Serif-Web-Regular.ttf', ... }], + }) + ``` + + `Font.registerHyphenationCallback((word) => [word])` вимикає переноси. +- **Статика**: весь макет (титул, лейбли, логотипи партнерів) запечений у фонове зображення `certificateBgDataUri` (data-URI у `certificate-bg.ts`) — PDF-код накладає лише динаміку на виміряні з мокапа координати. +- **Динаміка**: ім'я (бокс 463.4×41.3 pt), назва курсу (uppercase, може перенестись на 2 рядки — тому в `fitFontSize` передається подвійна ширина 930), дата, CERT-ID. +- **`fitFontSize(text, maxWidth, maxSize, minSize, emFactor)`** — оцінка ширини по середній ширині гліфа (0.62 em для кирилиці PT Serif Bold, 0.85 для uppercase з letter-spacing) і кламп у діапазон. Ім'я: 20→12 pt; курс: 14→11 pt. +- **QR**: **вручну** — `QRCode.create(url, { errorCorrectionLevel: 'M' })` з пакета `qrcode`, далі матриця модулів рендериться ``-ами в `` 37 pt (бібліотека не вміє React-PDF-примітиви, тому DIY): + + ```tsx + const qr = QRCode.create(url, { errorCorrectionLevel: 'M' }) + const cellSize = size / qr.modules.size + // подвійний цикл по modules.get(row, col) → + ``` + + Сидить у білому боксі 52×52 pt, обгорнутому `` — QR і клікабельний, і сканований. `verifyUrl = ${getServerSideURL()}/verify/${token}`. + +:::danger Зовнішня рантайм-залежність +Перше завантаження сертифіката на холодній лямбді тягне TTF з CDN. Недоступність jsDelivr = неможливість згенерувати PDF. Це відомий компроміс (шрифт не роздуває бандл); при hardening — запекти шрифт локально, як фон. +::: + +## Верифікація + +- **`/verify`** — публічна лендинг-сторінка з формою ручного вводу токена (`VerifyForm`). +- **`/verify/[token]`** — server component без автентифікації: + 1. `verifyCertificateToken(token)` невалідний → червона картка «недійсний». + 2. Enrollment фетчиться `findByID` у `try/catch`; відсутній **або** `status !== 'completed'` → та сама червона картка (одна відповідь на обидва випадки — не розкриваємо, існує запис чи ні). + 3. Курс і користувач фетчаться **незалежно**, кожен у своєму `try/catch`, з fallback `'Unknown Course'` / `'Unknown'` — видалений курс не ламає верифікацію факту завершення. + 4. Зелена картка: ім'я, курс, дата (`completedAt ?? updatedAt`), `CERT-` + той самий 12-символьний дериват. + +:::info Лінки без locale-префікса +Сторінка `/[locale]/certificates` (гейт `requireSession`; completed enrollments, `sort: '-completedAt'`, `limit: 100`) генерує download-лінки **без** префікса локалі — middleware сам переписує їх на `uk`. Так само `verifyUrl` в QR не містить локалі. +::: + +## Сторінка «Мої сертифікати» + +`/[locale]/certificates` — у matcher middleware (захищений префікс), плюс `requireSession` у коді. Логіка: completed enrollments поточного юзера, `sort: '-completedAt'`, `depth: 1` (щоб мати назви курсів), `limit: 100`; кожен рядок — лінк на `/courses//certificate`. + +## Гострі кути одним списком + +1. Курс без кроків і без тесту ніколи не завершується → сертифіката не існуватиме (див. [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu)). +2. `quizPassed` липкий: провал ретейку сертифікат не відкликає. +3. Токени безстрокові; ротація `PAYLOAD_SECRET` = масова інвалідизація QR. +4. Порівняння підпису не constant-time. +5. PDF залежить від jsDelivr у рантаймі. +6. `completedAt ?? updatedAt` — якщо адмін вручну виставив `completed` без дати, датою сертифіката стане останнє оновлення enrollment. + +## Пов'язане + +- Правило `status === 'completed'` і promote-only синхронізація: [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu) +- Липкий `quizPassed` (провал ретейку не відкликає сертифікат): [Тести: оцінювання та спроби](/admin/docs/technical/biznes-logika/kvizy) +- Middleware і захищені шляхи: [Маршрути та middleware](/admin/docs/technical/arkhitektura/marshruty-i-middleware) +- Для адміністраторів і менеджерів: [Сертифікати](/admin/docs/manager/kursy/sertyfikaty) diff --git a/docs/admin-panel/technical/03-biznes-logika/05-komentari-laiky.md b/docs/admin-panel/technical/03-biznes-logika/05-komentari-laiky.md new file mode 100644 index 0000000..7c0cb1b --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/05-komentari-laiky.md @@ -0,0 +1,148 @@ +--- +title: "Коментарі та лайки: server actions" +description: Усі actions з commentsAndLikes.ts — bulk-лайки, валідації addComment, однорівневий каскад deleteComment, toggleLike і кешовані лічильники +--- + +## Модель у двох словах + +`comments`: `body` (maxLength 2000), `author` → users, `targetCollection` (`posts` | `courses`), `targetId` (number), `parent` → comments (тредінг). `likes`: `user`, `targetCollection` (`posts` | `courses` | **`comments`**), `targetId`, унікальний індекс (user, targetCollection, targetId). Повні схеми — [comments та likes](/admin/docs/technical/model-danykh/comments-likes). + +**Модерації немає взагалі** — коментар публікується одразу, read — `anyone`. Захист складається з rate limits (10 коментарів/хв, 60 лайків/хв — [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting)), `maxLength`, і права видалення (автор або адмін). + +Усі користувацькі шляхи — server actions у `src/actions/commentsAndLikes.ts`; REST-мутації обмежені access-правилами колекцій (див. [Ролі та контроль доступу](/admin/docs/technical/autentyfikatsiya/roli-i-dostup)). + +## `getComments(targetCollection, targetId)` + +- `find` comments по target: `sort: 'createdAt'` (asc), `limit: 500`, `depth: 1` (щоб мати автора). +- **Bulk-запит лайків**: замість N запитів на коментар — один: + + ```ts + const allCommentLikes = await payload.find({ + collection: 'likes', + where: { + and: [ + { targetCollection: { equals: 'comments' } }, + { targetId: { in: commentIds } }, + ], + }, + limit: 10000, + depth: 0, + }) + // один прохід: likeCounts[tid]++ та userLikes.add(tid) якщо like.user === поточний userId + ``` + +- Автор, якого не вдалося розгорнути (видалений юзер), мапиться в `{ id: 0, name: '' }` — UI показує «порожнього» автора, а не падає. +- Повертає `CommentWithMeta[]`: `{ id, body, author: { id, name, image }, parent, likesCount, userLiked, createdAt }` + `total: result.totalDocs`. + +Дія доступна і анонімам (read — `anyone`); `userLiked` для них завжди `false`. + +## `addComment(targetCollection, targetId, body, parentId?)` + +Валідації по черзі, коди помилок — стабільні рядки, які клієнт перекладає: + +1. Сесія → `AUTH_REQUIRED`. +2. `body.trim()` непорожній і ≤ 2000 → інакше `INVALID_BODY`. +3. **Target існує і published**: `find` по цільовій колекції з `{ id }, { _status: { equals: 'published' } }` → інакше `INVALID_TARGET`. Не можна коментувати чернетки чи неіснуючі документи. +4. **Parent у тому ж target**: якщо передано `parentId` — parent мусить існувати **і** мати ті самі `targetCollection` + `targetId` → інакше `INVALID_TARGET`. Блокує «пересадку» відповіді під чужий пост. +5. `payload.create` — 429 з хука колекції (`rateLimitCreate`, 10/60 с) мапиться в `RATE_LIMITED`. + +Після створення — `revalidateCounts('comments', targetCollection)` і повернення готового `CommentWithMeta` (з fallback на дані сесії, якщо `depth` не розгорнув автора). + +Примітка: хук колекції `comments` на create примусово ставить `author = req.user.id` для не-адмінів — навіть якби хтось викликав REST напряму, авторство не підробити. + +## `deleteComment(commentId)` + +1. Сесія → `AUTH_REQUIRED`; коментар не знайдено → `NOT_FOUND`. +2. Право: `authorId === userId` **або** юзер має роль `admin` (перевірка по **свіжому** `findByID` користувача, не по даних сесії — cookie cache може відставати) → інакше `FORBIDDEN`. +3. Каскад — три послідовні операції: + + ```ts + await payload.delete({ + collection: 'likes', + where: { and: [{ targetCollection: { equals: 'comments' } }, { targetId: { equals: commentId } }] }, + }) + await payload.delete({ collection: 'comments', where: { parent: { equals: commentId } } }) + await payload.delete({ collection: 'comments', id: commentId }) + ``` + + Тобто: лайки самого коментаря → **прямі діти (один рівень)** → сам коментар. + +:::warning Онуки осиротіють +Каскад не рекурсивний: відповіді на відповіді (онуки) залишаються в БД з `parent`, що вказує на видалений документ, — і їхні лайки теж. UI, що будує тред від кореня, їх не покаже, але рядки живуть. Практично тредінг у UI неглибокий, тож це прийнято свідомо; при чистці даних варто пам'ятати. +::: + +## `toggleLike(targetCollection, targetId)` і `getLikeInfo` + +`toggleLike` — сесія (інакше `AUTH_REQUIRED`), далі toggle: + +```ts +const existing = await payload.find({ collection: 'likes', where: { and: [user, targetCollection, targetId] }, limit: 1 }) + +if (existing.totalDocs > 0) { + await payload.delete({ collection: 'likes', id: existing.docs[0]!.id }) // unlike +} else { + await payload.create({ collection: 'likes', data: { user: userId, targetCollection, targetId } }) + // APIError 429 з rateLimitCreate → RATE_LIMITED +} + +const { totalDocs: count } = await payload.count({ ... }) // recount після мутації +return { success: true, liked: existing.totalDocs === 0, count } +``` + +`liked` — «лайкнуто тепер», якщо **до** кліку лайка не було. Дубль-create додатково блокує унікальний індекс (user, targetCollection, targetId) з APIError `'Already liked'` 409. `targetCollection` тут ширший, ніж у коментарів: `posts | courses | comments`. + +`getLikeInfo` — паралельно `count` (всього) + `find limit 1` (чи лайкнув поточний юзер); для анонімів другий запит не робиться. + +### Коди помилок actions + +| Код | Означає | Де виникає | +| --- | --- | --- | +| `AUTH_REQUIRED` | немає сесії | addComment, deleteComment, toggleLike | +| `INVALID_BODY` | порожній або > 2000 символів | addComment | +| `INVALID_TARGET` | target не published/не існує, або parent з іншого target | addComment | +| `RATE_LIMITED` | 429 з хука колекції | addComment (10/60 с), toggleLike (60/60 с) | +| `NOT_FOUND` | коментар не існує | deleteComment | +| `FORBIDDEN` | не автор і не адмін | deleteComment | + +## `revalidateCounts`: пропуск для comments + +```ts +function revalidateCounts(kind: 'likes' | 'comments', targetCollection: LikeTargetCollection) { + if (targetCollection === 'posts' || targetCollection === 'courses') { + revalidateTag(`${kind}-counts-${targetCollection}`) + } +} +``` + +Теги існують лише для posts/courses — лайки **коментарів** не мають кешованих лічильників (вони завжди читаються наживо через `getComments`), тож ревалідовувати нічого; виклик мовчки пропускається. + +## Кешовані лічильники — `src/utilities/contentCounts.ts` + +Для списків (каталог курсів, стрічка постів) поштучні `count` були б дорогими, тому є `getCachedLikesCounts(targetCollection)` / `getCachedCommentsCounts(targetCollection)` — один raw SQL на **всю** колекцію: + +```sql +SELECT target_id, COUNT(*) AS count +FROM likes -- або comments +WHERE target_collection::text = ${targetCollection} +GROUP BY target_id +``` + +Результат — мапа `Record`, загорнута в `unstable_cache` з `revalidate: 120` і тегами `likes-counts-` / `comments-counts-` (саме їх смикає `revalidateCounts`). Поруч — `getCachedEnrollmentStats()` (enrolled/completed по курсах, 60 с, тег `course-enrollment-stats`). Хелпер `runRows` нормалізує відповідь драйвера (масив або `{ rows }`). + +:::info Display-only +Кешовані лічильники можуть відставати до 120 с (або до найближчого `revalidateTag` після мутації). Живий стан «лайкнув я чи ні» **завжди** тягнеться клієнтськи через actions — тому кнопка лайка коректна навіть при застарілому числі поруч. +::: + +## А що з REST? + +Server actions працюють через Local API (за замовчуванням обходить access control — тому кожен action несе власні перевірки), але REST-поверхня колекцій теж жива, і її обмежують access-правила: `comments.update` — admin-only (юзер не може відредагувати навіть свій коментар — лише видалити), `likes.update` — `() => false` для всіх. На create обидві колекції мають хуки, що для не-адмінів примусово ставлять `author`/`user = req.user.id`, тож REST-шлях не дає підробити авторство. Деталі — [Ролі та контроль доступу](/admin/docs/technical/autentyfikatsiya/roli-i-dostup). + +## Рендеринг: `InteractionSection` + +Ланцюжок компонентів: `InteractionSection` (server) → `InteractionClient` → `LikeButton` / `CommentsSection` / `CommentForm` / `CommentItem`. Секція монтується на сторінці курсу (overview) і сторінках постів. На профілі користувача останні коментарі показує `ProfileLatestComments` — якщо юзер не увімкнув `hideProfileComments` (див. [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil)). + +## Пов'язане + +- Схеми колекцій та access: [comments та likes](/admin/docs/technical/model-danykh/comments-likes) +- Ліміти 10/60 і 60/60: [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting) +- Форсування `author`/`user` у хуках колекцій: [Ролі та контроль доступу](/admin/docs/technical/autentyfikatsiya/roli-i-dostup) 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 new file mode 100644 index 0000000..04f5794 --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/06-rate-limiting.md @@ -0,0 +1,152 @@ +--- +title: Rate limiting +description: Fixed-window лімітер на спільній таблиці rate_limit — атомарний upsert, fail-open, фабрика rateLimitCreate і повна таблиця всіх лімітів +--- + +## Архітектура + +Один Postgres-бекенд, два споживачі: + +1. **Better Auth** — лімітує `/api/auth/*` власним механізмом зі `storage: 'database'`. +2. **`checkRateLimit`** (`src/lib/rate-limit.ts`) — усе інше: server actions, кастомні роути, `beforeValidate`-хуки колекцій. + +Обидва пишуть в **одну таблицю `rate_limit`** (створена міграцією `20260724_190000`; колекцію `rateLimit` будує payload-auth — прихована, admin-only). Префікси ключів (`enroll-create:`, `otp-send:ip:`, шляхи Better Auth) тримають простори імен неперетинними. Vercel-лямбди не ділять пам'ять, тому in-memory лічильники не працюють — лише БД. + +## `checkRateLimit`: атомарний fixed-window + +```ts +export async function checkRateLimit( + payload: BasePayload, + { key, windowSeconds, max }: { key: string; windowSeconds: number; max: number }, +): Promise // { ok: true } | { ok: false; retryAfter: number } +``` + +Серце — один SQL-стейтмент: + +```sql +INSERT INTO rate_limit ("key", "count", "last_request", "updated_at", "created_at") +VALUES (${key}, 1, ${now}, now(), now()) +ON CONFLICT ("key") DO UPDATE SET + "count" = CASE WHEN rate_limit."last_request" <= ${windowStartCutoff} + THEN 1 ELSE rate_limit."count" + 1 END, + "last_request" = CASE WHEN rate_limit."last_request" <= ${windowStartCutoff} + THEN ${now} ELSE rate_limit."last_request" END, + "updated_at" = now() +RETURNING "count", "last_request" +``` + +Ключові рішення: + +- **Атомарність**: upsert одночасно і рахує запит, і скидає протухле вікно. Дві конкурентні лямбди не подвоюють лічильник і не гублять скидання — все вирішує БД. +- **`last_request` = початок вікна** (epoch ms), а не час останнього запиту. Тому `retryAfter = ceil((windowStart + windowMs - now) / 1000)` (мінімум 1 с) обчислюється точно; віддається в HTTP як `Retry-After` там, де ліміт перевіряють роути. +- **Перевищення**: `count > max` → `{ ok: false, retryAfter }`. +- **GC**: з імовірністю 1% після успішної перевірки — `DELETE FROM rate_limit WHERE last_request <= now - 30 днів`. Інакше IP-ключі накопичувалися б вічно; рідко і дешево, тому інлайн. +- **FAILS OPEN**: увесь блок у `try/catch` — будь-яка помилка БД логується і повертає `{ ok: true }`. + +:::info Чому fail open +Лімітер захищає ендпоінти — він не має права їх класти. Якщо таблиця недоступна, гірший сценарій «пропустили зайвий запит» кращий за «увесь сайт віддає 429/500 через допоміжну підсистему». +::: + +## Вмикання: `isRateLimitEnabled()` + +```ts +if (process.env.RATE_LIMIT === 'false') return false +if (process.env.RATE_LIMIT === 'true') return true +return process.env.NODE_ENV === 'production' +``` + +Єдиний перемикач для **обох** лімітерів (Better Auth читає його в `options.ts` як `rateLimit.enabled`). Дефолт: увімкнено лише в production. `RATE_LIMIT=true` — опт-ін для dev-сесії; `RATE_LIMIT=false` — опт-аут навіть для production-білда (E2E ганяє `next start` з одного IP раннера і без цього миттєво впирається в ліміти). Читається при кожному виклику, тож тести можуть перемикати per-process. + +## `getClientIp`: null означає «пропусти», не «спільний bucket» + +```ts +const forwarded = request.headers.get('x-forwarded-for') +const first = forwarded?.split(',')[0]?.trim() +if (first) return first +return request.headers.get('x-real-ip') +``` + +На Vercel проксі ставить `x-forwarded-for`. Якщо заголовків немає (прямі локальні з'єднання) — повертається `null`, і викликач **пропускає IP-ліміт** замість того, щоб звалити всіх у один bucket з ключем `ip:null` (де один клієнт вичерпував би ліміт для всіх). Так роблять OTP-роути: `if (ip) { перевірка } ...`. + +## `rateLimitCreate`: фабрика для хуків колекцій + +`src/hooks/rateLimitCreate.ts` — `beforeValidate`-фабрика, що вішається на колекцію, аби **обидва** шляхи запису (REST з `req.user` і server actions через Local API з user id у `data`) проходили один чокпоінт: + +```ts +rateLimitCreate({ prefix, userField = 'user', windowSeconds, max }) +``` + +Поведінка: + +- лише `operation === 'create'`; +- **адмін-байпас**: `req.user.role?.includes('admin')` → пропуск (сідінг, ручні операції в адмінці); +- `userId = req.user?.id ?? data[userField]`; **немає user id — пропуск** (системні записи, хуки, seeds); +- перевищення → `throw new APIError('Забагато запитів. Спробуйте пізніше.', 429)` — server actions ловлять і маплять у свої коди (`RATE_LIMITED` тощо). + +## Як користуватись у власному коді + +Роут з IP-лімітом і коректним `Retry-After` (патерн з `verify-registration`): + +```ts +import { checkRateLimit, getClientIp } from '@/lib/rate-limit' + +const ip = getClientIp(request) +if (ip) { + const perIp = await checkRateLimit(payload, { key: `my-feature:ip:${ip}`, windowSeconds: 600, max: 20 }) + if (!perIp.ok) { + return NextResponse.json({ error: 'Too many requests' }, { + status: 429, + headers: { 'Retry-After': String(perIp.retryAfter) }, + }) + } +} +``` + +Server action з per-user лімітом (патерн з `submitQuizAttempt`): + +```ts +const limit = await checkRateLimit(payload, { + key: `my-action:${session.user.id}`, + windowSeconds: 3600, + max: 30, +}) +if (!limit.ok) return { success: false, error: 'Забагато спроб. Спробуйте пізніше.' } +``` + +Правила іменування ключів: `:` для per-user, `:ip:` / `:email:` для анонімних потоків. Префікс має бути унікальним у кодовій базі — таблиця спільна з Better Auth. + +## Повна таблиця лімітів + +| Ключ / шлях | Вікно | Max | Де застосовано | +| --- | --- | --- | --- | +| `enroll-create:` | 600 с | 30 | хук `enrollments` (`rateLimitCreate`) | +| `comment-create:` | 60 с | 10 | хук `comments` (`userField: 'author'`) | +| `like-create:` | 60 с | 60 | хук `likes` | +| `quiz-submit:` | 3600 с | 30 | `submitQuizAttempt` (server action) | +| `otp-send:ip:` | 600 с | 20 | `/api/auth/verify-registration` send-otp | +| `otp-send:email:` | 300 с | 3 | там само (страхує від бомбардування чужої скриньки) | +| `otp-verify:ip:` | 600 с | 30 | verify-otp | +| `otp-verify:email:` | 600 с | 10 | verify-otp (обмежує перебір кодів) | +| Better Auth глобально | 60 с | 60 | всі `/api/auth/*` | +| `/sign-in/email` | 60 с | 10 | Better Auth customRules | +| `/sign-up/email` | 60 с | 5 | — « — | +| `/email-otp/send-verification-otp` | 60 с | 3 | — « — (кожен виклик = реальний лист Resend) | +| `/forget-password/email-otp` | 60 с | 3 | — « — | +| `/sign-in/email-otp` | 60 с | 10 | — « — | +| `/email-otp/verify-email` | 60 с | 10 | — « — | +| `/email-otp/reset-password` | 60 с | 10 | — « — | +| `/get-session` | — | **вимкнено** (`false`) | сесія политься часто й read-only; запис рядка на кожну перевірку — чистий оверхед | + +Логіка вибору чисел: per-IP OTP-ліміти щедрі (шкільні комп'ютерні класи сидять за одним NAT), per-email — суворі; `quiz-submit` 30/год не зачіпає людину, лише скрипти; `like-create` 60/60 — фактично «не частіше кліку на секунду». + +Тести — `tests/int/rate-limit.int.spec.ts`. Про локальне ввімкнення лімітів під час розробки — [Локальне середовище](/admin/docs/technical/rozrobka/lokalne-seredovyshche). + +:::tip Дебаг «звідки 429» +Ключі в таблиці `rate_limit` людиночитні (`comment-create:42`, `otp-send:email:x@y.z`, `/sign-in/email…`) — `SELECT key, count, to_timestamp(last_request/1000) FROM rate_limit ORDER BY updated_at DESC` одразу показує, який ліміт спрацював і коли відкриється вікно. +::: + +## Пов'язане + +- OTP-потік, який ці ліміти охороняють: [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp) +- Хуки колекцій enrollments/comments/likes: [Колекція enrollments](/admin/docs/technical/model-danykh/enrollments), [comments та likes](/admin/docs/technical/model-danykh/comments-likes) +- Конфіг Better Auth: [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth) diff --git a/docs/admin-panel/technical/03-biznes-logika/_category.json b/docs/admin-panel/technical/03-biznes-logika/_category.json new file mode 100644 index 0000000..c4a1bbe --- /dev/null +++ b/docs/admin-panel/technical/03-biznes-logika/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Бізнес-логіка", + "description": "Завершення курсів, тести, XP, сертифікати, взаємодія та rate limiting." +} diff --git a/docs/admin-panel/technical/04-autentyfikatsiya/01-better-auth.md b/docs/admin-panel/technical/04-autentyfikatsiya/01-better-auth.md new file mode 100644 index 0000000..9648414 --- /dev/null +++ b/docs/admin-panel/technical/04-autentyfikatsiya/01-better-auth.md @@ -0,0 +1,144 @@ +--- +title: "Better Auth: інтеграція" +description: payload-auth і getPayloadAuth, resolveBaseURL з його пастками, сесії з cookie-кешем, лінкування акаунтів, умовний Google і сесійні хелпери +--- + +## Стек + +Автентифікація — `better-auth` (^1.4.19), інтегрована в Payload плагіном `payload-auth` (^1.9.4). Плагін першим у списку `src/plugins/index.ts` створює колекції `users` (розширена), `sessions`, `accounts`, `verifications`, `rateLimit`, `admin-invitations` (`hidePluginCollections: true`). Опції — `src/lib/auth/options.ts` (два експорти: `betterAuthOptions` для самого Better Auth і `betterAuthPluginOptions` для плагіна). App Router-обробник — `/api/auth/[...all]`. + +## `getPayloadAuth`: використовуйте його, не bare `getPayload` + +`src/lib/payload.ts`: + +```ts +import configPromise from '@payload-config' +import { getPayloadAuth } from 'payload-auth/better-auth' +import type { ConstructedBetterAuthPluginOptions } from './auth/options' + +export const getPayload = () => + getPayloadAuth(configPromise) +``` + +`getPayloadAuth` повертає інстанс Payload, збагачений типізованим `payload.betterAuth` (API: `getSession`, `signInEmail`, `signUpEmail`, `updateUser`, `setPassword`, `createVerificationOTP`...). Bare `getPayload({ config })` з пакета `payload` цього поля в типах не має — тож у будь-якому коді, якому потрібен `payload.betterAuth`, імпортуйте `getPayload` саме з `@/lib/payload`. (Частина суто контентного коду використовує bare-варіант — це ок, поки auth API не потрібен.) + +## `resolveBaseURL`: 5 рівнів пріоритету + +```ts +function resolveBaseURL(): string { + if (process.env.NEXT_PUBLIC_BETTER_AUTH_URL) return process.env.NEXT_PUBLIC_BETTER_AUTH_URL + if (process.env.VERCEL_PROJECT_PRODUCTION_URL && process.env.VERCEL_ENV === 'production') + return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` + if (process.env.VERCEL_PROJECT_PRODUCTION_URL) + return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + return 'http://localhost:3000' +} +``` + +| # | Джерело | Умова | Типове оточення | +| --- | --- | --- | --- | +| 1 | `NEXT_PUBLIC_BETTER_AUTH_URL` | задано | явний override | +| 2 | `https://VERCEL_PROJECT_PRODUCTION_URL` | лише `VERCEL_ENV === 'production'` | прод (щоб превʼю не підписували куки прод-доменом) | +| 3 | `https://VERCEL_URL` | задано | превʼю-деплої (унікальний URL) | +| 4 | `https://VERCEL_PROJECT_PRODUCTION_URL` | без умови | залишковий fallback | +| 5 | `http://localhost:3000` | — | локальна розробка | + +:::warning Пастка домену +`VERCEL_PROJECT_PRODUCTION_URL` — це **найкоротший** прод-домен проєкту у Vercel. Під час міграції домену (старий + новий підключені одночасно) «найкоротшим» може виявитися старий — і auth baseURL мовчки лишиться на ньому. Ліки: виставити `NEXT_PUBLIC_BETTER_AUTH_URL` **лише для production-оточення** у Vercel; якщо задати її для всіх оточень — зламаються превʼю (кука підписана не тим origin). +::: + +**`trustedOrigins`** — будується як `Set` (дедуплікація, бо кілька джерел можуть збігатися): + +```ts +const trustedOrigins = new Set([baseURL]) +if (process.env.VERCEL_URL) trustedOrigins.add(`https://${process.env.VERCEL_URL}`) +if (process.env.VERCEL_PROJECT_PRODUCTION_URL) + trustedOrigins.add(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`) +if (process.env.NEXT_PUBLIC_BETTER_AUTH_URL) + trustedOrigins.add(process.env.NEXT_PUBLIC_BETTER_AUTH_URL) +``` + +Запити з інших origin Better Auth відкидає — саме тому дев-сервер на нестандартному порті ламає форму логіну та Google OAuth (локально origin буде `localhost:3001`, а в списку — лише `localhost:3000`); обхід — `GET /api/dev-login`, який логінить server-side (див. [Dev-login та сідінг](/admin/docs/technical/autentyfikatsiya/dev-login-i-sid)). + +## Сесії + +```ts +session: { + expiresIn: 60 * 60 * 24 * 7, // 7 днів + updateAge: 60 * 60 * 24, // rolling: продовжується раз на добу активності + cookieCache: { enabled: true, maxAge: 5 * 60 }, // 5 хв +} +``` + +`cookieCache` кладе знімок user+session прямо в підписану куку: `getSession` протягом 5 хв не ходить у БД. + +:::warning Stale cookie cache +Після **server-side** оновлення користувача (наприклад, `payload.update` на `users`, як робить `removeAvatar`) кука ще до 5 хв віддає старі дані — ім'я, аватар, ролі. Шляхи, де це критично, мають примусово освіжити сесію з клієнта (так робить клієнт після `removeAvatar` — див. [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil)). Оновлення через `betterAuth.api.updateUser` перевипускає куку саме тому. +::: + +## Акаунти, Google, плагіни + +- **Email + пароль**: `enabled: true`, `requireEmailVerification: false` — верифікацію замінює власний OTP-гейт у `databaseHooks.user.create.before` (розібраний у [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp)). +- **Account linking**: `enabled: true`, `trustedProviders: ['google', 'email-password']` — вхід через Google з email'ом існуючого акаунта лінкується, а не плодить дубль. +- **Google OAuth** — умовний двічі: провайдер конфігурується лише коли задані `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET`; а кнопка в UI показується **лише в production** (`googleEnabled = id && isProduction`) — у дев-оточеннях redirect URI все одно не збігся б. +- **`nextCookies()`** — обов'язково **останній** плагін у списку: перехоплює `Set-Cookie` з server actions і роутів Next. +- **`emailOTP`** — конфіг там само (6 цифр, 300 с, 3 спроби); деталі у статті про реєстрацію. +- **Rate limit**: `enabled: isRateLimitEnabled()`, `window: 60, max: 60`, `storage: 'database'` + customRules per-path — повна таблиця в [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting). +- `appName: 'Залізна Зміна'`. + +## Сесійні хелпери + +`getSession` (`src/lib/auth/getSession.ts`) — канонічний спосіб читати сесію на сервері: + +```ts +import { cache } from 'react' +import { getPayload } from '@/lib/payload' +import { headers } from 'next/headers' + +export const getSession = cache(async () => { + const payload = await getPayload() + return payload.betterAuth.api.getSession({ headers: await headers() }) +}) +``` + +`cache()` з React дедуплікує: скільки б компонентів у дереві не викликали `getSession()` — один реальний виклик на request. + +`safeRedirectPath` (`src/utilities/safeRedirect.ts`) — обов'язковий фільтр для будь-якого `?redirect=` з URL: + +```ts +if (!value.startsWith('/') || value.startsWith('//') || /[\\]|:\/\//.test(value)) { + return fallback +} +``` + +| Хелпер | Файл | Поведінка | +| --- | --- | --- | +| `getSession()` | `src/lib/auth/getSession.ts` | кешований виклик `betterAuth.api.getSession` (див. вище) | +| `requireSession(locale, currentPath)` | `src/lib/auth/requireSession.ts` | немає сесії → `redirect('/login?redirect=')` з локальним префіксом | +| `getMeUser()` | `src/utilities/getMeUser.ts` | сесія + повний користувацький документ | +| `safeRedirectPath(value, fallback)` | `src/utilities/safeRedirect.ts` | приймає лише same-site відносні шляхи: відкидає абсолютні URL, `//host`, бекслеші та `://` | + +:::danger Не викликайте getSession у публічних ISR-сторінках +Інваріант перформансу платформи: компоненти публічних сторінок не читають сесію (це зробило б їх динамічними і вбило ISR). Прогрес юзера на каталозі тягнеться клієнтськи (`useMyCourseStatuses`). Див. [Огляд архітектури](/admin/docs/technical/arkhitektura/ohliad). +::: + +## Де в коді використовується `payload.betterAuth.api` + +| Метод | Викликач | Навіщо | +| --- | --- | --- | +| `getSession` | `getSession()` хелпер | читання сесії скрізь | +| `signInEmail` | `/api/dev-login`, сід-скрипт | програмний вхід | +| `signUpEmail` | сід-скрипт | створення dev-адміна через штатний потік | +| `createVerificationOTP` | `/api/auth/verify-registration` | код для ще-не-існуючого юзера | +| `updateUser` | `updateAvatar` | запис + перевипуск session-куки | +| `setPassword` | `setInitialPassword` | перший пароль для Google-акаунта | + +Це і є практична відповідь, навіщо `getPayloadAuth` замість bare `getPayload`. + +## Пов'язане + +- Реєстрація і OTP-гейт: [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp) +- Ролі, `collectionOverrides` і access: [Ролі та контроль доступу](/admin/docs/technical/autentyfikatsiya/roli-i-dostup) +- Схема users/sessions/accounts/verifications: [users та auth-колекції](/admin/docs/technical/model-danykh/users-i-auth) +- Листи (Resend, OTP, інвайти): [Email — Resend](/admin/docs/technical/infrastruktura/email) diff --git a/docs/admin-panel/technical/04-autentyfikatsiya/02-reiestratsiia-otp.md b/docs/admin-panel/technical/04-autentyfikatsiya/02-reiestratsiia-otp.md new file mode 100644 index 0000000..fee2e7c --- /dev/null +++ b/docs/admin-panel/technical/04-autentyfikatsiya/02-reiestratsiia-otp.md @@ -0,0 +1,149 @@ +--- +title: Реєстрація через OTP +description: Власний OTP-гейт замість requireEmailVerification — verify-registration роут, in-memory pre-verified список, databaseHooks-гейт створення юзера і його відома вада +--- + +## Чому не `requireEmailVerification: true` + +Better Auth вміє нативну email-верифікацію, але вона працює за схемою «створити юзера → надіслати лінк → чекати кліку»: у БД з'являються неверифіковані акаунти, а UX побудований на magic-link. Платформа хоче навпаки: **спершу** підтвердити email 6-значним кодом, і лише **потім** дозволити створення користувача. Тому `requireEmailVerification: false`, а гейтом слугує зв'язка: кастомний роут `/api/auth/verify-registration` + in-memory список pre-verified + `databaseHooks.user.create.before`, який відхиляє sign-up без підтвердження. + +Потік цілком: + +1. Форма реєстрації → `POST /api/auth/verify-registration` `{ action: 'send-otp', email }` — лист з кодом. +2. Юзер вводить код → той самий роут `{ action: 'verify-otp', email, otp }` — email позначається pre-verified. +3. Форма викликає звичайний `signUp.email` Better Auth (`/api/auth/sign-up/email`). +4. `databaseHooks.user.create.before` знаходить pre-verified позначку, споживає її і пропускає створення вже з `emailVerified: true`. + +## Роут: `POST /api/auth/verify-registration` + +`src/app/api/auth/verify-registration/route.ts`; дії розрізняються полем `action` у body. + +### `action: 'send-otp'` + +1. Email нормалізується (`toLowerCase().trim()`) і валідується regex'ом → інакше 400. +2. **Ліміти ДО будь-якої роботи** — кожен виклик шле реальний лист через Resend: + - per-IP `otp-send:ip:` — 20/600 с (щедро: шкільні класи сидять за одним NAT); якщо `getClientIp` повернув `null` — IP-перевірка пропускається; + - per-email `otp-send:email:` — **3/300 с** (суворий: рівно стільки надсилань, скільки живе один OTP; зупиняє бомбардування чужої скриньки). + - Перевищення → 429 + заголовок `Retry-After`. +3. **Існуючий користувач → 409** `Email already taken` (реєстрація, не логін). +4. Код створюється **внутрішнім** `payload.betterAuth.api.createVerificationOTP({ body: { email, type: 'email-verification' } })` — на відміну від публічного send-ендпоінта він не перевіряє існування юзера (юзера ще нема!). +5. Лист — напряму `new Resend(...)` (не через `payload.sendEmail`), subject `Код підтвердження: `, HTML з `src/lib/email/verification-otp.ts`. Без `RESEND_API_KEY` лист не шлеться (dev: код можна дістати з таблиці `verifications`). + +### `action: 'verify-otp'` + +1. Ліміти: `otp-verify:ip:` 30/600 с, `otp-verify:email:` 10/600 с. Це критично: вбудований кап «3 спроби» живе **в записі** verification і скидається кожним новим OTP — сам по собі він дозволяє необмежений перебір через цикл «замовив код → 3 спроби → замовив новий». Зовнішні вікна обмежують сумарний темп вгадування. +2. Запис шукається по `identifier = email-verification-otp-` у колекції `verifications`; нема → 400 `Invalid OTP`. +3. **Expiry**: `expiresAt` у минулому → запис видаляється + 400 `OTP expired` (життя коду — 5 хв, з конфігу `emailOTP`). +4. **Формат value — `"otp:attempts"`**, і split робиться по **останньому** `:` (`value.lastIndexOf(':')`) — сам OTP числовий, але формат не має права зламатися, якби він містив двокрапку. +5. `attempts >= 3` → запис видаляється + 403 `Too many attempts` (рядок, який форма реєстрації вже перекладає). +6. Неспівпадіння → `attempts + 1` пишеться назад + 400 `Invalid OTP`. +7. Збіг → `markPreVerified(email)`, запис видаляється, відповідь `{ verified: true }`. + +Ключовий фрагмент лічильника спроб: + +```ts +const value = record.value as string // "otp:attempts" +const lastColon = value.lastIndexOf(':') +const storedOtp = value.substring(0, lastColon) +const attempts = parseInt(value.substring(lastColon + 1), 10) + +if (attempts >= 3) { /* delete + 403 */ } +if (storedOtp !== otp) { + await payload.update({ collection: 'verifications', id: record.id, + data: { value: `${storedOtp}:${attempts + 1}` } }) + return NextResponse.json({ error: 'Invalid OTP' }, { status: 400 }) +} +``` + +### Зведення відповідей роуту + +| Дія | Умова | Статус, body | +| --- | --- | --- | +| будь-яка | невідомий `action` | 400 `Invalid action` | +| send-otp | невалідний email | 400 `Invalid email` | +| send-otp | перевищено ip/email ліміт | 429 + `Retry-After` | +| send-otp | юзер існує | 409 `Email already taken` | +| send-otp | успіх | 200 `{ success: true }` | +| verify-otp | немає email/otp | 400 `Email and OTP required` | +| verify-otp | ліміти | 429 `Too many attempts` + `Retry-After` | +| verify-otp | запису немає / код не збігся | 400 `Invalid OTP` | +| verify-otp | протух | 400 `OTP expired` | +| verify-otp | ≥3 спроби | 403 `Too many attempts` | +| verify-otp | успіх | 200 `{ verified: true }` | + +## Pre-verified: in-memory Map на `globalThis` + +`src/lib/auth/pre-verified.ts`: + +```ts +const globalStore = globalThis as unknown as { __preVerifiedEmails?: Map } + +export function markPreVerified(email: string): void { + store.set(email.toLowerCase(), Date.now() + 10 * 60 * 1000) +} + +export function consumePreVerified(email: string): boolean { + const expiry = store.get(email.toLowerCase()) + if (!expiry || expiry < Date.now()) { store.delete(key); return false } + store.delete(key) + return true +} +``` + +- TTL **10 хв** — юзер має завершити sign-up за 10 хв після коду. +- **Single-use**: `consumePreVerified` видаляє запис і при успіху, і при протуханні. +- На `globalThis`, щоб пережити HMR-перезавантаження в dev. + +:::danger Відома вада: не шариться між лямбдами +Map живе в пам'яті **одного** процесу. На Vercel `verify-otp` і подальший `sign-up` можуть приземлитися в різні serverless-інстанси — тоді `consumePreVerified` поверне `false` і реєстрацію буде відхилено, попри валідний код. Це прийнятий компроміс (трафік малий, обидва запити йдуть підряд і зазвичай гріють один інстанс); правильний фікс — тримати позначку в БД (наприклад, у `verifications`). Якщо користувачі скаржаться «код прийнято, а реєстрація падає» — це воно. +::: + +## Гейт створення: `databaseHooks.user.create.before` + +У `src/lib/auth/options.ts` — останній рубіж, через який проходить **будь-яке** створення юзера Better Auth'ом: + +```ts +before: async (user, ctx) => { + if (user.emailVerified) return // 1. вже верифікований (Google OAuth) + const inviteToken = /* 2. чотири джерела */ + ctx?.headers?.get('x-admin-invite-token') ?? + ctx?.query?.adminInviteToken ?? + ctx?.body?.adminInviteToken ?? + ctx?.body?.additionalData?.adminInviteToken + if (валідний inviteToken існує в admin-invitations) return // інвайт замість OTP + if (!consumePreVerified(user.email)) return false // 3. відхилити реєстрацію + return { data: { ...user, emailVerified: true } } // 4. пропустити і позначити +} +``` + +1. `emailVerified` вже `true` (Google дає верифікований email) — пропуск. +2. **Admin-invite** (`/admin/signup?token=…`) не проходить публічний OTP-потік — валідний токен з колекції `admin-invitations` авторизує створення. Чотири джерела токена дзеркалять власний invite-middleware payload-auth (header, query, body, `body.additionalData`). +3. Інакше — `consumePreVerified(email)`; фейл → `return false` — Better Auth **відхиляє** створення користувача. +4. Успіх → юзер створюється одразу з `emailVerified: true`. + +## Конфіг плагіна `emailOTP` + +```ts +emailOTP({ + otpLength: 6, + expiresIn: 300, // 5 хв + allowedAttempts: 3, + sendVerificationOnSignUp: false, // наш потік шле код ДО sign-up + async sendVerificationOTP({ email, otp, type }) { + if (type !== 'email-verification' && type !== 'forget-password') return + if (!process.env.RESEND_API_KEY) return // no-op без ключа + // Resend: subject «Код підтвердження: …» або «Код для скидання пароля: …» + }, +}) +``` + +## Forgot password + +Скидання пароля йде **стандартними** ендпоінтами плагіна emailOTP (`/forget-password/email-otp` → лист «Код для скидання пароля», далі `/email-otp/reset-password`), без кастомного роуту — тут юзер уже існує, тож вбудований потік підходить. Ліміти: 3/60 с на надсилання, 10/60 с на скидання (customRules Better Auth — [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting)). Встановлення **першого** пароля для Google-акаунта — окремий action `setInitialPassword` (див. [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil)). + +## Пов'язане + +- Загальний конфіг Better Auth: [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth) +- Ліміти OTP у зведеній таблиці: [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting) +- Колекція `verifications`: [users та auth-колекції](/admin/docs/technical/model-danykh/users-i-auth) +- Шаблони листів: [Email — Resend](/admin/docs/technical/infrastruktura/email) 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 new file mode 100644 index 0000000..5397379 --- /dev/null +++ b/docs/admin-panel/technical/04-autentyfikatsiya/03-roli-i-dostup.md @@ -0,0 +1,142 @@ +--- +title: Ролі та контроль доступу +description: Баг hasMany-select ролі та його фікс, усі access-функції з застереженнями, свідома неповнота users.access і критичні правила Local API +--- + +## Модель ролей + +`users.role` — **hasMany select** (масив рядків), який будує payload-auth. Конфіг — `src/lib/auth/options.ts`: + +| Параметр | Значення | Ефект | +| --- | --- | --- | +| `roles` | `['learner', 'admin']` | допустимі опції select | +| `defaultRole` | `'learner'` | роль нових користувачів | +| `adminRoles` | `['admin']` | хто входить в адмін-панель | +| `defaultAdminRole` | `'admin'` | роль для інвайтнутих адміністраторів | +| `allowedFields` | `['name']` | що не-адмін може міняти в собі через API | + +Українські лейбли («Адміністратор» / «Учасник») додає `src/plugins/ukrainianAdmin.ts`. Перевірки скрізь через `includes('admin')` — користувач може мати обидві ролі одночасно. + +## Баг: bare-string default → мовчки порожні ролі + +payload-auth будує `role` як hasMany select, але копіює `defaultRole` всередину як **голий рядок** `'learner'`. Payload застосовує цей default, а далі Drizzle-шар запису (`@payloadcms/drizzle` `transform/write/traverseFields.js`) пише рядки в select-hasMany-таблицю **лише коли значення — масив**; не-масив він **мовчки дропає**. Результат: кожен користувач, створений через Better Auth (email-реєстрація, Google, інвайти), отримував **порожній** `role` — без помилок, без логів. + +Фікс — `collectionOverrides` у `betterAuthPluginOptions`: + +```ts +collectionOverrides: ({ collection }) => ({ + ...collection, + fields: collection.fields.map((field) => + field.type === 'select' && field.name === 'role' + ? { ...field, defaultValue: [DEFAULT_USER_ROLE] } // масив, не рядок + : field, + ), +}), +``` + +Плюс міграція `src/migrations/20260729_100000_backfill_user_roles.ts`, що бекфілить `['learner']` уже постраждалим користувачам. Урок загальніший: **дефолт hasMany-поля завжди мусить бути масивом** — інакше Drizzle тихо його з'їсть. + +## Access-функції — `src/access/` + +Еталонна `admin` (`src/access/admin.ts`): + +```ts +export const admin: IsAdmin = ({ req: { user } }) => { + if (!user || !('role' in user)) return false + return Boolean(user.role?.includes('admin')) +} +``` + +`'role' in user` — не параноя: `req.user` може бути й користувачем іншої auth-колекції (наприклад, ключем `payload-mcp-api-keys`), у якого поля `role` немає взагалі. + +| Export | Логіка | Застереження | +| --- | --- | --- | +| `admin` | `user.role?.includes('admin')` (див. вище) | єдина «адмінська» перевірка в проєкті | +| `anyone` | `() => true` | повністю публічно, включно з анонімами | +| `authenticated` | `Boolean(user)` | role-agnostic — будь-який залогінений | +| `authenticatedOrPublished` | залогінений → `true`; анонім → `{ _status: { equals: 'published' } }` | див. попередження нижче | + +:::warning `authenticatedOrPublished`: learner читає чернетки +Для **залогіненого** користувача функція повертає `true` без фільтра по `_status` — тобто звичайний learner може читати **чернетки** pages/posts/courses через REST/GraphQL API (не через сайт: фронтові фетчі йдуть з `overrideAccess: false` + `draft: false`). Це свідомий трейд-оф простоти; якщо чернетки колись міститимуть чутливе — фільтр доведеться повернути й авторизованим. +::: + +Інлайнові хелпери в колекціях (admin-байпас + ownership-фільтр): + +```ts +const adminOrOwn: Access = ({ req: { user } }) => { + if (!user) return false + if ('role' in user && user.role?.includes('admin')) return true + return { user: { equals: user.id } } +} +``` + +- `adminOrOwn` — `Enrollments.ts`, `Likes.ts`, `QuizAttempts.ts` (адмін бачить усе, інші — лише свої записи, анонім — нічого); +- `adminOrAuthor` — `Comments.ts`, те саме з фільтром `{ author: { equals: user.id } }`. + +Обидва повертають **query-фільтр**, а не boolean — Payload вшиває його в запит, тож «чужі» документи для власника просто не існують (у списках, лічильниках і по прямому id). + +## Чому в `users` немає власних read/update + +`src/collections/Users/index.ts` задає **лише** `admin: admin`, `create: admin`, `delete: admin`. `read`/`update` свідомо не перевизначені — діють дефолти payload-auth: admin-or-self, причому self-update для не-адміна обрізаний до `allowedFields: ['name']` (з опцій плагіна). + +:::danger Не «доповнюйте» users.access +Наївний власний `update: adminOrSelf` без відтворення `allowedFields` знову відкрив би **ескалацію ролі**: `PATCH /api/users/:id` з `{ "role": ["admin"] }` від самого користувача. Всі інші зміни профілю (аватар, about, соцлінки) йдуть через server actions з валідацією — [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil). +::: + +## Критичні правила проєкту (Local API і хуки) + +Три правила з CLAUDE.md, які напряму стосуються доступу: + +1. **`overrideAccess: false` разом з `user`.** Local API за замовчуванням **обходить** access control; передати `user` без `overrideAccess: false` означає «виконати від імені юзера, але з правами root»: + + ```ts + await payload.find({ collection: 'posts', user: someUser, overrideAccess: false }) + ``` + +2. **Передавайте `req` у вкладені операції хуків** — інакше вкладений запис вилітає з транзакції батьківської операції й ламає атомарність: + + ```ts + async ({ doc, req }) => { + await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req }) + } + ``` + +3. **Context-прапорці проти циклів** — `update` усередині `afterChange` тієї ж колекції без guard'а зациклюється: + + ```ts + async ({ doc, req, context }) => { + if (context.skipHooks) return + await req.payload.update({ ..., context: { skipHooks: true }, req }) + } + ``` + +## Хто що може: зведена таблиця по колекціях + +| Колекція | create | read | update | delete | +| --- | --- | --- | --- | --- | +| `pages`, `posts`, `courses` | admin | authenticatedOrPublished | admin | admin | +| `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 | +| `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` для не-адмінів. + +### Дві додаткові поверхні + +- **MCP** (`@payloadcms/plugin-mcp`) — окремий шар дозволів поверх access: повний CRUD на posts/pages/categories/courses/course-categories/comments; media — find + update без create/delete; likes — find/create/delete без update. Автентифікація — ключі колекції `payload-mcp-api-keys`. +- **Глобал `home-calendar`** — задано лише `read: true`; `update` випадає в Payload-дефолт «будь-який автентифікований користувач». Відома шпарина конфігурації: технічно learner може оновити календар через API (див. [Глобали](/admin/docs/technical/model-danykh/hlobaly)). + +### Вхід в адмін-панель + +Роль впливає і на `/admin`: `adminRoles: ['admin']` у конфігу payload-auth — learner із валідною сесією в панель не потрапляє. Кнопка запрошення адміністраторів (`InviteUserButton`) генерує токен у `admin-invitations`, який на sign-up обходить OTP-гейт (див. [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp)). + +## Пов'язане + +- Схеми колекцій: [Огляд моделі даних](/admin/docs/technical/model-danykh/ohliad) +- Інтеграція payload-auth: [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth) +- Server actions як єдиний користувацький шлях запису: [Завершення курсу](/admin/docs/technical/biznes-logika/zavershennia-kursu), [Коментарі та лайки: server actions](/admin/docs/technical/biznes-logika/komentari-laiky) diff --git a/docs/admin-panel/technical/04-autentyfikatsiya/04-dev-login-i-sid.md b/docs/admin-panel/technical/04-autentyfikatsiya/04-dev-login-i-sid.md new file mode 100644 index 0000000..80f6fe2 --- /dev/null +++ b/docs/admin-panel/technical/04-autentyfikatsiya/04-dev-login-i-sid.md @@ -0,0 +1,138 @@ +--- +title: Dev-login та сідінг +description: GET /api/dev-login з жорстким блоком на Vercel, лінивий авто-сід dev-адміна з канонічним набором даних і pnpm seed:dev-admin як ресет +--- + +## Навіщо + +Кожна dev-сесія працює на власній Neon-гілці БД (гілка на git-гілку — [База даних Neon](/admin/docs/technical/infrastruktura/baza-danykh)), і щоразу створювати адміна руками — марна праця. Тому будь-яка dev-база має гарантованого адміна з наповненим профілем і активністю, а вхід — одна навігація: `http://localhost:3000/api/dev-login`. + +### Чому не звичайна форма логіну + +Форма йде через клієнтський виклик `/api/auth/sign-in/email`, і Better Auth перевіряє origin проти `trustedOrigins`, які зав'язані на `localhost:3000` — на будь-якому іншому порті (другий worktree, `PORT=3001`) форма і Google OAuth ламаються. `/api/dev-login` логінить **server-side** (`payload.betterAuth.api.signInEmail`), тож origin-перевірки не стосується і працює на будь-якому порті. Деталі trustedOrigins — [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth); повний довідник — `docs/dev-admin-login.md`. + +## `devLoginEnabled()`: подвійний запобіжник + +`src/app/api/dev-login/route.ts`: + +```ts +function devLoginEnabled(): boolean { + if (process.env.VERCEL) return false + return process.env.NODE_ENV !== 'production' || process.env.ALLOW_DEV_LOGIN === '1' +} +``` + +- **Жорсткий блок на Vercel**: будь-який деплой (production, превʼю) → роут відповідає 404, і жодна змінна це не переможе. Ендпоінт, що видає адмін-сесію без пароля, не має права існувати в хмарі. +- Поза Vercel: дозволено в не-production, а для локального production-білда (E2E через `next start`) — опт-ін `ALLOW_DEV_LOGIN=1`. + +| Оточення | `VERCEL` | `NODE_ENV` | `ALLOW_DEV_LOGIN` | Роут | +| --- | --- | --- | --- | --- | +| Vercel production / превʼю | set | будь-який | будь-який | **404** | +| локальний `pnpm dev` | — | development | — | працює | +| локальний `next start` (E2E) | — | production | не задано | 404 | +| локальний `next start` (E2E) | — | production | `1` | працює | + +## Креденшли — `src/lib/auth/dev-credentials.ts` + +```ts +export const DEV_ADMIN = { + email: 'dev-admin@example.com', + password: 'dev-admin-password', + name: 'Дев Адмін', +} as const +``` + +Один модуль ділять сід-скрипт і роут — вони фізично не можуть розійтися. + +## Потік запиту + +1. `signInEmail` через `payload.betterAuth.api` з `asResponse: true` (потрібна повна `Response` — з неї переносяться куки): + + ```ts + payload.betterAuth.api.signInEmail({ + body: { email: DEV_ADMIN.email, password: DEV_ADMIN.password }, + asResponse: true, + }) + ``` + +2. **Фейл → лінивий сід**: `seedDevAdmin(payload)` і повторний `signIn`. Конкурентні спроби (дві вкладки на свіжій базі) колапсуються module-level промісом: + + ```ts + pendingSeed ??= seedDevAdmin(payload).finally(() => { pendingSeed = null }) + await pendingSeed + ``` + +3. Фейл сіду або повторного входу → 500 з підказкою про `pnpm seed:dev-admin`. +4. Успіх → **303** редірект + перенесення всіх `Set-Cookie` з відповіді Better Auth: + + ```ts + const response = NextResponse.redirect(new URL(redirectTo, url.origin), 303) + for (const cookie of authResponse.headers.getSetCookie()) { + response.headers.append('set-cookie', cookie) + } + ``` + +5. **`?redirect=` гард**: приймається лише значення, що `startsWith('/')`, інакше — `/`. Приклад: `/api/dev-login?redirect=/admin`. + +CLI-варіант (сесія для curl-тестів): + +```bash +curl -si -c cookies.txt http://localhost:3000/api/dev-login +curl -s -b cookies.txt http://localhost:3000/api/users/me +``` + +## Сід-канон — `src/lib/auth/seed-dev-admin.ts` + +`seedDevAdmin` ідемпотентно приводить базу до канонічного стану: + +- **Адмін-користувач** (`ensureAdminUser`): якщо акаунт з email dev-адміна існує, але пароль не працює — акаунт **перестворюється** (спершу зачистка enrollments/attempts/comments/likes/sessions/accounts, потім delete) — фіксовані креденшли і є сенсом акаунта. Створення йде через `markPreVerified` + звичайний `signUpEmail` (тобто через той самий OTP-гейт, що й реальні юзери — [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp)), далі `payload.update`: `role: ['admin']`, `emailVerified: true`, заповнені `about` і три `socialLinks` (telegram, youtube, website). +- **Курси** (`ensureCourses`): беруться published-курси з кроками; якщо їх менше двох — досоздаються демо-курси («Демо-курс: перша допомога» — з тестом на 2 питання, passingScore 70; «Демо-курс: тактична підготовка» — без тесту), обидва published, slug з унікальним суфіксом. +- **Активність** (щоразу з нуля — `resetAdminActivity` видаляє попередню): + - **Завершений курс**: enrollment на перший курс — усі кроки в `completedSteps`, `status: 'completed'`, `enrolledAt` = −14 днів, `completedAt` = −7 днів; якщо курс з тестом — `quizPassed: true`, `bestQuizScore: 100`, `quizAttempts: 1` + запис у `quiz-attempts` на 100% (форма `answers` дзеркалить те, що пише `submitQuizAttempt`). Отже сертифікат одразу доступний. + - **Курс у процесі**: другий курс, `doneCount = min(max(1, floor(steps/2)), steps.length - 1)` — щонайменше один крок пройдено, але ніколи всі; `status: 'in_progress'`, `enrolledAt` = −3 дні. + - **Коментарі**: 2 на курси + 1 на перший published-пост (якщо є). + +Повертає `SeedDevAdminSummary`: + +```ts +export interface SeedDevAdminSummary { + userId: number + completedCourse: string + completedCourseHasQuiz: boolean + inProgressCourse: string + comments: number +} +``` + +### Канон одним поглядом + +| Об'єкт | Стан після сіду | +| --- | --- | +| Користувач | `Дев Адмін`, `role: ['admin']`, `emailVerified`, about + 3 соцлінки | +| Enrollment №1 | completed: всі кроки, −14д/−7д; з тестом → `quizPassed`, best 100, спроба на 100% | +| Enrollment №2 | in_progress: `floor(steps/2)` кроків, clamp [1, len−1], −3д | +| Коментарі | 2 на курси + 1 на пост (якщо є published-пост) | +| Курси | існуючі published або 2 демо-курси (перший — з тестом) | + +## `pnpm seed:dev-admin` = ресет + +Сідінг **автоматичний** — окремо запускати нічого не треба: свіжа гілка чи стерта база сама засіється при першому `/api/dev-login`. Скрипт `pnpm seed:dev-admin` існує для іншого: **повернути** зіпсовані під час тестування дані до канону (він викликає той самий `seedDevAdmin`, який зачищає активність адміна і накатує її заново). + +:::tip Типові сценарії +- Зайти в адмінку: відкрити `http://localhost:3000/api/dev-login?redirect=/admin`. +- Тестувати сертифікат: dev-адмін уже має завершений курс — одразу `/courses//certificate`. +- Розламали enrollment у тестах: `pnpm seed:dev-admin` і все як було. +- Дев-сервер на іншому порті: `http://localhost:3001/api/dev-login` — працює, бо логін server-side. +::: + +## Взаємодія з рештою системи + +- Сід створює юзера через **штатний** `signUpEmail` + `markPreVerified`, а не прямий `payload.create` — тому в `accounts` з'являється коректний credential-запис, і пароль реально працює у формі логіну теж. +- Демо-курс з тестом проходить ті самі валідації колекції `courses` (minRows, isCorrect), що й курси з адмінки — сід не може створити «неможливий» курс. +- Completed-enrollment сіда — легальна ціль для всіх фіч: сертифікат (PDF + QR-верифікація), XP (derived покаже 3×30 + 100), історія спроб тесту. + +## Пов'язане + +- Better Auth і trustedOrigins (чому форма логіну не працює на порті ≠ 3000): [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth) +- Neon-гілки та локальне середовище: [Локальне середовище](/admin/docs/technical/rozrobka/lokalne-seredovyshche) +- Довідник у репо: `docs/dev-admin-login.md` diff --git a/docs/admin-panel/technical/04-autentyfikatsiya/05-avatary-i-profil.md b/docs/admin-panel/technical/04-autentyfikatsiya/05-avatary-i-profil.md new file mode 100644 index 0000000..7c85322 --- /dev/null +++ b/docs/admin-panel/technical/04-autentyfikatsiya/05-avatary-i-profil.md @@ -0,0 +1,136 @@ +--- +title: Аватари та налаштування профілю +description: users.image як URL-снапшот, асиметрія updateAvatar/removeAvatar навколо примх better-auth, реальні ліміти розміру і решта actions профілю +--- + +## `users.image` — рядок-снапшот, не relationship + +Поле `image` на `users` створює better-auth, і це **plain text колонка з URL**, а не upload-relationship на `media`. Наслідок: значення — знімок URL на момент збереження, який **не** слідує за змінами способу роздачі медіа. + +Історія, чому це важливо: після переходу на роздачу з Blob CDN (вимкнення Payload access control на media) старі аватари зі снапшотами виду `/api/media/file/...` почали б віддавати 404 — їх довелося переписати міграцією `src/migrations/20260724_200000_backfill_blob_urls.ts` прямо в БД. Кожна майбутня зміна схеми URL медіа означає такий самий бекфіл по `users.image`. + +Всі actions нижче — `src/actions/accountSettings.ts`, кожен починається з `getSession()` → `AUTH_REQUIRED`. + +## `updateAvatar(formData)` + +1. Валідації файлу: `file instanceof File` (→ `INVALID_FILE`); MIME з мапи `image/jpeg | png | webp | gif` (→ `INVALID_TYPE`); розмір ≤ `AVATAR_MAX_BYTES` = **5 MiB** (→ `TOO_LARGE`). +2. Upload у `media` — аватар стає звичайним медіа-документом з усіма imageSizes: + + ```ts + const media = await payload.create({ + collection: 'media', + data: { alt: session.user.name || session.user.email }, + file: { + data: buffer, + mimetype: file.type, + name: `avatar-${session.user.id}-${Date.now()}.${ext}`, + size: buffer.length, + }, + }) + ``` + +3. Снапшот URL: `getMediaUrl(media.sizes?.square?.url ?? media.url)` — береться квадратний варіант 500×500, fallback на оригінал; порожній результат → `UPLOAD_FAILED`. +4. Запис — через **`payload.betterAuth.api.updateUser({ body: { image: url }, headers })`**, а не `payload.update`: updateUser перевипускає session-куку, тож хедер одразу показує новий аватар (без 5-хвилинного stale cookie cache — [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth)). + +Старі media-документи попередніх аватарів не видаляються — вони лишаються в медіатеці. + +### Ліміти розміру: чотири шари + +| Шар | Значення | Що дає | +| --- | --- | --- | +| `AVATAR_MAX_BYTES` | 5 MiB | явна валідація в action | +| MIME-мапа | jpeg/png/webp/gif | інші типи → `INVALID_TYPE` | +| `serverActions.bodySizeLimit` | `5mb` (next.config) | дефолтний 1 MB **тихо різав** фото ще до нашого коду | +| Vercel request body | ~4.5 MB | **реальний прод-кап** — менший за наш номінальний ліміт | + +Тобто в production фото між 4.5 і 5 MB відхилить платформа, а не наша валідація — з менш зрозумілою помилкою. + +### Куди дивиться `getMediaUrl` + +`getMediaUrl` (`src/utilities/getMediaUrl.ts`) пропускає абсолютні URL як є (з cache-tag) і доклеює `getClientSideURL` до відносних. З Blob-сховищем (`disablePayloadAccessControl: true` на media) `media.sizes.square.url` — вже абсолютний CDN-URL, тож у `users.image` лягає прямий лінк на Blob. Деталі роздачі — [Медіа та Vercel Blob](/admin/docs/technical/infrastruktura/media-blob). + +## `removeAvatar()` — чому НЕ через Better Auth + +```ts +// better-auth's updateUser drops null fields, so clear the image via Payload; +// the client refreshes the session cookie cache afterwards. +await payload.update({ collection: 'users', id: Number(session.user.id), data: { image: null } }) +``` + +`betterAuth.api.updateUser` **мовчки відкидає null-поля** — «видалити аватар» через нього неможливо. Тому запис іде повз Better Auth, через `payload.update`. Ціна обхідного шляху: session-кука не перевипускається, отже **клієнт після успіху сам освіжає cookie cache** — інакше хедер ще до 5 хв показував би видалений аватар. + +## Google-аватари + +Вхід через Google кладе в `image` URL виду `lh3.googleusercontent.com/...` (Better Auth копіює його з профілю провайдера). Ці URL не в `remotePatterns` next/image (там лише `*.public.blob.vercel-storage.com` і self-origins), тож пропустити їх через `next/image` не можна — на профілі вони рендеряться сирим ``. `no-referrer` важливий: із заголовком referrer стороннього сайту Google інколи відповідає 403 на аватар. + +Дві категорії значень `users.image` співіснують у базі: + +| Джерело | Вигляд URL | Через що рендериться | +| --- | --- | --- | +| `updateAvatar` | `https://.public.blob.vercel-storage.com/...-500x500.` | `next/image` (у remotePatterns) | +| Google OAuth | `https://lh3.googleusercontent.com/...` | сирий `` | + +## Решта actions профілю + +| Action | Валідації | Запис | +| --- | --- | --- | +| `updateAbout(about)` | string, trim, ≤ 500 (`TOO_LONG`); порожній рядок → `null` | `payload.update` `about` | +| `updateHideProfileComments(hide)` | strict boolean (`INVALID_VALUE`) | `payload.update`; вмикає приховання блоку коментарів на публічному профілі (див. [Коментарі та лайки: server actions](/admin/docs/technical/biznes-logika/komentari-laiky)) | +| `updateSocialLinks(links)` | масив ≤ 8; platform з білого списку; url непорожній, ≤ 300 символів, парситься `new URL`, протокол лише `http:`/`https:` — інакше `INVALID_LINKS`/`INVALID_URL` (вся операція атомарно відхиляється) | `payload.update` `socialLinks` | +| `setInitialPassword(newPassword)` | довжина **8–128** (`INVALID_PASSWORD`) | див. нижче | + +Білий список платформ дзеркалить select-опції поля `socialLinks` колекції `users`: + +```ts +const SOCIAL_PLATFORMS = ['instagram', 'facebook', 'telegram', 'youtube', + 'tiktok', 'linkedin', 'x', 'website'] +const MAX_SOCIAL_LINKS = 8 +const MAX_URL_LENGTH = 300 +``` + +Всі ці записи йдуть через `payload.update`, тобто **повз** Better Auth — а отже підпадають під stale cookie cache: дані в сесійній куці можуть відставати до 5 хв. Для about/соцлінок це неважливо (їх читають зі свіжого документа users), для аватара — важливо, тому там окремі шляхи (див. вище). + +### `setInitialPassword`: лише перший пароль + +Для Google-акаунтів без пароля. Спершу перевірка, що credential-акаунта ще немає: + +```ts +const credential = await payload.find({ + collection: 'accounts', + where: { and: [{ user: { equals: userId } }, { providerId: { equals: 'credential' } }] }, + limit: 1, depth: 0, +}) +if (credential.totalDocs > 0) return { success: false, error: 'HAS_PASSWORD' } + +await payload.betterAuth.api.setPassword({ body: { newPassword }, headers: await headers() }) +``` + +`setPassword` призначений тільки для акаунтів без credential-провайдера; **зміна** існуючого пароля мусить іти через `changePassword` з поточним паролем — інакше викрадена сесія дозволяла б перехопити акаунт, просто переписавши пароль. `HAS_PASSWORD` — відмова, яку UI показує як «у вас уже є пароль». + +## Зведення кодів помилок + +| Код | Action | Причина | +| --- | --- | --- | +| `AUTH_REQUIRED` | всі | немає сесії | +| `INVALID_FILE` | updateAvatar | у formData немає `File` | +| `INVALID_TYPE` | updateAvatar | MIME поза jpeg/png/webp/gif | +| `TOO_LARGE` | updateAvatar | > 5 MiB | +| `UPLOAD_FAILED` | updateAvatar | media створено, але URL не зібрався | +| `INVALID_ABOUT` / `TOO_LONG` | updateAbout | не рядок / > 500 символів | +| `INVALID_VALUE` | updateHideProfileComments | не boolean | +| `INVALID_LINKS` | updateSocialLinks | > 8 елементів, невідома платформа, порожній/задовгий url | +| `INVALID_URL` | updateSocialLinks | не парситься `new URL` або протокол ≠ http(s) | +| `INVALID_PASSWORD` | setInitialPassword | довжина поза 8–128 | +| `HAS_PASSWORD` | setInitialPassword | credential-акаунт уже існує | + +Усі коди — стабільні рядки; переклад у людські повідомлення живе на клієнті (`src/utilities/i18n.ts`). + +:::info Чому actions, а не REST +`users.update` для не-адміна обрізаний до `allowedFields: ['name']` (див. [Ролі та контроль доступу](/admin/docs/technical/autentyfikatsiya/roli-i-dostup)), тож поля профілю через REST не редагуються в принципі. Server actions — єдиний шлях, і кожен несе власну валідацію замість довіри до клієнта. +::: + +## Пов'язане + +- Cookie cache і його протухання: [Better Auth: інтеграція](/admin/docs/technical/autentyfikatsiya/better-auth) +- Медіа, imageSizes і Blob CDN: [posts, pages, media](/admin/docs/technical/model-danykh/posts-pages-media), [Медіа та Vercel Blob](/admin/docs/technical/infrastruktura/media-blob) +- Схема `users`: [users та auth-колекції](/admin/docs/technical/model-danykh/users-i-auth) diff --git a/docs/admin-panel/technical/04-autentyfikatsiya/_category.json b/docs/admin-panel/technical/04-autentyfikatsiya/_category.json new file mode 100644 index 0000000..c7662a2 --- /dev/null +++ b/docs/admin-panel/technical/04-autentyfikatsiya/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Автентифікація та доступ", + "description": "Better Auth, OTP-реєстрація, ролі й контроль доступу." +} diff --git a/docs/admin-panel/technical/05-infrastruktura/01-baza-danykh.md b/docs/admin-panel/technical/05-infrastruktura/01-baza-danykh.md new file mode 100644 index 0000000..d8cca3f --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/01-baza-danykh.md @@ -0,0 +1,148 @@ +--- +title: База даних Neon +description: Топологія Neon-бранчів, бранч на git-гілку, команди neonctl і чому dev-сесіям потрібен саме unpooled connection string. +--- + +Платформа працює на PostgreSQL у [Neon](https://neon.tech) — serverless-Postgres із гілкуванням бази даних (branch = миттєва copy-on-write копія схеми та даних). Гілкування — основа всього робочого процесу: кожна dev-сесія, кожен CI-прогін і превʼю-деплой отримують власну ізольовану копію бази. + +## Два проєкти, один напрям довіри + +| Проєкт | ID | Що містить | +| --- | --- | --- | +| **Dev** | `ancient-cell-80589995` | Все ефемерне: сесійні бранчі, CI-бранчі, превʼю-дані | +| **Prod** | `sweet-night-33633526` (назва `production-1`) | Рівно одну гілку `production` з бойовими даними | + +Ніщо в dev-проєкті ніколи не вказує на prod-проєкт, і жоден автоматичний процес не пише в prod, окрім самого production-деплою (див. [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi)). Раніше Neon↔Vercel інтеграція порушувала цей інваріант, створюючи `preview/*` бранчі у prod-проєкті на кожен push у PR — її відʼєднано 2026-07-22. + +## Топологія бранчів dev-проєкту + +``` +Dev project (ancient-cell-80589995) +├── dev спільний парент: сід-дані (dev-admin, демо-курси), +│ схему тримає актуальною drizzle push із dev-серверів +├── ci-base migration-managed базлайн із чистим payload_migrations; +│ парент усіх ci/* бранчів +├── preview довгоживучий бранч для Vercel preview-деплоїв +├── feat/* один на dev-сесію, названий ЯК git-гілка, форк від dev +└── ci/* ефемерні, один на CI-прогін (ci/), форк від ci-base +``` + +Чому `ci-base` існує окремо від `dev`: `dev` керується drizzle push, тому його таблиця `payload_migrations` містить запис із `batch=-1`, через який `payload migrate` зависає на інтерактивному промпті. `ci-base` побудований *з міграцій* — його bookkeeping чистий, і CI може неінтерактивно репетирувати міграції PR-а. Деталі: [Push vs міграції](/admin/docs/technical/infrastruktura/mihratsii). + +## Бранч на git-гілку + +Кожна dev-сесія створює власний Neon-бранч, названий **точно як git-гілка**. Це не конвенція заради краси — від імені залежить автоматичне прибирання: `.github/workflows/neon-cleanup.yml` при закритті PR видаляє Neon-бранч за іменем `github.head_ref` (через `neondatabase/delete-branch-action@v3`), а додатковий крок замітає «сирітські» бранчі з тим самим стемом, але іншим 6-символьним суфіксом (буває, коли сесія створила Neon-бранч під одним random-суфіксом, а git-гілку запушила під іншим). Захищені імена `dev`, `ci-base`, `preview`, `main`, `production` ніколи не видаляються. + +Додатково обидва CI-воркфлоу (`ci.yml`, `e2e.yml`) на кожному прогоні замітають протухлі бранчі: `ci/*` старші за 2 години — завжди сміття; інші незахищені бранчі видаляються лише якщо їм понад 24 години і жоден відкритий PR їх не використовує. + +## Команди сесії + +```bash +# 1. Створити бранч, названий як git-гілка, від парента dev +pnpm exec neonctl branches create --name feat/my-feature --parent dev \ + --project-id ancient-cell-80589995 --output json + +# 2. Отримати ДИРЕКТНИЙ (unpooled) connection string за branch.id з відповіді +pnpm exec neonctl connection-string \ + --project-id ancient-cell-80589995 + +# 3. Прописати в .env (НЕ в .env.local!) +# DATABASE_URL=postgresql://... + +# Наприкінці сесії (або довіритись neon-cleanup.yml при закритті PR) +pnpm exec neonctl branches delete --project-id ancient-cell-80589995 +``` + +:::tip +`neonctl` автентифікований локально. Якщо CLI пропонує інтерактивний вибір організації — додайте `--org-id org-misty-credit-72207517`. +::: + +## Анатомія connection string + +Директний і pooled рядки відрізняються лише хостом: + +``` +# директний (для dev-сесій) +postgresql://user:pass@ep-xxx-yyy.eu-central-1.aws.neon.tech/neondb?sslmode=require + +# pooled (для проду/превʼю; НЕ для dev) +postgresql://user:pass@ep-xxx-yyy-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=require +``` + +`src/payload.config.ts` перед передачею в адаптер нормалізує SSL-режим: + +```ts +const normalizeDatabaseURL = (url: string): string => + url.replace(/([?&]sslmode=)(prefer|require|verify-ca)\b/i, '$1verify-full') +``` + +`sslmode=require` (який видає neonctl) шифрує, але не верифікує сертифікат сервера; `pg` сьогодні фактично поводиться як `verify-full`, однак попереджає, що pg v9 це змінить — тому режим робиться явним. Neon віддає публічно-довірений сертифікат, тож `verify-full` працює без додаткових CA-файлів. Також конфіг логує хост `DATABASE_URL` при завантаженні (`[payload] DATABASE_URL host: ...`) — перший рядок, на який варто дивитися при «не та база». + +Оскільки сесійні бранчі форкаються від засідженого `dev`, кожен із них одразу містить акаунт `dev-admin@example.com` з даними — жодного пер-бранчевого сідингу (див. [Dev-login та сідінг](/admin/docs/technical/autentyfikatsiya/dev-login-i-sid)). + +## ⚠️ Директний (unpooled) connection string для dev + +Для dev-сесій використовуйте **лише директний** connection string: без прапорця `--pooled` у neonctl і без суфікса `-pooler` у хості URL. + +### Механізм поломки + +Neon надає pooled-endpoint через **pgbouncer у transaction-режимі**: одне фізичне зʼєднання з Postgres по черзі обслуговує транзакції різних клієнтів. Drizzle push (який запускається на старті dev-сервера і при HMR, бо в конфізі `push: !process.env.CI`) під час інтроспекції виконує `SET search_path` — **session-level** команду, яку pgbouncer не відкочує між транзакціями. Далі: + +1. Push «отруює» зʼєднання зміненим `search_path` і повертає його в пул. +2. Інший клієнт (Better Auth, фронтенд-запит) отримує це саме зʼєднання. +3. Некваліфіковані імена таблиць перестають резолвитись → **рандомні** помилки `42P01 relation "..." does not exist` на таблицях, які точно існують. + +Симптоми плаваючі, бо залежать від того, кому дістанеться отруєне зʼєднання: спостерігалося (2026-07-24) як падіння sign-in при робочому фронтенді, потім випадкові 500-ки на сторінках. + +:::danger Діагностика +Якщо dev-сервер кидає `relation "..." does not exist` на таблицях, що існують, — перевірте `DATABASE_URL` на `-pooler` у хості, замініть на директний рядок і перезапустіть сервер. +::: + +Продакшн і превʼю **лишаються pooled** — вони ніколи не запускають drizzle push, тож механізм не спрацьовує, а pooling потрібен serverless-функціям. Один локальний dev-сервер чудово живе без пулера. + +## ⚠️ .env.local ніколи не містить DATABASE_URL + +`vitest.setup.ts` завантажує `.env.local` з `override: true`: + +```ts +config({ path: '.env.local', override: true }) +config({ path: '.env' }) +``` + +Значення `DATABASE_URL` у `.env.local` мовчки переб'є і `.env`, і навіть явно передані змінні оточення — dev-сервер та Vitest непомітно підключаться до чужого бранча. Це вже призводило до багатогодинного дебагу; правило залізне: `DATABASE_URL` живе **тільки в `.env`**. Деталі про сетап тестів: [Тестування](/admin/docs/technical/rozrobka/testuvannia). + +## Життєвий цикл бранчів + +| Бранч | Створюється | Живе | Видаляється | +| --- | --- | --- | --- | +| `feat/*` (сесійний) | вручну на старті сесії | одну сесію/PR | `neon-cleanup.yml` при закритті PR, або вручну | +| `ci/` | `neondatabase/create-branch-action` у CI | один прогін | крок `always()` того ж воркфлоу; страховка — sweep (> 2 год) | +| `preview` | вручну, форк від `dev` | довго | не видаляється; при протуханні схеми — перефоркується від `dev` вручну | +| `dev` | — | завжди | ніколи (захищений у всіх sweep-ах) | +| `ci-base` | — | завжди | ніколи; при забрудненні перебудовується з міграцій | + +Якщо `preview` відстав від схеми або дані замусорені: видалити бранч, форкнути від `dev` заново і оновити Preview-scoped `DATABASE_URL` у Vercel, якщо endpoint змінився. + +## Гострі кути + +- **Зупиняйте dev-сервер перед перемиканням git-гілок.** Якщо схема у щойно вичекнутому коді старіша за схему підключеного бранча, `push` інтерактивно запропонує **видалити новіші колонки разом із даними**. +- Один dev-сервер на worktree; кілька серверів одночасно — лише з різних worktree і кожен зі своїм Neon-бранчем у `.env` (див. [Локальне середовище](/admin/docs/technical/rozrobka/lokalne-seredovyshche)). +- Якщо сервер «не відповідає» — перш за все перевірте, на який Neon-бранч дивиться `DATABASE_URL` у `.env`. +- Rollback на проді: Neon point-in-time restore прод-проєкту + `down()` кожної міграції. + +## Міні-FAQ + +**Чи можна працювати двом сесіям на одному бранчі?** Ні. Push двох серверів із різними версіями коду проти однієї схеми — гарантований конфлікт. Бранч на сесію коштує секунди. + +**Чи можна форкнути сесійний бранч від прод-даних?** Ні — сесійні бранчі живуть у dev-проєкті і форкаються від `dev`. Прод-проєкт для відлагодження недоторканний; для відтворення прод-багів використовуйте сід-дані або відтворіть стан вручну. + +**Бранч видалили, а `.env` ще вказує на нього?** Сервер почне падати на підключенні. Створіть новий бранч і оновіть `DATABASE_URL` — дані сесії були ефемерними за визначенням. + +**Скільки живуть невикористані бранчі?** До першого sweep-а: сесійні без відкритого PR — 24 години, `ci/*` — 2 години. Не тримайте на сесійному бранчі нічого цінного. + +## Повʼязані статті + +- [Push vs міграції](/admin/docs/technical/infrastruktura/mihratsii) — push vs міграції, CI-репетиція +- [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi) — як prod-база отримує міграції +- [Локальне середовище](/admin/docs/technical/rozrobka/lokalne-seredovyshche) — повний сетап сесії +- [Цикл розробки фічі](/admin/docs/technical/rozrobka/tsykl-rozrobky-fichi) — місце Neon-бранча в циклі фічі diff --git a/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md b/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md new file mode 100644 index 0000000..34cac07 --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/02-mihratsii.md @@ -0,0 +1,155 @@ +--- +title: Push vs міграції +description: Drizzle push для локальної ітерації, міграції як схемний контракт, CI-репетиція та production-gated запуск на Vercel. +--- + +Схема бази живе у двох режимах: **push** (авто-синхронізація для локальної ітерації) і **міграції** (контракт, яким схема доїжджає до CI та продакшну). Плутати їх не можна — у кожного своя територія. + +## Push: локальна ітерація + +У `src/payload.config.ts` адаптер налаштовано як `push: !process.env.CI`. На старті dev-сервера (і при HMR) Drizzle порівнює схему коду зі схемою підключеного Neon-бранча і мовчки досинхронізовує різницю. Ви міняєте поле в колекції — таблиця оновлюється сама, без жодного файлу міграції. + +Push працює **тільки** на dev- і сесійних бранчах. У CI (`CI=1`) і на проді push вимкнений — там правлять файли міграцій. + +:::warning +Побічний ефект push — запис `batch=-1` у `payload_migrations` на push-керованих бранчах. Саме через нього **ніколи не запускайте `payload migrate` на dev/сесійних бранчах**: команда зависне на інтерактивному промпті. Міграції там і не потрібні — push уже все синхронізував. +::: + +### Як влаштований bookkeeping + +Payload веде облік застосованих міграцій у таблиці `payload_migrations`: імʼя файлу + номер батча. `payload migrate` порівнює файли в `src/migrations/` із записами таблиці і застосовує відсутні. Drizzle push пише туди спеціальний запис із `batch=-1` («схемою керує push») — натрапивши на нього, `payload migrate` питає підтвердження інтерактивно, що в неінтерактивному контексті виглядає як вічний hang. Це і є фізична причина правила «жодних migrate на push-керованих бранчах». + +## Залізне правило: кожна схемна зміна = міграція в тому ж PR + +Push ніде, крім вашої машини, не запускається. Тому **кожен PR зі зміною схеми зобовʼязаний містити міграцію** в `src/migrations/` (плюс реєстрацію в `src/migrations/index.ts`). Без неї CI впаде на репетиції, а якщо якимось дивом доїде до merge — впаде production-білд. + +### Створення міграції + +```bash +pnpm payload migrate:create my_change_name +``` + +`migrate:create` порівнює код зі станом бази і генерує diff — але **diff забруднений**: він підбирає сторонній шум (косметичні розбіжності push-керованої бази, чужі дрейфи). Згенерований файл — це чернетка, яку **редагують вручну** до рівно вашої зміни: + +1. Видаліть усе, що не стосується вашої фічі. +2. Захистіть DDL від повторного запуску: `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS` — міграція має бути **ідемпотентною** (безпечний rerun поверх бази, де push уже створив обʼєкти). +3. Напишіть чесний `down()`. + +Скелет міграції: + +```ts +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "about" varchar(500); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "users" DROP COLUMN IF EXISTS "about"; + `) +} +``` + +Приклади канону в репо — `src/migrations/20260724_140000_xp_events.ts` (нова таблиця + FK), `20260724_190000_rate_limit.ts`, `20260729_100000_backfill_user_roles.ts` (data-міграція). Не забудьте додати міграцію в `src/migrations/index.ts` — незареєстрований файл просто не виконається. + +### Чому ідемпотентність — не формальність + +Той самий DDL може виконуватися проти баз у різному стані: `ci-base`-форк (обʼєкта ще немає), прод (обʼєкт міг бути створений push-ем в ранню епоху проєкту), повторний прогін після часткового фейлу. Історичний факт: прод-схема спершу була створена push-ем, і guarded-міграції застосувалися поверх неї без конфліктів саме завдяки `IF NOT EXISTS` — реконсиляцію решти дрейфу зробила `20260721_233000_reconcile_schema_drift.ts`. + +:::tip +`payload run` для довільних скриптів зламаний у цьому проєкті — запускайте скрипти через `tsx --env-file=.env script.ts` (так працює і `pnpm seed:dev-admin`). +::: + +## CI-репетиція міграцій + +PR-воркфлоу (`ci.yml` для Vitest, `e2e.yml` для Playwright) виконують однакову схему: + +1. Форкають ефемерний бранч `ci/` від **`ci-base`** у dev-проєкті Neon. +2. Запускають `pnpm payload migrate` — застосовуються міграції, змерджені після останнього рефрешу `ci-base`, **плюс міграція самого PR-а**. Це і є пре-мерджева репетиція: зламана чи відсутня міграція валить CI, а не production-деплой. +3. Ганяють тести (E2E додатково сідить дані і білдить застосунок — prerender білда теж валідує, що код і схема узгоджені). +4. Видаляють бранч завжди — і на успіху, і на фейлі. + +`ci-base` тримається актуальним воркфлоу `.github/workflows/refresh-ci-base.yml`: на кожен push у `main` він запускає `pnpm payload migrate` проти `ci-base`, тож змерджені міграції пропагуються автоматично. Чому CI не форкає від `dev` — через той самий `batch=-1` (див. вище і [База даних Neon](/admin/docs/technical/infrastruktura/baza-danykh)). + +Локальна репетиція CI-поведінки: `CI=1` вимикає push, тож `CI=1 pnpm payload migrate` проти свіжого форку `ci-base` відтворює те, що зробить воркфлоу. + +## Продакшн: migrate-on-vercel.mjs + +Прод отримує міграції рівно один раз — під час **production**-білда Vercel, через скрипт у `package.json`: + +```jsonc +"vercel-build": "node scripts/migrate-on-vercel.mjs && pnpm build" +``` + +Повний вміст `scripts/migrate-on-vercel.mjs`: + +```js +import { spawnSync } from 'node:child_process' + +const env = process.env.VERCEL_ENV ?? '(unset)' + +if (env !== 'production') { + console.log(`[migrate-on-vercel] VERCEL_ENV=${env} — skipping migrations.`) + process.exit(0) +} + +if (!process.env.DATABASE_URL) { + console.error('[migrate-on-vercel] VERCEL_ENV=production but DATABASE_URL is not set — refusing to build.') + process.exit(1) +} + +console.log('[migrate-on-vercel] VERCEL_ENV=production — running payload migrate…') + +const result = spawnSync('pnpm', ['payload', 'migrate'], { stdio: 'inherit' }) + +if (result.status !== 0) { + console.error('[migrate-on-vercel] migration failed — aborting build so the previous deployment keeps serving.') + process.exit(result.status ?? 1) +} + +console.log('[migrate-on-vercel] migrations applied.') +``` + +Три гарантії скрипта: + +| Умова | Дія | Навіщо | +| --- | --- | --- | +| `VERCEL_ENV !== 'production'` | `exit 0`, міграції пропущено | Превʼю дивляться на бранч `preview` у dev-проєкті і **ніколи** не мутують схему | +| production без `DATABASE_URL` | `exit 1`, білд відмовлено | Краще не задеплоїти, ніж білдити прод без бази | +| `payload migrate` впав | `exit` з кодом помилки → **білд перерваний** | Попередній деплой продовжує обслуговувати трафік — зламана міграція не кладе прод | + +Міграції виконуються один раз, у детермінований момент, одним раннером, **до** того, як новий код почне приймати трафік — а не на холодних стартах serverless-функцій, де були б function-timeout і гонки конкурентності. + +## Чому НЕ prodMigrations адаптера + +Payload має вбудований механізм `prodMigrations` (міграції на ініціалізації). Тут він свідомо **не** використовується: Payload ініціалізується під час prerender-фази `next build`, тобто startup-керовані міграції виконувалися б **усередині кожного білда** — включно з превʼю — проти тієї бази, яку бачить конкретне оточення. Зовнішній production-gated скрипт дає контроль, якого адаптер дати не може. + +Історична примітка: раніше Build Command у дашборді Vercel був перевизначений на `payload migrate && pnpm build` — він ганяв міграції без гейта в кожному оточенні і тінив би `vercel-build`. Перевизначення знято; поведінка білда тепер повністю контролюється репозиторієм. + +## Чеклист перед відкриттям PR зі схемною зміною + +1. Міграція в `src/migrations/` створена, відредагована до вашої зміни, ідемпотентна, з `down()`. +2. Зареєстрована в `src/migrations/index.ts`. +3. `pnpm generate:types` виконано, `src/payload-types.ts` у коміті. +4. Локально все працює (push уже синхронізував ваш бранч — це не перевірка міграції, а перевірка коду). +5. Довірте перевірку самої міграції CI — крок «Run migrations» проти форку `ci-base` і є її першим чесним прогоном. + +## Шпаргалка «де що запускається» + +| Середовище | Push | `payload migrate` | +| --- | --- | --- | +| Локальний dev (сесійний бранч) | ✅ на старті сервера | ❌ ніколи (зависне на `batch=-1`) | +| CI (`ci/*` від `ci-base`) | ❌ (`CI=1`) | ✅ перед тестами | +| `ci-base` | ❌ | ✅ на кожен push у `main` | +| Preview-деплой | ❌ | ❌ (гейт `VERCEL_ENV`) | +| Production-деплой | ❌ | ✅ один раз у `vercel-build` | + +## Повʼязані статті + +- [База даних Neon](/admin/docs/technical/infrastruktura/baza-danykh) — топологія бранчів, unpooled-правило +- [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi) — решта production-білда +- [Цикл розробки фічі](/admin/docs/technical/rozrobka/tsykl-rozrobky-fichi) — місце міграції в чеклісті PR-а +- [Тестування](/admin/docs/technical/rozrobka/testuvannia) — що саме ганяє CI diff --git a/docs/admin-panel/technical/05-infrastruktura/03-deploi.md b/docs/admin-panel/technical/05-infrastruktura/03-deploi.md new file mode 100644 index 0000000..edf1427 --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/03-deploi.md @@ -0,0 +1,143 @@ +--- +title: Деплой на Vercel +description: Production-білд із міграціями, on-demand превʼю через [preview], змінні оточення та ISR-стратегія з таблицею cache tags. +--- + +Платформа деплоїться на Vercel (Hobby-план, регіон `fra1`). Головні принципи: міграції запускаються лише в production-білді, превʼю будуються лише на явний запит, і жоден автоматичний процес із PR-а не може дотягнутися до prod-бази. + +## vercel.json + +```jsonc +{ + "regions": ["fra1"], + "ignoreCommand": "if [ \"$VERCEL_ENV\" = \"production\" ]; then exit 1; elif printf '%s' \"$VERCEL_GIT_COMMIT_MESSAGE\" | grep -qF '[preview]'; then exit 1; else exit 0; fi" +} +``` + +- **`regions: ["fra1"]`** — функції у Франкфурті, поруч із Neon-базою (мінімізує латентність кожного запиту до БД). +- **`ignoreCommand`** — у семантиці Vercel `exit 1` = «білдити», `exit 0` = «пропустити». Отже: production завжди білдиться; превʼю білдиться **лише** якщо commit message містить `[preview]`; усе інше скасовується («Canceled by Ignored Build Step»). + +### Превʼю on-demand + +Hobby-план автоматично білдив би превʼю на **кожен** push у PR — з одним конкурентним білдом і спільною квотою хвилин це редундантно, бо GitHub Actions і так тестує кожен PR. Тому превʼю опційні: + +```bash +# превʼю без кодових змін +git commit --allow-empty -m "chore: preview [preview]" && git push + +# ad-hoc превʼю поточного чекаута (CLI-деплої не консультуються з ignoreCommand) +vercel deploy +``` + +Превʼю дивляться на довгоживучий бранч `preview` у **dev**-проєкті Neon (форк від `dev`, тож із сід-контентом) і **ніколи не запускають міграції** — превʼю схемозмінного PR-а може рендерити помилки проти старішої схеми, це прийнято й очікувано. Neon↔Vercel інтеграція **відʼєднана** (2026-07-22): раніше вона форкала `preview/*` бранчі у prod-проєкті на кожен push, навіть для скасованих білдів. + +## Production-білд + +```jsonc +"vercel-build": "node scripts/migrate-on-vercel.mjs && pnpm build", +"postbuild": "next-sitemap --config next-sitemap.config.cjs" +``` + +Мердж у `main` → Vercel запускає `vercel-build`: спершу міграції (production-only, фейл = перерваний білд, попередній деплой лишається живим — повний розбір у [Push vs міграції](/admin/docs/technical/infrastruktura/mihratsii)), потім `next build`, потім `postbuild` генерує sitemap. + +Sitemap-нюанси: `siteUrl` береться з `NEXT_PUBLIC_SERVER_URL` → `https://VERCEL_PROJECT_PRODUCTION_URL` → `https://example.com`; `robots.txt` забороняє `/admin/*`; дві динамічні sitemap-и віддають лише published-документи (`overrideAccess: false`, `draft: false`, limit 1000). Sitemap-и **лише uk, без hreflang** — відома вада. + +### Білд торкається бази + +`next build` ініціалізує Payload під час prerender-фази (`generateStaticParams` тягне published-курси і сторінки), тож **кожен білд читає базу** того оточення, в якому виконується. Це фундаментальний факт, з якого випливає половина рішень тут: чому міграції не можна вішати на ініціалізацію Payload (виконувались би в кожному білді, включно з превʼю), чому превʼю потребують робочого `DATABASE_URL` (бранч `preview` dev-проєкту), і чому у worktree без `.env` падає навіть простий `pnpm build`. + +### Rollback + +Зламаний деплой відкочується штатними засобами Vercel (Promote попереднього деплою). Зламані **дані/схема** — це Neon point-in-time restore прод-проєкту плюс `down()` відповідної міграції. Найчастіший сценарій «зламаної міграції» не вимагає нічого: білд перервався, прод так і лишився на попередній версії — виправте міграцію наступним PR-ом. + +## Типові сценарії + +### Викотити фічу на прод + +```bash +gh pr merge --squash # merge у main +# Vercel сам: migrate-on-vercel → next build → postbuild sitemap → deploy +``` + +Слідкувати за білдом — у дашборді Vercel; рядки `[migrate-on-vercel]` у лозі білда показують, чи бігли міграції і як завершились. + +### Подивитись превʼю схемобезпечної зміни + +```bash +git commit --allow-empty -m "chore: preview [preview]" && git push +``` + +Превʼю отримає URL виду `-.vercel.app` і дані бранча `preview` (сід-контент з `dev`). Памʼятайте: схема превʼю може відставати — це не баг. + +### Відкотити невдалий деплой + +Код: Promote попереднього деплою в дашборді Vercel (миттєво). Дані: Neon point-in-time restore прод-проєкту + `down()` міграції — але найчастіше нічого відкочувати не треба, бо зламана міграція просто перервала білд і прод не змінився. + +## Змінні оточення + +| Змінна | Призначення | Де читається | +| --- | --- | --- | +| `DATABASE_URL` | Neon connection string (Production-scoped → prod-проєкт; Preview-scoped → бранч `preview`) | `src/payload.config.ts` (адаптер), `scripts/migrate-on-vercel.mjs` | +| `PAYLOAD_SECRET` | Секрет Payload; також ключ HMAC сертифікатних токенів | `src/payload.config.ts`, `src/utilities/certificateToken.ts` | +| `BETTER_AUTH_SECRET` | Підпис сесій Better Auth | `src/lib/auth/options.ts` | +| `BLOB_READ_WRITE_TOKEN` | Токен Vercel Blob; його наявність вмикає blob-storage-плагін | `src/plugins/index.ts`, `redirects.js` (витягує store id регекспом) | +| `STORAGE_VERCEL_BLOB_BASE_URL` | Явний base URL блоб-стора (перекриває деривацію з токена) | `redirects.js`, `src/migrations/20260724_200000_backfill_blob_urls.ts` | +| `RESEND_API_KEY` | Вмикає відправку email; без нього — консольний адаптер/no-op | `src/payload.config.ts`, `src/lib/auth/options.ts`, `src/app/api/auth/verify-registration/route.ts` | +| `EMAIL_FROM` | Адреса відправника (дефолт `onboarding@resend.dev`) | ті самі три файли | +| `CRON_SECRET` | Bearer-токен для jobs runner і хедер `x-reindex-secret` реіндексу пошуку | `src/payload.config.ts` (jobs.access.run), `src/app/api/reindex-search/route.ts` | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google OAuth; провайдер реєструється лише коли обидві задані | `src/lib/auth/options.ts` | +| `NEXT_PUBLIC_SERVER_URL` | Канонічний origin: server URL Payload, `remotePatterns`, sitemap `siteUrl` | `src/utilities/getURL.ts`, `next.config.js`, `next-sitemap.config.cjs` | +| `NEXT_PUBLIC_BETTER_AUTH_URL` | Найвищий пріоритет у `resolveBaseURL` Better Auth (важливо при міграції домену) | `src/lib/auth/options.ts` | +| `PREVIEW_SECRET` | Спільний секрет draft-превʼю: лінк з адмінки → `/next/preview` | `src/utilities/generatePreviewPath.ts`, `src/app/(frontend)/next/preview/route.ts` | +| `RATE_LIMIT` | `'false'` вимикає / `'true'` вмикає rate limiting; інакше — увімкнено лише в production | `src/lib/rate-limit.ts` | +| `ALLOW_DEV_LOGIN` | `'1'` дозволяє `/api/dev-login` у локальному production-білді; на Vercel маршрут вимкнений безумовно (`process.env.VERCEL`) | `src/app/api/dev-login/route.ts` | + +:::warning +`NEXT_PUBLIC_SERVER_URL` на проді наразі **не задано** — origin резолвиться через `VERCEL_PROJECT_PRODUCTION_URL`, який Vercel обчислює як *найкоротший* production-домен. Під час міграції домену це старий домен — спершу задайте env-перекриття (`NEXT_PUBLIC_BETTER_AUTH_URL`, `NEXT_PUBLIC_SERVER_URL`), потім міняйте домени. +::: + +## ISR-стратегія + +Публічні сторінки — статичні з інкрементальною ревалідацією; сторінки кроків курсу та квізів рендеряться per-request (вони залежать від прогресу користувача). `generateStaticParams` пререндерить published-сторінки і курси в обох локалях (uk без префікса, `/en`), тож перший відвідувач після деплою вже отримує статику. + +Два залізні правила продуктивності (закріплені з PR #67): у компонентах публічних сторінок **немає `getSession`** (зробило б сторінку динамічною і зламало б спільний ISR-кеш — прогрес користувача добирається клієнтськи) і **немає `useSearchParams`** у серверному дереві. Джерело істини про те, що реально пререндериться, — `prerender-manifest` білда. + +| Сторінка | `revalidate` | +| --- | --- | +| Головна | 300 с | +| Каталог курсів, сторінка курсу, категорія курсів | 300 с | +| Лідерборд | 300 с | +| Пости | 600 с | + +### Cache tags + +| Тег | Що інвалідує | Хто бʼє | +| --- | --- | --- | +| `pages-sitemap`, `posts-sitemap` | динамічні sitemap-и | `revalidatePage` / `revalidatePost` | +| `redirects` | кеш редіректів plugin-redirects | `revalidateRedirects` | +| `global_header`, `global_footer`, `global_home-calendar` | глобали | afterChange-хуки глобалів | +| `xp-leaderboard` | all-time лідерборд (unstable_cache 300 с) | `logXpEvent` після успішного запису | +| `course-enrollment-stats` | стрічка останніх завершень (60 с) | `revalidateCoursePages` | +| `likes-counts-`, `comments-counts-` | кешовані лічильники (120 с) | `revalidateCounts` (skip для лайків коментарів) | +| `pages_`, `posts_` | конкретні документи | відповідні afterChange-хуки | + +### revalidateCourse і scheduled publish + +`src/hooks/revalidateCourse.ts` бʼє всі ISR-поверхні курсу: обидва локальні префікси (`''`, `'/en'`) × `/courses/`, `/courses`, `/courses/category/[slug]`, `/`; при rename/unpublish — додатково старий slug. Увесь блок обгорнутий у `try/catch`: **відкладена публікація (schedulePublish) виконується поза HTTP-запитом, де `revalidatePath` кидає виняток** — пропущений bust компенсується часовим вікном `revalidate`, а сейв документа ніколи не фейлиться через ревалідацію. + +### Draft preview (PREVIEW_SECRET) + +Кнопка Preview в адмінці веде на `/next/preview?slug&collection&path&previewSecret=$PREVIEW_SECRET` (генерується `src/utilities/generatePreviewPath.ts`; мапа префіксів: posts → `/posts`, pages → `''`; **курси live preview не мають**). Маршрут перевіряє: `previewSecret === PREVIEW_SECRET` (інакше 403), наявність трьох параметрів (404), `path` починається з `/` (500), `payload.auth()` повертає користувача (403) — і лише тоді вмикає `draftMode` та редіректить. `/next/exit-preview` вимикає. Секрет має бути заданий і в оточенні білда, і в рантаймі — розсинхрон між адмінкою та фронтендом дає постійні 403. + +## Перевірка проду + +:::warning Vercel Security Checkpoint +`curl` до продакшн-домену впирається у Vercel Security Checkpoint (JS-челендж) і не покаже реальної відповіді. Перевіряйте прод **браузером**, а програмні перевірки робіть same-origin `fetch`-ем зі сторінки, відкритої в браузері (наприклад, через DevTools-консоль). +::: + +## Повʼязані статті + +- [Push vs міграції](/admin/docs/technical/infrastruktura/mihratsii) — механіка `migrate-on-vercel.mjs` +- [База даних Neon](/admin/docs/technical/infrastruktura/baza-danykh) — чому превʼю живуть у dev-проєкті Neon +- [Медіа та Vercel Blob](/admin/docs/technical/infrastruktura/media-blob) — блоб-стори і редіректи легасі-URL +- [Маршрути та middleware](/admin/docs/technical/arkhitektura/marshruty-i-middleware) — маршрути, які все це обслуговують diff --git a/docs/admin-panel/technical/05-infrastruktura/04-media-blob.md b/docs/admin-panel/technical/05-infrastruktura/04-media-blob.md new file mode 100644 index 0000000..d79c1fa --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/04-media-blob.md @@ -0,0 +1,139 @@ +--- +title: Медіа та Vercel Blob +description: Умовне підключення blob-сховища, роздача з CDN без access control, легасі-редіректи /api/media/file/* і next/image remotePatterns. +--- + +Завантажені файли (колекції `media` та `course-files`) зберігаються у Vercel Blob і роздаються напряму з CDN. Локально без токена все падає назад на диск (`public/media`, `public/course-files`). + +## Умовне підключення плагіна + +`src/plugins/index.ts`: + +```ts +const vercelBlobPlugin = process.env.BLOB_READ_WRITE_TOKEN + ? vercelBlobStorage({ + collections: { + [Media.slug]: { disablePayloadAccessControl: true }, + [CourseFiles.slug]: { disablePayloadAccessControl: true }, + }, + token: process.env.BLOB_READ_WRITE_TOKEN, + }) + : null +``` + +Плагін реєструється **лише** коли задано `BLOB_READ_WRITE_TOKEN`. Без нього (свіжий клон, CI) аплоади йдуть на локальний диск — це штатний режим, а не помилка. + +## Дві upload-колекції + +| | `media` (`src/collections/Media.ts`) | `course-files` (`src/collections/CourseFiles.ts`) | +| --- | --- | --- | +| Призначення | зображення контенту, обкладинки, OG | вкладення кроків курсів | +| Поля | `alt` text (localized, не required), `caption` richText (localized) | `title` text (localized, опційний) | +| mimeTypes | будь-які зображення | `application/pdf`, PPT, PPTX | +| Варіанти розмірів | 7 (див. нижче) | немає | +| Особливості | `folders: true` (payload-folders), `focalPoint: true` | — | +| staticDir (фолбек без токена) | `public/media` | `public/course-files` | + +Access в обох: create/update/delete — `admin`, read — `anyone`. Саме публічний read і робить безпечним наступний блок. + +### Чому disablePayloadAccessControl: true + +За замовчуванням Payload проксіює кожен файл через свій маршрут `/api//file/`, щоб застосувати access control. Тут обидві колекції мають `read: anyone` — перевіряти нічого, тож проксі лише додає serverless-виклик на кожен файл. `disablePayloadAccessControl: true` змушує Payload зберігати в документі **прямий CDN-URL** блоба: жодної лямбди на роздачу, кешування на edge. + +:::warning +Це безпечно **тільки** тому, що read публічний. URL блобів «unguessable-only» — вони не автентифікуються; якщо колись зʼявиться приватне медіа, цю опцію доведеться зняти для нього. Blob keys — це чисте імʼя файлу. +::: + +## Стори + +| Оточення | Store ID | Base URL | +| --- | --- | --- | +| Production | `u3oxntmyhu0z5gqa` | `https://u3oxntmyhu0z5gqa.public.blob.vercel-storage.com` | +| Dev/Preview | `1kbvjtajlddub6ss` | `https://1kbvjtajlddub6ss.public.blob.vercel-storage.com` | + +Стор визначається токеном: base URL деривується з `BLOB_READ_WRITE_TOKEN` регекспом `^vercel_blob_rw_([a-z\d]+)_[a-z\d]+$` (store id — перша група), або задається явно через `STORAGE_VERCEL_BLOB_BASE_URL`. + +## Легасі-редіректи /api/*/file/* + +Медіа переїхало на Blob CDN у коміті `e353834`, після чого `/api/media/file/*` і `/api/course-files/file/*` лишилися вказувати на local-disk static handler Payload — він логує "missing on the disk" і нічого не віддає, бо аплоади більше не торкаються файлової системи. Дві лінії захисту: + +1. **Міграція `src/migrations/20260724_200000_backfill_blob_urls.ts`** — переписала URL, збережені в базі. +2. **Редіректи в `redirects.js`** — покривають посилання, до яких міграція дотягнутися не може: закешований браузером HTML, ISR-сторінки з попередніх деплоїв, зовнішні лінки, вже розшарені OG-зображення: + +```js +const legacyFileRedirects = blobBaseUrl + ? ['media', 'course-files'].map((collection) => ({ + source: `/api/${collection}/file/:filename`, + destination: `${blobBaseUrl}/:filename`, + permanent: false, + })) + : [] +``` + +`redirects.js` **дублює** деривацію `blobBaseUrl` з адаптера (замість імпорту) свідомо: файл завантажується `next.config.js`-ом **до** того, як резолвляться path aliases. Дублікат тримається в синхроні з міграцією backfill — при зміні логіки адаптера оновлюйте всі три місця. + +:::danger Відома вада: users.image +`users.image` — це **plain-text URL-снапшот**, а не relationship на `media`. Аватари, збережені до PR #67, зафіксували URL виду `/api/media/file/*`, які тепер 404-лять (редірект рятує лише поки живий той самий стор). Детальніше: [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil). +::: + +## next/image remotePatterns + +`next.config.js` дозволяє `next/image` оптимізувати зображення з: + +- `*.public.blob.vercel-storage.com` (https) — сам CDN; +- усіх **self-origins**: `NEXT_PUBLIC_SERVER_URL`, `https://VERCEL_PROJECT_PRODUCTION_URL`, `__NEXT_PRIVATE_ORIGIN` (дедупліковані; фолбек `http://localhost:3000`). + +`NEXT_PUBLIC_SERVER_URL` стоїть **поруч** із `VERCEL_PROJECT_PRODUCTION_URL`, а не замість нього: Vercel резолвить останній у *найкоротший* production-домен, тобто під час міграції домену це старий домен — **обидва мають лишатися дозволеними**, інакше картинки одного з доменів зламаються. + +## Розміри зображень і focal point + +Колекція `media` (`src/collections/Media.ts`) генерує варіанти через sharp: + +| Розмір | Габарити | +| --- | --- | +| `thumbnail` | 300 w (адмін-превʼю) | +| `square` | 500×500 (використовується для аватарів) | +| `small` | 600 w | +| `medium` | 900 w | +| `large` | 1400 w | +| `xlarge` | 1920 w | +| `og` | 1200×630, crop center | + +Увімкнено **`focalPoint: true`** — редактор в адмінці задає точку фокусу, і кропи (`square`, `og`) центруються на ній, а не на геометричному центрі. `course-files` (PDF/PPT/PPTX) варіантів не має. + +Хелпер `src/utilities/getMediaUrl.ts` пропускає абсолютні URL як є (додаючи cache tag) і докладає `getClientSideURL()` до відносних — код компонентів не мусить знати, диск це чи CDN. Адмін-превʼю використовує розмір `thumbnail` (`adminThumbnail: 'thumbnail'`). + +## Як медіа потрапляє на сторінку + +Шлях зображення від адмінки до браузера: + +1. Редактор завантажує файл у `media` → sharp генерує варіанти → blob-адаптер кладе оригінал і варіанти в стор, у документі зберігаються **абсолютні CDN-URL**. +2. Компонент бере потрібний розмір (`sizes.medium?.url` тощо) через `getMediaUrl`. +3. `next/image` оптимізує/ресайзить на льоту — саме тому CDN-хост мусить бути в `remotePatterns`, інакше рантайм-помилка «hostname not configured». +4. OG-зображення для соцмереж — розмір `og` (1200×630), який `generateMeta`/`mergeOpenGraph` підставляють у метатеги. + +Оскільки URL абсолютні і стабільні, ISR-сторінки кешують їх у HTML — зміна стора без backfill-міграції лишає старі URL у кеші до наступної ревалідації (див. чеклист нижче). + +## Аватари — окремий випадок + +Аватари користувачів проходять через `media` (server action `updateAvatar` зберігає `sizes.square?.url ?? url`), але в `users.image` лягає **текстовий снапшот** URL, не relationship. Ліміти: `AVATAR_MAX_BYTES` 5 MiB, MIME jpeg/png/webp/gif; `serverActions.bodySizeLimit: '5mb'` у `next.config.js` (дефолтний 1 MB мовчки різав телефонні фото); реальна прод-стеля — 4.5 MB (ліміт тіла запиту Vercel). Google-аватари — це зовнішні `googleusercontent`-URL, рендеряться сирим ``. Повний розбір: [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil). + +## Дрібниці, які варто знати + +- **Папки в медіатеці**: `media` має `folders: true` (payload-folders) — редактори організують бібліотеку в папки; на URL файлів це не впливає. +- **MCP-доступ**: mcpPlugin відкриває `media` для AI-інструментів лише частково — `find` і `update` дозволені, `create`/`delete` — ні (`src/plugins/index.ts`). +- **Лексикал і зображення**: rich-text використовує UploadFeature — картинки в контенті це relationship на `media`, тож переносяться разом із документом і не дублюються. +- **`alt` не обовʼязковий** — поле localized, але без `required`; порожній alt у контентних зображеннях легальний (декоративні картинки). + +## Чеклист при міграції домену або стора + +1. Новий домен → додати в `NEXT_PUBLIC_SERVER_URL`, **не** прибираючи старий з remotePatterns (він лишається через `VERCEL_PROJECT_PRODUCTION_URL`). +2. Новий блоб-стор → новий `BLOB_READ_WRITE_TOKEN`; якщо URL-схема нестандартна — явний `STORAGE_VERCEL_BLOB_BASE_URL`. +3. Старі URL у базі → нова backfill-міграція за зразком `20260724_200000_backfill_blob_urls.ts`. +4. Памʼятати про `users.image`-снапшоти — їх backfill теж має переписати. + +## Повʼязані статті + +- [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi) — де живуть `BLOB_READ_WRITE_TOKEN` та інші змінні +- [posts, pages, media](/admin/docs/technical/model-danykh/posts-pages-media) — колекції `media` і `course-files` +- [Аватари та налаштування профілю](/admin/docs/technical/autentyfikatsiya/avatary-i-profil) — снапшоти аватарів і їхні наслідки diff --git a/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md b/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md new file mode 100644 index 0000000..03b4432 --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/05-poshuk-synkhronizatsiia.md @@ -0,0 +1,139 @@ +--- +title: Пошук — синхронізація локалей +description: Чому plugin-search породжує «привидні» картки в мультилокальному сетапі, як їх лікують backfill-плагін і read-side hack, і як безпечно реіндексувати. +--- + +Повнотекстовий пошук побудований на `@payloadcms/plugin-search`: плагін тримає окрему колекцію `search`, куди afterChange-хуком синхронізує документи чотирьох колекцій — `searchIndexedCollections = ['posts', 'courses', 'course-categories', 'pages']` (`src/search/localeSync.ts`). Ця стаття — розбір головної болячки плагіна в мультилокальному проєкті і трьох шарів обходу, які тут напрацьовані. + +## Структура search-рядка + +Понад стандартні поля плагіна (`title`, `doc` — polymorphic relationship на джерело, `priority`) `src/search/fieldOverrides.ts` додає: + +| Поле | Тип | Навіщо | +| --- | --- | --- | +| `slug` | text | побудова URL картки без depth-запиту | +| `collectionType` | text, indexed, readOnly | тип джерела для роутингу картки і dedupe | +| `meta` | group: `title`, `description`, `image` (upload→media) | сніпет і превʼю у видачі | +| `categories` | array `{ relationTo, categoryID, title }` | бейджі категорій без join-ів | + +Шлях запису при кожному сейві індексованого документа: + +``` +save doc (req.locale = X) + → plugin-search sync hook: upsert search-рядка, localized-поля ЛИШЕ в локалі X + → beforeSyncWithSearch: мапінг колекційних полів у meta/categories + → backfillSearchTitleLocales (наш): дозапис title у решту локалей +``` + +## Проблема: односторонній запис локалей + +Контент локалізований (uk — дефолт, en — друга локаль, `fallback: true`). Але sync-хук plugin-search пише localized-поля search-документа **лише для локалі запиту, який зберіг документ** (`req.locale`). А Payload при читанні **не фолбечить з дефолтної локалі на інші** для search-рядків. + +Наслідок: документ, збережений редактором з en-локалі адмінки, отримує search-рядок, у якого `title` заповнений лише в en. Пошук з uk-локалі бачить рядок із порожнім `title` — у видачі зʼявляється **«привидна» картка** без назви (або документ узагалі невидимий для uk-запиту по title). Саме такий ghost зʼявився на проді 2026-07-24. + +### Статус upstream + +Save-path **не виправлений** у plugin-search навіть у 4.0-canary — хук досі пише лише `req.locale`. Що виправлено: з версії **3.61.1** реіндекс з адмінки (кнопка **ReindexButton** плагіна на списку колекції `search`, `dist/Search/ui/ReindexButton`) проходить **по всіх локалях**. Тобто разова санація можлива кнопкою, але кожен наступний сейв документа знову створює однолокальний рядок — тому потрібні власні шари нижче. + +## Шар 1: searchLocaleSync (write-side backfill) + +Кастомний плагін `searchLocaleSync` (`src/search/localeSync.ts`) реєструється в `src/plugins/index.ts` **одразу після** `searchPlugin` — порядок критичний, бо його хук `backfillSearchTitleLocales` має опинитися в `afterChange` **після** власного sync-хука плагіна (хуки виконуються в порядку реєстрації, а бекфілити можна лише той рядок, який sync уже створив/оновив): + +```ts +// src/plugins/index.ts — фрагмент масиву plugins +searchPlugin({ collections: searchIndexedCollections, beforeSync: beforeSyncWithSearch, ... }), +searchLocaleSync, // ← ОДРАЗУ після; не пересувати +``` + +Сам плагін — чиста трансформація конфіга: для кожної колекції зі `searchIndexedCollections` доклеює `backfillSearchTitleLocales` в кінець її `afterChange`. + +Механіка `backfillSearchTitleLocales`: + +1. **Skip** для `_status === 'draft'` і `deletedAt` (чернетки плагін не синхронізує, trashed — видаляє). +2. Skip, якщо в конфізі немає `localization`. +3. `syncedLocale = req.locale || defaultLocale`; missing locales = решта `localeCodes`. +4. Знаходить search-рядок по `doc.relationTo` + `doc.value` (= колекція + id джерела). +5. **Перечитує джерело з `locale: 'all'`** і `select: { title: true }` — отримує обʼєкт `{ uk: ..., en: ... }` (або plain string, якщо поле нелокалізоване — тоді той самий рядок для всіх локалей). +6. Для кожної відсутньої локалі, де в джерела є title, — `payload.update` search-рядка з цією `locale`. + +:::warning Бекфілиться лише title +`meta.title`, `meta.description`, `meta.image` і `categories` у search-рядку **залишаються однолокальними** — їх backfill не чіпає. Для видачі це прийнятно (title — головне), але памʼятайте про це, читаючи search-дані напряму. +::: + +## Шар 2: read-side hack на сторінці пошуку + +`src/app/(frontend)/[locale]/search/page.tsx` страхується від рядків, які backfill ще/вже не покрив: + +- запит до `search` виконується з **`fallbackLocale: locale === 'uk' ? 'en' : 'uk'`** — протилежна локаль як фолбек, щоб рядок з title лише в іншій локалі не рендерився порожнім; +- результати **дедуплікуються** по ключу `` `${collectionType}:${originalDocId}` `` — застарілі рядки можуть дублювати той самий документ. + +Сам запит: limit 12, `or` по `title`, `meta.description`, `meta.title`, `slug` (`like`), без ранжування і пагінації. + +## Свідомі обмеження реалізації + +Щоб не будувати зайвого, зафіксовано межі поточного пошуку: + +- **Без ранжування** — порядок результатів визначає БД, не релевантність; `priority`-поле плагіна не використовується. +- **Без пагінації** — рівно 12 результатів; для поточного обсягу контенту достатньо. +- **`like`-матчинг, не повнотекстовий** — немає стемінгу/морфології; українські словоформи матчаться лише префіксно. +- **Однолокальні `meta` і `categories`** у search-рядках — сніпет може показатись мовою збереження документа. + +Якщо контенту стане суттєво більше, наступний крок — Postgres FTS або зовнішній рушій, але це окремий проєкт, не твік поточного. + +## beforeSync: що потрапляє в індекс + +`beforeSyncWithSearch` (`src/search/beforeSync.ts`) мапить документ у search-рядок; додаткові поля індексу оголошені в `src/search/fieldOverrides.ts` (`slug`, `collectionType` indexed/readOnly, група `meta`, масив `categories`). + +| Колекція | Мапінг | +| --- | --- | +| `courses` | `meta` з `title`/`description`/`heroImage`; категорія — `findByID` до `course-categories` з `disableErrors` + `select: title`; відсутня → `console.error` і `categories: []` | +| `course-categories` | аналогічно курсам | +| `pages` | `meta.title = meta?.title \|\| title` | +| `posts` (default-гілка) | spread SEO-`meta`; категорії по одній через `findByID` | + +## Реіндекс + +### Кастомний endpoint + +`POST /api/reindex-search` (`src/app/api/reindex-search/route.ts`), автентифікація — хедер `x-reindex-secret`, який має дорівнювати `CRON_SECRET` (інакше 401). Алгоритм: + +1. Видаляє **всі** search-документи (limit 10000). +2. Re-save (`payload.update` з `data: {}`) кожного документа джерельних колекцій, щоб тригернути sync-хуки: `posts`/`courses`/`pages` — **лише published** і завжди з **`draft: false`**; `course-categories` — всі (вони без drafts). +3. Повертає `{ ok, deleted, reindexed: { posts, courses, courseCategories, pages } }`. + +:::danger Чому draft: false обовʼязковий +Re-save документа, у якого є новіший pending draft, **без** `draft: false` промоутнув би статус цього драфта — документ **тихо розпублікувався б**. Саме тому endpoint перебирає лише published-доки і явно фіксує `draft: false` на кожному update. +::: + +Re-saves ідуть без явної `locale` → sync пише дефолтну (uk), а en доїжджає через `backfillSearchTitleLocales` (знову ж — лише title). + +Виклик endpoint-а: + +```bash +curl -X POST -H "x-reindex-secret: $CRON_SECRET" \ + https://<домен>/api/reindex-search +``` + +### Кнопка в адмінці + +Альтернатива для разової санації (наприклад, лікування ghost-карток на проді): вбудований **ReindexButton** плагіна на списку колекції `search` в адмінці — з 3.61.1 він реіндексує по всіх локалях. Кастомний endpoint лишається для автоматизації (cron/CLI) і дає контроль над draft-семантикою. + +## Тести і видалення документів + +Поведінка backfill-а зафіксована інтеграційним тестом `tests/int/search-locale-sync.int.spec.ts` — при зміні логіки синхронізації починайте з нього. + +Видалення і trash обробляє сам plugin-search: search-рядок видаляється разом із джерелом; unpublish (перехід у draft) теж прибирає рядок — тому «зник з пошуку після редагування» найчастіше означає, що документ ненавмисно розпублікували. + +## Шпаргалка діагностики + +| Симптом | Причина | Лікування | +| --- | --- | --- | +| Картка без назви у видачі | search-рядок з title лише в іншій локалі | кнопка Reindex або `POST /api/reindex-search`; перевірити, що `searchLocaleSync` стоїть одразу після `searchPlugin` | +| Документ дублюється у видачі | застарілі search-рядки | dedupe на сторінці вже ховає; реіндекс чистить базу | +| Опис/категорія порожні в одній локалі | backfill покриває лише title | очікувано; за потреби — реіндекс кнопкою (all-locale) | +| Документ зник з пошуку після реіндексу | він не published | очікувано: endpoint індексує лише published | + +## Повʼязані статті + +- [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi) — де живе `CRON_SECRET` +- [payload.config.ts і плагіни](/admin/docs/technical/arkhitektura/payload-config) — порядок плагінів у `src/plugins/index.ts` diff --git a/docs/admin-panel/technical/05-infrastruktura/06-email.md b/docs/admin-panel/technical/05-infrastruktura/06-email.md new file mode 100644 index 0000000..106c037 --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/06-email.md @@ -0,0 +1,134 @@ +--- +title: Email — Resend +description: Два шляхи відправки через Resend, повна таблиця листів, шаблони та поведінка системи без RESEND_API_KEY. +--- + +Уся пошта йде через [Resend](https://resend.com). У кодовій базі є **два незалежні шляхи** відправки — це важливо розуміти при дебагу «чому лист не прийшов». + +## Шлях 1: resendAdapter → payload.sendEmail + +`src/payload.config.ts`: + +```ts +email: process.env.RESEND_API_KEY + ? resendAdapter({ + defaultFromAddress: process.env.EMAIL_FROM || 'onboarding@resend.dev', + defaultFromName: 'Learning Platform', + apiKey: process.env.RESEND_API_KEY, + }) + : undefined +``` + +Через цей адаптер (`payload.sendEmail`) ідуть: **лист-запрошення адміністратора** (`sendInviteEmail` у `src/lib/auth/options.ts`) і **листи form-builder-плагіна**. `defaultFromName` — «Learning Platform». + +## Шлях 2: прямий new Resend + +Два місця створюють клієнт Resend напряму, повз адаптер Payload: + +- `src/lib/auth/options.ts` — `sendVerificationOTP` плагіна emailOTP (типи `email-verification` і `forget-password`); +- `src/app/api/auth/verify-registration/route.ts` — OTP реєстрації (action `send-otp`). + +Обидва читають ті самі `RESEND_API_KEY` та `EMAIL_FROM || 'onboarding@resend.dev'`, але **не** залежать від того, чи сконфігурований email-адаптер Payload. + +## Таблиця всіх листів + +| Лист | Тригер | Subject | Шаблон | +| --- | --- | --- | --- | +| OTP реєстрації | `POST /api/auth/verify-registration` (action `send-otp`) | `Код підтвердження: ` | `buildOtpEmailHtml(otp, 'email-verification')` | +| OTP підтвердження email | emailOTP-плагін, type `email-verification` | той самий | той самий | +| OTP скидання пароля | emailOTP-плагін, type `forget-password` | `Код для скидання пароля: ` | `buildOtpEmailHtml(otp, 'forget-password')` | +| Запрошення адміністратора | кнопка «Надіслати лист» у invite-модалці (InviteUserButton) | `Запрошення до панелі адміністратора — Залізна Зміна` | `buildInviteEmailHtml(url)` | +| Листи форм | formBuilderPlugin, конфігуруються автором форми в адмінці | задає редактор | тіло з плейсхолдерами `{{fieldName}}`, `{{*}}`, `{{*:table}}` | + +:::info Транзакційних листів про навчання НЕМАЄ +Записався на курс, завершив курс, отримав сертифікат — **жоден** із цих подій не породжує листа. Lifecycle-email-и — відома прогалина (P0 у беклозі), а не забутий баг конфігурації. +::: + +## Параметри OTP + +Обидва OTP-шляхи оперують однаковими константами (emailOTP-плагін у `src/lib/auth/options.ts` і власна реалізація у `verify-registration`): + +| Параметр | Значення | +| --- | --- | +| Довжина коду | 6 цифр (`otpLength: 6`) | +| Термін дії | 300 с = 5 хвилин (`expiresIn: 300`) — саме про це футер листа | +| Спроби вводу | 3 на один код (`allowedAttempts: 3`; у verify-registration — лічильник `attempts` у value) | +| Формат value у `verifications` | `otp:attempts`, split по **останньому** `:` | + +Прострочений код видаляється при спробі верифікації (400); вичерпані спроби — видалення + 403; новий `send-otp` завжди створює свіжий код зі свіжим лічильником. Повний флоу з rate-limit-ами: [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp). + +## Шаблони + +`src/lib/email/verification-otp.ts` і `src/lib/email/admin-invite.ts` — функції, що повертають повний HTML-документ: + +- inline-styled HTML (email-клієнти не вміють зовнішні стилі), ``, табличний лейаут; +- картка max-width 420px на фоні `#f4f4f5`; +- бренд-акцент: CTA-кнопка запрошення — помаранчева `#f98c1f` з текстом `#04122e`; +- OTP-шаблон параметризований типом (`email-verification` | `forget-password`) — різні підзаголовок і футер; футер нагадує: «Код дійсний 5 хвилин»; +- обидва мають фолбек-рядок «якщо ви не очікували цього листа — ігноруйте». + +Правити вигляд листів = правити ці два файли; жодних MJML/React Email тут немає. + +## Поведінка без RESEND_API_KEY + +Локальна розробка зазвичай іде без ключа — система деградує передбачувано: + +| Компонент | Без ключа | +| --- | --- | +| Email-адаптер Payload | `email: undefined` → Payload використовує консольний транспорт: «листи» логуються в stdout dev-сервера | +| `sendVerificationOTP` (emailOTP) | ранній `return` — **no-op**, лист не шлеться і не логується | +| OTP реєстрації (`verify-registration`) | гілка `if (process.env.RESEND_API_KEY)` пропускається — відповідь успішна, листа немає | + +:::tip Як дістати OTP локально +Без ключа код не приходить ніде, але він лежить у базі: колекція `verifications`, identifier `email-verification-otp-`, value у форматі `otp:attempts`. Для браузерного тестування простіше обійти реєстрацію взагалі через [Dev-login та сідінг](/admin/docs/technical/autentyfikatsiya/dev-login-i-sid). +::: + +## Листи form-builder + +formBuilderPlugin (payment вимкнено) дозволяє редактору форми сконфігурувати листи прямо в адмінці: одержувачі, subject і тіло з плейсхолдерами — `{{fieldName}}` підставляє значення поля сабміту, `{{*}}` — усі поля списком, `{{*:table}}` — таблицею. Відправка йде через email-адаптер Payload, тобто без `RESEND_API_KEY` листи форм потраплять лише в консоль dev-сервера. + +## sendInviteEmail — обовʼязковий + +`betterAuthPlugin` конфігурує `adminInvitations.sendInviteEmail` (`src/lib/auth/options.ts`). Якщо цю функцію не задати, ендпоінт send-invite **відповідає 500** — тому вона визначена завжди і йде через `payload.sendEmail`. Коли відправка фейлиться (немає адаптера, помилка Resend), функція повертає `{ success: false }` з повідомленням «Не вдалося надіслати лист із запрошенням» — модалка показує його редакторові, а invite-лінк усе одно можна скопіювати вручну. + +## Запрошення адміністратора: повний флоу + +Лист-запрошення — частина ланцюжка, який дозволяє реєстрацію в обхід OTP-гейта: + +1. Адмін у списку користувачів натискає кнопку запрошення (`InviteUserButton`, кастомний компонент у Description колекції `users`) — обирає роль, отримує invite-URL і може натиснути «Надіслати лист». +2. Кнопка створює запис в `admin-invitations` (колекція payload-auth) з токеном; відправка йде через `sendInviteEmail` → `payload.sendEmail` → resendAdapter. +3. Запрошений відкриває URL і реєструється. Гейт реєстрації (`databaseHooks.user.create.before` у `src/lib/auth/options.ts`) пропускає створення користувача, якщо invite-токен (header `x-admin-invite-token`, query, body або additionalData) збігається з активним записом `admin-invitations` — OTP-верифікація email у цьому флоу не потрібна, `emailVerified` ставиться одразу. + +Без валідного токена і без pre-verified email реєстрація **відхиляється** — тому «зламаний» лист запрошення реально блокує онбординг адміна; запасний вихід — скопіювати invite-URL з модалки і передати іншим каналом. + +## Локальне тестування листів + +Рецепти для dev-середовища: + +1. **Подивитись адаптерний лист без відправки** — не задавайте `RESEND_API_KEY`: `payload.sendEmail` виведе вміст у stdout dev-сервера (invite-лист, листи форм). +2. **Реальна відправка з dev** — задайте `RESEND_API_KEY` тестового акаунта Resend; `EMAIL_FROM` можна лишити дефолтним `onboarding@resend.dev` (Resend приймає його для тестових відправок на власну адресу). +3. **Пройти OTP-флоу без пошти** — дістати код із колекції `verifications` в адмінці (identifier `email-verification-otp-`, у value до першого `:`), або взагалі оминути реєстрацію через dev-login. +4. **Перевірити верстку шаблону** — функції `buildOtpEmailHtml`/`buildInviteEmailHtml` чисті: викличте в скрипті через `tsx` і збережіть результат в `.html`, відкрийте в браузері. + +## Куди дивитись у коді + +| Що | Файл | +| --- | --- | +| Адаптер Resend | `src/payload.config.ts` (ключ `email`) | +| OTP-відправка (emailOTP) і invite | `src/lib/auth/options.ts` | +| OTP реєстрації (прямий Resend) | `src/app/api/auth/verify-registration/route.ts` | +| HTML-шаблони | `src/lib/email/verification-otp.ts`, `src/lib/email/admin-invite.ts` | +| Кнопка запрошення | `src/components/admin/InviteUserButton` | + +## Чеклист дебагу «лист не прийшов» + +1. `RESEND_API_KEY` задано в цьому оточенні? (Прод/превʼю мають різні env-скоупи — див. [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi).) +2. Який шлях у цього листа — адаптер чи прямий Resend? Консольний лог dev-сервера покриває лише адаптерний шлях. +3. Для OTP: чи не зʼїв запит rate limit? `otp-send:email` — 3 листи / 300 с на адресу, `otp-send:ip` — 20 / 600 с (див. [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting)). +4. `EMAIL_FROM` верифікований у Resend? Дефолтний `onboarding@resend.dev` працює лише для тестів. + +## Повʼязані статті + +- [Реєстрація через OTP](/admin/docs/technical/autentyfikatsiya/reiestratsiia-otp) — повний флоу OTP-реєстрації +- [Rate limiting](/admin/docs/technical/biznes-logika/rate-limiting) — ліміти на відправку кодів +- [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi) — змінні `RESEND_API_KEY`, `EMAIL_FROM` diff --git a/docs/admin-panel/technical/05-infrastruktura/_category.json b/docs/admin-panel/technical/05-infrastruktura/_category.json new file mode 100644 index 0000000..88536a3 --- /dev/null +++ b/docs/admin-panel/technical/05-infrastruktura/_category.json @@ -0,0 +1,4 @@ +{ + "label": "Інфраструктура", + "description": "База даних, міграції, деплой, сховище медіа, пошук та email." +} diff --git a/docs/admin-panel/technical/06-rozrobka/01-lokalne-seredovyshche.md b/docs/admin-panel/technical/06-rozrobka/01-lokalne-seredovyshche.md new file mode 100644 index 0000000..cc5bdf1 --- /dev/null +++ b/docs/admin-panel/technical/06-rozrobka/01-lokalne-seredovyshche.md @@ -0,0 +1,144 @@ +--- +title: Локальне середовище +description: Від клону до працюючого dev-сервера — вимоги, worktrees, Neon-бранч на сесію, dev-login і шпаргалка команд. +--- + +## Вимоги + +| Інструмент | Версія | Звідки | +| --- | --- | --- | +| Node.js | `22.x` | `engines` у `package.json` | +| pnpm | `^10` (зафіксовано `pnpm@10.30.2` у `packageManager`) | `corepack enable` підхопить сам | + +## Клон і встановлення + +```bash +git clone && cd learning-platform +pnpm ii # аліас: pnpm --ignore-workspace install +``` + +`pnpm ii` — канонічна команда встановлення: прапорець `--ignore-workspace` ізолює проєкт від будь-якого батьківського pnpm-workspace. + +Далі потрібні `.env` (секрети + `DATABASE_URL`) і `.env.local` — скопіюйте з робочої копії або візьміть у мейнтейнера. Мінімальний набір для повноцінної локальної роботи: + +| Змінна | Без неї | +| --- | --- | +| `DATABASE_URL` | нічого не працює (тільки в `.env`, ніколи в `.env.local`) | +| `PAYLOAD_SECRET` | Payload не стартує | +| `BETTER_AUTH_SECRET` | автентифікація не працює; падає production-білд | +| `BLOB_READ_WRITE_TOKEN` | аплоади йдуть на локальний диск (для більшості задач — ок), але падають `pnpm build` і `generate:importmap` у worktree-сценаріях | +| `PREVIEW_SECRET` | не працює draft-превʼю з адмінки | +| `RESEND_API_KEY` (опц.) | листи не шляться — очікувано в dev (див. [Email — Resend](/admin/docs/technical/infrastruktura/email)) | +| `CRON_SECRET` (опц.) | недоступний `POST /api/reindex-search` | + +Повний довідник змінних із місцями читання: [Деплой на Vercel](/admin/docs/technical/infrastruktura/deploi). + +## Git worktrees + +Паралельні задачі живуть у git worktrees (`.claude/worktrees/...`). Worktree — це окремий чекаут, і **env-файли в нього не переїжджають самі**: + +:::warning Скопіюйте .env і .env.local у кожен worktree +Без них ламаються неочевидні речі: production-білд (`pnpm build`) і `pnpm generate:importmap` падають через відсутні `BETTER_AUTH_SECRET` і `BLOB_READ_WRITE_TOKEN`. Симптоми виглядають як зламаний код, хоча це просто порожнє оточення. +::: + +```bash +cp ../learning-platform/.env ../learning-platform/.env.local . +``` + +## Neon-бранч на сесію + +Кожна сесія розробки отримує власний бранч бази, названий як git-гілка, з парентом `dev` — повні команди та пояснення (unpooled-правило, заборона `DATABASE_URL` у `.env.local`, авто-cleanup) у [База даних Neon](/admin/docs/technical/infrastruktura/baza-danykh). Коротко: + +```bash +pnpm exec neonctl branches create --name --parent dev \ + --project-id ancient-cell-80589995 --output json +pnpm exec neonctl connection-string --project-id ancient-cell-80589995 +# → DATABASE_URL у .env (директний рядок, БЕЗ -pooler) +``` + +Бранч успадковує від `dev` сід-дані: акаунт dev-адміна, демо-курси, коментарі. + +## Dev-сервер + +```bash +pnpm dev # next dev --turbopack, порт 3000 +``` + +Правила: + +- **Один сервер на worktree.** Кілька одночасно — лише з різних worktree, кожен зі своїм Neon-бранчем у власному `.env`. Ніколи два сервери проти одного бранча. +- **Порт 3000 — дефолт.** Якщо зайнятий сервером іншого worktree — не вбивайте його, стартуйте на вільному порту: `.claude/launch.json` має `autoPort: true`, вручну — `PORT=3001 pnpm dev`. +- На нестандартному порту Google OAuth і формовий sign-in можуть падати (`NEXT_PUBLIC_SERVER_URL` і `trustedOrigins` очікують `localhost:3000`) — використовуйте dev-login, він працює на будь-якому порту. + +## Вхід: dev-login + +Один перехід у браузері — і ви залогінені адміном: + +``` +http://localhost:3000/api/dev-login # → сесія + redirect на / +http://localhost:3000/api/dev-login?redirect=/admin +``` + +```bash +curl -si -c cookies.txt http://localhost:3000/api/dev-login # CLI-сесія +``` + +Креденшали: `dev-admin@example.com` / `dev-admin-password` (`src/lib/auth/dev-credentials.ts`). Маршрут самовиліковний — якщо акаунта немає (несідований бранч, стерта база), він сідить і повторює вхід. На Vercel вимкнений безумовно. Деталі й сід-дані: [Dev-login та сідінг](/admin/docs/technical/autentyfikatsiya/dev-login-i-sid). + +## Шпаргалка команд + +```bash +pnpm dev # dev-сервер (Turbopack, :3000) +pnpm build # production-білд +pnpm start # запуск production-білда +pnpm generate:types # регенерація src/payload-types.ts після зміни схеми +pnpm generate:importmap # регенерація import map після нового admin-компонента +pnpm test:int # інтеграційні тести (Vitest) +pnpm test:e2e # E2E (Playwright, білдить і стартує на :3100) +pnpm lint # ESLint +pnpm seed:dev-admin # РЕСЕТ даних dev-адміна до канонічного стану +pnpm dev:prod # локальний production-прогін: rm -rf .next && build && start +pnpm ii # встановлення залежностей (--ignore-workspace) +``` + +Нюанси окремих команд: + +- `pnpm generate:types` — після **кожної** зміни схеми; результат `src/payload-types.ts` комітиться. +- `pnpm generate:importmap` — після додавання будь-якого кастомного admin-компонента (рядкові шляхи `@/components/...` в конфізі резолвляться через згенерований `src/app/(payload)/admin/importMap.js`). +- `pnpm dev:prod` — production-білд локально; `/api/dev-login` у ньому вимкнений, поверніть його через `ALLOW_DEV_LOGIN=1`. +- Довільні скрипти запускайте як `tsx --env-file=.env