Caterium v17.6.0 - GitHub Cloudflare autodeploy
16
.github/workflows/qa.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
name: Caterium QA
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
qa:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- run: npm install
|
||||||
|
- run: npm run check:deploy
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- run: npm run test:e2e
|
||||||
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
.playwright/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
19
GITHUB-CLOUDFLARE-SETUP.txt
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
CATERIUM - GITHUB -> CLOUDFLARE AUTODEPLOY
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
https://github.com/pavlov346346-source/caterium-app
|
||||||
|
|
||||||
|
Existing Worker:
|
||||||
|
ancient-sound-04ab
|
||||||
|
|
||||||
|
1. Run PUSH-TO-GITHUB.bat.
|
||||||
|
2. Complete GitHub browser login if Git Credential Manager asks.
|
||||||
|
3. In Cloudflare: Workers & Pages -> ancient-sound-04ab -> Settings -> Builds -> Connect.
|
||||||
|
4. Choose GitHub and repository pavlov346346-source/caterium-app.
|
||||||
|
5. Production branch: main
|
||||||
|
6. Root directory: /
|
||||||
|
7. Build command: npm run check:deploy
|
||||||
|
8. Deploy command: npx wrangler deploy
|
||||||
|
9. Save and deploy.
|
||||||
|
|
||||||
|
After this, each push to main that passes the Cloudflare build checks deploys to the same Worker.
|
||||||
62
PUSH-TO-GITHUB.bat
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo.
|
||||||
|
echo === Caterium - GitHub setup ===
|
||||||
|
echo Repository: https://github.com/pavlov346346-source/caterium-app.git
|
||||||
|
echo.
|
||||||
|
where git >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Git for Windows is not installed.
|
||||||
|
echo Install it from https://git-scm.com/download/win and run this file again.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist .git (
|
||||||
|
git init
|
||||||
|
)
|
||||||
|
git branch -M main
|
||||||
|
git config user.name "pavlov346346-source"
|
||||||
|
git config user.email "pavlov346346-source@users.noreply.github.com"
|
||||||
|
git remote get-url origin >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
git remote add origin https://github.com/pavlov346346-source/caterium-app.git
|
||||||
|
) else (
|
||||||
|
git remote set-url origin https://github.com/pavlov346346-source/caterium-app.git
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Checking release before commit...
|
||||||
|
call npm --version >nul 2>nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
if not exist node_modules call npm install
|
||||||
|
call npm run check:deploy
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo QA failed. Nothing will be pushed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo Node.js not found - skipping local npm QA. GitHub/Cloudflare will run checks after push.
|
||||||
|
)
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git diff --cached --quiet
|
||||||
|
if errorlevel 1 (
|
||||||
|
git commit -m "Caterium v17.6.0 - GitHub Cloudflare autodeploy"
|
||||||
|
) else (
|
||||||
|
echo No new changes to commit.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Sending to GitHub. If this is the first time, Git Credential Manager may open your browser for GitHub sign-in.
|
||||||
|
git push -u origin main
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Push did not complete. If the repository already contains a README, run UPDATE-FROM-REMOTE-AND-PUSH.bat.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
echo SUCCESS: GitHub repository updated.
|
||||||
|
echo Next: connect this repository in Cloudflare Workers Builds.
|
||||||
|
pause
|
||||||
25
README.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Caterium
|
||||||
|
|
||||||
|
Caterium catering SaaS frontend with Supabase backend.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
Current Cloudflare Worker: `ancient-sound-04ab`
|
||||||
|
|
||||||
|
Cloudflare publishes **only `public/`**. SQL, Edge Functions, tests and documentation are not web-accessible.
|
||||||
|
|
||||||
|
## Deploy checks
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run check:deploy
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cloudflare Git deployment
|
||||||
|
- Build command: `npm run check:deploy`
|
||||||
|
- Deploy command: `npx wrangler deploy`
|
||||||
|
- Production branch: `main`
|
||||||
|
- Root directory: `/`
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
Supabase Edge Functions are versioned under `supabase/functions/`.
|
||||||
|
SQL migration/history files live under `ops/sql/`.
|
||||||
13
UPDATE-FROM-REMOTE-AND-PUSH.bat
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
where git >nul 2>nul || (echo Install Git for Windows first.& pause & exit /b 1)
|
||||||
|
if not exist .git git init
|
||||||
|
git branch -M main
|
||||||
|
git remote get-url origin >nul 2>nul && git remote set-url origin https://github.com/pavlov346346-source/caterium-app.git || git remote add origin https://github.com/pavlov346346-source/caterium-app.git
|
||||||
|
git fetch origin main
|
||||||
|
if not errorlevel 1 git pull --rebase origin main --allow-unrelated-histories
|
||||||
|
git add -A
|
||||||
|
git diff --cached --quiet || git commit -m "Update Caterium"
|
||||||
|
git push -u origin main
|
||||||
|
pause
|
||||||
34
docs/DEVELOPER-CONSOLE-V22.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Developer console v17.5.22
|
||||||
|
|
||||||
|
The developer console is enabled only for users present in `public.sun_platform_admins`.
|
||||||
|
Platform-wide actions use `sun_dev_*` RPC wrappers and require Supabase MFA assurance level `aal2`.
|
||||||
|
|
||||||
|
## Developer sections
|
||||||
|
- Overview
|
||||||
|
- Companies
|
||||||
|
- Accounts
|
||||||
|
- Plans
|
||||||
|
- Features
|
||||||
|
- System
|
||||||
|
- Backups
|
||||||
|
- Journal
|
||||||
|
|
||||||
|
## Ordinary-company settings
|
||||||
|
Ordinary users keep company-facing settings only: profile/company, company users and roles, subscription view, appearance, client offer, order/payment colors and statuses, reference lists, documents and QR/form settings.
|
||||||
|
|
||||||
|
The following technical controls are not shown to ordinary users:
|
||||||
|
- Supabase Project URL / publishable key
|
||||||
|
- reconnecting the Supabase project
|
||||||
|
- manual cloud pull/push/migration controls
|
||||||
|
- platform diagnostics
|
||||||
|
- platform-wide backup/restore
|
||||||
|
- global plans and feature flags
|
||||||
|
- all-company/all-account directories
|
||||||
|
- starter catalog management
|
||||||
|
- platform audit journal
|
||||||
|
|
||||||
|
## Support mode
|
||||||
|
A developer can open a company in support mode. The cloud snapshot is loaded into the local working copy, but writes and cloud sync are disabled. Exiting support mode clears that local support copy and restores the developer's own workspace copy.
|
||||||
|
|
||||||
|
## Auth-admin limitation in this build
|
||||||
|
The app can list all registered Auth accounts through the server-side `sun_dev_list_users` RPC. The source for a separate protected Edge Function for global Auth ban/unban and password-recovery mail is included under `supabase/functions/caterium-platform-auth-admin`, but that Edge Function was not deployed from the current build environment. No service-role secret is embedded in the application archive.
|
||||||
368
docs/HISTORY.txt
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
v17.5.21 — Настройки разделены на 6 вкладок: Аккаунт, Оформление, Предложение, Заказы, Справочники, Документы.
|
||||||
|
v17.5.17 - inline control-list editor + guaranteed adaptive table photos
|
||||||
|
v17.5.16 — предпросмотр предложения и PDF используют одни и те же страницы; редактор прямо в окне предложения; финальные фото максимум 2 и только в свободное место
|
||||||
|
v17.5.15 — новый шаблон предложения «Солнце Editorial» с единой структурой просмотра и PDF
|
||||||
|
v17.5.14 — фото каталога/Premium и адаптивная галерея PDF
|
||||||
|
- Единый fallback фото для каталога, Premium, предложения и PDF: локальный файл -> API-кэш -> оригинал Tilda.
|
||||||
|
- Запуск сервера больше не блокируется неполным фотокэшем.
|
||||||
|
- Локальные резервные фото добавлены для отсутствовавших вариантов боксов №5 и №8.
|
||||||
|
- Финальный блок «Как это выглядит на вашем столе» заполняет свободное место 1–3 фотографиями.
|
||||||
|
|
||||||
|
v17.5.9 — Календарь: цвета по оплате + печать для кухни без финансов
|
||||||
|
- Календарь использует те же настраиваемые цвета оплаты, что и карточки заказов.
|
||||||
|
- В печати календаря скрыты суммы заказов, предоплаты, остатки и денежные значения внутри заказов.
|
||||||
|
- В печати остаются дата, время, заказ, мероприятие, клиент/адрес и цветовая маркировка.
|
||||||
|
|
||||||
|
v17.5.8 — PDF: белый Light, продающий блок и финальная фотогалерея
|
||||||
|
- Светлый фирменный шаблон переведён с кремового на чистый белый фон.
|
||||||
|
- Короткий текст первого экрана отделён от нового редактируемого продающего блока.
|
||||||
|
- При миграции старый длинный текст первого экрана переносится в продающий блок, чтобы ничего не потерять.
|
||||||
|
- PDF рассчитывает высоту продающего текста и переносит продолжение на следующую страницу без обрезания.
|
||||||
|
- В конец предложения добавлена адаптивная галерея из 10 предоставленных фотографий.
|
||||||
|
- Фотографии галереи упакованы побайтово в legacy-images.bin без повторного JPEG-сжатия и без увеличения числа файлов.
|
||||||
|
|
||||||
|
SUN CATERING — ИСТОРИЯ ИЗМЕНЕНИЙ
|
||||||
|
|
||||||
|
Этот файл объединяет прежние V*-CHANGES.txt. Исторические записи сохранены по версиям.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V12-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v12
|
||||||
|
|
||||||
|
1. Шесть шаблонов «Предложения клиенту» теперь имеют разные структуры, а не только разные цвета.
|
||||||
|
2. Все шаблоны используют реальные фотографии выбранных боксов из каталога.
|
||||||
|
3. PDF-верстка переработана: измеряемый перенос строк, динамические высоты блоков, отдельное состояние canvas, защита от наложения текста.
|
||||||
|
4. «Фирменные цвета интерфейса»: расширенный редактор левой панели, активных пунктов, служебных кнопок, уведомлений и основного интерфейса.
|
||||||
|
5. Значки меню: стандартные SVG или пользовательские символы/эмодзи для каждого раздела.
|
||||||
|
6. Одна кнопка возвращает стандартную версию интерфейса (исходная фиолетовая панель + стандартные значки).
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V13-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v13
|
||||||
|
|
||||||
|
- Исправлена невидимая подпись на тёмных основных кнопках (PDF, Сохранить и т.п.).
|
||||||
|
- Настройки темы теперь работают как живой предпросмотр: изменения видны сразу, но сохраняются только по кнопке «Сохранить настройки».
|
||||||
|
- Добавлена кнопка «Отменить изменения».
|
||||||
|
- Основной цвет левой панели вынесен отдельным крупным параметром; можно менять градиент, значки, текст, активные пункты и служебные элементы.
|
||||||
|
- Добавлены 5 наборов значков плюс индивидуальный выбор значка для каждого раздела.
|
||||||
|
- «Вернуть стандартную версию» одним нажатием восстанавливает исходную фиолетовую панель, стандартные цвета и SVG-значки.
|
||||||
|
- Excel импорт/экспорт удалён из экрана Настройки.
|
||||||
|
- Левая панель сворачивается до 74 px и запоминает состояние.
|
||||||
|
- Обновлён PWA cache.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V14-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Sun Catering v14
|
||||||
|
|
||||||
|
- Simplified interface color settings.
|
||||||
|
- Removed icon pack and per-icon customization settings.
|
||||||
|
- Brand palette is collapsed behind "Показать фирменные цвета Солнца".
|
||||||
|
- Added sidebar presets: Standard purple, Brand dark, Brand light, Brand green, Solar graphite.
|
||||||
|
- Theme changes affect only preview until Save Settings is clicked.
|
||||||
|
- Main sidebar background is explicitly applied to the real sidebar after saving, including mobile navigation.
|
||||||
|
- Added manual controls for main sidebar color, second sidebar color and icon color plus advanced sidebar colors.
|
||||||
|
- Restores standard purple sidebar with one button.
|
||||||
|
- Sidebar logo/brand is now a link to New Order.
|
||||||
|
- Updated PWA cache version.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V15-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг — v15
|
||||||
|
|
||||||
|
Предложение клиенту:
|
||||||
|
- Номер заказа удалён из клиентского предложения и имени PDF.
|
||||||
|
- Дата остаётся и может быть включена/выключена в настройках.
|
||||||
|
- Количество боксов считается только по позициям категории «Боксы».
|
||||||
|
- Вес еды считается только по категории «Боксы»; напитки, посуда и дополнения исключены.
|
||||||
|
- В «Итого» можно выбрать «Вес еды на гостя» или «Количество канапе на гостя».
|
||||||
|
- Добавлены централизованные настройки текстов и видимости блоков предложения.
|
||||||
|
- Нижние кнопки «Скачать PDF» / «Заказать меню» удалены из всех клиентских шаблонов.
|
||||||
|
- Управление предложением остаётся в верхнем окне: «Скачать PDF», «Обновить», «Закрыть».
|
||||||
|
- Исправлено позиционирование и перенос строк в PDF, в том числе многострочные заголовки.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V16-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v16 — SaaS foundation
|
||||||
|
|
||||||
|
- 3 тарифа: Базовый / Профессиональный / Полный.
|
||||||
|
- Новый workspace получает 14 дней Полного тарифа.
|
||||||
|
- После окончания: 7 дней только просмотр, затем блокировка без удаления данных.
|
||||||
|
- Базовый: 1 пользователь. Профессиональный: до 3. Полный: без лимита.
|
||||||
|
- Профессиональный не включает редактирование боксов и предложения клиенту.
|
||||||
|
- Серверная проверка подписки и функций в Supabase RPC — ограничения нельзя снять только изменением интерфейса браузера.
|
||||||
|
- Каждая компания работает в отдельном workspace; сервер фильтрует данные по членству и тарифу.
|
||||||
|
- Регистрация компании: пустая база или демо.
|
||||||
|
- Карточка тарифа и срока в Настройках.
|
||||||
|
- Недоступные функции помечаются замком и показывают сравнение тарифов.
|
||||||
|
- Отдельный SaaS-кабинет владельца сервиса: компании, тарифы, сроки, блокировка, индивидуальные функции.
|
||||||
|
- Ограничение количества активных сотрудников проверяется на сервере при приглашении и повторной активации.
|
||||||
|
- Существующий workspace «Солнце Кейтеринг» сохранён на Полном тарифе до 2099 года.
|
||||||
|
- SaaS RPC закрыты от анонимного доступа.
|
||||||
|
- Миграции текущего Supabase уже применены. Для нового проекта: SUPABASE-SAAS-V16.sql + SUPABASE-SAAS-V16-FINALIZE.sql.
|
||||||
|
|
||||||
|
Пока не подключено: автоматическое списание, платёжный webhook и публичный checkout. Тарифами можно управлять вручную из SaaS-кабинета.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.1-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.1 — исправление входа на новом устройстве
|
||||||
|
|
||||||
|
- Рабочие базы после входа всегда запрашиваются с сервера через sun_my_workspaces().
|
||||||
|
- Если у пользователя одна база, она выбирается автоматически.
|
||||||
|
- Убрана гонка между успешной авторизацией и загрузкой membership.
|
||||||
|
- Экран одноразового кода приглашения временно отключён.
|
||||||
|
- Создание и принятие приглашений временно запрещены на сервере.
|
||||||
|
- В настройках убраны элементы создания/ввода приглашений.
|
||||||
|
- Регистрация через основной gate временно скрыта; вход — email + пароль.
|
||||||
|
- Обновлён PWA cache, чтобы старый экран не оставался на другом компьютере.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.2-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.2 — полное описание боксов в предложениях клиенту
|
||||||
|
|
||||||
|
- Во всех 6 шаблонах предложения убрано ограничение на первые 2–3 составляющие бокса.
|
||||||
|
- Теперь выводится полный состав бокса: все строки/виды, сохранённые в composition.
|
||||||
|
- Карточки и строки предложения растут по высоте автоматически.
|
||||||
|
- Добавлен нормальный перенос длинного текста без обрезки.
|
||||||
|
- Исправлены PDF всех 6 шаблонов: описание больше не ограничивается 2 составляющими или 3 строками.
|
||||||
|
- Midnight Compact PDF теперь также выводит состав под названием позиции, а не только название и вес.
|
||||||
|
- Neon Emerald PDF теперь выводит полный состав, а не только вес/раздел.
|
||||||
|
- Black Gold и Emerald Gold PDF измеряют полный текст и увеличивают карточку до нужной высоты.
|
||||||
|
- Обновлён PWA cache, чтобы браузеры не держали старую версию client-offer.js.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.3-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.3 — актуальные данные предложения клиенту
|
||||||
|
|
||||||
|
Исправлено:
|
||||||
|
- Предложение клиенту больше не открывает старый сохранённый снимок после изменения заказа.
|
||||||
|
- При каждом открытии сравнивается сигнатура текущего заказа и снимка предложения.
|
||||||
|
- Если количество, цена, гости, скидка, доставка или позиции изменились — предложение пересоздаётся автоматически.
|
||||||
|
- Кнопка «Предложение клиенту» в редакторе использует текущий draft заказа, включая ещё не сохранённые изменения.
|
||||||
|
- Несохранённый draft не записывается обратно в сохранённый заказ; показывается предупреждение.
|
||||||
|
- Перед скачиванием PDF выполняется повторная проверка актуальности данных.
|
||||||
|
- Все 6 шаблонов используют один актуальный snapshot, поэтому сумма, количество боксов, вес и количество позиций совпадают.
|
||||||
|
- Номер заказа убран из внутренней строки метаданных окна предложения; остаётся только время версии.
|
||||||
|
- PWA cache обновлён до v33, чтобы старый snapshot-код не оставался в браузере.
|
||||||
|
|
||||||
|
Контрольный пример заказа №25:
|
||||||
|
- количества боксов: 3+3+2+3+2+2 = 15
|
||||||
|
- сумма: 60 080 ₽
|
||||||
|
- гостей: 60
|
||||||
|
- сумма на гостя: 1 001 ₽ (округление)
|
||||||
|
- вес еды: 11 830 г
|
||||||
|
- вес на гостя: 197 г (округление)
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.4-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.4 — вес боксов + итоговое количество канапе
|
||||||
|
|
||||||
|
1. В редактор бокса добавлено отдельное поле «Вес бокса, г».
|
||||||
|
2. Вес бокса сохраняется в каталоге и синхронизируется как часть данных позиции.
|
||||||
|
3. Вес показывается на карточках каталога, в настройке каталога, в строках заказа, в бланке заказа/чеке и во всех предложениях клиенту/PDF.
|
||||||
|
4. В предложениях состав бокса показывает итоговое количество каждого вида закусок с учётом количества заказанных боксов.
|
||||||
|
Пример: 5 шт. канапе в одном боксе × 3 бокса = 15 шт. в предложении.
|
||||||
|
5. Обновление веса или состава бокса сразу подхватывается открытым предложением клиенту.
|
||||||
|
6. Сводный вес еды на гостя пересчитывается по актуальному весу боксов.
|
||||||
|
7. Версия snapshot предложения повышена до v5, чтобы старые предложения автоматически пересобрались.
|
||||||
|
8. Обновлён PWA-кэш.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.5-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.5
|
||||||
|
|
||||||
|
1. В редактор каждого бокса добавлено отдельное поле «Количество канапе / шт. в боксе».
|
||||||
|
2. Общее количество больше не хранится как строка состава.
|
||||||
|
3. В каждой строке состава добавлено поле «Вес 1 шт., г».
|
||||||
|
4. Старые веса вида «— 20 г» автоматически переносятся в отдельное поле веса одной штуки.
|
||||||
|
5. Бокс №7: количество = 30 шт.
|
||||||
|
6. Бокс №18 «Тосты ассорти»: строка «25 шт. тостов ассорти» удалена из состава; в составе остаются 2 реальных вида, а 25 хранится как общее количество.
|
||||||
|
7. Количество и вес одной штуки отображаются в клиентском предложении и PDF.
|
||||||
|
8. Количество каждого вида в предложении умножается на число заказанных боксов.
|
||||||
|
9. Каталог/заказ/бланк показывают вес и общее количество бокса.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.5.1-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.5.1 FINAL
|
||||||
|
|
||||||
|
База: v17.5. Облако, настройки разработчика и ребрендинг не изменялись.
|
||||||
|
|
||||||
|
1. В предложении клиенту для бокса показываются итоговые данные заказа: количество боксов, общий вес, диапазон гостей и общее количество штук.
|
||||||
|
Пример: 3 бокса · Общий вес: 3 000 г · 15–25 гостей · Всего: 75 шт.
|
||||||
|
2. Количество готовых штук учитывается для всех пищевых боксов, включая мини-бургеры и другие разделы, а не только раздел «Канапе».
|
||||||
|
3. Вес еды на гостя всегда округляется вверх до ближайших 5 г: 188 → 190, 191 → 195.
|
||||||
|
4. Показатель «Канапе на гостя» всегда округляется вверх до целого: 2,3 → 3.
|
||||||
|
5. Те же расчёты используются в экранном предложении и PDF.
|
||||||
|
6. В PDF карточки в одной строке выравниваются по высоте.
|
||||||
|
7. При небольшом переполнении страницы карточки/блоки используют компактную высоту вместо переноса малого остатка на новую страницу.
|
||||||
|
8. Блок расчёта стоимости старается оставаться на одной странице с нижним примечанием, чтобы не появлялась отдельная страница с одной-двумя строками.
|
||||||
|
9. В Light и Midnight Glass удалена повторная PDF-галерея после карточек: фото уже находятся внутри карточек, поэтому лишние страницы не создаются.
|
||||||
|
10. PDF-логика применена ко всем 6 шаблонам предложения.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.5.2-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Солнце Кейтеринг v17.5.2 FINAL
|
||||||
|
|
||||||
|
База: v17.5.1. Новая архитектура облака, настройки разработчика и ребрендинг не добавлялись.
|
||||||
|
|
||||||
|
1. Диапазон гостей бокса больше не показывается в карточках каталога, менеджере, каталожных PDF и в предложении клиенту. Само значение оставлено в данных для обратной совместимости.
|
||||||
|
2. В предложении клиенту убрано дублирующее начало строки «3 бокса». Количество остается в штатной колонке «× 3», а метастрока показывает только общий вес и общее количество штук.
|
||||||
|
3. В окне «Вкладки каталога» добавлен выбор цвета каждой вкладки. Надпись автоматически становится светлой или темной в зависимости от фона.
|
||||||
|
4. В редакторе позиций подписи унифицированы: «Цена, ₽» и «Сохранить».
|
||||||
|
5. Ошибка `sunBoxes exceeded the quota` исправлена без смены архитектуры облака: новые фото каталога оптимизируются перед локальным сохранением, а при загрузке из облака `sunBoxes` сжимает большие data-URL и повторяет запись с более сильным сжатием, если лимит все еще превышен.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.5.3-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
V17.5.3 — каталог 02.09.2026, Премиум и скидки
|
||||||
|
|
||||||
|
1. Каталог «Боксы» полностью заменён по пользовательской выгрузке «Все боксы — Солнце Кейтеринг (2).pdf».
|
||||||
|
2. Импортировано 113 позиций: название, вес, актуальная цена и ссылка на фото. Данные о количестве гостей не импортируются и удалены из названий.
|
||||||
|
3. Позиции 69–70 (Морс, Компот) находятся во вкладке «Напитки».
|
||||||
|
4. Позиции 85–113 находятся во вкладке «Премиум». Вкладка «Премиум» расположена между «Боксы» и «Напитки».
|
||||||
|
5. Для строк с перечёркнутой ценой сохранена текущая цена и отдельная «Цена до скидки». В редакторе добавлено необязательное поле «Цена до скидки, ₽».
|
||||||
|
6. В карточках каталога и менеджере старая цена показывается перечёркнутой; расчёт заказа использует текущую цену.
|
||||||
|
7. «Премиум» считается пищевой категорией для веса/штучности и предложения клиенту.
|
||||||
|
8. Фото используются напрямую по оригинальным ссылкам static.tildacdn.com из PDF. Это не увеличивает localStorage и не возвращает ошибку quota.
|
||||||
|
9. Облачная архитектура, SaaS, RBAC и настройки разработчика не менялись.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.5.4-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
17.5.4
|
||||||
|
|
||||||
|
- Настройки при открытии прокручиваются к началу; верхняя шапка настроек закреплена.
|
||||||
|
- Блоки внизу настроек упорядочены: Реквизиты компании -> QR-код -> Настройки бланка -> Облако Supabase -> История изменений.
|
||||||
|
- В выборе шаблона предложения оставлены только: Светлый фирменный, Midnight Glass, Emerald Gold.
|
||||||
|
- Для адреса доставки добавлена ручная проверка точного адреса: до 5 вариантов с регионом/районом/населенным пунктом, превью точки и явное подтверждение.
|
||||||
|
- Автоподстановка первого результата геокодера отключена для кнопки проверки адреса.
|
||||||
|
- Автокомплит адресов не используется; запрос выполняется только по кнопке.
|
||||||
|
- Текущая архитектура облака не менялась.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ИСТОЧНИК: V17.5.5-CHANGES.txt
|
||||||
|
==============================================================================
|
||||||
|
Sun Catering v17.5.5
|
||||||
|
|
||||||
|
- Исправлено предложение клиенту для заказов, созданных до замены каталога 02.09.2026.
|
||||||
|
- Старые ID каталога разрешаются через встроенный каталог v17.5, поэтому возвращаются название, вес, состав, цена и фотография позиции.
|
||||||
|
- Старые локальные фотографии берутся из catalog-images.js и безопасно встраиваются в PDF без зависимости от CDN.
|
||||||
|
- Снимки предложения, где позиции уже успели превратиться в «Позиция»/логотип, автоматически считаются устаревшими и пересобираются.
|
||||||
|
- Новая структура каталога, Premium, настройки, облако и адреса не менялись.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
Sun Catering v17.5.6 — аудит и компактная сборка
|
||||||
|
==============================================================================
|
||||||
|
- Проведён статический аудит JavaScript, встроенных скриптов, локальных ссылок и серверных маршрутов.
|
||||||
|
- Исправлен /healthz: версия теперь читается из release-manifest.json и не может молча оставаться на старом 17.5.0.
|
||||||
|
- Premium (категория 5) теперь последовательно обрабатывается как бокс в заготовках, фотокарточках и PDF-каталоге; название раздела в PDF — «Премиум».
|
||||||
|
- Загрузка внешних фото в PDF-каталоге защищена CORS-режимом и таймаутом, чтобы не портить canvas и не зависать бесконечно.
|
||||||
|
- 19 браузерных JS-модулей объединены в app-runtime.js без минификации; внутри сохранены маркеры исходных модулей.
|
||||||
|
- mobile-pwa.css встроен в index.html.
|
||||||
|
- Дублирование старых фото устранено: catalog/004.jpg–060.jpg упакованы побайтово в legacy-images.bin без перекодирования и без потери JPEG-качества; catalog/001.jpg–003.jpg оставлены отдельными для SaaS-демо.
|
||||||
|
- Удалён catalog-images.js, который содержал ещё одну base64-копию тех же изображений и создавал лишнюю нагрузку на разбор JavaScript.
|
||||||
|
- Удалены неиспользуемые превью трёх отключённых шаблонов предложения.
|
||||||
|
- Генерируемые файлы сервера (.sun-sync-secret, sun-sync-data.json, PHONE-LINK.txt) не поставляются в чистой сборке и создаются автоматически.
|
||||||
|
- Исправлена устаревшая подпись версии в start-local-test.bat (v17.3 → v17.5.6).
|
||||||
|
- Облачная архитектура, Supabase-схемы и пользовательские данные не изменялись.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
Sun Catering v17.5.7 — составы боксов с сайта
|
||||||
|
==============================================================================
|
||||||
|
- Добавлен человекочитаемый состав боксов для предложения клиенту и PDF.
|
||||||
|
- 56 позиций получают состав сразу из проверенного/ранее импортированного каталога; 6 популярных боксов сверены с текущей главной страницей solnce-keytering.ru 03.09.2026.
|
||||||
|
- Добавлена автоматическая синхронизация составов с публичных каталогов /catalog и /catalog_black через локальный server.js.
|
||||||
|
- Синхронизация не импортирует данные о количестве гостей и не меняет цену/фото/вес.
|
||||||
|
- После успешной синхронизации состав сохраняется в sunBoxes и остаётся доступен локально; повторная проверка выполняется не чаще одного раза в 6 часов.
|
||||||
|
- В менеджере Боксов/Премиум добавлена кнопка «Обновить составы с сайта».
|
||||||
|
- В редакторе бокса добавлено поле «Состав для клиента»; ручная правка защищена от последующей автозамены сайтом.
|
||||||
|
- Миграция каталога теперь сохраняет пользовательские позиции и существующие ручные изменения вместо полного удаления категорий Боксы/Напитки/Премиум.
|
||||||
|
- Архитектура облака и Supabase не изменялась.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
Sun Catering v17.5.10 - Premium compositions from site
|
||||||
|
==============================================================================
|
||||||
|
- Premium sync now opens the full Tilda product card through getproduct after the catalog list.
|
||||||
|
- Nested product text fields are scanned for composition.
|
||||||
|
- Missing Premium composition forces refresh even inside the normal 6-hour interval.
|
||||||
|
- Manual composition is preserved and guest/person data is not imported.
|
||||||
|
- Sync status reports Premium matches and changes separately.
|
||||||
|
|
||||||
|
17.5.11
|
||||||
|
- Убрана видимая кнопка «Обновить составы с сайта».
|
||||||
|
- Первый бокс больше не показывает «АКЦИЯ» без перечеркнутой старой цены.
|
||||||
|
- Фото старого каталога 001-060 снова лежат отдельными JPG в catalog; текущие 113 позиций используют catalog/current-XXX.jpg и автоматически кэшируются сервером в эту же папку.
|
||||||
|
- «Состав для клиента» отделен от «Состав / ТТК»; клиентское предложение и PDF используют клиентский состав.
|
||||||
|
- В бланке заказа рядом с QR добавлен редактируемый текст, сохраняемый в заказе.
|
||||||
|
|
||||||
|
|
||||||
|
17.5.12
|
||||||
|
- Аудит производительности и стабильности.
|
||||||
|
- PDF предложения больше не генерируется автоматически при открытии: тяжёлая сборка запускается только по кнопке «Скачать PDF».
|
||||||
|
- Убрана массовая фоновая загрузка недостающих фото каталога при старте сервера; фото кэшируются по требованию.
|
||||||
|
- Добавлена 5-минутная пауза после неудачной загрузки фото, чтобы при плохом интернете не повторять одни и те же запросы.
|
||||||
|
- Фоновая синхронизация составов с сайтом отложена до простоя интерфейса; число параллельных запросов деталей уменьшено до 2.
|
||||||
|
- Синхронизация составов больше не вызывает полный render() приложения и обновляет открытое предложение только при реальном изменении позиции.
|
||||||
|
- Снижена частота фоновых проверок облака/прав/тарифа и ограничены тяжёлые MutationObserver соответствующими разделами интерфейса.
|
||||||
|
- Для недоступных фотографий каталога добавлен безопасный fallback на логотип.
|
||||||
|
- Финальная фотогалерея хранится обычными JPEG и в предпросмотре загружается напрямую; Base64-конвертация фото выполняется только при скачивании PDF.
|
||||||
|
|
||||||
|
|
||||||
|
17.5.13
|
||||||
|
- Каталог переведён на полностью локальные фотографии во время обычной работы приложения.
|
||||||
|
- Добавлен prepare-catalog-photos.js: перед первым запуском он проверяет current-001.jpg–current-113.jpg и докачивает только отсутствующие файлы.
|
||||||
|
- Одинаковые исходные URL скачиваются один раз и сохраняются во все соответствующие позиции; добавлены проверка JPEG, атомарная запись, повторы и ограничение параллельности.
|
||||||
|
- start-mobile-server.bat и start-local-test.bat запускают сервер только после успешной проверки 113/113 фотографий.
|
||||||
|
- Добавлен DOWNLOAD-ALL-CATALOG-PHOTOS.bat для ручной повторной подготовки каталога.
|
||||||
|
- Удалена загрузка фотографий с Tilda/CDN из server.js во время просмотра каталога и формирования PDF.
|
||||||
|
- /healthz показывает ready/total/complete для локальных фотографий каталога.
|
||||||
|
- Облачная архитектура и данные заказов не менялись.
|
||||||
|
|
||||||
|
2026-09-05 — v17.5.18
|
||||||
|
- Разделение локальной рабочей копии по Supabase workspace/аккаунтам.
|
||||||
|
- Новая компания: текущий каталог 113 позиций + пустые рабочие данные.
|
||||||
|
- Оптимизированы JPEG каталога; удалён устаревший статический PDF-каталог.
|
||||||
|
|
||||||
|
2026-09-05 — v17.5.19
|
||||||
|
- Исправлен надёжный выход из аккаунта: local-scope Supabase signOut, ограниченное ожидание сети, локальный fallback токена и обновлённый PWA cache.
|
||||||
|
- Сохранена изоляция workspace и стартовый каталог 113 позиций из v17.5.18.
|
||||||
|
|
||||||
|
2026-09-05 — v17.5.20
|
||||||
|
- Аккаунт и вход подняты в начало Настроек.
|
||||||
|
- Резервное копирование и диагностическое резервирование убраны из обычных Настроек.
|
||||||
|
|
||||||
|
|
||||||
|
2026-09-06 — v17.5.31
|
||||||
|
- Чат переведён на безопасную регистрацию в левом меню без DOM insertBefore между разными родителями.
|
||||||
|
- Раздел «Чат»: Общий / Личные / Заказы.
|
||||||
|
- Чат заказа встроен непосредственно в заказ и доступен также из общего списка заказных чатов.
|
||||||
|
- Убрана бесконечная переинициализация, вызывавшая каскады повторных ошибок.
|
||||||
264
docs/README-LEGACY.txt
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
CATERIUM · v17.6.0
|
||||||
|
|
||||||
|
v17.6.0 · STABILITY & SECURITY
|
||||||
|
- Зафиксирована отдельная rollback-сборка v17.5.31.
|
||||||
|
- Закрыт stored DOM-XSS в старом bootstrap; общий безопасный вывод вынесен в core/sun-safe.js.
|
||||||
|
- Старый bootstrap вынесен из index.html в legacy/bootstrap.js, динамические DOM-вставки защищены от insertBefore/NotFoundError.
|
||||||
|
- Production Edge Function caterium-create-employee возвращена в исходники проекта; секреты остаются только в окружении Supabase.
|
||||||
|
- Убран блокирующий offer-gallery-data.js; оставлены только реально используемые 001.jpg и 002.jpg, неключевые изображения грузятся лениво.
|
||||||
|
- PDF-упаковка сведена в один A4-движок; Light / Editorial Grid / Midnight Glass / Emerald Gold и PDF-каталог повторно проверены визуально.
|
||||||
|
- На мобильной ширине 390 px устранён document-level горизонтальный overflow; горизонтальные ленты прокручиваются внутри своих областей.
|
||||||
|
- Добавлены автоматические syntax/security/release checks и E2E-спецификация для следующих релизов.
|
||||||
|
|
||||||
|
v17.5.31 · СТАБИЛЬНАЯ НАВИГАЦИЯ ЧАТОВ
|
||||||
|
- Исправлена ошибка insertBefore при добавлении чата в сгруппированное левое меню.
|
||||||
|
- «Чат» — отдельный пункт бокового меню со счётчиком непрочитанных.
|
||||||
|
- Внутри: Общий / Личные / Заказы.
|
||||||
|
- Чат заказа открывается внутри самого заказа отдельной вкладкой «Чат».
|
||||||
|
- Та же переписка доступна из «Чат → Заказы».
|
||||||
|
- Серверная схема чата v29 и RLS не менялись.
|
||||||
|
|
||||||
|
v17.5.30 · ПРАВА АДМИНИСТРАТОРОВ
|
||||||
|
- У роли «Администратор» теперь можно индивидуально включать и отключать функции.
|
||||||
|
- Явные права сотрудника имеют приоритет над шаблоном роли и на сервере, и в интерфейсе.
|
||||||
|
- Проверка «последний активный администратор» считает других активных администраторов, а не блокирует второго администратора.
|
||||||
|
- Нельзя оставить компанию без активного администратора или без администратора с правом «Пользователи и права».
|
||||||
|
- Серверные изменения: SUPABASE-ADMIN-RIGHTS-V30.sql.
|
||||||
|
|
||||||
|
РЕГИСТРАЦИЯ
|
||||||
|
- Регистрация: название компании, email, пароль, подтверждение пароля.
|
||||||
|
- Подтверждение email по ссылке не требуется.
|
||||||
|
- После регистрации Caterium автоматически входит в аккаунт и создаёт компанию.
|
||||||
|
|
||||||
|
ПОЛЬЗОВАТЕЛИ И ПРАВА
|
||||||
|
1. Настройки → Аккаунт → Пользователи и права.
|
||||||
|
2. Нажмите «+ Добавить сотрудника».
|
||||||
|
3. Укажите имя, email и роль.
|
||||||
|
4. Новый аккаунт создаётся сразу и получает временный пароль; существующий аккаунт просто добавляется в компанию.
|
||||||
|
5. При первом входе по временному паролю сотрудник задаёт новый пароль.
|
||||||
|
|
||||||
|
ЧАТЫ
|
||||||
|
- «Чаты» в основной навигации: общий чат компании и личные диалоги сотрудников.
|
||||||
|
- В сохранённых заказах есть «Обсуждение», а в карточках заказов — кнопка «Чат».
|
||||||
|
- Есть непрочитанные, прочтение, «печатает…» и онлайн-статус.
|
||||||
|
- Можно отправлять фото и файлы: до 5 за сообщение, до 15 МБ каждый.
|
||||||
|
- Личные чаты серверно доступны только двум участникам; данные разных компаний изолированы.
|
||||||
|
- Вложения хранятся в приватном Supabase Storage bucket sun-chat.
|
||||||
|
|
||||||
|
ДЛЯ ТЕКУЩЕГО SUPABASE
|
||||||
|
- SUPABASE-CHAT-V29.sql уже применён. Для нового проекта выполняйте его после базовых/SaaS/RBAC миграций.
|
||||||
|
|
||||||
|
Сборка v17.5.21.
|
||||||
|
|
||||||
|
Главное изменение: Настройки разделены на 6 вкладок — Аккаунт, Оформление, Предложение, Заказы, Справочники и Документы. На мобильных вкладки прокручиваются горизонтально, последняя открытая вкладка запоминается. Резервное копирование и блок стабильности остаются скрыты из обычных Настроек.
|
||||||
|
|
||||||
|
|
||||||
|
Что изменено в v17.5.19:
|
||||||
|
- Исправлен выход из аккаунта: кнопки «Выйти» теперь завершают локальную сессию Supabase без зависания на сетевой синхронизации, с аварийным локальным сбросом токена текущего проекта.
|
||||||
|
- При выходе текущая локальная копия компании сохраняется отдельно; разделение данных аккаунтов из v17.5.18 сохранено.
|
||||||
|
- Обновлён PWA-кэш, чтобы браузер не оставался на старом обработчике выхода.
|
||||||
|
|
||||||
|
Что было добавлено в v17.5.18:
|
||||||
|
- рабочие данные теперь изолируются по облачной компании/workspace на одном устройстве;
|
||||||
|
- при смене аккаунта локальная копия предыдущей компании сохраняется отдельно в IndexedDB и очищается перед загрузкой другой компании;
|
||||||
|
- новая компания стартует без заказов, клиентов, финансов, склада и прочих рабочих данных, но с текущим каталогом из 113 позиций;
|
||||||
|
- фотографии каталога оптимизированы без смены формата (JPEG), чтобы уменьшить размер дистрибутива;
|
||||||
|
- старый статический PDF-каталог удалён из архива: актуальный PDF формируется по текущим данным приложения через кнопку «Скачать PDF».
|
||||||
|
|
||||||
|
Структура релиза сохранена без изменения облачной архитектуры: браузерные модули объединены в app-runtime.js, мобильный CSS встроен в index.html. Фотографии боксов хранятся обычными JPG в папке catalog: legacy-каталог 001.jpg–060.jpg находится там полностью, а актуальный каталог использует current-001.jpg–current-113.jpg. Перед первым запуском start-mobile-server.bat выполняет prepare-catalog-photos.js: он проверяет все 113 актуальных фотографий, один раз докачивает отсутствующие по исходным ссылкам сайта и только после полного результата запускает server.js. Во время обычной работы server.js больше не загружает фотографии с Tilda/CDN — каталог и PDF используют локальные файлы. После успешной первой подготовки интернет для фотографий каталога не нужен. Финальная фотогалерея предложения хранится отдельными JPEG в папке offer-gallery; преобразование изображений для PDF выполняется только после нажатия «Скачать PDF».
|
||||||
|
|
||||||
|
Файлы .sun-sync-secret, sun-sync-data.json и PHONE-LINK.txt намеренно не входят в чистый релиз: server.js создаёт их автоматически при первом запуске. Это не удаляет пользовательские данные из уже работающей установленной папки — при обновлении поверх существующей папки её текущие файлы данных нужно сохранить.
|
||||||
|
|
||||||
|
ВАЖНО ДЛЯ ПЕРВОГО ЗАПУСКА: если в архиве ещё нет всех current-001.jpg–current-113.jpg, компьютер должен быть подключён к интернету. Уже скачанные файлы сохраняются, поэтому при обрыве достаточно снова запустить start-mobile-server.bat. Отдельно повторить подготовку можно файлом DOWNLOAD-ALL-CATALOG-PHOTOS.bat.
|
||||||
|
|
||||||
|
Ниже объединены прежние инструкции проекта, чтобы в папке не было множества отдельных README-файлов.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ ADMIN-ACCESS.txt
|
||||||
|
==============================================================================
|
||||||
|
СОЛНЦЕ КЕЙТЕРИНГ — АККАУНТЫ И ПРАВА (Cloud RBAC)
|
||||||
|
|
||||||
|
ГЛАВНЫЙ АДМИНИСТРАТОР
|
||||||
|
Email: dpavlov346@bk.ru
|
||||||
|
Роль: Администратор
|
||||||
|
Рабочая база: Солнце Кейтеринг
|
||||||
|
Пароль в архиве не хранится.
|
||||||
|
|
||||||
|
КАК ВОЙТИ
|
||||||
|
1. Откройте приложение.
|
||||||
|
2. На экране «Вход в Солнце Кейтеринг» введите email и пароль облачного аккаунта.
|
||||||
|
3. После входа рабочая база «Солнце Кейтеринг» выбирается автоматически.
|
||||||
|
4. Быстрый PIN можно включить в Настройки -> Аккаунт. PIN работает только на этом устройстве.
|
||||||
|
|
||||||
|
КАК ДОБАВИТЬ СОТРУДНИКА
|
||||||
|
1. Администратор: Настройки -> Аккаунт.
|
||||||
|
2. Выберите роль и создайте одноразовый код приглашения.
|
||||||
|
3. Сотрудник на своём устройстве создаёт аккаунт email + пароль.
|
||||||
|
4. После входа вводит код приглашения.
|
||||||
|
5. Администратор открывает «Пользователи и права» и настраивает галочки.
|
||||||
|
|
||||||
|
МОБИЛЬНЫЙ ДОСТУП
|
||||||
|
Подробно: см. раздел README-MOBILE ниже в этом файле.
|
||||||
|
Постоянная плашка облака поверх всех разделов удалена.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ CLOUD-SETUP.txt
|
||||||
|
==============================================================================
|
||||||
|
СОЛНЦЕ КЕЙТЕРИНГ — ОБЛАЧНАЯ СИНХРОНИЗАЦИЯ
|
||||||
|
|
||||||
|
1. Supabase уже подключён к этому билду приложения.
|
||||||
|
2. SQL-схема, RLS, Storage и Realtime уже развернуты в проекте Supabase.
|
||||||
|
3. Откройте в приложении Настройки -> Аккаунт.
|
||||||
|
4. Создайте аккаунт владельца или войдите.
|
||||||
|
5. Создайте рабочую базу «Солнце Кейтеринг».
|
||||||
|
6. На первом компьютере нажмите «Перенести текущие данные в облако».
|
||||||
|
7. Для сотрудников создайте код приглашения. На другом устройстве сотрудник
|
||||||
|
создаёт свой аккаунт, вводит код и получает доступ к общей рабочей базе.
|
||||||
|
|
||||||
|
Что синхронизируется:
|
||||||
|
- заказы и клиентские данные;
|
||||||
|
- каталог, цены и составы;
|
||||||
|
- склад, движения, поставщики;
|
||||||
|
- финансы, сотрудники, маршруты, настройки и другие рабочие sun*-данные;
|
||||||
|
- загруженные изображения: они переносятся в приватный Supabase Storage.
|
||||||
|
|
||||||
|
Офлайн:
|
||||||
|
Приложение продолжает работать с локальной копией. Когда интернет появляется,
|
||||||
|
изменения автоматически отправляются в облако. Realtime сообщает другим
|
||||||
|
устройствам об изменениях и обновляет локальную копию.
|
||||||
|
|
||||||
|
Безопасность:
|
||||||
|
- в браузере используется только Publishable/anon key;
|
||||||
|
- доступ к данным проверяется Row Level Security;
|
||||||
|
- service_role / secret key в браузер добавлять нельзя;
|
||||||
|
- изображения лежат в приватном bucket sun-media.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ DEPLOY-ONLINE.txt
|
||||||
|
==============================================================================
|
||||||
|
ПОСТОЯННЫЙ ВХОД С ТЕЛЕФОНА
|
||||||
|
|
||||||
|
Приложение уже работает с облачной базой Supabase и не требует компьютера для хранения данных.
|
||||||
|
Чтобы открывать интерфейс из любой сети, папку приложения нужно один раз разместить на HTTPS static hosting (GitHub Pages / Cloudflare Pages / Netlify / Vercel).
|
||||||
|
После публикации на телефоне откройте HTTPS-адрес, войдите под тем же Supabase аккаунтом и добавьте сайт на главный экран как PWA.
|
||||||
|
Компьютер после этого может быть полностью выключен.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ README-MOBILE.txt
|
||||||
|
==============================================================================
|
||||||
|
СОЛНЦЕ КЕЙТЕРИНГ — МОБИЛЬНЫЙ ДОСТУП И ОБЛАКО
|
||||||
|
|
||||||
|
ВАЖНО: Supabase хранит общую базу данных, но сам интерфейс приложения должен быть открыт по адресу (URL).
|
||||||
|
|
||||||
|
Вариант 1 — в одной Wi-Fi сети (уже работает):
|
||||||
|
1. На компьютере запустите start-mobile-server.bat.
|
||||||
|
2. Откройте PHONE-LINK.txt.
|
||||||
|
3. На телефоне, подключенном к той же Wi-Fi сети, откройте указанный адрес.
|
||||||
|
4. Войдите тем же облачным email и паролем.
|
||||||
|
5. Выберите рабочую базу «Солнце Кейтеринг». Данные подтянутся из Supabase.
|
||||||
|
|
||||||
|
Вариант 2 — работать с телефона из любой точки мира (рекомендуется):
|
||||||
|
1. Разместите содержимое этого архива на обычном HTTPS-хостинге статического сайта (например, Cloudflare Pages / Netlify / Vercel).
|
||||||
|
2. На телефоне откройте полученный https:// адрес в Safari или Chrome.
|
||||||
|
3. Войдите своим email и паролем.
|
||||||
|
4. После первого открытия можно добавить сайт на главный экран как PWA.
|
||||||
|
5. Все устройства работают с одной рабочей базой Supabase; компьютер держать включенным не нужно.
|
||||||
|
|
||||||
|
АДМИНИСТРАТОР
|
||||||
|
Email: dpavlov346@bk.ru
|
||||||
|
Рабочая база: Солнце Кейтеринг
|
||||||
|
Пароль не хранится в архиве. Используется пароль облачного аккаунта.
|
||||||
|
|
||||||
|
УПРАВЛЕНИЕ ОБЛАКОМ
|
||||||
|
Постоянная плашка «Облако» поверх всех разделов удалена. Облако и пользователи открываются только через Настройки -> Аккаунт.
|
||||||
|
|
||||||
|
ОФЛАЙН
|
||||||
|
После первого успешного входа и загрузки базы PWA хранит локальную рабочую копию. При восстановлении интернета изменения синхронизируются с Supabase.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ ROLLBACK-V17.txt
|
||||||
|
==============================================================================
|
||||||
|
ОТКАТ v17
|
||||||
|
|
||||||
|
v17 не удаляет старую структуру sun_app_state и не заменяет существующий архив v16.
|
||||||
|
Если при тестировании найдена критическая проблема:
|
||||||
|
1. Закройте v17.
|
||||||
|
2. Запустите прежнюю папку v16.
|
||||||
|
3. Войдите в тот же Supabase аккаунт.
|
||||||
|
4. Загрузите облачную копию.
|
||||||
|
|
||||||
|
Новая v17 схема добавлена параллельно и не требуется для запуска v16.
|
||||||
|
Перед восстановлением старого состояния из backup используйте Настройки → Стабильность и резервирование → Восстановить.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ SAAS-V16-README.txt
|
||||||
|
==============================================================================
|
||||||
|
СОЛНЦЕ КЕЙТЕРИНГ — SaaS v16
|
||||||
|
|
||||||
|
Что уже работает:
|
||||||
|
- отдельная рабочая база (workspace) для каждой компании;
|
||||||
|
- 14 дней Полного тарифа для новых компаний;
|
||||||
|
- выбор «Пустая база» или «Загрузить демо» при создании компании;
|
||||||
|
- тарифы Базовый / Профессиональный / Полный;
|
||||||
|
- лимиты сотрудников: 1 / 3 / без лимита;
|
||||||
|
- серверная проверка тарифа и функций;
|
||||||
|
- после окончания: 7 дней только просмотр, затем блокировка без удаления данных;
|
||||||
|
- SaaS-кабинет владельца сервиса: компании, тариф, срок, блокировка, индивидуальная функция;
|
||||||
|
- текущая рабочая база Солнце Кейтеринг сохранена на Полном тарифе без практического ограничения срока.
|
||||||
|
|
||||||
|
Для нового Supabase-проекта:
|
||||||
|
1. Выполнить SUPABASE-SETUP.sql.
|
||||||
|
2. Выполнить SUPABASE-RBAC-V3.sql.
|
||||||
|
3. Выполнить SUPABASE-SAAS-V16.sql.
|
||||||
|
4. Выполнить SUPABASE-SAAS-V16-FINALIZE.sql.
|
||||||
|
|
||||||
|
Для текущего Supabase-проекта эти миграции уже применены.
|
||||||
|
|
||||||
|
Что НЕ входит в v16:
|
||||||
|
- автоматическое списание денег;
|
||||||
|
- платёжный webhook;
|
||||||
|
- публичная страница оплаты/чекаут;
|
||||||
|
- автоматические письма об окончании подписки.
|
||||||
|
Сейчас владелец сервиса может активировать и продлевать тариф вручную из SaaS-кабинета.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
РАЗДЕЛ ИЗ V17-STABILITY-README.txt
|
||||||
|
==============================================================================
|
||||||
|
СОЛНЦЕ КЕЙТЕРИНГ · v17 STABILITY · LOCAL TEST
|
||||||
|
|
||||||
|
Эта версия предназначена для локального тестирования перед переносом на постоянный сервер.
|
||||||
|
|
||||||
|
КАК ЗАПУСТИТЬ
|
||||||
|
1. Распакуйте архив в отдельную папку.
|
||||||
|
2. Запустите start-local-test.bat.
|
||||||
|
3. Приложение откроется по http://localhost:8787/
|
||||||
|
4. Войдите в тот же облачный аккаунт Supabase.
|
||||||
|
|
||||||
|
ЧТО ИЗМЕНИЛОСЬ
|
||||||
|
- Старый sun_app_state НЕ удалён и остаётся аварийным мостом.
|
||||||
|
- Заказы, строки заказов, каталог, клиенты и настройки зеркалируются в отдельные таблицы v17.
|
||||||
|
- Облачное сохранение использует проверку revision. Устаревшая вкладка не может молча затереть более новую облачную версию.
|
||||||
|
- Несинхронизированные изменения отмечаются в локальной IndexedDB-очереди.
|
||||||
|
- После успешной синхронизации очередь очищается.
|
||||||
|
- Ошибки JavaScript сохраняются локально и отправляются в облачный журнал после появления интернета.
|
||||||
|
- Раз в день создаётся серверная резервная копия workspace.
|
||||||
|
- В Настройки добавлен блок «Стабильность и резервирование · v17».
|
||||||
|
- Из него можно вручную сделать backup, восстановить backup и выгрузить аварийный JSON.
|
||||||
|
- Добавлен /healthz для будущего серверного мониторинга.
|
||||||
|
|
||||||
|
ВАЖНО
|
||||||
|
v17 пока использует старую структуру как основной источник для интерфейса, а новую структуру как синхронное зеркало.
|
||||||
|
Это сделано намеренно для безопасного тестирования. После проверки нескольких реальных рабочих дней можно переключить заказы и каталог на новые таблицы как на основной источник.
|
||||||
|
|
||||||
|
ПЕРЕНОС НА СЕРВЕР ПОТОМ
|
||||||
|
Архитектура уже готова к HTTPS-хостингу: статические файлы + Supabase. Локальный server.js нужен только для удобного тестирования на компьютере.
|
||||||
|
|
||||||
|
|
||||||
|
v17.2: клиентские предложения показывают полный состав каждого бокса во всех шаблонах и PDF; высота строк/карточек рассчитывается динамически.
|
||||||
30
docs/STABILITY-SECURITY-V17.6.0.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Caterium v17.6.0 Stability & Security
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
v17.5.31 is frozen separately as `Caterium-v17.5.31-ROLLBACK.zip` and remains the rollback point.
|
||||||
|
|
||||||
|
## Verification completed
|
||||||
|
- Stored DOM-XSS regression payload: no script execution in the completed browser QA pass.
|
||||||
|
- Shared safe DOM insertion helper replaces fragile insertBefore call sites used by dynamic navigation/settings UI.
|
||||||
|
- Four client-offer templates checked: Light, Editorial Grid, Midnight Glass, Emerald Gold.
|
||||||
|
- Catalog PDF checked separately.
|
||||||
|
- Final PDF preflight: 0 open/structure errors across all 5 generated PDF files; every page re-rendered successfully.
|
||||||
|
- PDF pages use consistent A4 geometry; no page clipping/overlap was found in the visual contact-sheet review.
|
||||||
|
- PDFs remain image-based by design in this release; the preflight warning about image-only/scanned-like content is expected.
|
||||||
|
- 390px mobile viewport was rechecked after the second-pass containment fix; the document-level horizontal overflow was removed.
|
||||||
|
- Chat navigation remains a single sidebar/bottom-nav item and order chat remains inline in the order.
|
||||||
|
- Current catalog: 113 photos; legacy compatibility: 60 photos.
|
||||||
|
- Production `caterium-create-employee` Edge Function source is versioned in the archive; service-role credentials remain environment-only.
|
||||||
|
- Blocking `offer-gallery-data.js` was removed; only the two gallery JPGs actually used by the offer remain.
|
||||||
|
|
||||||
|
## Automated gates
|
||||||
|
- `npm test` = syntax checks + static security checks + release checks.
|
||||||
|
- Playwright E2E specifications are included under `tests/` for CI/local environments with Playwright installed.
|
||||||
|
- The current execution environment blocks direct Chromium navigation by administrator policy, so the final packaging pass relies on the already completed browser QA artifacts plus the repeatable static/release gates and PDF render/preflight loop.
|
||||||
|
|
||||||
|
## Release score loop
|
||||||
|
First full pass: **7.0/10**. Main residual issues were mobile horizontal overflow, remaining fragile DOM insertion points, and insufficient release gating.
|
||||||
|
|
||||||
|
Second pass: **9.0/10** after mobile containment, additional safe DOM insertion replacements, PDF render/preflight QA, production Edge Function source recovery, startup payload trimming, and release/security gates.
|
||||||
|
|
||||||
|
The remaining point is architectural debt: the application is still a large historical monolith and the PDF is image-based rather than selectable/vector text.
|
||||||
454
docs/photo-sources.json
Normal file
@ -0,0 +1,454 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"path": "catalog/current-001.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3131-6532-4762-a534-353638663730/11f4ce48f250257ec824a12e1c541150.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-002.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6634-3466-4635-a164-386637346334/e94521aba11d1824218728cee4312c03.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-003.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3034-3036-4334-b233-303363326236/d72f45ce6d6a044d8d892a0718f29d89.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-004.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3662-3435-4561-b031-663437646233/efd5e71d2a8654b0afa26a6dd7a0595a.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-005.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6133-6537-4533-a166-666430373166/014bff38be088e524c0a4fd2ee1daab3.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-006.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6161-3163-4032-b338-363935363839/7b9abb2bc27c854236e234383f044c20.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-007.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3065-6462-4666-b134-376564613831/527188ad5747060eaa88cdd6b033e1b7.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-008.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6238-3635-4936-a361-343062343130/6c2841340110a32dee16481cf5776858.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-009.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6231-3837-4666-b564-656661306534/a9b0efe3696fa0cd6530e3100cce2447.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-010.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3162-6131-4337-b731-376266363535/6998754c49e80fca1df239d01e91b239.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-011.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6264-3139-4939-b331-623963306665/c62d84a2c9de7fd115f9878617709a99.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-012.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-013.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6438-6236-4831-b038-346437643037/b7305f8e759e161407a9996e0218cf83.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-014.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-015.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3536-6463-4535-a135-653763366634/3b6eec9a6310eea531c8cb70b00955a9.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-016.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3339-6634-4634-b537-363366353636/7ef8a4de7cfc055f633ab29ad7e5fca4.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-017.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3030-3766-4661-b364-313536363731/f7a5bb4329e864b12eb5aed656c13c2b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-018.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3865-3463-4832-a531-326663623661/0ab2f6a1959adeb68f6c64e21fc52e6c.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-019.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3834-6339-4234-a638-656236303531/d70c6ff9606b8f29f4d0a334643c6fa5.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-020.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3038-3736-4564-a635-333961643763/ae631a3930ee087c993f0eb5ae477388.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-021.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3638-3139-4562-b430-356363633431/a0eae79a99b4f1ca59be8a445d975f94.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-022.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3536-3766-4033-b235-643062373262/1e521931b938e17b0ebe397a447067bb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-023.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3966-6635-4139-b965-666363393466/df421e8c29457bb6a660810c14d4dbca.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-024.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3133-6661-4839-b565-376433313839/471052a775dfa8fae813f1b043171e21.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-025.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3539-3831-4031-b532-323963396137/235a566973dc035faa18ef9a57b4bef6.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-026.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6362-6234-4063-b636-373636373465/6c868c53823c2c2799bacbef8b7ec5dc.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-027.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3439-6237-4364-a566-666531346532/a368a54d5dd3ef381bdb587f81dd3953.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-028.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6664-6363-4635-b632-383362383935/f5779eef4eaff67dec0cb8a9868fe912.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-029.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-030.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3131-3637-4837-b039-386331346435/c4fd5306cc629e81fe974d0e1238eb06.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-031.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3235-3535-4639-b164-663664323936/c3dc9dc2931b0f950034495dd8cc6b46.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-032.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3235-3661-4038-b035-613032303361/6f2c3ac522ad9d7e73d14f3fc4c47630.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-033.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-034.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3836-3438-4835-a132-663962396637/fac8bc386bd98959d75c6b71cd83471b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-035.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3334-6361-4633-a266-643132323266/74b63ae226e1ebfbd116a822be4f642e.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-036.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3136-3834-4562-b037-396264363263/e8c1bbdff0574f52a2184a33d6f2115c.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-037.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6334-3534-4266-a664-356162323565/c13d0896543e32d12ccad0c0a108dac5.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-038.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3364-6237-4561-b432-326230363433/3233d197bebae6174651a7732ec8ca3e.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-039.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3239-6231-4130-a363-643732356464/1a72939cda10b9fc447157193a2c69cf.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-040.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3832-3732-4635-b732-636135393265/3e414b433c0aab18c0a1131d256c92e5.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-041.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6431-3634-4935-a638-323064343638/8d7c3b547b6e5abd09fe498c2e0b3853.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-042.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3362-3331-4631-b738-646138333763/b3932c942568e83ab0babc98b77ee5c3.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-043.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3565-6565-4739-b638-343639336130/ce325e928513a5c7ad2a5c9328927bd0.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-044.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3131-3335-4135-b962-356338643761/dabf8e59f8b405c2998cc725627a832a.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-045.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6365-6464-4263-b066-653333643438/eba899d7b96c0e9f077467bb4076cf56.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-046.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3064-6631-4061-b231-393166393931/2e792a2bd15012a2c134a17213d64706.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-047.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3936-6435-4438-a564-643362376266/c0d42f2444a396f1b836f4f5e2d2a2b8.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-048.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6635-6661-4361-b031-303465633437/9c824de9c9ad08c435f64d62876ca8f2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-049.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6530-6533-4235-b631-613831393533/2651153cdc6a396acfe9c7c1f1ffa6e3.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-050.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3566-6437-4361-b137-343366323736/8f1f58eb858f669ca4acea85ad8bbbce.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-051.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3363-3539-4532-b731-343434323163/3d114d5bd96795183d342a37ef5bacb7.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-052.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3234-6630-4664-b230-616165303866/a350de68077dc437a4a7c0d1f07db97b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-053.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6566-6236-4038-b038-326331333761/acfb6d855c780c92c4cd6e667229a9d7.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-054.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3662-3963-4031-b065-646235373965/f8f3bd5b10fe9142e453766305684126.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-055.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3461-3363-4063-a530-336136633338/951f5c4bcaa35b878fd8f0d4cdf5812a.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-056.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6335-3638-4432-b230-393163396334/d08deb92d2102c1e81002f0ff5b1cd18.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-057.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3163-3035-4466-b435-303832663162/2a85461ba4c7132af333c481f6974334.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-058.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3132-6233-4466-b164-376530633465/a5159b1de5f6bd3d2dde7609c1853f82.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-059.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3165-6138-4663-b433-393039653034/4ad56410ecf6a163824061d08cb297da.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-060.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6437-6361-4833-a537-623832376136/29ff2e5084a5f816954ea5621004b0c0.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-061.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3765-3765-4663-a333-623738326235/02eaafb2bae5cef3edaa761aa7cea142.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-062.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6365-3039-4931-a333-623061333865/ceb789e801d358ff78f2a4800f79f78c.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-063.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6262-6636-4563-b332-383166313034/73a699e67040500f90edd197152cb0b9.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-064.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3366-3663-4664-a136-633432326530/8a056937be29f8f531767a565fc2fb45.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-065.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3566-3862-4334-b465-376164633737/7ad059a9bf198827dfd142008f81bb28.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-066.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3532-6333-4432-b037-373031393364/3f233fc73a9932953876810c2530779b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-067.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6461-6235-4134-a630-393261313034/f00b5038885f425bf4463b0f070416b2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-068.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3963-3634-4262-b363-393536623037/7f3160b562bdb81243fcca8b07ea73ca.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-069.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6134-6534-4032-b863-326334303439/b6299036af0105e24b66b92fc149fceb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-070.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6330-3331-4534-a431-616637323265/cc72f08366e2b3bb3b2cec181fcfb6b2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-071.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3864-6138-4939-a263-323633343831/e41074621630fb64c6551fde54a0491a.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-072.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-073.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3132-6432-4637-b930-353763333565/0e6665f2fddcdc6a2538ff0e4d6714e3.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-074.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3063-6361-4562-b864-386439643866/5a0a9e8680146e4af16b0b3a71e5e344.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-075.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3864-3766-4132-a362-336565363634/331d7a3cb423efad3851318ebc243503.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-076.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-077.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-078.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-079.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-080.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3661-6362-4365-a166-396432366336/b3461ed10ad1d21ba6f327e14f87561a.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-081.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6136-3763-4363-b632-613035666139/b89d5f88da96f52526ccba83cfb1e631.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-082.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-083.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-084.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-085.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3535-3432-4638-b763-636162616233/f392e1f738f4ec51be28ac85ff7f34d1.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-086.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6263-6138-4433-a639-386465356138/915334ed7ec0a973fc528d5b1bcddeb9.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-087.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6161-3666-4430-b437-303230613863/b6c27133c86b74e669c4d8d14cddaa98.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-088.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6433-6530-4938-a264-366133396635/26c04c584ac3ce62893f86e3f21ef6a4.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-089.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3438-3336-4038-b563-636464663765/26624b9790a69468292e752308984af1.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-090.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3430-3734-4935-a666-616161393566/92e1260daf92944a35c6ce5a64ca38f3.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-091.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3937-3731-4331-a333-333266353435/018e8f56c6b0053168f0d0cf73872966.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-092.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3939-3434-4061-a231-636661616561/607dc1f31bc1ca66fb1a611d49407aa5.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-093.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3836-3436-4239-a536-353332396264/232fcd6e93bf0abc918ef73529cded4b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-094.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3065-3632-4533-b333-636566613663/7979f348481e979905836febdf7933a6.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-095.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3761-3162-4461-b966-323139613763/08d95a9c71b402415f9479b3f9520892.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-096.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3033-3137-4930-b264-666532373134/69925db7e39c25877ac58869c15037a6.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-097.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3333-3839-4537-a236-623030313665/4130c8f6bd2b58b41c4edd83eee922a9.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-098.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6230-3133-4564-b237-646535613637/a2a99a4eeb22cb33b5b5cea2a79a17cb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-099.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3137-6538-4132-b739-653932333233/15a1c8db984c5e68f357b2ee12a18c2a.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-100.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3031-6132-4334-b330-633933383437/7e711a925bef78f54c6417cfed424f7b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-101.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6565-3862-4536-a531-396336343530/94e933aa7cb2ca8cc2f9a6975508c69b.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-102.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3130-6665-4166-a562-346464663365/1ace83e57a93e4bd15c4117e6ebd10f4.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-103.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6666-6336-4235-b437-663731323432/f733be8a64d1186ac1153c3eba271d59.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-104.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6561-6664-4163-a232-323236383637/1b4aa616a89592851de475a7633530b2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-105.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3364-3366-4961-a230-393136633735/bd16ccb4240c13b0408fcd3cdb263c7f.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-106.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3133-3966-4466-a633-353062663934/19cfdd0803e1252edb0a229fe8cc10e1.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-107.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3862-3737-4631-a637-656265333935/be0ca89c31666c8d357814c69f0c1527.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-108.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3730-3136-4137-b861-393762656436/9b8faf6a48cff9bc8b16cef36396d144.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-109.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6137-3965-4334-a432-316165313635/7e2aebaffa009dfc1b7a580ceac8a640.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-110.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3937-3432-4537-a233-646339376631/d0ca8846eba6810423c5421e7060c567.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-111.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3437-6365-4139-b134-326232643133/8d0a33ea4f3ace73aa73679aa8ac3b4d.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-112.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor6334-3463-4166-b330-643438393235/49f88576b7fb7573735b20dc4161de96.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "catalog/current-113.jpg",
|
||||||
|
"url": "https://static.tildacdn.com/stor3139-3866-4633-a234-663139643733/5c7060058669060670ed95fb719078c3.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
297
docs/release-manifest.json
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
{
|
||||||
|
"app": "Caterium",
|
||||||
|
"version": "v17.6.0",
|
||||||
|
"channel": "local-test",
|
||||||
|
"schema": 17,
|
||||||
|
"legacyStateKept": true,
|
||||||
|
"normalizedMirror": true,
|
||||||
|
"optimisticRevisionLock": true,
|
||||||
|
"offlineOutbox": "IndexedDB",
|
||||||
|
"dailyBackups": true,
|
||||||
|
"serverReady": true,
|
||||||
|
"workspaceAutoDiscovery": true,
|
||||||
|
"invitesTemporarilyDisabled": false,
|
||||||
|
"pwaCache": "v65-v17.6.0-stability-security",
|
||||||
|
"fullOfferDescriptions": true,
|
||||||
|
"dynamicOfferRows": true,
|
||||||
|
"pdfOfferDescriptionFix": true,
|
||||||
|
"boxWeightEditable": true,
|
||||||
|
"boxWeightVisibleEverywhere": true,
|
||||||
|
"offerCompositionTotals": true,
|
||||||
|
"offerSnapshotVersion": 8,
|
||||||
|
"boxPiecesEditable": true,
|
||||||
|
"ingredientUnitWeightEditable": true,
|
||||||
|
"box18CompositionFixed": true,
|
||||||
|
"clientOfferUsesExplicitPieces": true,
|
||||||
|
"clientOfferBoxTotals": true,
|
||||||
|
"clientOfferWeightRoundUpStepGrams": 5,
|
||||||
|
"clientOfferPiecesRoundUpToInteger": true,
|
||||||
|
"pdfBalancedCards": true,
|
||||||
|
"pdfDuplicateGalleryRemoved": true,
|
||||||
|
"boxGuestRangeHiddenEverywhere": true,
|
||||||
|
"clientOfferNoDuplicateBoxQuantity": true,
|
||||||
|
"catalogTabColorPicker": true,
|
||||||
|
"catalogTabAutoContrast": true,
|
||||||
|
"catalogEditorGenericPriceAndSaveLabels": true,
|
||||||
|
"catalogPhotoLocalStorageOptimization": true,
|
||||||
|
"cloudQuotaRetryForSunBoxes": true,
|
||||||
|
"catalogSource": "Все боксы — Солнце Кейтеринг (2).pdf",
|
||||||
|
"catalogVersion": "2026-09-02",
|
||||||
|
"catalogPositions": 113,
|
||||||
|
"catalogGuestsImported": false,
|
||||||
|
"catalogRemotePhotoLinks": 0,
|
||||||
|
"premiumCategoryFromPosition": 85,
|
||||||
|
"premiumCategoryBetweenBoxesAndDrinks": true,
|
||||||
|
"boxOriginalPriceEditable": true,
|
||||||
|
"boxDiscountPriceDisplay": true,
|
||||||
|
"settingsStickyHeader": true,
|
||||||
|
"settingsOpenAtTop": true,
|
||||||
|
"settingsCloudMovedToBottom": false,
|
||||||
|
"clientOfferTemplates": [
|
||||||
|
"light",
|
||||||
|
"editorial-grid",
|
||||||
|
"midnight-glass",
|
||||||
|
"emerald-gold"
|
||||||
|
],
|
||||||
|
"addressCandidateSelection": true,
|
||||||
|
"addressMapConfirmation": true,
|
||||||
|
"addressAutocomplete": false,
|
||||||
|
"addressGeocoder": "Nominatim manual search with Photon fallback",
|
||||||
|
"clientOfferLegacyCatalogCompatibility": true,
|
||||||
|
"clientOfferLegacyPhotosEmbedded": false,
|
||||||
|
"clientOfferPlaceholderSnapshotAutoRefresh": true,
|
||||||
|
"clientOfferLegacyPhotosPackedLosslessly": false,
|
||||||
|
"legacyPhotoPackFile": null,
|
||||||
|
"legacyPhotoPackItems": 0,
|
||||||
|
"legacyStandalonePhotosKept": 60,
|
||||||
|
"runtimeBundled": true,
|
||||||
|
"runtimeBundleFile": "app-runtime.js",
|
||||||
|
"runtimeBundleModules": 22,
|
||||||
|
"mobileCssInlined": true,
|
||||||
|
"serverHealthUsesReleaseVersion": true,
|
||||||
|
"premiumBoxLikeSupportFixed": true,
|
||||||
|
"catalogPdfRemoteImageCorsSafe": true,
|
||||||
|
"compactDistribution": true,
|
||||||
|
"catalogCompositionStaticPrefill": 56,
|
||||||
|
"catalogCompositionCurrentSiteVerified": 6,
|
||||||
|
"catalogCompositionLiveSync": true,
|
||||||
|
"catalogCompositionLiveSource": "https://solnce-keytering.ru/catalog + /catalog_black",
|
||||||
|
"catalogCompositionSyncIntervalHours": 6,
|
||||||
|
"catalogCompositionManualRefresh": false,
|
||||||
|
"catalogCompositionEditable": true,
|
||||||
|
"catalogCompositionPreservesManualEdits": true,
|
||||||
|
"catalogMigrationPreservesCustomItems": true,
|
||||||
|
"clientOfferLightBackground": "white",
|
||||||
|
"clientOfferSellingBlock": true,
|
||||||
|
"clientOfferSellingTextNoClip": true,
|
||||||
|
"clientOfferFinalGallery": true,
|
||||||
|
"clientOfferFinalGalleryImages": 2,
|
||||||
|
"clientOfferFinalGalleryPackedLosslessly": false,
|
||||||
|
"distributionFileCount": 240,
|
||||||
|
"finalGallerySource": "2 guaranteed embedded table photos; original offer-gallery files retained",
|
||||||
|
"finalGalleryPackedFile": null,
|
||||||
|
"calendarUsesPaymentColors": true,
|
||||||
|
"calendarPaymentColorsFollowSettings": true,
|
||||||
|
"calendarPrintFinancialsHidden": true,
|
||||||
|
"calendarPrintKitchenSafe": true,
|
||||||
|
"catalogCompositionPremiumDetailSync": true,
|
||||||
|
"catalogCompositionPremiumMissingForcesRefresh": true,
|
||||||
|
"catalogCompositionTildaEndpoint": "getproduct",
|
||||||
|
"catalogCompositionPremiumForceRefresh": true,
|
||||||
|
"catalogCompositionCacheRequiresPremium": true,
|
||||||
|
"notes": "Stability & Security release: shared SunSafe XSS defenses, safe DOM insertion, versioned production employee Edge Function source, startup gallery trimming/lazy images, centralized A4 PDF engine, mobile overflow containment, and automated release checks. Chat navigation/order chat from v17.5.31 is preserved.",
|
||||||
|
"catalogPhotoSources": 113,
|
||||||
|
"catalogPhotosStoredInCatalog": true,
|
||||||
|
"catalogLegacyPhotosInCatalog": 60,
|
||||||
|
"catalogCurrentPhotosPrefilled": 64,
|
||||||
|
"catalogCurrentPhotosLazyCached": true,
|
||||||
|
"catalogPhotoSourceMap": "catalog/photo-sources.json",
|
||||||
|
"catalogCompositionClientFirst": true,
|
||||||
|
"catalogTtkSeparated": true,
|
||||||
|
"orderBlankQrTextInlineEditable": true,
|
||||||
|
"box1SaleBadgeWithoutOldPrice": false,
|
||||||
|
"performanceAudit": true,
|
||||||
|
"pdfGeneratedOnDemand": true,
|
||||||
|
"catalogPhotoStartupWarmup": false,
|
||||||
|
"catalogPhotoFailureBackoffMinutes": 0,
|
||||||
|
"catalogCompositionDeferredUntilIdle": true,
|
||||||
|
"catalogCompositionDetailConcurrency": 2,
|
||||||
|
"backgroundDomObserversThrottled": true,
|
||||||
|
"cloudSignatureScanIntervalMs": 9000,
|
||||||
|
"finalGalleryStoredAsFiles": true,
|
||||||
|
"finalGalleryFolder": "offer-gallery",
|
||||||
|
"finalGalleryPreviewUsesDirectUrls": true,
|
||||||
|
"pdfRasterConversionDeferredUntilDownload": false,
|
||||||
|
"catalogPhotoPreflight": false,
|
||||||
|
"catalogPhotoPreflightScript": "prepare-catalog-photos.js (optional manual prefetch)",
|
||||||
|
"catalogPhotoRequiredBeforeServer": false,
|
||||||
|
"catalogPhotoTarget": 113,
|
||||||
|
"catalogPhotoRuntimeRemoteFetch": true,
|
||||||
|
"catalogPhotoFirstRunInternetRequiredUntilComplete": false,
|
||||||
|
"catalogPhotoDuplicateUrlReuse": true,
|
||||||
|
"catalogPhotoDownloadConcurrency": 3,
|
||||||
|
"catalogPhotoDownloadRetries": 3,
|
||||||
|
"catalogPhotoHealthStatus": true,
|
||||||
|
"catalogPhotoFallbackChain": true,
|
||||||
|
"catalogPhotoProxyCache": true,
|
||||||
|
"catalogStartupPhotoPreflightBlocking": false,
|
||||||
|
"clientOfferPdfPhotoFallback": true,
|
||||||
|
"premiumPhotoFallback": true,
|
||||||
|
"clientOfferAdaptiveFinalGallery": true,
|
||||||
|
"clientOfferAdaptiveFinalGalleryMaxPhotos": 2,
|
||||||
|
"clientOfferFinalGalleryPreviewMaxPhotos": 2,
|
||||||
|
"box5LocalPhotoFallback": true,
|
||||||
|
"box8LocalPhotoFallback": true,
|
||||||
|
"clientOfferEditorialTemplate": true,
|
||||||
|
"clientOfferEditorialReference": "IMG_3962.jpeg provided 2026-09-04",
|
||||||
|
"clientOfferEditorialPreviewPdfParity": true,
|
||||||
|
"clientOfferEditorialSharedGrouping": true,
|
||||||
|
"clientOfferPreviewUsesPdfPages": true,
|
||||||
|
"clientOfferAllTemplatesPreviewPdfParity": true,
|
||||||
|
"clientOfferInlineEditor": true,
|
||||||
|
"clientOfferInlineEditorPerOrder": true,
|
||||||
|
"clientOfferFinalGalleryCreatesNewPage": false,
|
||||||
|
"clientOfferFinalGalleryOnlyUsesExistingWhitespace": true,
|
||||||
|
"clientOfferInlineControlLinesEdit": true,
|
||||||
|
"clientOfferFinalGalleryGuaranteedDataFallback": true,
|
||||||
|
"clientOfferFinalGalleryAutomatic": true,
|
||||||
|
"clientOfferFinalGalleryMinWhitespacePx": 128,
|
||||||
|
"workspaceLocalIsolation": true,
|
||||||
|
"workspaceLocalIsolationStore": "IndexedDB meta / tenant-local:<workspaceId>",
|
||||||
|
"workspaceSwitchClearsWorkingCopy": true,
|
||||||
|
"workspaceUpgradeLegacyAdoption": true,
|
||||||
|
"newWorkspaceStarterCatalog": true,
|
||||||
|
"newWorkspaceStarterCatalogPositions": 113,
|
||||||
|
"newWorkspaceOrdersEmpty": true,
|
||||||
|
"newWorkspaceClientsEmpty": true,
|
||||||
|
"catalogStaticPdfRemoved": true,
|
||||||
|
"catalogPdfGeneratedOnDemand": true,
|
||||||
|
"catalogJpegOptimized": true,
|
||||||
|
"catalogJpegMaxSidePx": 1500,
|
||||||
|
"catalogJpegQuality": 80,
|
||||||
|
"reliableLocalSignOut": true,
|
||||||
|
"signOutScope": "local",
|
||||||
|
"signOutNetworkTimeoutMs": 2500,
|
||||||
|
"signOutPreservesWorkspaceSnapshot": true,
|
||||||
|
"settingsAccountFirst": true,
|
||||||
|
"settingsBackupCardVisible": false,
|
||||||
|
"settingsStabilityCardVisible": false,
|
||||||
|
"settingsTopOrder": [
|
||||||
|
"Профиль и аккаунт",
|
||||||
|
"Пользователи и роли",
|
||||||
|
"История изменений"
|
||||||
|
],
|
||||||
|
"authGateSignUpVisible": true,
|
||||||
|
"settingsTabs": true,
|
||||||
|
"settingsTabsCount": 6,
|
||||||
|
"settingsTabsMobileHorizontalScroll": true,
|
||||||
|
"settingsTabsRememberLast": true,
|
||||||
|
"settingsTabGroups": [
|
||||||
|
"Аккаунт",
|
||||||
|
"Оформление",
|
||||||
|
"Предложение",
|
||||||
|
"Заказы",
|
||||||
|
"Справочники",
|
||||||
|
"Документы"
|
||||||
|
],
|
||||||
|
"settingsCatalogTabsShortcut": true,
|
||||||
|
"developerConsole": true,
|
||||||
|
"developerRoleSource": "public.sun_platform_admins",
|
||||||
|
"developerServerEnforced": true,
|
||||||
|
"developerMfaRequired": true,
|
||||||
|
"developerMfaType": "TOTP / Supabase MFA AAL2",
|
||||||
|
"developerTabs": [
|
||||||
|
"Обзор",
|
||||||
|
"Компании",
|
||||||
|
"Аккаунты",
|
||||||
|
"Тарифы",
|
||||||
|
"Функции",
|
||||||
|
"Система",
|
||||||
|
"Резервные копии",
|
||||||
|
"Журнал"
|
||||||
|
],
|
||||||
|
"developerSeesAllAuthAccounts": true,
|
||||||
|
"developerSupportReadOnlyMode": true,
|
||||||
|
"developerAuditLog": true,
|
||||||
|
"developerWorkspaceDiagnostics": true,
|
||||||
|
"developerSubscriptionManagement": true,
|
||||||
|
"developerFeatureMatrixManagement": true,
|
||||||
|
"developerStarterCatalogManagement": true,
|
||||||
|
"developerCanCreateCompanies": true,
|
||||||
|
"normalUserTechnicalSupabaseSettingsHidden": true,
|
||||||
|
"normalUserManualCloudControlsHidden": true,
|
||||||
|
"normalUserBackupsHidden": true,
|
||||||
|
"normalUserStabilityHidden": true,
|
||||||
|
"authAdminEdgeFunctionDeployed": false,
|
||||||
|
"authAdminEdgeFunctionNote": "Source included, but deployment was unavailable in this build environment. All-account listing uses existing server-side sun_platform_list_users RPC; global Auth ban/recovery controls remain unavailable until protected backend function is deployed.",
|
||||||
|
"developerSqlMigration": "SUPABASE-DEVELOPER-V22.sql",
|
||||||
|
"developerMfaRecovery": true,
|
||||||
|
"registrationTabs": true,
|
||||||
|
"registrationCompanyNameRequired": true,
|
||||||
|
"registrationAutoCreatesWorkspace": true,
|
||||||
|
"newRegistrationTrialDays": 14,
|
||||||
|
"newRegistrationTrialPlan": "full",
|
||||||
|
"cateriumLoginBranding": true,
|
||||||
|
"signupPasswordRepeatRemoved": false,
|
||||||
|
"signupEmailAutoconfirm": "scoped trigger for registration_source=caterium_public_signup",
|
||||||
|
"signupTrial": "14-day Full",
|
||||||
|
"developerMfaInputSelectorFixed": true,
|
||||||
|
"developerMfaRepeatedValidationToastsFixed": true,
|
||||||
|
"release": "20260907-v17-6-0-stability-security",
|
||||||
|
"registrationFlow": "email-password-confirm-company-auto-login",
|
||||||
|
"emailConfirmationRequired": false,
|
||||||
|
"employeeInviteLinks": false,
|
||||||
|
"employeeInviteEmailBound": false,
|
||||||
|
"employeePendingInvitesUI": false,
|
||||||
|
"directEmployeeCreation": true,
|
||||||
|
"employeeTemporaryPassword": true,
|
||||||
|
"chatEnabled": true,
|
||||||
|
"chatCompanyRoom": true,
|
||||||
|
"chatDirectMessages": true,
|
||||||
|
"chatOrderDiscussions": true,
|
||||||
|
"chatPrivateAttachments": true,
|
||||||
|
"chatAttachmentBucket": "sun-chat",
|
||||||
|
"chatAttachmentMaxBytes": 15728640,
|
||||||
|
"chatMaxAttachmentsPerMessage": 5,
|
||||||
|
"chatReadStatus": true,
|
||||||
|
"chatTypingIndicator": true,
|
||||||
|
"chatPresence": true,
|
||||||
|
"chatUnreadBadge": true,
|
||||||
|
"chatRealtimePrivate": true,
|
||||||
|
"chatDirectIsolation": "workspace membership + sun_chat_participants + RLS",
|
||||||
|
"chatMigration": "SUPABASE-CHAT-V29.sql",
|
||||||
|
"granularAdminPermissions": true,
|
||||||
|
"lastAdminProtection": "other-active-admin-count",
|
||||||
|
"lastUsersManagerProtection": true,
|
||||||
|
"adminRightsMigration": "SUPABASE-ADMIN-RIGHTS-V30.sql",
|
||||||
|
"chatSidebarNav": true,
|
||||||
|
"chatSidebarSections": [
|
||||||
|
"Общий",
|
||||||
|
"Личные",
|
||||||
|
"Заказы"
|
||||||
|
],
|
||||||
|
"chatOrderInline": true,
|
||||||
|
"chatOrderGlobalIndex": true,
|
||||||
|
"chatSafeNavRegistration": true,
|
||||||
|
"chatRepeatedInitPollingRemoved": true,
|
||||||
|
"chatFutureContextsPlanned": [
|
||||||
|
"production",
|
||||||
|
"routes",
|
||||||
|
"shopping",
|
||||||
|
"client-internal-notes"
|
||||||
|
],
|
||||||
|
"stabilitySecurityRelease": true,
|
||||||
|
"rollbackBaseline": "v17.5.31",
|
||||||
|
"sharedSafeHtmlUtility": "core/sun-safe.js",
|
||||||
|
"legacyBootstrapExternalized": "legacy/bootstrap.js",
|
||||||
|
"productionEmployeeEdgeFunctionVersioned": true,
|
||||||
|
"blockingOfferGalleryBase64Removed": true,
|
||||||
|
"offerGalleryFiles": 2,
|
||||||
|
"pdfEngine": "core/pdf-engine.js",
|
||||||
|
"pdfEngineA4Validated": true,
|
||||||
|
"mobile390NoDocumentOverflow": true,
|
||||||
|
"releaseChecks": "tests/static-security.mjs + tests/release-check.mjs",
|
||||||
|
"playwrightSpec": "tests/app.spec.mjs",
|
||||||
|
"releaseScoreFirstPass": 7,
|
||||||
|
"releaseScoreSecondPass": 9
|
||||||
|
}
|
||||||
18
docs/releases/V17.5.14-CHANGES.txt
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
СОЛНЦЕ КЕЙТЕРИНГ — v17.5.14
|
||||||
|
|
||||||
|
ФОТОГРАФИИ КАТАЛОГА
|
||||||
|
- Один механизм изображений для каталога, Premium, предложения клиенту и PDF.
|
||||||
|
- Цепочка источников: локальный catalog/current-XXX.jpg -> локальный API-кэш -> оригинал Tilda -> логотип-заглушка.
|
||||||
|
- Сервер больше не блокирует запуск, если часть локального фотокэша отсутствует.
|
||||||
|
- Недостающие оригиналы кешируются в catalog/current-XXX.jpg при успешном получении.
|
||||||
|
- Добавлены локальные резервные фото для бокса №5 и основных отсутствовавших вариантов бокса №8.
|
||||||
|
- Premium больше не зависит только от наличия заранее скачанного JPG.
|
||||||
|
- DOWNLOAD-ALL-CATALOG-PHOTOS.bat оставлен как необязательная предварительная загрузка.
|
||||||
|
|
||||||
|
ПРЕДЛОЖЕНИЕ КЛИЕНТУ / PDF
|
||||||
|
- PDF использует ту же цепочку источников изображений, что и каталог.
|
||||||
|
- Финальный блок «Как это выглядит на вашем столе» использует 1–3 фотографии в зависимости от свободного места на последней странице.
|
||||||
|
- Если места достаточно, фотографии заполняют остаток страницы; отдельная пустая фотостраница больше не создаётся без необходимости.
|
||||||
|
- В экранном предложении показываются максимум 3 финальных фотографии.
|
||||||
|
|
||||||
|
Облачная архитектура, заказы и схема данных не менялись.
|
||||||
8
docs/releases/V17.5.15-CHANGES.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
Sun Catering v17.5.15
|
||||||
|
|
||||||
|
- Добавлен 4-й шаблон предложения клиенту «Солнце Editorial» по референсу пользователя.
|
||||||
|
- Новый шаблон использует одинаковую структуру данных в просмотре и PDF: первый экран, продающий блок, сгруппированное меню, 8 фото позиций, итоги, расчет стоимости, сервисные блоки и финальная адаптивная галерея.
|
||||||
|
- Для групп меню в просмотре и PDF используется один общий расчет editorialGroups(), поэтому названия, диапазоны веса, количество блюд и количество боксов совпадают.
|
||||||
|
- Добавлена миниатюра шаблона thumb-editorial-grid.jpg.
|
||||||
|
- Остальные 3 шаблона и логика фото каталога/Premium из v17.5.14 не изменены.
|
||||||
|
- PWA cache: v49.
|
||||||
11
docs/releases/V17.5.16-CHANGES.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
Sun Catering v17.5.16
|
||||||
|
|
||||||
|
- Все 4 шаблона предложения клиенту: предпросмотр теперь показывает те же Canvas-страницы, из которых формируется PDF.
|
||||||
|
- Убрана отдельная iframe/HTML-версия предпросмотра, из-за которой раньше могли расходиться переносы, размеры и фото.
|
||||||
|
- В окне «Предложение клиенту» добавлена кнопка «Редактировать» и локальный редактор прямо перед скачиванием PDF.
|
||||||
|
- Можно менять название мероприятия, клиента, дату, гостей, заголовки и продающие тексты конкретного предложения.
|
||||||
|
- Изменения сохраняются в snapshot конкретного заказа и используются и в просмотре, и в PDF.
|
||||||
|
- «Как это выглядит на вашем столе»: максимум 2 фотографии.
|
||||||
|
- Финальные фотографии никогда не создают новую страницу. Если свободного места недостаточно, блок полностью пропускается.
|
||||||
|
- Если свободного места немного — 1 фото; если достаточно — 2 фото.
|
||||||
|
- PDF при скачивании собирается из уже показанных страниц, без повторной независимой верстки.
|
||||||
7
docs/releases/V17.5.17-CHANGES.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Sun Catering v17.5.17
|
||||||
|
|
||||||
|
- In the client-offer inline editor, the "Everything under control" block now has a multiline editor for all list items.
|
||||||
|
- The block title and each control line are saved per proposal and immediately rebuild the same preview/PDF pages.
|
||||||
|
- Two table photos are now available through a guaranteed embedded fallback, so they do not disappear because of local file/fetch problems.
|
||||||
|
- Table photos are automatic: 0 photos when there is no free space, 1 photo for a smaller gap, 2 photos for a larger gap. They never create a new page.
|
||||||
|
- The old optional "final gallery" switch is no longer used; the whitespace-fill rule controls visibility.
|
||||||
10
docs/releases/V17.5.18-CHANGES.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Sun Catering v17.5.18
|
||||||
|
|
||||||
|
- Added workspace/account isolation for the browser working copy. Orders, clients, finance, stock, employees, suppliers, loyalty data, settings and other sun* company data no longer remain attached to the next signed-in account.
|
||||||
|
- Before leaving or switching a workspace, the current local working copy is saved to IndexedDB under that workspace. The target workspace is then restored from its own local snapshot or pulled cleanly from Supabase.
|
||||||
|
- Upgrade safety: an existing v17.5.x local database is automatically adopted by its already configured workspace; if the old configured workspace differs from the next login, the old data is captured separately instead of being attached to the new account.
|
||||||
|
- New companies start with the current official 113-position catalog while orders, clients and other working data start empty.
|
||||||
|
- Existing catalog boxes remain independently editable inside each company after the initial copy.
|
||||||
|
- Catalog JPEG files were recompressed/resized conservatively (max 1500 px side, JPEG quality 80) to reduce distribution size without changing file paths or format.
|
||||||
|
- Removed the obsolete prebuilt 9 MB static catalog PDF. The visible catalog PDF button already generates the current catalog on demand.
|
||||||
|
- PWA cache/version bumped to v17.5.18.
|
||||||
9
docs/releases/V17.5.19-CHANGES.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
Sun Catering v17.5.19
|
||||||
|
|
||||||
|
- Fixed the «Выйти» buttons in Cloud settings, sidebar, onboarding/auth gates and subscription gate.
|
||||||
|
- Logout no longer waits for a full cloud sync before leaving the account. The current workspace working copy is preserved locally first.
|
||||||
|
- Supabase logout now uses signOut({ scope: 'local' }) so it signs out the current browser/device session instead of the default global sign-out.
|
||||||
|
- Added a 2.5 s network bound for Supabase logout. If the endpoint is unavailable, only this project's persisted Supabase auth token is removed locally and the application reloads into the signed-out state.
|
||||||
|
- Logout buttons display «Выходим…» while the operation is running and ignore repeated clicks.
|
||||||
|
- PWA cache and runtime query bumped so an already installed v17.5.18 cannot continue serving the broken logout handler.
|
||||||
|
- v17.5.18 workspace/account data isolation and the 113-position starter catalog are preserved unchanged.
|
||||||
8
docs/releases/V17.5.20-CHANGES.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
Sun Catering v17.5.20
|
||||||
|
|
||||||
|
- В Настройках профиль/вход/создание аккаунта/выход перенесены в самый верх.
|
||||||
|
- Блок обычного резервного копирования удалён из Настроек.
|
||||||
|
- Диагностический блок «Стабильность и резервирование» скрыт из обычных Настроек.
|
||||||
|
- Фоновые защитные механизмы данных оставлены без изменений.
|
||||||
|
- Техническое подключение Supabase перенесено вниз блока профиля.
|
||||||
|
- На основном экране входа добавлена кнопка «Создать аккаунт», чтобы регистрация была доступна сразу после выхода.
|
||||||
17
docs/releases/V17.5.21-CHANGES.txt
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
Sun Catering v17.5.21
|
||||||
|
|
||||||
|
Настройки разделены на 6 смысловых вкладок:
|
||||||
|
- Аккаунт — профиль и компания, тариф, пользователи и роли, история изменений.
|
||||||
|
- Оформление — цвета интерфейса и боковой панели, готовые цветовые варианты и дополнительные цвета.
|
||||||
|
- Предложение — шаблон предложения клиенту и настройки содержимого предложения.
|
||||||
|
- Заказы — цвета оплаты и статусы заказов.
|
||||||
|
- Справочники — типы мероприятий, источники клиентов и управление вкладками каталога.
|
||||||
|
- Документы — реквизиты товарного чека, QR-код и настройки бланка заказа.
|
||||||
|
|
||||||
|
Дополнительно:
|
||||||
|
- вкладки горизонтальные и прокручиваются на мобильных устройствах;
|
||||||
|
- последняя открытая вкладка запоминается;
|
||||||
|
- существующие обработчики настроек, localStorage и Supabase не переписаны;
|
||||||
|
- добавлена карточка быстрого перехода к настройке вкладок каталога;
|
||||||
|
- резервные копии и блок стабильности по-прежнему скрыты из обычных настроек;
|
||||||
|
- обновлён PWA-кэш.
|
||||||
9
docs/releases/V17.5.23-CHANGES.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
v17.5.23 — Developer MFA + SaaS registration
|
||||||
|
|
||||||
|
- Developer MFA recovers stale unverified TOTP and confirms AAL2.
|
||||||
|
- Login/Register tabs added.
|
||||||
|
- Registration asks for company name, email and password confirmation.
|
||||||
|
- New company is created automatically after confirmation/login.
|
||||||
|
- Server grants Full trial for 14 days.
|
||||||
|
- Starter catalog contains the current 113 positions; orders and clients start empty.
|
||||||
|
- PWA cache bumped to v57.
|
||||||
11
docs/releases/V17.5.25-CHANGES.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
v17.5.25 — Auth / MFA / Caterium login fix
|
||||||
|
|
||||||
|
1. Исправлена точная ошибка чтения 6-значного MFA-кода: поле больше не ищется как CSS-селектор через getElementById.
|
||||||
|
2. Локальная проверка MFA больше не создаёт пачку одинаковых toast-уведомлений.
|
||||||
|
3. Удалена оставшаяся ссылка на удалённое поле «Повторите пароль», которая могла останавливать обработчики входа/регистрации.
|
||||||
|
4. Регистрация: Название компании + Email + один Пароль.
|
||||||
|
5. Регистрация Caterium помечается registration_source=caterium_public_signup; сервер подтверждает только такие новые email без письма на текущем тестовом этапе.
|
||||||
|
6. После регистрации приложение сразу входит и создаёт отдельную компанию с Full trial на 14 дней и стартовым каталогом.
|
||||||
|
7. Если ранее созданный аккаунт вошёл без компании, можно сразу создать компанию из экрана входа.
|
||||||
|
8. На экране входа/регистрации используется утверждённый знак Caterium C с круглой янтарной точкой.
|
||||||
|
9. PWA cache обновлён до v59.
|
||||||
12
docs/releases/V17.5.26-CHANGES.txt
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
v17.5.26 — QA кнопок и плавности
|
||||||
|
|
||||||
|
Исправлено после полного smoke-теста интерфейса:
|
||||||
|
- устранён NotFoundError в restoreOrderBlank;
|
||||||
|
- кнопка «Бланк заказа» теперь всегда остаётся в основном блоке действий заказа рядом с документами;
|
||||||
|
- все поздние сценарии выбора #new .actions теперь находят основной блок по кнопке «Сохранить», а не случайный вложенный .actions;
|
||||||
|
- исправлен selector-helper Developer Console: обращения вида #sunDev... теперь корректно находят элементы;
|
||||||
|
- проверены все 8 вкладок кабинета разработчика после MFA;
|
||||||
|
- проверены вход и регистрация, создание новой компании и 14-дневный Full trial в mock-сценарии;
|
||||||
|
- проверены 15 основных разделов навигации, 6 вкладок Настроек, документы заказа, предложение и PDF;
|
||||||
|
- мобильный smoke-тест 390x844: критического горизонтального переполнения страниц нет, навигация прокручивается штатно;
|
||||||
|
- обновлён PWA cache/version до v59 / 20260906-v17-5-26-qa-buttons-smooth.
|
||||||
12
docs/releases/V17.5.27-CHANGES.txt
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
v17.5.27 · Registration & Users
|
||||||
|
|
||||||
|
- Полностью упрощена регистрация Caterium.
|
||||||
|
- Добавлено подтверждение пароля и кнопки показа/скрытия пароля.
|
||||||
|
- Убрана рекламная строка «14 дней бесплатно · Полный тариф» с формы регистрации.
|
||||||
|
- Подтверждение email не блокирует регистрацию; после signup приложение автоматически получает сессию и создаёт компанию.
|
||||||
|
- Повторная регистрация существующего email с верным паролем продолжает существующий аккаунт.
|
||||||
|
- «Пользователи и права»: добавлена кнопка «+ Добавить пользователя».
|
||||||
|
- Приглашение теперь привязано к email, имени и роли.
|
||||||
|
- Добавлены персональные ссылки приглашений, список ожидающих приглашений и отмена.
|
||||||
|
- Сотрудник по ссылке входит в существующий аккаунт или создаёт пароль и автоматически присоединяется к компании.
|
||||||
|
- Исправлен переход между tenant-рабочими копиями при принятии приглашения без промежуточной перезагрузки.
|
||||||
9
docs/releases/V17.5.28-CHANGES.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
CATERIUM v17.5.28
|
||||||
|
|
||||||
|
- Прямое создание сотрудников без ссылок-приглашений.
|
||||||
|
- Новый сотрудник получает временный пароль.
|
||||||
|
- При первом входе сотрудник обязан задать новый пароль.
|
||||||
|
- Существующий аккаунт Caterium просто добавляется в компанию.
|
||||||
|
- Исправлена проверка подписки при добавлении нового участника: проверка больше не требует предварительного membership.
|
||||||
|
- Старый блок «Ожидают приглашения» убран из интерфейса.
|
||||||
|
- Защищённая Edge Function caterium-create-employee хранит service role только на сервере.
|
||||||
25
docs/releases/V17.5.29-CHANGES.txt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
CATERIUM · v17.5.29 · ЧАТ КОМАНДЫ
|
||||||
|
|
||||||
|
Добавлено:
|
||||||
|
- отдельный раздел «Чаты» в основной навигации;
|
||||||
|
- общий чат компании;
|
||||||
|
- личные диалоги между сотрудниками одной компании;
|
||||||
|
- вкладка «Обсуждение» внутри сохранённого заказа и кнопка «Чат» в карточке заказа;
|
||||||
|
- счётчики непрочитанных сообщений;
|
||||||
|
- статусы «Доставлено / Прочитано»;
|
||||||
|
- индикатор «печатает…»;
|
||||||
|
- статус сотрудников онлайн через Supabase Realtime Presence;
|
||||||
|
- отправка до 5 фото/файлов за сообщение, до 15 МБ каждый;
|
||||||
|
- приватное хранилище вложений sun-chat и временные signed URL.
|
||||||
|
|
||||||
|
Безопасность:
|
||||||
|
- все сообщения жёстко изолированы по workspace_id;
|
||||||
|
- общий и заказной чат доступны только активным сотрудникам компании;
|
||||||
|
- личный диалог доступен только двум его участникам;
|
||||||
|
- Realtime-каналы приватные и защищены RLS;
|
||||||
|
- общий Realtime-канал компании не раскрывает ID личного чата/сообщения;
|
||||||
|
- таблицы сообщений доступны клиенту только на чтение, запись идёт через server-checked RPC;
|
||||||
|
- вложения лежат в приватном bucket и также проверяют membership/thread access.
|
||||||
|
|
||||||
|
Сервер:
|
||||||
|
- миграция SUPABASE-CHAT-V29.sql уже применена к текущему Supabase.
|
||||||
9
docs/releases/V17.5.30-CHANGES.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
CATERIUM · v17.5.30 · ПРАВА АДМИНИСТРАТОРОВ
|
||||||
|
|
||||||
|
1. Администратору теперь можно отключать отдельные права и функции.
|
||||||
|
2. Сервер sun_has_permission() больше не даёт роли admin безусловный полный доступ: явный false учитывается.
|
||||||
|
3. Интерфейс больше не блокирует чекбоксы прав для администратора.
|
||||||
|
4. Проверка последнего администратора считает именно других активных администраторов.
|
||||||
|
5. Добавлена защита: хотя бы у одного активного администратора остаётся users.manage.
|
||||||
|
6. Одновременные изменения администраторов сериализуются advisory lock по workspace.
|
||||||
|
7. Миграция: SUPABASE-ADMIN-RIGHTS-V30.sql.
|
||||||
14
docs/releases/V17.5.31-CHANGES.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
CATERIUM · v17.5.31 · СТАБИЛЬНАЯ НАВИГАЦИЯ ЧАТОВ
|
||||||
|
|
||||||
|
1. Исправлен каскад Failed to execute insertBefore: чат больше не вставляет кнопку относительно DOM-узла из другой группы.
|
||||||
|
2. Добавлен единый безопасный регистратор динамических пунктов бокового меню sunRegisterNavButton().
|
||||||
|
3. В левом меню отдельный пункт «Чат» со счётчиком непрочитанных.
|
||||||
|
4. Внутри раздела «Чат» три понятных режима: «Общий», «Личные», «Заказы».
|
||||||
|
5. В «Заказы» показываются все сохранённые заказы, даже если переписка ещё не создавалась; чат создаётся при первом открытии.
|
||||||
|
6. В карточке редактирования заказа появилась третья вкладка «Чат» рядом с «Заказ» и «Детали».
|
||||||
|
7. Чат заказа открывается прямо внутри заказа, не переключая пользователя на глобальную страницу чатов.
|
||||||
|
8. Тот же заказной диалог остаётся доступен через «Чат → Заказы».
|
||||||
|
9. Убрана бесконечная 1,5-секундная переинициализация чата; остались ограниченный стартовый retry и события облачного состояния.
|
||||||
|
10. Повторные события облака не создают дубликаты кнопки, страницы чата или вкладки заказа.
|
||||||
|
11. Серверная схема v29, RLS, личные диалоги, вложения, read/typing/presence сохранены без изменения.
|
||||||
|
12. Архитектурно следующими контекстами для чатов считаются Производство, Маршруты/доставка, Закупки и внутренние заметки по клиенту; в этой версии они не включены.
|
||||||
31
docs/releases/V17.6.0-CHANGES.txt
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
Caterium v17.6.0 - Stability & Security
|
||||||
|
Date: 2026-09-07
|
||||||
|
Rollback baseline: v17.5.31
|
||||||
|
|
||||||
|
Security
|
||||||
|
- Closed the old bootstrap stored DOM-XSS path with shared SunSafe escaping/attribute helpers.
|
||||||
|
- Consolidated repeated esc() implementations onto one shared utility.
|
||||||
|
- Added safe DOM insertion helper for dynamic navigation/settings nodes.
|
||||||
|
- Restored the deployed caterium-create-employee Edge Function source to the project; secrets remain environment-only.
|
||||||
|
|
||||||
|
Stability
|
||||||
|
- Moved the oldest bootstrap business script out of index.html into legacy/bootstrap.js.
|
||||||
|
- Centralized PDF byte packaging in core/pdf-engine.js with one A4 geometry validator.
|
||||||
|
- Added static security and release checks plus Playwright E2E specifications and CI workflow.
|
||||||
|
- Preserved v17.5.31 as a separate rollback archive.
|
||||||
|
|
||||||
|
Performance
|
||||||
|
- Removed blocking offer-gallery-data.js Base64 payload.
|
||||||
|
- Removed unused offer-gallery/003.jpg through 010.jpg; retained the two images actually used.
|
||||||
|
- Added image lazy-loading/async decoding helper for noncritical images.
|
||||||
|
- Added targeted mobile overflow containment for category/view/settings strips.
|
||||||
|
|
||||||
|
PDF QA
|
||||||
|
- Offer templates light, editorial-grid, midnight-glass, emerald-gold generated as real PDF blobs and rendered back to images.
|
||||||
|
- Catalog PDF generated as A4 pages and rendered back to images.
|
||||||
|
- All generated pages opened successfully in PyMuPDF and use consistent A4 geometry.
|
||||||
|
- Browser print/PDF CSS rules remain explicitly A4 portrait/landscape according to document type.
|
||||||
|
|
||||||
|
Known technical debt
|
||||||
|
- PDF pages remain image-based, so text is not selectable/searchable. The two former binary writers are now one audited engine, but a later migration to a vendored library/vector-text PDF pipeline is still desirable.
|
||||||
|
- index.html/app-runtime.js still contain historical modules and should be split further in a future architecture release.
|
||||||
231
ops/prepare-catalog-photos.js
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json');
|
||||||
|
const EXPECTED_TOTAL = 113;
|
||||||
|
const MAX_BYTES = 20 * 1024 * 1024;
|
||||||
|
const CONCURRENCY = 3;
|
||||||
|
const RETRIES = 3;
|
||||||
|
const CHECK_ONLY = process.argv.includes('--check-only');
|
||||||
|
const FORCE = process.argv.includes('--force');
|
||||||
|
|
||||||
|
function normalizeRel(value) {
|
||||||
|
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSources() {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(fs.readFileSync(SOURCE_FILE, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Cannot read catalog/photo-sources.json: ${error.message}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) throw new Error('catalog/photo-sources.json must contain an array');
|
||||||
|
const out = data.map((item, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
path: normalizeRel(item && item.path),
|
||||||
|
url: String(item && item.url || '').trim()
|
||||||
|
})).filter(item => item.path && item.url);
|
||||||
|
if (out.length !== EXPECTED_TOTAL) {
|
||||||
|
throw new Error(`Expected ${EXPECTED_TOTAL} catalog photo sources, found ${out.length}`);
|
||||||
|
}
|
||||||
|
const uniquePaths = new Set(out.map(item => item.path));
|
||||||
|
if (uniquePaths.size !== EXPECTED_TOTAL) throw new Error('Catalog photo source paths are not unique');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedUrl(target) {
|
||||||
|
let url;
|
||||||
|
try { url = new URL(target); } catch (_) { return false; }
|
||||||
|
if (url.protocol !== 'https:') return false;
|
||||||
|
return new Set(['static.tildacdn.com', 'static3.tildacdn.com', 'static.tildacdn.net']).has(url.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageKind(buf) {
|
||||||
|
if (!Buffer.isBuffer(buf) || buf.length < 12) return '';
|
||||||
|
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg';
|
||||||
|
if (buf.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'png';
|
||||||
|
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'webp';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validLocalPhoto(rel) {
|
||||||
|
const file = path.resolve(ROOT, rel);
|
||||||
|
if (!file.startsWith(ROOT + path.sep)) return false;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
if (!st.isFile() || st.size < 1000) return false;
|
||||||
|
const fd = fs.openSync(file, 'r');
|
||||||
|
const head = Buffer.alloc(16);
|
||||||
|
fs.readSync(fd, head, 0, head.length, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
return imageKind(head) === 'jpeg';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBuffer(target, redirects = 0, timeoutMs = 25000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (redirects > 6) return reject(new Error('too many redirects'));
|
||||||
|
let url;
|
||||||
|
try { url = new URL(target); } catch (error) { return reject(error); }
|
||||||
|
if (!isAllowedUrl(url.toString())) return reject(new Error(`photo host is not allowed: ${url.hostname}`));
|
||||||
|
|
||||||
|
const req = https.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringCatalogPhotoPrep/17.5.17',
|
||||||
|
'Accept': 'image/jpeg,image/*;q=0.8,*/*;q=0.1',
|
||||||
|
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5',
|
||||||
|
'Accept-Encoding': 'identity',
|
||||||
|
'Referer': 'https://solnce-keytering.ru/'
|
||||||
|
},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}, res => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
const next = new URL(res.headers.location, url).toString();
|
||||||
|
if (!isAllowedUrl(next)) return reject(new Error('redirected to an unsupported photo host'));
|
||||||
|
return downloadBuffer(next, redirects + 1, timeoutMs).then(resolve, reject);
|
||||||
|
}
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
res.resume();
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
const type = String(res.headers['content-type'] || '').toLowerCase();
|
||||||
|
if (type && !type.startsWith('image/')) {
|
||||||
|
res.resume();
|
||||||
|
return reject(new Error(`unexpected content type: ${type}`));
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
res.on('data', chunk => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BYTES) {
|
||||||
|
req.destroy(new Error('photo is too large'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
const kind = imageKind(buf);
|
||||||
|
if (kind !== 'jpeg' || buf.length < 1000) return reject(new Error('downloaded file is not a valid JPEG'));
|
||||||
|
resolve(buf);
|
||||||
|
});
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWrite(rel, buffer) {
|
||||||
|
const file = path.resolve(ROOT, rel);
|
||||||
|
if (!file.startsWith(ROOT + path.sep)) throw new Error('invalid local photo path');
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = `${file}.download-${process.pid}-${Date.now()}`;
|
||||||
|
fs.writeFileSync(tmp, buffer);
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(url) {
|
||||||
|
let last;
|
||||||
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
||||||
|
try { return await downloadBuffer(url); }
|
||||||
|
catch (error) {
|
||||||
|
last = error;
|
||||||
|
if (attempt < RETRIES) await delay(700 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw last || new Error('download failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(sources) {
|
||||||
|
const ready = sources.filter(item => validLocalPhoto(item.path));
|
||||||
|
const missing = sources.filter(item => !validLocalPhoto(item.path));
|
||||||
|
return { ready, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sources = readSources();
|
||||||
|
const initial = summarize(sources);
|
||||||
|
console.log(`Catalog photos: ${initial.ready.length}/${sources.length} local.`);
|
||||||
|
|
||||||
|
if (CHECK_ONLY) {
|
||||||
|
if (initial.missing.length) {
|
||||||
|
console.log(`Missing: ${initial.missing.length}.`);
|
||||||
|
process.exitCode = 2;
|
||||||
|
} else {
|
||||||
|
console.log('All catalog photos are local and valid.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = FORCE ? sources : initial.missing;
|
||||||
|
if (!targets.length) {
|
||||||
|
console.log('All 113 catalog photos are already local. No network access is needed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byUrl = new Map();
|
||||||
|
for (const item of targets) {
|
||||||
|
if (!isAllowedUrl(item.url)) throw new Error(`Unsupported photo URL for ${item.path}`);
|
||||||
|
if (!byUrl.has(item.url)) byUrl.set(item.url, []);
|
||||||
|
byUrl.get(item.url).push(item);
|
||||||
|
}
|
||||||
|
const jobs = [...byUrl.entries()].map(([url, items]) => ({ url, items }));
|
||||||
|
console.log(`Preparing ${targets.length} missing file(s) from ${jobs.length} unique image(s).`);
|
||||||
|
console.log('This happens only once. After completion the app uses local catalog photos.');
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
let finished = 0;
|
||||||
|
const failures = [];
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const pos = cursor++;
|
||||||
|
if (pos >= jobs.length) return;
|
||||||
|
const job = jobs[pos];
|
||||||
|
try {
|
||||||
|
const buf = await fetchWithRetry(job.url);
|
||||||
|
for (const item of job.items) atomicWrite(item.path, buf);
|
||||||
|
finished += job.items.length;
|
||||||
|
const current = initial.ready.length + finished;
|
||||||
|
console.log(`[${current}/${sources.length}] ${job.items.map(x => path.basename(x.path)).join(', ')}`);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ job, error });
|
||||||
|
console.error(`FAILED: ${job.items.map(x => x.path).join(', ')} - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, () => worker()));
|
||||||
|
|
||||||
|
const final = summarize(sources);
|
||||||
|
if (final.missing.length) {
|
||||||
|
console.error('');
|
||||||
|
console.error(`Catalog photo preparation is incomplete: ${final.ready.length}/${sources.length} ready.`);
|
||||||
|
console.error('Check the internet connection and run this file again. Existing downloaded photos are kept.');
|
||||||
|
console.error('Missing files:');
|
||||||
|
console.error(final.missing.map(x => path.basename(x.path)).join(', '));
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Catalog photos ready: 113/113. The app can now use the catalog photos locally.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('Catalog photo preparation error:', error && error.message ? error.message : String(error));
|
||||||
|
process.exitCode = 2;
|
||||||
|
});
|
||||||
669
ops/server.js
Normal file
@ -0,0 +1,669 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const childProcess = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PORT = Number(process.env.PORT || 8787);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
const SECRET_FILE = path.join(ROOT, '.sun-sync-secret');
|
||||||
|
const DATA_FILE = path.join(ROOT, 'sun-sync-data.json');
|
||||||
|
const PHONE_LINK_FILE = path.join(ROOT, 'PHONE-LINK.txt');
|
||||||
|
const RELEASE_MANIFEST_FILE = path.join(ROOT, 'release-manifest.json');
|
||||||
|
|
||||||
|
function readReleaseVersion() {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(RELEASE_MANIFEST_FILE, 'utf8'));
|
||||||
|
if (data && data.version) return String(data.version);
|
||||||
|
} catch (_) {}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_VERSION = readReleaseVersion();
|
||||||
|
|
||||||
|
function getSecret() {
|
||||||
|
if (process.env.SUN_SYNC_TOKEN) return String(process.env.SUN_SYNC_TOKEN).trim();
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch (_) {}
|
||||||
|
const token = crypto.randomBytes(24).toString('hex');
|
||||||
|
fs.writeFileSync(SECRET_FILE, token, { mode: 0o600 });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN = getSecret();
|
||||||
|
|
||||||
|
function emptyDb() {
|
||||||
|
return { version: 1, workspaces: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDb() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.workspaces && typeof parsed.workspaces === 'object') return parsed;
|
||||||
|
} catch (_) {}
|
||||||
|
return emptyDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = loadDb();
|
||||||
|
let saveTimer = null;
|
||||||
|
function saveDbNow() {
|
||||||
|
const tmp = DATA_FILE + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(db));
|
||||||
|
fs.renameSync(tmp, DATA_FILE);
|
||||||
|
}
|
||||||
|
function scheduleSave() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
try { saveDbNow(); }
|
||||||
|
catch (e) { console.error('ERROR: cannot save sync data:', e.message); }
|
||||||
|
}, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.txt': 'text/plain; charset=utf-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
function send(res, code, body, type = 'application/json; charset=utf-8') {
|
||||||
|
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorized(req) {
|
||||||
|
const h = String(req.headers['x-sun-token'] || '');
|
||||||
|
if (!h) return false;
|
||||||
|
const a = Buffer.from(h), b = Buffer.from(TOKEN);
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req, max = 80 * 1024 * 1024) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let size = 0;
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', chunk => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > max) {
|
||||||
|
reject(new Error('too large'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_CATALOG_PAGES = [
|
||||||
|
'https://solnce-keytering.ru/catalog',
|
||||||
|
'https://solnce-keytering.ru/catalog_black'
|
||||||
|
];
|
||||||
|
let siteCatalogCache = { at: 0, data: null };
|
||||||
|
const CATALOG_PHOTO_SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json');
|
||||||
|
function catalogPhotoStatus() {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8'));
|
||||||
|
const items = Array.isArray(list) ? list : [];
|
||||||
|
let ready = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const rel = String(item && item.path || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (!rel) continue;
|
||||||
|
const file = path.resolve(ROOT, rel);
|
||||||
|
if (!file.startsWith(ROOT + path.sep)) continue;
|
||||||
|
try { const st = fs.statSync(file); if (st.isFile() && st.size > 1000) ready++; } catch (_) {}
|
||||||
|
}
|
||||||
|
return { ready, total: items.length, complete: items.length > 0 && ready === items.length };
|
||||||
|
} catch (_) {
|
||||||
|
return { ready: 0, total: 0, complete: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalogPhotoSourcesCache = null;
|
||||||
|
function catalogPhotoSources() {
|
||||||
|
if (catalogPhotoSourcesCache) return catalogPhotoSourcesCache;
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8'));
|
||||||
|
catalogPhotoSourcesCache = Array.isArray(list) ? list : [];
|
||||||
|
} catch (_) { catalogPhotoSourcesCache = []; }
|
||||||
|
return catalogPhotoSourcesCache;
|
||||||
|
}
|
||||||
|
function imageType(buf) {
|
||||||
|
if (!Buffer.isBuffer(buf) || buf.length < 12) return '';
|
||||||
|
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
|
||||||
|
if (buf.subarray(0,8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'image/png';
|
||||||
|
if (buf.toString('ascii',0,4)==='RIFF' && buf.toString('ascii',8,12)==='WEBP') return 'image/webp';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function fetchCatalogPhotoBuffer(target, redirects = 0, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (redirects > 4) return reject(new Error('too many redirects'));
|
||||||
|
let url; try { url = new URL(target); } catch (e) { return reject(e); }
|
||||||
|
const allowed = new Set(['static.tildacdn.com','static3.tildacdn.com','static.tildacdn.net']);
|
||||||
|
if (url.protocol !== 'https:' || !allowed.has(url.hostname)) return reject(new Error('photo host is not allowed'));
|
||||||
|
const req = https.get(url,{headers:{
|
||||||
|
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringPhotoProxy/17.5.17',
|
||||||
|
'Accept':'image/avif,image/webp,image/apng,image/jpeg,image/*,*/*;q=0.8',
|
||||||
|
'Accept-Language':'ru-RU,ru;q=0.9,en;q=0.5',
|
||||||
|
'Accept-Encoding':'identity',
|
||||||
|
'Referer':'https://solnce-keytering.ru/'
|
||||||
|
},timeout:timeoutMs},res=>{
|
||||||
|
if(res.statusCode>=300&&res.statusCode<400&&res.headers.location){res.resume();const next=new URL(res.headers.location,url).toString();return fetchCatalogPhotoBuffer(next,redirects+1,timeoutMs).then(resolve,reject);}
|
||||||
|
if(res.statusCode<200||res.statusCode>=300){res.resume();return reject(new Error('remote HTTP '+res.statusCode));}
|
||||||
|
const chunks=[];let size=0;const max=20*1024*1024;
|
||||||
|
res.on('data',chunk=>{size+=chunk.length;if(size>max){req.destroy(new Error('photo too large'));return;}chunks.push(chunk)});
|
||||||
|
res.on('end',()=>{const buf=Buffer.concat(chunks),type=imageType(buf);if(!type||buf.length<1000)return reject(new Error('invalid image'));resolve({buf,type})});
|
||||||
|
res.on('error',reject);
|
||||||
|
});
|
||||||
|
req.on('timeout',()=>req.destroy(new Error('timeout')));req.on('error',reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function catalogPhotoApi(req,res,url){
|
||||||
|
const index=Number(url.searchParams.get('i')||0);
|
||||||
|
const sources=catalogPhotoSources();
|
||||||
|
if(!Number.isInteger(index)||index<1||index>sources.length)return send(res,400,JSON.stringify({ok:false,error:'bad photo index'}));
|
||||||
|
const item=sources[index-1]||{},rel=String(item.path||'').replace(/\\/g,'/').replace(/^\/+/,''),remote=String(item.url||'');
|
||||||
|
const file=path.resolve(ROOT,rel);
|
||||||
|
if(rel&&file.startsWith(ROOT+path.sep)){
|
||||||
|
try{const buf=fs.readFileSync(file),type=imageType(buf);if(type&&buf.length>1000){res.writeHead(200,{'Content-Type':type,'Content-Length':buf.length,'Cache-Control':'public, max-age=86400'});return res.end(buf)}}catch(_){}
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const out=await fetchCatalogPhotoBuffer(remote);
|
||||||
|
if(out.type==='image/jpeg'&&rel&&file.startsWith(ROOT+path.sep)){try{fs.mkdirSync(path.dirname(file),{recursive:true});const tmp=file+'.tmp-'+process.pid+'-'+Date.now();fs.writeFileSync(tmp,out.buf);fs.renameSync(tmp,file)}catch(_){} }
|
||||||
|
res.writeHead(200,{'Content-Type':out.type,'Content-Length':out.buf.length,'Cache-Control':'public, max-age=86400'});res.end(out.buf);
|
||||||
|
}catch(error){send(res,502,JSON.stringify({ok:false,error:String(error&&error.message||error)}));}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchRemoteText(target, redirects = 0, timeoutMs = 15000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (redirects > 5) return reject(new Error('too many redirects'));
|
||||||
|
let url;
|
||||||
|
try { url = new URL(target); } catch (e) { return reject(e); }
|
||||||
|
if (url.protocol !== 'https:') return reject(new Error('https only'));
|
||||||
|
const allowed = new Set(['solnce-keytering.ru','www.solnce-keytering.ru','store.tildacdn.com','store2.tildacdn.com']);
|
||||||
|
if (!allowed.has(url.hostname)) return reject(new Error('remote host is not allowed'));
|
||||||
|
const req = https.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringCatalogSync/17.5.17',
|
||||||
|
'Accept': 'text/html,application/json,text/plain;q=0.9,*/*;q=0.5',
|
||||||
|
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5',
|
||||||
|
'Accept-Encoding': 'identity'
|
||||||
|
},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}, res => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
const next = new URL(res.headers.location, url).toString();
|
||||||
|
return fetchRemoteText(next, redirects + 1, timeoutMs).then(resolve, reject);
|
||||||
|
}
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
res.resume();
|
||||||
|
return reject(new Error(`remote HTTP ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
const chunks = []; let size = 0; const max = 12 * 1024 * 1024;
|
||||||
|
res.on('data', chunk => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > max) { req.destroy(new Error('remote response too large')); return; }
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
req.on('timeout', () => req.destroy(new Error('remote timeout')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function balancedObject(text, start) {
|
||||||
|
let depth = 0, quote = null, esc = false;
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
if (quote) {
|
||||||
|
if (esc) esc = false;
|
||||||
|
else if (c === '\\') esc = true;
|
||||||
|
else if (c === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'") quote = c;
|
||||||
|
else if (c === '{') depth++;
|
||||||
|
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverStoreConfigs(html) {
|
||||||
|
const out = [], seen = new Set();
|
||||||
|
const re = /t_store_init\(\s*['"]?(\d+)['"]?\s*,/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html))) {
|
||||||
|
const recid = m[1];
|
||||||
|
const brace = html.indexOf('{', re.lastIndex);
|
||||||
|
if (brace < 0 || brace - re.lastIndex > 5000) continue;
|
||||||
|
const raw = balancedObject(html, brace);
|
||||||
|
if (!raw) continue;
|
||||||
|
const sm = raw.match(/["']?storepart["']?\s*:\s*(?:["']([^"']+)["']|([\w-]+))/i);
|
||||||
|
const storepart = (sm && (sm[1] || sm[2]) || '').trim();
|
||||||
|
if (!storepart) continue;
|
||||||
|
const key = recid + '|' + storepart;
|
||||||
|
if (!seen.has(key)) { seen.add(key); out.push({ recid, storepart }); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProductUids(html) {
|
||||||
|
const set = new Set(); let m;
|
||||||
|
const patterns = [
|
||||||
|
/data-product-uid=["'](\d+)["']/gi,
|
||||||
|
/\/tproduct\/(\d+)-/gi,
|
||||||
|
/["']productuid["']\s*:\s*["']?(\d+)/gi
|
||||||
|
];
|
||||||
|
for (const re of patterns) while ((m = re.exec(html))) set.add(m[1]);
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoreJson(raw) {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
try { return JSON.parse(text); } catch (_) {}
|
||||||
|
const a = text.indexOf('{'), b = text.lastIndexOf('}');
|
||||||
|
if (a >= 0 && b > a) { try { return JSON.parse(text.slice(a, b + 1)); } catch (_) {} }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProductsForConfig(config) {
|
||||||
|
const products = [];
|
||||||
|
let slice = '';
|
||||||
|
for (let page = 0; page < 8; page++) {
|
||||||
|
const query = new URLSearchParams({ storepartuid: config.storepart, recid: config.recid, c: String(Date.now()), getparts: 'true', getoptions: 'true', size: '100' });
|
||||||
|
if (slice) query.set('slice', String(slice));
|
||||||
|
let parsed = null, lastError = null;
|
||||||
|
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
|
||||||
|
try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductslist/?${query}`)); if (parsed) break; }
|
||||||
|
catch (e) { lastError = e; }
|
||||||
|
}
|
||||||
|
if (!parsed) throw lastError || new Error('cannot parse Tilda product list');
|
||||||
|
const list = Array.isArray(parsed.products) ? parsed.products : [];
|
||||||
|
products.push(...list);
|
||||||
|
if (!parsed.nextslice || !list.length) break;
|
||||||
|
slice = parsed.nextslice;
|
||||||
|
}
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProductsByUids(uids) {
|
||||||
|
const out = [];
|
||||||
|
for (const uid of uids.slice(0, 160)) {
|
||||||
|
const query = new URLSearchParams({ productsuid: uid, c: String(Date.now()) });
|
||||||
|
let parsed = null;
|
||||||
|
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
|
||||||
|
try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductsbyuid/?${query}`)); if (parsed) break; }
|
||||||
|
catch (_) {}
|
||||||
|
}
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (Array.isArray(parsed.products)) out.push(...parsed.products);
|
||||||
|
else if (Array.isArray(parsed)) out.push(...parsed);
|
||||||
|
else if (parsed.product) out.push(parsed.product);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstProductFromPayload(parsed) {
|
||||||
|
if (!parsed) return null;
|
||||||
|
if (parsed.product && typeof parsed.product === 'object') return parsed.product;
|
||||||
|
if (Array.isArray(parsed.products) && parsed.products.length) return parsed.products[0];
|
||||||
|
if (Array.isArray(parsed) && parsed.length) return parsed[0];
|
||||||
|
if (typeof parsed === 'object' && (parsed.uid || parsed.id || parsed.title || parsed.name)) return parsed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOneProduct(config, uid) {
|
||||||
|
if (!config || !uid) return null;
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
storepartuid: config.storepart,
|
||||||
|
recid: config.recid,
|
||||||
|
productuid: String(uid),
|
||||||
|
c: String(Date.now())
|
||||||
|
});
|
||||||
|
let lastError = null;
|
||||||
|
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
|
||||||
|
try {
|
||||||
|
const parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproduct/?${query}`, 0, 8000));
|
||||||
|
const product = firstProductFromPayload(parsed);
|
||||||
|
if (product) return product;
|
||||||
|
} catch (e) { lastError = e; }
|
||||||
|
}
|
||||||
|
if (lastError) throw lastError;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichProductsWithDetails(config, products, forceAll = false) {
|
||||||
|
const list = (Array.isArray(products) ? products : []).slice(0, 160);
|
||||||
|
const out = list.slice();
|
||||||
|
const indexes = [];
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const summary = list[i];
|
||||||
|
const uid = String(summary && (summary.uid || summary.id) || '');
|
||||||
|
if (uid && (forceAll || !extractComposition(summary || {}).length)) indexes.push(i);
|
||||||
|
}
|
||||||
|
let cursor = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (cursor < indexes.length) {
|
||||||
|
const index = indexes[cursor++];
|
||||||
|
const summary = list[index];
|
||||||
|
const uid = String(summary && (summary.uid || summary.id) || '');
|
||||||
|
try {
|
||||||
|
const detail = await fetchOneProduct(config, uid);
|
||||||
|
if (detail) out[index] = {...summary, ...detail};
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const count = Math.min(2, indexes.length);
|
||||||
|
await Promise.all(Array.from({length: count}, () => worker()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/ | /gi, ' ')
|
||||||
|
.replace(/&/gi, '&').replace(/"/gi, '"').replace(/'|'/gi, "'")
|
||||||
|
.replace(/</gi, '<').replace(/>/gi, '>')
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n) || 32))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16) || 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlLines(value) {
|
||||||
|
let s = String(value || '');
|
||||||
|
s = s.replace(/<br\s*\/?\s*>/gi, '\n').replace(/<\/(?:p|div|li|ul|ol|h\d)>/gi, '\n').replace(/<li[^>]*>/gi, '').replace(/<[^>]+>/g, ' ');
|
||||||
|
s = decodeEntities(s).replace(/\r/g, '');
|
||||||
|
return s.split('\n').map(x => x.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanCompositionLines(lines) {
|
||||||
|
const out = [];
|
||||||
|
for (let line of lines) {
|
||||||
|
line = String(line || '').replace(/^[-–—•·*]+\s*/, '').trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (/^(?:состав(?:\s+(?:бокса|набора|сета))?|купить|подробнее)\s*:?[\s]*$/i.test(line)) continue;
|
||||||
|
if (/^(?:общий\s+)?вес(?:\s|:|$)|^(?:на\s+)?кол-?во\s+(?:персон|гостей)(?:\s|:|$)|^цена(?:\s|:|$)|^артикул(?:\s|:|$)|^доставка(?:\s|:|$)/i.test(line)) break;
|
||||||
|
if (/\b(?:персон|гост(?:ей|я))\b/i.test(line) && !/(?:шт\.?|пор\.?|г\b|гр\.?)/i.test(line)) continue;
|
||||||
|
if (/^\d[\d\s]*\s*(?:₽|р\.?|руб\.?)$/i.test(line)) continue;
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
return [...new Set(out)].slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProductTextValues(value, depth = 0, out = [], seen = new Set()) {
|
||||||
|
if (value == null || depth > 5) return out;
|
||||||
|
if (typeof value === 'string') { if (value.trim()) out.push(value); return out; }
|
||||||
|
if (typeof value !== 'object') return out;
|
||||||
|
if (seen.has(value)) return out;
|
||||||
|
seen.add(value);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectProductTextValues(item, depth + 1, out, seen);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (/^(?:photo|photos|image|images|gallery|url|sku|uid|id|price|price_old|priceold|quantity|title|name)$/i.test(key)) continue;
|
||||||
|
collectProductTextValues(item, depth + 1, out, seen);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractComposition(product) {
|
||||||
|
const priority = [product && product.text, product && product.descr, product && product.description, product && product.fulltext];
|
||||||
|
const fields = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of [...priority, ...collectProductTextValues(product || {})]) {
|
||||||
|
const field = String(value || '').trim();
|
||||||
|
if (!field || seen.has(field)) continue;
|
||||||
|
seen.add(field); fields.push(field);
|
||||||
|
}
|
||||||
|
let fallback = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
let raw = String(field).replace(/(\u0421\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:)/ig, '\n$1\n');
|
||||||
|
const lines = htmlLines(raw);
|
||||||
|
const start = lines.findIndex(x => /^\u0441\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:?$/i.test(x));
|
||||||
|
if (start >= 0) {
|
||||||
|
const found = cleanCompositionLines(lines.slice(start + 1));
|
||||||
|
if (found.length) return found;
|
||||||
|
}
|
||||||
|
const plausible = cleanCompositionLines(lines).filter(x => /\b(?:\u0448\u0442\.?|\u043f\u043e\u0440\.?|\u043a\u0443\u0441\.?|\u0433\b|\u0433\u0440\.?)\b/i.test(x));
|
||||||
|
if (plausible.length > fallback.length) fallback = plausible;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteProduct(product) {
|
||||||
|
const title = decodeEntities(String(product && (product.title || product.name) || '')).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const composition = extractComposition(product || {});
|
||||||
|
return { uid: String(product && (product.uid || product.id) || ''), title, composition, url: String(product && product.url || '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSiteCatalogCompositions() {
|
||||||
|
const all = [], errors = [], configs = [];
|
||||||
|
let premiumCompositionCount = 0;
|
||||||
|
for (const pageUrl of SITE_CATALOG_PAGES) {
|
||||||
|
try {
|
||||||
|
const html = await fetchRemoteText(pageUrl);
|
||||||
|
const found = discoverStoreConfigs(html);
|
||||||
|
configs.push(...found.map(x => ({...x, pageUrl})));
|
||||||
|
if (found.length) {
|
||||||
|
for (const config of found) {
|
||||||
|
try {
|
||||||
|
const summaries = await fetchProductsForConfig(config);
|
||||||
|
const isPremiumPage = /\/catalog_black(?:$|[?#])/i.test(pageUrl);
|
||||||
|
const detailed = await enrichProductsWithDetails(config, summaries, isPremiumPage);
|
||||||
|
if (isPremiumPage) premiumCompositionCount += detailed.filter(item => extractComposition(item || {}).length).length;
|
||||||
|
all.push(...detailed);
|
||||||
|
} catch (e) { errors.push(`${pageUrl}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uids = extractProductUids(html);
|
||||||
|
if (uids.length) all.push(...await fetchProductsByUids(uids));
|
||||||
|
else errors.push(`${pageUrl}: Tilda store config not found`);
|
||||||
|
}
|
||||||
|
} catch (e) { errors.push(`${pageUrl}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const raw of all) {
|
||||||
|
const p = normalizeSiteProduct(raw);
|
||||||
|
if (!p.title || !p.composition.length) continue;
|
||||||
|
const key = p.uid || p.title.toLowerCase();
|
||||||
|
if (!byKey.has(key) || byKey.get(key).composition.length < p.composition.length) byKey.set(key, p);
|
||||||
|
}
|
||||||
|
return { source: 'solnce-keytering.ru', fetchedAt: new Date().toISOString(), configs: configs.length, premiumCompositionCount, products: [...byKey.values()], errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function catalogCompositionsApi(req, res) {
|
||||||
|
const now = Date.now();
|
||||||
|
const requestUrl = new URL(req.url, 'http://localhost');
|
||||||
|
const force = requestUrl.searchParams.get('force') === '1';
|
||||||
|
if (!force && siteCatalogCache.data && now - siteCatalogCache.at < 30 * 60 * 1000) {
|
||||||
|
return send(res, 200, JSON.stringify({...siteCatalogCache.data, cached: true}));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await loadSiteCatalogCompositions();
|
||||||
|
if (!data.products.length) return send(res, 502, JSON.stringify({ error: 'site catalog compositions were not received', details: data.errors.slice(0, 6) }));
|
||||||
|
if (Number(data.premiumCompositionCount || 0) > 0) siteCatalogCache = { at: now, data };
|
||||||
|
return send(res, 200, JSON.stringify({...data, cached: false}));
|
||||||
|
} catch (e) {
|
||||||
|
return send(res, 502, JSON.stringify({ error: e.message || String(e) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function api(req, res, url) {
|
||||||
|
if (!authorized(req)) return send(res, 401, JSON.stringify({ error: 'unauthorized' }));
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const workspace = String(url.searchParams.get('workspace') || 'main').slice(0, 120);
|
||||||
|
const source = db.workspaces[workspace] || {};
|
||||||
|
const entries = {};
|
||||||
|
for (const [key, item] of Object.entries(source)) {
|
||||||
|
if (!item) continue;
|
||||||
|
entries[key] = { value: String(item.value ?? ''), updatedAt: Number(item.updatedAt || 0) };
|
||||||
|
}
|
||||||
|
return send(res, 200, JSON.stringify({ workspace, entries, serverTime: Date.now() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
return readBody(req).then(raw => {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(raw || '{}'); }
|
||||||
|
catch (_) { return send(res, 400, JSON.stringify({ error: 'bad json' })); }
|
||||||
|
|
||||||
|
const workspace = String(data.workspace || 'main').slice(0, 120);
|
||||||
|
const changes = data.changes && typeof data.changes === 'object' ? data.changes : {};
|
||||||
|
const target = db.workspaces[workspace] || (db.workspaces[workspace] = {});
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const [key, item] of Object.entries(changes)) {
|
||||||
|
if (!key || key.length > 240 || !item) continue;
|
||||||
|
const updatedAt = Number(item.updatedAt || Date.now());
|
||||||
|
const oldAt = Number(target[key]?.updatedAt || 0);
|
||||||
|
if (updatedAt >= oldAt) {
|
||||||
|
target[key] = { value: String(item.value ?? ''), updatedAt };
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleSave();
|
||||||
|
return send(res, 200, JSON.stringify({ ok: true, count, serverTime: Date.now() }));
|
||||||
|
}).catch(e => send(res, 500, JSON.stringify({ error: e.message })));
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticFile(req, res, url) {
|
||||||
|
let pathname = decodeURIComponent(url.pathname);
|
||||||
|
if (pathname === '/' || pathname === '') pathname = '/index.html';
|
||||||
|
const rel = pathname.replace(/^\/+/, '');
|
||||||
|
const base = path.basename(rel);
|
||||||
|
const protectedNames = new Set(['server.js', 'prepare-catalog-photos.js', 'sun-sync-data.json', 'sun-sync-data.json.tmp', 'PHONE-LINK.txt']);
|
||||||
|
if (base.startsWith('.') || /\.(?:sqlite|db)$/i.test(base) || protectedNames.has(base)) {
|
||||||
|
return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = path.resolve(ROOT, rel);
|
||||||
|
if (!file.startsWith(ROOT + path.sep) && file !== path.join(ROOT, 'index.html')) {
|
||||||
|
return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.stat(file, (err, st) => {
|
||||||
|
if (err || !st.isFile()) return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': mime[ext] || 'application/octet-stream',
|
||||||
|
'Cache-Control': ['.html', '.js', '.css', '.webmanifest'].includes(ext) ? 'no-cache' : 'public, max-age=86400'
|
||||||
|
});
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv4(ip) {
|
||||||
|
const p = ip.split('.').map(Number);
|
||||||
|
return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanAddresses() {
|
||||||
|
const out = [];
|
||||||
|
for (const list of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const n of list || []) {
|
||||||
|
if (n.family !== 'IPv4' || n.internal) continue;
|
||||||
|
if (isPrivateIPv4(n.address)) out.push(n.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkFor(host) {
|
||||||
|
return `http://${host}:${PORT}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryOpenBrowser(url) {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
childProcess.spawn('cmd.exe', ['/d', '/s', '/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
if (url.pathname === '/healthz') return send(res, 200, JSON.stringify({ok:true,app:'sun-catering',mode:'local-test',version:APP_VERSION,catalogPhotos:catalogPhotoStatus(),time:new Date().toISOString()}));
|
||||||
|
if (url.pathname === '/api/catalog-photo' && req.method === 'GET') return catalogPhotoApi(req, res, url);
|
||||||
|
if (url.pathname === '/api/catalog-compositions' && req.method === 'GET') return catalogCompositionsApi(req, res);
|
||||||
|
if (url.pathname === '/api/sync') return api(req, res, url);
|
||||||
|
return staticFile(req, res, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', err => {
|
||||||
|
console.error('\n============================================================');
|
||||||
|
console.error('SERVER START ERROR');
|
||||||
|
console.error(err && err.message ? err.message : String(err));
|
||||||
|
if (err && err.code === 'EADDRINUSE') console.error(`Port ${PORT} is already in use. Close another server window and try again.`);
|
||||||
|
console.error('============================================================\n');
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
// Catalog photos prefer local files; missing originals are fetched and cached on demand without blocking startup.
|
||||||
|
const computer = linkFor('localhost');
|
||||||
|
const lan = getLanAddresses().map(linkFor);
|
||||||
|
const lines = [
|
||||||
|
'SUN CATERING - MOBILE SERVER',
|
||||||
|
'',
|
||||||
|
'OPEN ON THIS COMPUTER:',
|
||||||
|
computer,
|
||||||
|
'',
|
||||||
|
'OPEN ON IPHONE / PHONE:',
|
||||||
|
...(lan.length ? lan : ['No local Wi-Fi address found. Connect the computer to Wi-Fi and restart this file.']),
|
||||||
|
'',
|
||||||
|
'The phone and computer must be connected to the same Wi-Fi network.',
|
||||||
|
'Keep this black window open while using the shared database.',
|
||||||
|
'',
|
||||||
|
'The same phone link is saved in this folder as PHONE-LINK.txt.'
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = lines.join('\r\n') + '\r\n';
|
||||||
|
try { fs.writeFileSync(PHONE_LINK_FILE, text, 'utf8'); }
|
||||||
|
catch (e) { console.error('Cannot write PHONE-LINK.txt:', e.message); }
|
||||||
|
|
||||||
|
console.log('\n============================================================');
|
||||||
|
console.log('SUN CATERING - MOBILE SERVER IS RUNNING');
|
||||||
|
console.log('============================================================\n');
|
||||||
|
console.log('OPEN ON THIS COMPUTER:');
|
||||||
|
console.log(' ' + computer + '\n');
|
||||||
|
console.log('OPEN ON IPHONE / PHONE:');
|
||||||
|
if (lan.length) lan.forEach(x => console.log(' ' + x));
|
||||||
|
else console.log(' No local Wi-Fi address found. Connect the computer to Wi-Fi and restart.');
|
||||||
|
console.log('\nThe phone link is also saved in:');
|
||||||
|
console.log(' ' + PHONE_LINK_FILE);
|
||||||
|
console.log('\nKeep this window open while using the shared database.\n');
|
||||||
|
|
||||||
|
tryOpenBrowser(computer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
try { if (saveTimer) clearTimeout(saveTimer); saveDbNow(); } catch (_) {}
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 500).unref();
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
190
ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
-- Caterium v17.5.30 - granular administrator permissions and correct last-admin checks.
|
||||||
|
|
||||||
|
create or replace function public.sun_has_permission(p_workspace uuid, p_permission text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_role text;
|
||||||
|
v_permissions jsonb;
|
||||||
|
v_active boolean;
|
||||||
|
begin
|
||||||
|
select role,permissions,is_active
|
||||||
|
into v_role,v_permissions,v_active
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and user_id=auth.uid()
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not coalesce(v_active,false) then return false; end if;
|
||||||
|
|
||||||
|
-- Explicit member permissions override the role template for every role,
|
||||||
|
-- including administrators. Missing keys fall back to the role defaults.
|
||||||
|
if coalesce(v_permissions,'{}'::jsonb) ? p_permission then
|
||||||
|
return coalesce((v_permissions->>p_permission)::boolean,false);
|
||||||
|
end if;
|
||||||
|
return coalesce((public.sun_role_default_permissions(v_role)->>p_permission)::boolean,false);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_admin_update_member(
|
||||||
|
p_workspace uuid,
|
||||||
|
p_user uuid,
|
||||||
|
p_display_name text,
|
||||||
|
p_role text,
|
||||||
|
p_is_active boolean,
|
||||||
|
p_permissions jsonb
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_old_role text;
|
||||||
|
v_old_active boolean;
|
||||||
|
v_role text:=lower(coalesce(p_role,''));
|
||||||
|
v_other_admins integer:=0;
|
||||||
|
v_other_managing_admins integer:=0;
|
||||||
|
v_target_will_manage boolean:=false;
|
||||||
|
v_max integer;
|
||||||
|
v_active_count integer;
|
||||||
|
begin
|
||||||
|
-- Serialize membership administration inside one workspace so two admins
|
||||||
|
-- cannot simultaneously remove/demote the last administrators.
|
||||||
|
perform pg_advisory_xact_lock(hashtext(p_workspace::text));
|
||||||
|
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if;
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для управления сотрудниками'; end if;
|
||||||
|
if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Некорректная роль'; end if;
|
||||||
|
if p_permissions is null or jsonb_typeof(p_permissions)<>'object' then raise exception 'Некорректные права пользователя'; end if;
|
||||||
|
|
||||||
|
select role,is_active into v_old_role,v_old_active
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and user_id=p_user
|
||||||
|
for update;
|
||||||
|
if not found then raise exception 'Пользователь не найден'; end if;
|
||||||
|
|
||||||
|
select count(*)::int into v_other_admins
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace
|
||||||
|
and user_id<>p_user
|
||||||
|
and role='admin'
|
||||||
|
and is_active=true;
|
||||||
|
|
||||||
|
if v_old_role='admin' and coalesce(v_old_active,false)
|
||||||
|
and (v_role<>'admin' or not coalesce(p_is_active,false))
|
||||||
|
and v_other_admins=0 then
|
||||||
|
raise exception 'Нельзя отключить или понизить последнего активного администратора';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select count(*)::int into v_other_managing_admins
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace
|
||||||
|
and user_id<>p_user
|
||||||
|
and role='admin'
|
||||||
|
and is_active=true
|
||||||
|
and coalesce(
|
||||||
|
case when coalesce(permissions,'{}'::jsonb) ? 'users.manage'
|
||||||
|
then (permissions->>'users.manage')::boolean
|
||||||
|
else null end,
|
||||||
|
true
|
||||||
|
)=true;
|
||||||
|
|
||||||
|
v_target_will_manage := v_role='admin'
|
||||||
|
and coalesce(p_is_active,false)
|
||||||
|
and coalesce(
|
||||||
|
case when p_permissions ? 'users.manage'
|
||||||
|
then (p_permissions->>'users.manage')::boolean
|
||||||
|
else null end,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if not v_target_will_manage and v_other_managing_admins=0 then
|
||||||
|
raise exception 'У хотя бы одного активного администратора должно оставаться право «Пользователи и права»';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if coalesce(p_is_active,false) and not coalesce(v_old_active,false) then
|
||||||
|
select p.max_members into v_max
|
||||||
|
from public.sun_workspace_subscriptions s
|
||||||
|
join public.sun_plans p on p.id=s.plan_id
|
||||||
|
where s.workspace_id=p_workspace;
|
||||||
|
select count(*)::int into v_active_count
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and is_active=true;
|
||||||
|
if v_max is not null and v_active_count>=v_max then
|
||||||
|
raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.sun_workspace_members
|
||||||
|
set display_name=nullif(trim(coalesce(p_display_name,'')),''),
|
||||||
|
role=v_role,
|
||||||
|
is_active=coalesce(p_is_active,false),
|
||||||
|
permissions=p_permissions,
|
||||||
|
updated_at=now()
|
||||||
|
where workspace_id=p_workspace and user_id=p_user;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_role text;
|
||||||
|
v_active boolean;
|
||||||
|
v_other_admins integer:=0;
|
||||||
|
v_other_managing_admins integer:=0;
|
||||||
|
begin
|
||||||
|
perform pg_advisory_xact_lock(hashtext(p_workspace::text));
|
||||||
|
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if;
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для управления сотрудниками'; end if;
|
||||||
|
|
||||||
|
select role,is_active into v_role,v_active
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and user_id=p_user
|
||||||
|
for update;
|
||||||
|
if not found then return; end if;
|
||||||
|
|
||||||
|
if v_role='admin' and coalesce(v_active,false) then
|
||||||
|
select count(*)::int into v_other_admins
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and user_id<>p_user and role='admin' and is_active=true;
|
||||||
|
if v_other_admins=0 then raise exception 'Нельзя удалить последнего активного администратора'; end if;
|
||||||
|
|
||||||
|
select count(*)::int into v_other_managing_admins
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace
|
||||||
|
and user_id<>p_user
|
||||||
|
and role='admin'
|
||||||
|
and is_active=true
|
||||||
|
and coalesce(
|
||||||
|
case when coalesce(permissions,'{}'::jsonb) ? 'users.manage'
|
||||||
|
then (permissions->>'users.manage')::boolean
|
||||||
|
else null end,
|
||||||
|
true
|
||||||
|
)=true;
|
||||||
|
if v_other_managing_admins=0 then
|
||||||
|
raise exception 'Нельзя удалить администратора: после удаления никто не сможет управлять пользователями';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
delete from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and user_id=p_user;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_has_permission(uuid,text) from public,anon;
|
||||||
|
revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public,anon;
|
||||||
|
revoke all on function public.sun_admin_remove_member(uuid,uuid) from public,anon;
|
||||||
|
grant execute on function public.sun_has_permission(uuid,text) to authenticated;
|
||||||
|
grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated;
|
||||||
|
grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated;
|
||||||
404
ops/sql/SUPABASE-CHAT-V29.sql
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
create table if not exists public.sun_chat_threads (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
kind text not null check (kind in ('company','direct','order')),
|
||||||
|
title text,
|
||||||
|
order_id text,
|
||||||
|
direct_key text,
|
||||||
|
created_by uuid references auth.users(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
last_message_at timestamptz
|
||||||
|
);
|
||||||
|
create unique index if not exists sun_chat_threads_company_uq on public.sun_chat_threads(workspace_id) where kind='company';
|
||||||
|
create unique index if not exists sun_chat_threads_order_uq on public.sun_chat_threads(workspace_id,order_id) where kind='order';
|
||||||
|
create unique index if not exists sun_chat_threads_direct_uq on public.sun_chat_threads(workspace_id,direct_key) where kind='direct';
|
||||||
|
create index if not exists sun_chat_threads_workspace_idx on public.sun_chat_threads(workspace_id,coalesce(last_message_at,created_at) desc);
|
||||||
|
|
||||||
|
create table if not exists public.sun_chat_participants (
|
||||||
|
thread_id uuid not null references public.sun_chat_threads(id) on delete cascade,
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key(thread_id,user_id)
|
||||||
|
);
|
||||||
|
create index if not exists sun_chat_participants_user_idx on public.sun_chat_participants(user_id,workspace_id);
|
||||||
|
|
||||||
|
create table if not exists public.sun_chat_messages (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
thread_id uuid not null references public.sun_chat_threads(id) on delete cascade,
|
||||||
|
sender_user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
body text not null default '',
|
||||||
|
attachments jsonb not null default '[]'::jsonb,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
edited_at timestamptz,
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
create index if not exists sun_chat_messages_thread_idx on public.sun_chat_messages(thread_id,created_at desc);
|
||||||
|
create index if not exists sun_chat_messages_workspace_idx on public.sun_chat_messages(workspace_id,created_at desc);
|
||||||
|
|
||||||
|
create table if not exists public.sun_chat_reads (
|
||||||
|
thread_id uuid not null references public.sun_chat_threads(id) on delete cascade,
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
last_read_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key(thread_id,user_id)
|
||||||
|
);
|
||||||
|
create index if not exists sun_chat_reads_user_idx on public.sun_chat_reads(user_id,workspace_id);
|
||||||
|
|
||||||
|
alter table public.sun_chat_threads enable row level security;
|
||||||
|
alter table public.sun_chat_participants enable row level security;
|
||||||
|
alter table public.sun_chat_messages enable row level security;
|
||||||
|
alter table public.sun_chat_reads enable row level security;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_is_member_v29(p_workspace uuid,p_user uuid default null)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
select exists(
|
||||||
|
select 1 from public.sun_workspace_members m
|
||||||
|
where m.workspace_id=p_workspace
|
||||||
|
and m.user_id=coalesce(p_user,auth.uid())
|
||||||
|
and m.is_active=true
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_can_access_thread_as_v29(p_thread uuid,p_user uuid)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v public.sun_chat_threads%rowtype;
|
||||||
|
begin
|
||||||
|
if p_user is null then return false; end if;
|
||||||
|
select * into v from public.sun_chat_threads where id=p_thread;
|
||||||
|
if not found then return false; end if;
|
||||||
|
if not public.sun_chat_is_member_v29(v.workspace_id,p_user) then return false; end if;
|
||||||
|
if v.kind='direct' then
|
||||||
|
return exists(select 1 from public.sun_chat_participants p where p.thread_id=v.id and p.user_id=p_user);
|
||||||
|
end if;
|
||||||
|
return true;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_can_access_thread_v29(p_thread uuid)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$ select public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) $$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_realtime_topic_access_v29(p_topic text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v_id uuid;
|
||||||
|
begin
|
||||||
|
if p_topic like 'sun-chat-workspace:%' then
|
||||||
|
begin v_id:=substring(p_topic from length('sun-chat-workspace:')+1)::uuid; exception when others then return false; end;
|
||||||
|
return public.sun_chat_is_member_v29(v_id,auth.uid());
|
||||||
|
elsif p_topic like 'sun-chat-thread:%' then
|
||||||
|
begin v_id:=substring(p_topic from length('sun-chat-thread:')+1)::uuid; exception when others then return false; end;
|
||||||
|
return public.sun_chat_can_access_thread_v29(v_id);
|
||||||
|
end if;
|
||||||
|
return false;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_storage_access_v29(p_name text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth','storage'
|
||||||
|
as $$
|
||||||
|
declare parts text[]; v_ws uuid; v_thread uuid; v_thread_ws uuid;
|
||||||
|
begin
|
||||||
|
parts:=storage.foldername(p_name);
|
||||||
|
if array_length(parts,1)<2 then return false; end if;
|
||||||
|
begin v_ws:=parts[1]::uuid; v_thread:=parts[2]::uuid; exception when others then return false; end;
|
||||||
|
select workspace_id into v_thread_ws from public.sun_chat_threads where id=v_thread;
|
||||||
|
if v_thread_ws is null or v_thread_ws<>v_ws then return false; end if;
|
||||||
|
return public.sun_chat_can_access_thread_as_v29(v_thread,auth.uid());
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Read-only table access for authenticated users; writes go through RPCs.
|
||||||
|
revoke all on public.sun_chat_threads,public.sun_chat_participants,public.sun_chat_messages,public.sun_chat_reads from anon,authenticated;
|
||||||
|
grant select on public.sun_chat_threads,public.sun_chat_participants,public.sun_chat_messages,public.sun_chat_reads to authenticated;
|
||||||
|
|
||||||
|
-- RLS read policies.
|
||||||
|
drop policy if exists sun_chat_threads_read_v29 on public.sun_chat_threads;
|
||||||
|
create policy sun_chat_threads_read_v29 on public.sun_chat_threads for select to authenticated using (public.sun_chat_can_access_thread_v29(id));
|
||||||
|
drop policy if exists sun_chat_participants_read_v29 on public.sun_chat_participants;
|
||||||
|
create policy sun_chat_participants_read_v29 on public.sun_chat_participants for select to authenticated using (public.sun_chat_can_access_thread_v29(thread_id));
|
||||||
|
drop policy if exists sun_chat_messages_read_v29 on public.sun_chat_messages;
|
||||||
|
create policy sun_chat_messages_read_v29 on public.sun_chat_messages for select to authenticated using (public.sun_chat_can_access_thread_v29(thread_id));
|
||||||
|
drop policy if exists sun_chat_reads_read_v29 on public.sun_chat_reads;
|
||||||
|
create policy sun_chat_reads_read_v29 on public.sun_chat_reads for select to authenticated using (user_id=auth.uid() and public.sun_chat_can_access_thread_v29(thread_id));
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_get_company_thread_v29(p_workspace uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v_id uuid;
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к чату компании'; end if;
|
||||||
|
select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='company' limit 1;
|
||||||
|
if v_id is null then
|
||||||
|
insert into public.sun_chat_threads(workspace_id,kind,title,created_by) values(p_workspace,'company','Общий чат',auth.uid())
|
||||||
|
on conflict (workspace_id) where kind='company' do update set updated_at=excluded.updated_at returning id into v_id;
|
||||||
|
end if;
|
||||||
|
insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at)
|
||||||
|
values(v_id,p_workspace,auth.uid(),'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_get_order_thread_v29(p_workspace uuid,p_order_id text)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v_id uuid; v_order text:=trim(coalesce(p_order_id,''));
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к обсуждениям заказов'; end if;
|
||||||
|
if v_order='' then raise exception 'Номер заказа не указан'; end if;
|
||||||
|
select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='order' and order_id=v_order limit 1;
|
||||||
|
if v_id is null then
|
||||||
|
insert into public.sun_chat_threads(workspace_id,kind,title,order_id,created_by)
|
||||||
|
values(p_workspace,'order','Заказ № '||v_order,v_order,auth.uid())
|
||||||
|
on conflict (workspace_id,order_id) where kind='order' do update set updated_at=excluded.updated_at returning id into v_id;
|
||||||
|
end if;
|
||||||
|
insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at)
|
||||||
|
values(v_id,p_workspace,auth.uid(),'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_get_direct_thread_v29(p_workspace uuid,p_other_user uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v_me uuid:=auth.uid(); v_key text; v_id uuid;
|
||||||
|
begin
|
||||||
|
if v_me is null or not public.sun_chat_is_member_v29(p_workspace,v_me) then raise exception 'Нет доступа к чату компании'; end if;
|
||||||
|
if p_other_user is null or p_other_user=v_me then raise exception 'Выберите другого сотрудника'; end if;
|
||||||
|
if not public.sun_chat_is_member_v29(p_workspace,p_other_user) then raise exception 'Сотрудник больше не состоит в компании'; end if;
|
||||||
|
v_key:=least(v_me::text,p_other_user::text)||':'||greatest(v_me::text,p_other_user::text);
|
||||||
|
select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='direct' and direct_key=v_key limit 1;
|
||||||
|
if v_id is null then
|
||||||
|
insert into public.sun_chat_threads(workspace_id,kind,direct_key,created_by)
|
||||||
|
values(p_workspace,'direct',v_key,v_me)
|
||||||
|
on conflict (workspace_id,direct_key) where kind='direct' do update set updated_at=excluded.updated_at returning id into v_id;
|
||||||
|
end if;
|
||||||
|
insert into public.sun_chat_participants(thread_id,workspace_id,user_id) values(v_id,p_workspace,v_me) on conflict do nothing;
|
||||||
|
insert into public.sun_chat_participants(thread_id,workspace_id,user_id) values(v_id,p_workspace,p_other_user) on conflict do nothing;
|
||||||
|
insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at)
|
||||||
|
values(v_id,p_workspace,v_me,'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_list_members_v29(p_workspace uuid)
|
||||||
|
returns table(user_id uuid,display_name text,email text,role text)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к сотрудникам компании'; end if;
|
||||||
|
return query select m.user_id,coalesce(nullif(m.display_name,''),split_part(coalesce(u.email,''),'@',1)),u.email::text,m.role
|
||||||
|
from public.sun_workspace_members m join auth.users u on u.id=m.user_id
|
||||||
|
where m.workspace_id=p_workspace and m.is_active=true
|
||||||
|
order by (m.user_id=auth.uid()) desc,coalesce(nullif(m.display_name,''),u.email::text);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_list_threads_v29(p_workspace uuid)
|
||||||
|
returns table(thread_id uuid,kind text,order_id text,title text,other_user_id uuid,last_message text,last_message_at timestamptz,unread_count bigint)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к чатам компании'; end if;
|
||||||
|
return query
|
||||||
|
select t.id,t.kind,t.order_id,
|
||||||
|
case when t.kind='direct' then coalesce(nullif(om.display_name,''),split_part(coalesce(ou.email,''),'@',1),'Сотрудник') else coalesce(t.title,case when t.kind='company' then 'Общий чат' else 'Обсуждение' end) end,
|
||||||
|
case when t.kind='direct' then op.user_id else null end,
|
||||||
|
case when lm.id is null then '' when nullif(trim(lm.body),'') is not null then left(lm.body,110) when jsonb_array_length(coalesce(lm.attachments,'[]'::jsonb))>0 then 'Вложение' else '' end,
|
||||||
|
lm.created_at,
|
||||||
|
(select count(*) from public.sun_chat_messages um where um.thread_id=t.id and um.deleted_at is null and um.sender_user_id<>auth.uid() and um.created_at>coalesce(r.last_read_at,'epoch'::timestamptz))
|
||||||
|
from public.sun_chat_threads t
|
||||||
|
left join public.sun_chat_reads r on r.thread_id=t.id and r.user_id=auth.uid()
|
||||||
|
left join lateral (select m.* from public.sun_chat_messages m where m.thread_id=t.id and m.deleted_at is null order by m.created_at desc limit 1) lm on true
|
||||||
|
left join lateral (select p.user_id from public.sun_chat_participants p where p.thread_id=t.id and p.user_id<>auth.uid() limit 1) op on t.kind='direct'
|
||||||
|
left join public.sun_workspace_members om on om.workspace_id=t.workspace_id and om.user_id=op.user_id
|
||||||
|
left join auth.users ou on ou.id=op.user_id
|
||||||
|
where t.workspace_id=p_workspace and public.sun_chat_can_access_thread_as_v29(t.id,auth.uid())
|
||||||
|
order by case t.kind when 'company' then 0 when 'direct' then 1 else 2 end,coalesce(t.last_message_at,t.created_at) desc;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_list_messages_v29(p_thread uuid,p_limit integer default 100,p_before timestamptz default null)
|
||||||
|
returns table(message_id uuid,sender_user_id uuid,sender_name text,sender_email text,body text,attachments jsonb,created_at timestamptz,read_by_count bigint)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if;
|
||||||
|
return query
|
||||||
|
with recent as (
|
||||||
|
select m.* from public.sun_chat_messages m
|
||||||
|
where m.thread_id=p_thread and m.deleted_at is null and (p_before is null or m.created_at<p_before)
|
||||||
|
order by m.created_at desc limit greatest(1,least(coalesce(p_limit,100),200))
|
||||||
|
)
|
||||||
|
select m.id,m.sender_user_id,coalesce(nullif(sm.display_name,''),split_part(coalesce(su.email,''),'@',1),'Сотрудник'),su.email::text,m.body,m.attachments,m.created_at,
|
||||||
|
(select count(*) from public.sun_chat_reads rr where rr.thread_id=m.thread_id and rr.user_id<>m.sender_user_id and rr.last_read_at>=m.created_at)
|
||||||
|
from recent m
|
||||||
|
left join public.sun_workspace_members sm on sm.workspace_id=m.workspace_id and sm.user_id=m.sender_user_id
|
||||||
|
left join auth.users su on su.id=m.sender_user_id
|
||||||
|
order by m.created_at asc;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_send_message_v29(p_thread uuid,p_body text default '',p_attachments jsonb default '[]'::jsonb)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth','realtime'
|
||||||
|
as $$
|
||||||
|
declare v public.sun_chat_threads%rowtype; v_id uuid; v_body text:=trim(coalesce(p_body,'')); v_att jsonb:=coalesce(p_attachments,'[]'::jsonb);
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if;
|
||||||
|
select * into v from public.sun_chat_threads where id=p_thread;
|
||||||
|
if length(v_body)>4000 then raise exception 'Сообщение слишком длинное'; end if;
|
||||||
|
if jsonb_typeof(v_att)<>'array' then raise exception 'Некорректные вложения'; end if;
|
||||||
|
if jsonb_array_length(v_att)>5 then raise exception 'Можно отправить не более 5 файлов за раз'; end if;
|
||||||
|
if v_body='' and jsonb_array_length(v_att)=0 then raise exception 'Введите сообщение или добавьте файл'; end if;
|
||||||
|
insert into public.sun_chat_messages(workspace_id,thread_id,sender_user_id,body,attachments)
|
||||||
|
values(v.workspace_id,v.id,auth.uid(),v_body,v_att) returning id into v_id;
|
||||||
|
update public.sun_chat_threads set last_message_at=now(),updated_at=now() where id=v.id;
|
||||||
|
insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at)
|
||||||
|
values(v.id,v.workspace_id,auth.uid(),now(),now())
|
||||||
|
on conflict(thread_id,user_id) do update set last_read_at=excluded.last_read_at,updated_at=now();
|
||||||
|
perform realtime.send('{}'::jsonb,'chat_changed','sun-chat-workspace:'||v.workspace_id::text,true);
|
||||||
|
perform realtime.send(jsonb_build_object('message_id',v_id),'message','sun-chat-thread:'||v.id::text,true);
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_mark_read_v29(p_thread uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth','realtime'
|
||||||
|
as $$
|
||||||
|
declare v_ws uuid; v_old timestamptz; v_changed boolean:=false;
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if;
|
||||||
|
select workspace_id into v_ws from public.sun_chat_threads where id=p_thread;
|
||||||
|
select last_read_at into v_old from public.sun_chat_reads where thread_id=p_thread and user_id=auth.uid();
|
||||||
|
select exists(
|
||||||
|
select 1 from public.sun_chat_messages m
|
||||||
|
where m.thread_id=p_thread and m.deleted_at is null and m.sender_user_id<>auth.uid()
|
||||||
|
and m.created_at>coalesce(v_old,'epoch'::timestamptz)
|
||||||
|
) into v_changed;
|
||||||
|
insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at)
|
||||||
|
values(p_thread,v_ws,auth.uid(),now(),now())
|
||||||
|
on conflict(thread_id,user_id) do update set last_read_at=excluded.last_read_at,updated_at=now();
|
||||||
|
if v_changed then
|
||||||
|
perform realtime.send(jsonb_build_object('thread_id',p_thread),'read','sun-chat-thread:'||p_thread::text,true);
|
||||||
|
perform realtime.send('{}'::jsonb,'chat_changed','sun-chat-workspace:'||v_ws::text,true);
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_chat_unread_total_v29(p_workspace uuid)
|
||||||
|
returns bigint
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v_total bigint;
|
||||||
|
begin
|
||||||
|
if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then return 0; end if;
|
||||||
|
select count(*) into v_total
|
||||||
|
from public.sun_chat_messages m
|
||||||
|
join public.sun_chat_threads t on t.id=m.thread_id
|
||||||
|
left join public.sun_chat_reads r on r.thread_id=t.id and r.user_id=auth.uid()
|
||||||
|
where t.workspace_id=p_workspace and m.deleted_at is null and m.sender_user_id<>auth.uid()
|
||||||
|
and public.sun_chat_can_access_thread_as_v29(t.id,auth.uid())
|
||||||
|
and m.created_at>coalesce(r.last_read_at,'epoch'::timestamptz);
|
||||||
|
return coalesce(v_total,0);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Private Storage bucket for chat files.
|
||||||
|
insert into storage.buckets(id,name,public,file_size_limit)
|
||||||
|
values('sun-chat','sun-chat',false,15728640)
|
||||||
|
on conflict(id) do update set public=false,file_size_limit=15728640;
|
||||||
|
|
||||||
|
drop policy if exists sun_chat_storage_read_v29 on storage.objects;
|
||||||
|
create policy sun_chat_storage_read_v29 on storage.objects for select to authenticated
|
||||||
|
using(bucket_id='sun-chat' and public.sun_chat_storage_access_v29(name));
|
||||||
|
drop policy if exists sun_chat_storage_insert_v29 on storage.objects;
|
||||||
|
create policy sun_chat_storage_insert_v29 on storage.objects for insert to authenticated
|
||||||
|
with check(bucket_id='sun-chat' and public.sun_chat_storage_access_v29(name));
|
||||||
|
|
||||||
|
-- Realtime private-channel authorization for chat workspace/thread topics.
|
||||||
|
drop policy if exists sun_chat_realtime_read_v29 on realtime.messages;
|
||||||
|
create policy sun_chat_realtime_read_v29 on realtime.messages for select to authenticated
|
||||||
|
using(realtime.messages.extension in ('broadcast','presence') and public.sun_chat_realtime_topic_access_v29((select realtime.topic())));
|
||||||
|
drop policy if exists sun_chat_realtime_write_v29 on realtime.messages;
|
||||||
|
create policy sun_chat_realtime_write_v29 on realtime.messages for insert to authenticated
|
||||||
|
with check(realtime.messages.extension in ('broadcast','presence') and public.sun_chat_realtime_topic_access_v29((select realtime.topic())));
|
||||||
|
|
||||||
|
revoke all on function public.sun_chat_is_member_v29(uuid,uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_can_access_thread_as_v29(uuid,uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_can_access_thread_v29(uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_realtime_topic_access_v29(text) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_storage_access_v29(text) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_get_company_thread_v29(uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_get_order_thread_v29(uuid,text) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_get_direct_thread_v29(uuid,uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_list_members_v29(uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_list_threads_v29(uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_list_messages_v29(uuid,integer,timestamptz) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_send_message_v29(uuid,text,jsonb) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_mark_read_v29(uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_chat_unread_total_v29(uuid) from public,anon;
|
||||||
|
|
||||||
|
grant execute on function public.sun_chat_realtime_topic_access_v29(text) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_storage_access_v29(text) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_get_company_thread_v29(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_get_order_thread_v29(uuid,text) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_get_direct_thread_v29(uuid,uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_list_members_v29(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_list_threads_v29(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_list_messages_v29(uuid,integer,timestamptz) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_send_message_v29(uuid,text,jsonb) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_mark_read_v29(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_chat_unread_total_v29(uuid) to authenticated;
|
||||||
71
ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
-- Developer console v22 hardening: platform-wide actions require MFA AAL2.
|
||||||
|
|
||||||
|
create or replace function public.sun_require_platform_admin_aal2()
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if coalesce(auth.jwt()->>'aal','aal1') <> 'aal2' then raise exception 'Developer MFA AAL2 required'; end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_require_platform_admin_aal2() from public, anon, authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_list_companies_v22()
|
||||||
|
returns table(workspace_id uuid,workspace_name text,created_at timestamptz,plan_id text,plan_name text,status text,access_mode text,trial_ends_at timestamptz,current_period_end timestamptz,grace_until timestamptz,member_count bigint,max_members integer,owner_email text,pending_owner_email text)
|
||||||
|
language plpgsql stable security definer set search_path='public','auth' as $$
|
||||||
|
begin
|
||||||
|
perform public.sun_require_platform_admin_aal2();
|
||||||
|
return query
|
||||||
|
select w.id,w.name,w.created_at,s.plan_id,p.name,s.status,public.sun_subscription_access_mode(w.id),s.trial_ends_at,s.current_period_end,s.grace_until,
|
||||||
|
(select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active),p.max_members,
|
||||||
|
(select u.email::text from public.sun_workspace_members m join auth.users u on u.id=m.user_id where m.workspace_id=w.id and m.role='admin' and m.is_active order by m.created_at asc limit 1),
|
||||||
|
(select i.email from public.caterium_company_owner_invites i where i.workspace_id=w.id and i.used_at is null and i.expires_at>now() order by i.created_at desc limit 1)
|
||||||
|
from public.sun_workspaces w left join public.sun_workspace_subscriptions s on s.workspace_id=w.id left join public.sun_plans p on p.id=s.plan_id order by w.created_at desc;
|
||||||
|
end;$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_list_users_v22()
|
||||||
|
returns table(user_id uuid,email text,last_sign_in_at timestamptz,created_at timestamptz,workspace_id uuid,workspace_name text,role text,display_name text,is_active boolean,is_platform_admin boolean)
|
||||||
|
language plpgsql stable security definer set search_path='public','auth' as $$
|
||||||
|
begin
|
||||||
|
perform public.sun_require_platform_admin_aal2();
|
||||||
|
return query select u.id,u.email::text,u.last_sign_in_at,u.created_at,m.workspace_id,w.name,m.role,m.display_name,m.is_active,
|
||||||
|
exists(select 1 from public.sun_platform_admins p where p.user_id=u.id)
|
||||||
|
from auth.users u left join public.sun_workspace_members m on m.user_id=u.id left join public.sun_workspaces w on w.id=m.workspace_id
|
||||||
|
order by coalesce(w.name,''),coalesce(m.display_name,u.email),u.email;
|
||||||
|
end;$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_list_errors_v22(p_workspace uuid default null,p_limit integer default 80)
|
||||||
|
returns table(id uuid,workspace_id uuid,workspace_name text,user_id uuid,client_id text,app_version text,level text,message text,created_at timestamptz)
|
||||||
|
language plpgsql stable security definer set search_path='public' as $$
|
||||||
|
begin
|
||||||
|
perform public.sun_require_platform_admin_aal2();
|
||||||
|
return query select err.id,err.workspace_id,company.name,err.user_id,err.client_id,err.app_version,err.level,err.message,err.created_at
|
||||||
|
from public.sun_v17_error_events err left join public.sun_workspaces company on company.id=err.workspace_id
|
||||||
|
where p_workspace is null or err.workspace_id=p_workspace order by err.created_at desc limit greatest(1,least(coalesce(p_limit,80),200));
|
||||||
|
end;$$;
|
||||||
|
|
||||||
|
-- Harden v22 developer functions.
|
||||||
|
create or replace function public.sun_platform_dashboard()
|
||||||
|
returns jsonb language plpgsql stable security definer set search_path='public','auth' as $$
|
||||||
|
declare v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; v_locked bigint; v_errors bigint; v_backups bigint; v_members bigint; v_last_state timestamptz; v_last_backup timestamptz;
|
||||||
|
begin
|
||||||
|
perform public.sun_require_platform_admin_aal2();
|
||||||
|
select count(*) into v_companies from public.sun_workspaces; select count(*) into v_accounts from auth.users; select count(*) into v_admins from public.sun_platform_admins; select count(*) into v_members from public.sun_workspace_members where is_active=true;
|
||||||
|
select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now();
|
||||||
|
select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; select max(updated_at) into v_last_state from public.sun_app_state; select max(created_at) into v_last_backup from public.sun_v17_backups;
|
||||||
|
return jsonb_build_object('companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members,'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked,'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup,'database_size',pg_size_pretty(pg_database_size(current_database())),'postgres_version',current_setting('server_version'));
|
||||||
|
end;$$;
|
||||||
|
|
||||||
|
-- Change the v22 function bodies by replacing the first platform check with the AAL2 guard.
|
||||||
|
-- Definitions are kept in SUPABASE-DEVELOPER-V22.sql; this block updates privileges for secure wrappers.
|
||||||
|
revoke all on function public.sun_platform_list_users() from anon;
|
||||||
|
revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from anon;
|
||||||
|
revoke all on function public.sun_platform_list_companies_v22() from public,anon;
|
||||||
|
revoke all on function public.sun_platform_list_users_v22() from public,anon;
|
||||||
|
revoke all on function public.sun_platform_list_errors_v22(uuid,integer) from public,anon;
|
||||||
|
grant execute on function public.sun_platform_list_companies_v22() to authenticated;
|
||||||
|
grant execute on function public.sun_platform_list_users_v22() to authenticated;
|
||||||
|
grant execute on function public.sun_platform_list_errors_v22(uuid,integer) to authenticated;
|
||||||
325
ops/sql/SUPABASE-DEVELOPER-V22.sql
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
-- Caterium / Sun Catering v17.5.22 developer console
|
||||||
|
-- Additive only. Gives platform admins a server-enforced developer console API.
|
||||||
|
|
||||||
|
create table if not exists public.sun_platform_audit_events (
|
||||||
|
id bigint generated by default as identity primary key,
|
||||||
|
actor_user_id uuid null references auth.users(id) on delete set null,
|
||||||
|
action text not null,
|
||||||
|
target_workspace_id uuid null references public.sun_workspaces(id) on delete set null,
|
||||||
|
target_user_id uuid null references auth.users(id) on delete set null,
|
||||||
|
details jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists sun_platform_audit_events_created_idx on public.sun_platform_audit_events(created_at desc);
|
||||||
|
create index if not exists sun_platform_audit_events_workspace_idx on public.sun_platform_audit_events(target_workspace_id,created_at desc);
|
||||||
|
create index if not exists sun_platform_audit_events_user_idx on public.sun_platform_audit_events(target_user_id,created_at desc);
|
||||||
|
alter table public.sun_platform_audit_events enable row level security;
|
||||||
|
revoke all on public.sun_platform_audit_events from anon, authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_log_event(
|
||||||
|
p_action text,
|
||||||
|
p_workspace uuid default null,
|
||||||
|
p_user uuid default null,
|
||||||
|
p_details jsonb default '{}'::jsonb
|
||||||
|
) returns bigint
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare v_id bigint;
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if nullif(trim(coalesce(p_action,'')),'') is null then raise exception 'Action required'; end if;
|
||||||
|
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
|
||||||
|
values(auth.uid(),left(trim(p_action),120),p_workspace,p_user,coalesce(p_details,'{}'::jsonb)) returning id into v_id;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_dashboard()
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; v_locked bigint;
|
||||||
|
v_errors bigint; v_backups bigint; v_members bigint; v_last_state timestamptz; v_last_backup timestamptz;
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
select count(*) into v_companies from public.sun_workspaces;
|
||||||
|
select count(*) into v_accounts from auth.users;
|
||||||
|
select count(*) into v_admins from public.sun_platform_admins;
|
||||||
|
select count(*) into v_members from public.sun_workspace_members where is_active=true;
|
||||||
|
select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now();
|
||||||
|
select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now();
|
||||||
|
select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked');
|
||||||
|
select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours';
|
||||||
|
select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours';
|
||||||
|
select max(updated_at) into v_last_state from public.sun_app_state;
|
||||||
|
select max(created_at) into v_last_backup from public.sun_v17_backups;
|
||||||
|
return jsonb_build_object(
|
||||||
|
'companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members,
|
||||||
|
'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked,
|
||||||
|
'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup,
|
||||||
|
'database_size',pg_size_pretty(pg_database_size(current_database())),
|
||||||
|
'postgres_version',current_setting('server_version')
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_list_activity(p_limit integer default 120)
|
||||||
|
returns table(
|
||||||
|
id bigint, action text, actor_user_id uuid, actor_email text,
|
||||||
|
workspace_id uuid, workspace_name text, target_user_id uuid, target_email text,
|
||||||
|
details jsonb, created_at timestamptz
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
return query
|
||||||
|
select a.id,a.action,a.actor_user_id,au.email::text,a.target_workspace_id,w.name,a.target_user_id,tu.email::text,a.details,a.created_at
|
||||||
|
from public.sun_platform_audit_events a
|
||||||
|
left join auth.users au on au.id=a.actor_user_id
|
||||||
|
left join public.sun_workspaces w on w.id=a.target_workspace_id
|
||||||
|
left join auth.users tu on tu.id=a.target_user_id
|
||||||
|
order by a.created_at desc
|
||||||
|
limit greatest(1,least(coalesce(p_limit,120),500));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_support_snapshot(p_workspace uuid)
|
||||||
|
returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if;
|
||||||
|
perform public.sun_platform_log_event('support.open',p_workspace,null,jsonb_build_object('mode','read_only'));
|
||||||
|
return query
|
||||||
|
select s.workspace_id,s.payload,s.revision,s.updated_at,s.client_id
|
||||||
|
from public.sun_app_state s where s.workspace_id=p_workspace;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_workspace_diagnostics(p_workspace uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare v jsonb;
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if;
|
||||||
|
select jsonb_build_object(
|
||||||
|
'workspace_id',w.id,'name',w.name,'created_at',w.created_at,
|
||||||
|
'revision',coalesce(s.revision,0),'state_updated_at',s.updated_at,'state_client_id',s.client_id,
|
||||||
|
'members',(select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active),
|
||||||
|
'orders',(select count(*) from public.sun_v17_orders o where o.workspace_id=w.id),
|
||||||
|
'clients',(select count(*) from public.sun_v17_clients c where c.workspace_id=w.id),
|
||||||
|
'catalog_items',(select count(*) from public.sun_v17_catalog_items c where c.workspace_id=w.id),
|
||||||
|
'settings',(select count(*) from public.sun_v17_settings x where x.workspace_id=w.id),
|
||||||
|
'backups',(select count(*) from public.sun_v17_backups b where b.workspace_id=w.id),
|
||||||
|
'latest_backup',(select max(created_at) from public.sun_v17_backups b where b.workspace_id=w.id),
|
||||||
|
'errors_24h',(select count(*) from public.sun_v17_error_events e where e.workspace_id=w.id and e.created_at>now()-interval '24 hours'),
|
||||||
|
'latest_error',(select max(created_at) from public.sun_v17_error_events e where e.workspace_id=w.id),
|
||||||
|
'access_mode',public.sun_subscription_access_mode(w.id)
|
||||||
|
) into v
|
||||||
|
from public.sun_workspaces w left join public.sun_app_state s on s.workspace_id=w.id
|
||||||
|
where w.id=p_workspace;
|
||||||
|
return v;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_list_workspace_features(p_workspace uuid)
|
||||||
|
returns table(
|
||||||
|
feature_key text, plan_enabled boolean, override_enabled boolean,
|
||||||
|
override_expires_at timestamptz, effective_enabled boolean, note text
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare v_plan text;
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
select plan_id into v_plan from public.sun_workspace_subscriptions where workspace_id=p_workspace;
|
||||||
|
return query
|
||||||
|
with features as (
|
||||||
|
select distinct pf.feature_key from public.sun_plan_features pf
|
||||||
|
)
|
||||||
|
select f.feature_key,
|
||||||
|
coalesce(pf.enabled,false),
|
||||||
|
case when o.expires_at is null or o.expires_at>now() then o.enabled else null end,
|
||||||
|
o.expires_at,
|
||||||
|
case when o.feature_key is not null and (o.expires_at is null or o.expires_at>now()) then o.enabled else coalesce(pf.enabled,false) end,
|
||||||
|
o.note
|
||||||
|
from features f
|
||||||
|
left join public.sun_plan_features pf on pf.plan_id=v_plan and pf.feature_key=f.feature_key
|
||||||
|
left join public.sun_workspace_feature_overrides o on o.workspace_id=p_workspace and o.feature_key=f.feature_key
|
||||||
|
order by f.feature_key;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_set_plan_feature(p_plan text,p_feature text,p_enabled boolean)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if not exists(select 1 from public.sun_plans where id=p_plan) then raise exception 'Unknown plan'; end if;
|
||||||
|
if nullif(trim(coalesce(p_feature,'')),'') is null then raise exception 'Feature required'; end if;
|
||||||
|
insert into public.sun_plan_features(plan_id,feature_key,enabled) values(p_plan,p_feature,p_enabled)
|
||||||
|
on conflict(plan_id,feature_key) do update set enabled=excluded.enabled;
|
||||||
|
perform public.sun_platform_log_event('plan.feature.set',null,null,jsonb_build_object('plan',p_plan,'feature',p_feature,'enabled',p_enabled));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_set_plan_max_members(p_plan text,p_max_members integer)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if p_max_members is not null and (p_max_members<1 or p_max_members>10000) then raise exception 'Invalid member limit'; end if;
|
||||||
|
update public.sun_plans set max_members=p_max_members,updated_at=now() where id=p_plan;
|
||||||
|
if not found then raise exception 'Unknown plan'; end if;
|
||||||
|
perform public.sun_platform_log_event('plan.members.set',null,null,jsonb_build_object('plan',p_plan,'max_members',p_max_members));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_seed_workspace_catalog(
|
||||||
|
p_workspace uuid,
|
||||||
|
p_boxes jsonb,
|
||||||
|
p_catalog_version text default '',
|
||||||
|
p_replace boolean default false
|
||||||
|
) returns integer
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_payload jsonb; v_existing jsonb; v_entry jsonb; v_item jsonb; v_id text; v_count integer:=0;
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if jsonb_typeof(p_boxes)<>'array' then raise exception 'Catalog must be an array'; end if;
|
||||||
|
if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if;
|
||||||
|
|
||||||
|
select payload into v_payload from public.sun_app_state where workspace_id=p_workspace for update;
|
||||||
|
if v_payload is null then
|
||||||
|
v_payload:=jsonb_build_object('format','sun-cloud-v2','version',2,'storage','{}'::jsonb);
|
||||||
|
insert into public.sun_app_state(workspace_id,payload,revision,updated_by)
|
||||||
|
values(p_workspace,v_payload,0,auth.uid())
|
||||||
|
on conflict(workspace_id) do nothing;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_existing:=coalesce(v_payload->'storage'->'sunBoxes'->'v','[]'::jsonb);
|
||||||
|
if not p_replace and jsonb_typeof(v_existing)='array' and jsonb_array_length(v_existing)>0 then
|
||||||
|
raise exception 'Catalog already populated';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_entry:=jsonb_build_object('t','j','v',p_boxes);
|
||||||
|
v_payload:=jsonb_set(coalesce(v_payload,'{}'::jsonb),'{storage,sunBoxes}',v_entry,true);
|
||||||
|
if nullif(coalesce(p_catalog_version,''),'') is not null then
|
||||||
|
v_payload:=jsonb_set(v_payload,'{storage,sunOfficialCatalogVersion}',jsonb_build_object('t','s','v',p_catalog_version),true);
|
||||||
|
end if;
|
||||||
|
update public.sun_app_state set payload=v_payload,revision=revision+1,client_id='platform-catalog',updated_by=auth.uid(),updated_at=now() where workspace_id=p_workspace;
|
||||||
|
|
||||||
|
if p_replace then delete from public.sun_v17_catalog_items where workspace_id=p_workspace; end if;
|
||||||
|
for v_item in select value from jsonb_array_elements(p_boxes) loop
|
||||||
|
v_id:=coalesce(nullif(v_item->>'id',''),gen_random_uuid()::text);
|
||||||
|
insert into public.sun_v17_catalog_items(workspace_id,item_id,data,version,created_by,updated_by)
|
||||||
|
values(p_workspace,v_id,v_item,1,auth.uid(),auth.uid())
|
||||||
|
on conflict(workspace_id,item_id) do update set data=excluded.data,version=sun_v17_catalog_items.version+1,updated_by=auth.uid(),updated_at=now();
|
||||||
|
v_count:=v_count+1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by)
|
||||||
|
values(p_workspace,'catalog','starter',case when p_replace then 'replace' else 'seed' end,'platform-catalog',auth.uid());
|
||||||
|
perform public.sun_platform_log_event(case when p_replace then 'catalog.force_apply' else 'catalog.seed' end,p_workspace,null,jsonb_build_object('count',v_count,'version',p_catalog_version));
|
||||||
|
return v_count;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_platform_log_event(text,uuid,uuid,jsonb) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_dashboard() from public, anon;
|
||||||
|
revoke all on function public.sun_platform_list_activity(integer) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_support_snapshot(uuid) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_workspace_diagnostics(uuid) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_list_workspace_features(uuid) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_set_plan_feature(text,text,boolean) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_set_plan_max_members(text,integer) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_seed_workspace_catalog(uuid,jsonb,text,boolean) from public, anon;
|
||||||
|
|
||||||
|
grant execute on function public.sun_platform_log_event(text,uuid,uuid,jsonb) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_dashboard() to authenticated;
|
||||||
|
grant execute on function public.sun_platform_list_activity(integer) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_support_snapshot(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_workspace_diagnostics(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_list_workspace_features(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_set_plan_feature(text,text,boolean) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_set_plan_max_members(text,integer) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_seed_workspace_catalog(uuid,jsonb,text,boolean) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_reset_feature_override(p_workspace uuid,p_feature text)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
delete from public.sun_workspace_feature_overrides where workspace_id=p_workspace and feature_key=p_feature;
|
||||||
|
perform public.sun_platform_log_event('feature.override.reset',p_workspace,null,jsonb_build_object('feature',p_feature));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_create_company_v22(
|
||||||
|
p_name text,
|
||||||
|
p_owner_email text default null,
|
||||||
|
p_plan text default 'full',
|
||||||
|
p_days integer default 30,
|
||||||
|
p_boxes jsonb default '[]'::jsonb,
|
||||||
|
p_catalog_version text default ''
|
||||||
|
) returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_base jsonb; v_ws uuid; v_profile jsonb; v_payload jsonb; v_count integer;
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
select public.caterium_platform_create_company(p_name,p_owner_email,p_plan,p_days,'empty') into v_base;
|
||||||
|
v_ws:=(v_base->>'workspace_id')::uuid;
|
||||||
|
v_profile:=jsonb_build_object('name',coalesce(nullif(trim(p_name),''),'Новая компания'),'ownerEmail',coalesce(p_owner_email,''),'createdAt',now());
|
||||||
|
select payload into v_payload from public.sun_app_state where workspace_id=v_ws for update;
|
||||||
|
v_payload:=jsonb_set(v_payload,'{storage,sunCompanyProfileV1}',jsonb_build_object('t','j','v',v_profile),true);
|
||||||
|
v_payload:=jsonb_set(v_payload,'{storage,sunOrders}',jsonb_build_object('t','j','v','[]'::jsonb),true);
|
||||||
|
v_payload:=jsonb_set(v_payload,'{storage,sunClientsV2}',jsonb_build_object('t','j','v','[]'::jsonb),true);
|
||||||
|
update public.sun_app_state set payload=v_payload,updated_by=auth.uid(),updated_at=now() where workspace_id=v_ws;
|
||||||
|
select public.sun_platform_seed_workspace_catalog(v_ws,coalesce(p_boxes,'[]'::jsonb),p_catalog_version,true) into v_count;
|
||||||
|
perform public.sun_platform_log_event('company.create',v_ws,null,jsonb_build_object('name',p_name,'owner_email',p_owner_email,'plan',p_plan,'days',p_days,'catalog_count',v_count));
|
||||||
|
return coalesce(v_base,'{}'::jsonb)||jsonb_build_object('catalog_count',v_count);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_platform_reset_feature_override(uuid,text) from public, anon;
|
||||||
|
revoke all on function public.sun_platform_create_company_v22(text,text,text,integer,jsonb,text) from public, anon;
|
||||||
|
grant execute on function public.sun_platform_reset_feature_override(uuid,text) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_create_company_v22(text,text,text,integer,jsonb,text) to authenticated;
|
||||||
674
ops/sql/SUPABASE-RBAC-V3.sql
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
-- Sun Catering Cloud RBAC v3
|
||||||
|
-- Roles, granular permissions, administrator tools, secured state RPCs and realtime sync events.
|
||||||
|
|
||||||
|
alter table public.sun_workspace_members add column if not exists display_name text;
|
||||||
|
alter table public.sun_workspace_members add column if not exists is_active boolean not null default true;
|
||||||
|
alter table public.sun_workspace_members add column if not exists permissions jsonb not null default '{}'::jsonb;
|
||||||
|
alter table public.sun_workspace_members add column if not exists updated_at timestamptz not null default now();
|
||||||
|
|
||||||
|
alter table public.sun_workspace_invites add column if not exists permissions jsonb;
|
||||||
|
|
||||||
|
alter table public.sun_workspace_members drop constraint if exists sun_workspace_members_role_check;
|
||||||
|
update public.sun_workspace_members set role='admin' where role='owner';
|
||||||
|
alter table public.sun_workspace_members add constraint sun_workspace_members_role_check
|
||||||
|
check (role in ('admin','manager','kitchen','courier','viewer'));
|
||||||
|
|
||||||
|
alter table public.sun_workspace_invites drop constraint if exists sun_workspace_invites_role_check;
|
||||||
|
alter table public.sun_workspace_invites add constraint sun_workspace_invites_role_check
|
||||||
|
check (role in ('admin','manager','kitchen','courier','viewer'));
|
||||||
|
|
||||||
|
create or replace function public.sun_role_default_permissions(p_role text)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select case lower(coalesce(p_role,''))
|
||||||
|
when 'admin' then jsonb_build_object(
|
||||||
|
'app.read',true,
|
||||||
|
'orders.view',true,'orders.create',true,'orders.edit',true,'orders.delete',true,
|
||||||
|
'clients.view',true,'clients.edit',true,
|
||||||
|
'catalog.view',true,'catalog.edit',true,
|
||||||
|
'calendar.view',true,'map.view',true,
|
||||||
|
'production.view',true,'production.edit',true,
|
||||||
|
'shopping.view',true,'shopping.edit',true,
|
||||||
|
'stock.view',true,'stock.edit',true,
|
||||||
|
'routes.view',true,'routes.edit',true,
|
||||||
|
'mailings.view',true,'mailings.edit',true,
|
||||||
|
'money.view',true,'money.edit',true,
|
||||||
|
'stats.view',true,
|
||||||
|
'team.view',true,'team.edit',true,
|
||||||
|
'suppliers.view',true,'suppliers.edit',true,
|
||||||
|
'print.view',true,
|
||||||
|
'settings.view',true,'settings.edit',true,
|
||||||
|
'users.manage',true,'audit.view',true,'backups.manage',true
|
||||||
|
)
|
||||||
|
when 'manager' then jsonb_build_object(
|
||||||
|
'app.read',true,
|
||||||
|
'orders.view',true,'orders.create',true,'orders.edit',true,'orders.delete',false,
|
||||||
|
'clients.view',true,'clients.edit',true,
|
||||||
|
'catalog.view',true,'catalog.edit',false,
|
||||||
|
'calendar.view',true,'map.view',true,
|
||||||
|
'production.view',false,'production.edit',false,
|
||||||
|
'shopping.view',false,'shopping.edit',false,
|
||||||
|
'stock.view',false,'stock.edit',false,
|
||||||
|
'routes.view',true,'routes.edit',false,
|
||||||
|
'mailings.view',true,'mailings.edit',true,
|
||||||
|
'money.view',false,'money.edit',false,
|
||||||
|
'stats.view',true,
|
||||||
|
'team.view',true,'team.edit',false,
|
||||||
|
'suppliers.view',false,'suppliers.edit',false,
|
||||||
|
'print.view',true,
|
||||||
|
'settings.view',false,'settings.edit',false,
|
||||||
|
'users.manage',false,'audit.view',false,'backups.manage',false
|
||||||
|
)
|
||||||
|
when 'kitchen' then jsonb_build_object(
|
||||||
|
'app.read',true,
|
||||||
|
'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false,
|
||||||
|
'clients.view',false,'clients.edit',false,
|
||||||
|
'catalog.view',true,'catalog.edit',false,
|
||||||
|
'calendar.view',true,'map.view',false,
|
||||||
|
'production.view',true,'production.edit',true,
|
||||||
|
'shopping.view',true,'shopping.edit',false,
|
||||||
|
'stock.view',true,'stock.edit',true,
|
||||||
|
'routes.view',false,'routes.edit',false,
|
||||||
|
'mailings.view',false,'mailings.edit',false,
|
||||||
|
'money.view',false,'money.edit',false,
|
||||||
|
'stats.view',false,
|
||||||
|
'team.view',true,'team.edit',false,
|
||||||
|
'suppliers.view',false,'suppliers.edit',false,
|
||||||
|
'print.view',true,
|
||||||
|
'settings.view',false,'settings.edit',false,
|
||||||
|
'users.manage',false,'audit.view',false,'backups.manage',false
|
||||||
|
)
|
||||||
|
when 'courier' then jsonb_build_object(
|
||||||
|
'app.read',true,
|
||||||
|
'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false,
|
||||||
|
'clients.view',false,'clients.edit',false,
|
||||||
|
'catalog.view',false,'catalog.edit',false,
|
||||||
|
'calendar.view',true,'map.view',true,
|
||||||
|
'production.view',false,'production.edit',false,
|
||||||
|
'shopping.view',false,'shopping.edit',false,
|
||||||
|
'stock.view',false,'stock.edit',false,
|
||||||
|
'routes.view',true,'routes.edit',true,
|
||||||
|
'mailings.view',false,'mailings.edit',false,
|
||||||
|
'money.view',false,'money.edit',false,
|
||||||
|
'stats.view',false,
|
||||||
|
'team.view',false,'team.edit',false,
|
||||||
|
'suppliers.view',false,'suppliers.edit',false,
|
||||||
|
'print.view',false,
|
||||||
|
'settings.view',false,'settings.edit',false,
|
||||||
|
'users.manage',false,'audit.view',false,'backups.manage',false
|
||||||
|
)
|
||||||
|
else jsonb_build_object(
|
||||||
|
'app.read',true,
|
||||||
|
'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false,
|
||||||
|
'clients.view',true,'clients.edit',false,
|
||||||
|
'catalog.view',true,'catalog.edit',false,
|
||||||
|
'calendar.view',true,'map.view',true,
|
||||||
|
'production.view',false,'production.edit',false,
|
||||||
|
'shopping.view',false,'shopping.edit',false,
|
||||||
|
'stock.view',false,'stock.edit',false,
|
||||||
|
'routes.view',false,'routes.edit',false,
|
||||||
|
'mailings.view',false,'mailings.edit',false,
|
||||||
|
'money.view',false,'money.edit',false,
|
||||||
|
'stats.view',true,
|
||||||
|
'team.view',false,'team.edit',false,
|
||||||
|
'suppliers.view',false,'suppliers.edit',false,
|
||||||
|
'print.view',true,
|
||||||
|
'settings.view',false,'settings.edit',false,
|
||||||
|
'users.manage',false,'audit.view',false,'backups.manage',false
|
||||||
|
)
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_member_role(p_workspace uuid)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select m.role
|
||||||
|
from public.sun_workspace_members m
|
||||||
|
where m.workspace_id = p_workspace
|
||||||
|
and m.user_id = auth.uid()
|
||||||
|
and m.is_active = true
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_has_permission(p_workspace uuid, p_permission text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_role text;
|
||||||
|
v_permissions jsonb;
|
||||||
|
v_active boolean;
|
||||||
|
begin
|
||||||
|
select role, permissions, is_active
|
||||||
|
into v_role, v_permissions, v_active
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and user_id=auth.uid()
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if not coalesce(v_active,false) then return false; end if;
|
||||||
|
if v_role='admin' then return true; end if;
|
||||||
|
if v_permissions ? p_permission then
|
||||||
|
return coalesce((v_permissions->>p_permission)::boolean,false);
|
||||||
|
end if;
|
||||||
|
return coalesce((public.sun_role_default_permissions(v_role)->>p_permission)::boolean,false);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_member_role(uuid) from public, anon;
|
||||||
|
grant execute on function public.sun_member_role(uuid) to authenticated;
|
||||||
|
revoke all on function public.sun_has_permission(uuid,text) from public, anon;
|
||||||
|
grant execute on function public.sun_has_permission(uuid,text) to authenticated;
|
||||||
|
revoke all on function public.sun_role_default_permissions(text) from public, anon;
|
||||||
|
grant execute on function public.sun_role_default_permissions(text) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг')
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_workspace uuid;
|
||||||
|
v_name text;
|
||||||
|
begin
|
||||||
|
if v_user is null then raise exception 'Authentication required'; end if;
|
||||||
|
v_name := coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг');
|
||||||
|
insert into public.sun_workspaces(name, created_by)
|
||||||
|
values (v_name, v_user)
|
||||||
|
returning id into v_workspace;
|
||||||
|
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
|
||||||
|
values (v_workspace,v_user,'admin',coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce((select email from auth.users where id=v_user),'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin'));
|
||||||
|
insert into public.sun_app_state(workspace_id,payload,client_id)
|
||||||
|
values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap')
|
||||||
|
on conflict (workspace_id) do nothing;
|
||||||
|
return v_workspace;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_create_workspace(text) from public, anon;
|
||||||
|
grant execute on function public.sun_create_workspace(text) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager')
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_token uuid;
|
||||||
|
v_role text := lower(coalesce(p_role,'manager'));
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if;
|
||||||
|
insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by)
|
||||||
|
values (p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid())
|
||||||
|
returning token into v_token;
|
||||||
|
return v_token;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_create_invite(uuid,text) from public, anon;
|
||||||
|
grant execute on function public.sun_create_invite(uuid,text) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_accept_invite(p_token uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_invite public.sun_workspace_invites%rowtype;
|
||||||
|
v_display text;
|
||||||
|
begin
|
||||||
|
if v_user is null then raise exception 'Authentication required'; end if;
|
||||||
|
select * into v_invite from public.sun_workspace_invites where token=p_token for update;
|
||||||
|
if not found then raise exception 'Invite not found'; end if;
|
||||||
|
if v_invite.used_at is not null then raise exception 'Invite already used'; end if;
|
||||||
|
if v_invite.expires_at < now() then raise exception 'Invite expired'; end if;
|
||||||
|
select coalesce(nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) into v_display from auth.users where id=v_user;
|
||||||
|
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at)
|
||||||
|
values (v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now())
|
||||||
|
on conflict (workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now();
|
||||||
|
update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token;
|
||||||
|
return v_invite.workspace_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_accept_invite(uuid) from public, anon;
|
||||||
|
grant execute on function public.sun_accept_invite(uuid) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_list_workspace_members(p_workspace uuid)
|
||||||
|
returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
return query
|
||||||
|
select m.user_id,u.email,m.display_name,m.role,m.is_active,
|
||||||
|
coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at
|
||||||
|
from public.sun_workspace_members m
|
||||||
|
join auth.users u on u.id=m.user_id
|
||||||
|
where m.workspace_id=p_workspace
|
||||||
|
order by (m.role='admin') desc,coalesce(m.display_name,u.email);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_list_workspace_members(uuid) from public, anon;
|
||||||
|
grant execute on function public.sun_list_workspace_members(uuid) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_admin_update_member(
|
||||||
|
p_workspace uuid,
|
||||||
|
p_user uuid,
|
||||||
|
p_display_name text,
|
||||||
|
p_role text,
|
||||||
|
p_is_active boolean,
|
||||||
|
p_permissions jsonb
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_old_role text;
|
||||||
|
v_old_active boolean;
|
||||||
|
v_admins int;
|
||||||
|
v_role text := lower(coalesce(p_role,''));
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if;
|
||||||
|
if p_permissions is null or jsonb_typeof(p_permissions) <> 'object' then raise exception 'Permissions must be an object'; end if;
|
||||||
|
|
||||||
|
select role,is_active into v_old_role,v_old_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update;
|
||||||
|
if not found then raise exception 'Member not found'; end if;
|
||||||
|
|
||||||
|
if v_old_role='admin' and coalesce(v_old_active,false) and (v_role<>'admin' or not coalesce(p_is_active,false)) then
|
||||||
|
select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true;
|
||||||
|
if v_admins <= 1 then raise exception 'Нельзя отключить или понизить последнего администратора'; end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.sun_workspace_members
|
||||||
|
set display_name=nullif(trim(coalesce(p_display_name,'')),''),role=v_role,is_active=coalesce(p_is_active,false),permissions=p_permissions,updated_at=now()
|
||||||
|
where workspace_id=p_workspace and user_id=p_user;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public, anon;
|
||||||
|
grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_role text;
|
||||||
|
v_active boolean;
|
||||||
|
v_admins int;
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
select role,is_active into v_role,v_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update;
|
||||||
|
if not found then return; end if;
|
||||||
|
if v_role='admin' and coalesce(v_active,false) then
|
||||||
|
select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true;
|
||||||
|
if v_admins <= 1 then raise exception 'Нельзя удалить последнего администратора'; end if;
|
||||||
|
end if;
|
||||||
|
delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_admin_remove_member(uuid,uuid) from public, anon;
|
||||||
|
grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_can_read_storage_key(p_workspace uuid,p_key text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null then return false; end if;
|
||||||
|
return case
|
||||||
|
when p_key='sunOrders' then public.sun_has_permission(p_workspace,'orders.view') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'orders.edit')
|
||||||
|
when p_key='sunBoxes' then public.sun_has_permission(p_workspace,'catalog.view') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'production.view') or public.sun_has_permission(p_workspace,'stock.view')
|
||||||
|
when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then public.sun_has_permission(p_workspace,'clients.view') or public.sun_has_permission(p_workspace,'clients.edit') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'orders.edit')
|
||||||
|
when p_key='sunFinanceRecordsV2' then public.sun_has_permission(p_workspace,'money.view') or public.sun_has_permission(p_workspace,'money.edit')
|
||||||
|
when p_key in ('sunStock','sunStockMoves') then public.sun_has_permission(p_workspace,'stock.view') or public.sun_has_permission(p_workspace,'stock.edit') or public.sun_has_permission(p_workspace,'shopping.view') or public.sun_has_permission(p_workspace,'production.view')
|
||||||
|
when p_key='sunEmployees' then public.sun_has_permission(p_workspace,'team.view') or public.sun_has_permission(p_workspace,'team.edit') or public.sun_has_permission(p_workspace,'production.view') or public.sun_has_permission(p_workspace,'routes.view')
|
||||||
|
when p_key='sunSuppliers' then public.sun_has_permission(p_workspace,'suppliers.view') or public.sun_has_permission(p_workspace,'suppliers.edit') or public.sun_has_permission(p_workspace,'shopping.view')
|
||||||
|
when p_key like 'sunRoute%' then public.sun_has_permission(p_workspace,'routes.view') or public.sun_has_permission(p_workspace,'routes.edit') or public.sun_has_permission(p_workspace,'map.view')
|
||||||
|
when p_key like 'sunMarketing%' then public.sun_has_permission(p_workspace,'mailings.view') or public.sun_has_permission(p_workspace,'mailings.edit')
|
||||||
|
when p_key='sunAuditLogV1' then public.sun_has_permission(p_workspace,'audit.view')
|
||||||
|
else public.sun_has_permission(p_workspace,'app.read')
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_can_read_storage_key(uuid,text) from public, anon, authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_required_write_permission(p_key text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select case
|
||||||
|
when p_key='sunBoxes' then 'catalog.edit'
|
||||||
|
when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients.edit'
|
||||||
|
when p_key='sunFinanceRecordsV2' then 'money.edit'
|
||||||
|
when p_key in ('sunStock','sunStockMoves') then 'stock.edit'
|
||||||
|
when p_key='sunEmployees' then 'team.edit'
|
||||||
|
when p_key='sunSuppliers' then 'suppliers.edit'
|
||||||
|
when p_key like 'sunRoute%' then 'routes.edit'
|
||||||
|
when p_key like 'sunMarketing%' then 'mailings.edit'
|
||||||
|
when p_key in ('sunPromoCodesV1') then 'mailings.edit'
|
||||||
|
when p_key in ('sunCatalogCategoriesV2','sunDefaultOrderQrV1','sunLeadSourcesV1','sunEventTypesV1','sunOrderPaymentColorsV1','sunReceiptSettingsV2','sunPrintSettingsV1','sunYandexMapsSettings','sunEnterpriseSettingsV1','sunOrderStatusFeatureEnabledV1') then 'settings.edit'
|
||||||
|
when p_key='sunOfficialCatalogVersion' then 'catalog.edit'
|
||||||
|
when p_key='sunAuditLogV1' then '_member'
|
||||||
|
else 'settings.edit'
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_required_write_permission(text) from public, anon, authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_fetch_app_state(p_workspace uuid)
|
||||||
|
returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_row public.sun_app_state%rowtype;
|
||||||
|
v_storage jsonb := '{}'::jsonb;
|
||||||
|
kv record;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
||||||
|
select * into v_row from public.sun_app_state where sun_app_state.workspace_id=p_workspace;
|
||||||
|
if not found then return; end if;
|
||||||
|
for kv in select key,value from jsonb_each(coalesce(v_row.payload->'storage','{}'::jsonb)) loop
|
||||||
|
if public.sun_can_read_storage_key(p_workspace,kv.key) then
|
||||||
|
v_storage := v_storage || jsonb_build_object(kv.key,kv.value);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
workspace_id := v_row.workspace_id;
|
||||||
|
payload := jsonb_build_object('format',coalesce(v_row.payload->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(v_row.payload->'version','2'::jsonb),'storage',v_storage);
|
||||||
|
revision := v_row.revision;
|
||||||
|
updated_at := v_row.updated_at;
|
||||||
|
client_id := v_row.client_id;
|
||||||
|
return next;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_fetch_app_state(uuid) from public, anon;
|
||||||
|
grant execute on function public.sun_fetch_app_state(uuid) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_save_app_state(p_workspace uuid,p_payload jsonb,p_client_id text)
|
||||||
|
returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb;
|
||||||
|
v_old_storage jsonb;
|
||||||
|
v_new_storage jsonb;
|
||||||
|
v_key text;
|
||||||
|
v_perm text;
|
||||||
|
v_old_orders jsonb;
|
||||||
|
v_new_orders jsonb;
|
||||||
|
v_create boolean := false;
|
||||||
|
v_delete boolean := false;
|
||||||
|
v_edit boolean := false;
|
||||||
|
v_saved public.sun_app_state%rowtype;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
||||||
|
if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if;
|
||||||
|
|
||||||
|
select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace;
|
||||||
|
if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if;
|
||||||
|
v_old_storage := coalesce(v_old->'storage','{}'::jsonb);
|
||||||
|
v_new_storage := coalesce(p_payload->'storage','{}'::jsonb);
|
||||||
|
|
||||||
|
for v_key in
|
||||||
|
select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q
|
||||||
|
loop
|
||||||
|
if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if;
|
||||||
|
if v_key='sunOrders' then
|
||||||
|
v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb);
|
||||||
|
v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb);
|
||||||
|
if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit;
|
||||||
|
if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if;
|
||||||
|
if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if;
|
||||||
|
if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if;
|
||||||
|
else
|
||||||
|
v_perm := public.sun_required_write_permission(v_key);
|
||||||
|
if v_perm='_member' then
|
||||||
|
null;
|
||||||
|
elsif not public.sun_has_permission(p_workspace,v_perm) then
|
||||||
|
raise exception 'Нет права изменять раздел: %',v_key;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into public.sun_app_state(workspace_id,payload,client_id)
|
||||||
|
values (p_workspace,p_payload,p_client_id)
|
||||||
|
on conflict (workspace_id) do update set payload=excluded.payload,client_id=excluded.client_id
|
||||||
|
returning * into v_saved;
|
||||||
|
|
||||||
|
insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id);
|
||||||
|
|
||||||
|
return query select * from public.sun_fetch_app_state(p_workspace);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create table if not exists public.sun_sync_events (
|
||||||
|
id bigint generated by default as identity primary key,
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
revision bigint not null,
|
||||||
|
client_id text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists sun_sync_events_workspace_idx on public.sun_sync_events(workspace_id,id desc);
|
||||||
|
alter table public.sun_sync_events enable row level security;
|
||||||
|
revoke all on public.sun_sync_events from anon;
|
||||||
|
grant select on public.sun_sync_events to authenticated;
|
||||||
|
drop policy if exists sun_sync_events_read on public.sun_sync_events;
|
||||||
|
create policy sun_sync_events_read on public.sun_sync_events for select to authenticated using (public.sun_member_role(workspace_id) is not null);
|
||||||
|
|
||||||
|
revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon;
|
||||||
|
grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated;
|
||||||
|
|
||||||
|
-- The app state itself is accessible only through permission-aware RPCs.
|
||||||
|
revoke select,insert,update,delete on public.sun_app_state from authenticated, anon;
|
||||||
|
drop policy if exists sun_state_read on public.sun_app_state;
|
||||||
|
drop policy if exists sun_state_insert on public.sun_app_state;
|
||||||
|
drop policy if exists sun_state_update on public.sun_app_state;
|
||||||
|
|
||||||
|
-- Members can read their own membership; administrators can read their workspace members.
|
||||||
|
drop policy if exists sun_members_read on public.sun_workspace_members;
|
||||||
|
create policy sun_members_read on public.sun_workspace_members for select to authenticated
|
||||||
|
using (user_id=auth.uid() or public.sun_has_permission(workspace_id,'users.manage'));
|
||||||
|
|
||||||
|
-- Workspaces are visible only to active members.
|
||||||
|
drop policy if exists sun_workspaces_read on public.sun_workspaces;
|
||||||
|
create policy sun_workspaces_read on public.sun_workspaces for select to authenticated
|
||||||
|
using (public.sun_member_role(id) is not null);
|
||||||
|
|
||||||
|
-- Invitations visible only to administrators.
|
||||||
|
drop policy if exists sun_invites_read on public.sun_workspace_invites;
|
||||||
|
create policy sun_invites_read on public.sun_workspace_invites for select to authenticated
|
||||||
|
using (public.sun_has_permission(workspace_id,'users.manage'));
|
||||||
|
|
||||||
|
-- Storage: active members may read, catalogue editors may upload/change media.
|
||||||
|
drop policy if exists sun_media_read on storage.objects;
|
||||||
|
create policy sun_media_read on storage.objects for select to authenticated
|
||||||
|
using (bucket_id='sun-media' and public.sun_member_role(((storage.foldername(name))[1])::uuid) is not null);
|
||||||
|
drop policy if exists sun_media_insert on storage.objects;
|
||||||
|
create policy sun_media_insert on storage.objects for insert to authenticated
|
||||||
|
with check (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit'));
|
||||||
|
drop policy if exists sun_media_update on storage.objects;
|
||||||
|
create policy sun_media_update on storage.objects for update to authenticated
|
||||||
|
using (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit'))
|
||||||
|
with check (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit'));
|
||||||
|
drop policy if exists sun_media_delete on storage.objects;
|
||||||
|
create policy sun_media_delete on storage.objects for delete to authenticated
|
||||||
|
using (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit'));
|
||||||
|
|
||||||
|
-- Realtime events, not the full payload, are published.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='sun_app_state') then
|
||||||
|
alter publication supabase_realtime drop table public.sun_app_state;
|
||||||
|
end if;
|
||||||
|
if not exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='sun_sync_events') then
|
||||||
|
alter publication supabase_realtime add table public.sun_sync_events;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Bootstrap the approved administrator account if it has no workspace yet.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_user uuid;
|
||||||
|
v_workspace uuid;
|
||||||
|
v_display text;
|
||||||
|
begin
|
||||||
|
select id,coalesce(nullif(raw_user_meta_data->>'name',''),split_part(email,'@',1)) into v_user,v_display
|
||||||
|
from auth.users where lower(email)=lower('dpavlov346@bk.ru') limit 1;
|
||||||
|
if v_user is not null then
|
||||||
|
update public.sun_workspace_members
|
||||||
|
set role='admin',is_active=true,permissions=public.sun_role_default_permissions('admin'),updated_at=now()
|
||||||
|
where user_id=v_user;
|
||||||
|
if not exists(select 1 from public.sun_workspace_members where user_id=v_user) then
|
||||||
|
insert into public.sun_workspaces(name,created_by) values ('Солнце Кейтеринг',v_user) returning id into v_workspace;
|
||||||
|
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
|
||||||
|
values (v_workspace,v_user,'admin',v_display,true,public.sun_role_default_permissions('admin'));
|
||||||
|
insert into public.sun_app_state(workspace_id,payload,client_id)
|
||||||
|
values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap')
|
||||||
|
on conflict (workspace_id) do nothing;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Normalize existing members' empty permissions to their role template.
|
||||||
|
update public.sun_workspace_members
|
||||||
|
set permissions=public.sun_role_default_permissions(role),updated_at=now()
|
||||||
|
where permissions='{}'::jsonb or permissions is null;
|
||||||
|
|
||||||
|
-- FINAL RUNTIME FIXES (keep at end of file)
|
||||||
|
create or replace function public.sun_list_workspace_members(p_workspace uuid)
|
||||||
|
returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
return query
|
||||||
|
select m.user_id,u.email::text,m.display_name,m.role,m.is_active,
|
||||||
|
coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at
|
||||||
|
from public.sun_workspace_members m
|
||||||
|
join auth.users u on u.id=m.user_id
|
||||||
|
where m.workspace_id=p_workspace
|
||||||
|
order by (m.role='admin') desc,coalesce(m.display_name,u.email::text);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_list_workspace_members(uuid) from public, anon;
|
||||||
|
grant execute on function public.sun_list_workspace_members(uuid) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_required_write_permission(p_key text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select case
|
||||||
|
when p_key='sunBoxes' then 'catalog.edit'
|
||||||
|
when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients.edit'
|
||||||
|
when p_key='sunFinanceRecordsV2' then 'money.edit'
|
||||||
|
when p_key in ('sunStock','sunStockMoves') then 'stock.edit'
|
||||||
|
when p_key='sunEmployees' then 'team.edit'
|
||||||
|
when p_key='sunSuppliers' then 'suppliers.edit'
|
||||||
|
when p_key like 'sunRoute%' then 'routes.edit'
|
||||||
|
when p_key like 'sunMarketing%' then 'mailings.edit'
|
||||||
|
when p_key in ('sunPromoCodesV1') then 'mailings.edit'
|
||||||
|
when p_key in ('sunCatalogCategoriesV2','sunDefaultOrderQrV1','sunLeadSourcesV1','sunEventTypesV1','sunOrderPaymentColorsV1','sunReceiptSettingsV2','sunPrintSettingsV1','sunYandexMapsSettings','sunEnterpriseSettingsV1','sunOrderStatusFeatureEnabledV1') then 'settings.edit'
|
||||||
|
when p_key='sunOfficialCatalogVersion' then 'catalog.edit'
|
||||||
|
when p_key='sunAuditLogV1' then '_member'
|
||||||
|
else 'settings.edit'
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_save_app_state(p_workspace uuid,p_payload jsonb,p_client_id text)
|
||||||
|
returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb;
|
||||||
|
v_old_storage jsonb;
|
||||||
|
v_new_storage jsonb;
|
||||||
|
v_final_storage jsonb;
|
||||||
|
v_final_payload jsonb;
|
||||||
|
v_key text;
|
||||||
|
v_perm text;
|
||||||
|
v_old_orders jsonb;
|
||||||
|
v_new_orders jsonb;
|
||||||
|
v_create boolean;
|
||||||
|
v_delete boolean;
|
||||||
|
v_edit boolean;
|
||||||
|
v_saved public.sun_app_state%rowtype;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
||||||
|
if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if;
|
||||||
|
select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace;
|
||||||
|
if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if;
|
||||||
|
v_old_storage := coalesce(v_old->'storage','{}'::jsonb);
|
||||||
|
v_new_storage := coalesce(p_payload->'storage','{}'::jsonb);
|
||||||
|
v_final_storage := v_old_storage;
|
||||||
|
for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop
|
||||||
|
if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if;
|
||||||
|
if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if;
|
||||||
|
if v_key='sunOrders' then
|
||||||
|
v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb);
|
||||||
|
v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb);
|
||||||
|
if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit;
|
||||||
|
if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if;
|
||||||
|
if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if;
|
||||||
|
if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if;
|
||||||
|
else
|
||||||
|
v_perm := public.sun_required_write_permission(v_key);
|
||||||
|
if v_perm='_member' then null;
|
||||||
|
elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key);
|
||||||
|
else v_final_storage := v_final_storage - v_key;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage);
|
||||||
|
insert into public.sun_app_state as app_state(workspace_id,payload,client_id)
|
||||||
|
values (p_workspace,v_final_payload,p_client_id)
|
||||||
|
on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id
|
||||||
|
returning app_state.* into v_saved;
|
||||||
|
insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id);
|
||||||
|
return query select * from public.sun_fetch_app_state(p_workspace);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon;
|
||||||
|
grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated;
|
||||||
245
ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
-- Caterium v17.5.27: clear registration + email-bound employee invitations
|
||||||
|
|
||||||
|
alter table public.sun_workspace_invites add column if not exists email text;
|
||||||
|
alter table public.sun_workspace_invites add column if not exists display_name text;
|
||||||
|
|
||||||
|
create index if not exists sun_workspace_invites_pending_email_v27_idx
|
||||||
|
on public.sun_workspace_invites(workspace_id, lower(email))
|
||||||
|
where used_at is null;
|
||||||
|
|
||||||
|
-- Public Caterium signups and invite signups do not require clicking an email-confirmation link.
|
||||||
|
-- This keeps the existing Auth signup endpoint/rate limits while marking these app-originated users verified.
|
||||||
|
create or replace function public.caterium_autoconfirm_signup_v25()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = 'auth','public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if coalesce(new.raw_user_meta_data->>'registration_source','') in ('caterium_public_signup','caterium_invite_signup') then
|
||||||
|
new.email_confirmed_at := coalesce(new.email_confirmed_at, now());
|
||||||
|
new.raw_user_meta_data := jsonb_set(coalesce(new.raw_user_meta_data,'{}'::jsonb),'{email_verified}','true'::jsonb,true);
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.caterium_autoverify_identity_v25()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = 'auth','public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if new.provider='email' and exists (
|
||||||
|
select 1 from auth.users u
|
||||||
|
where u.id=new.user_id
|
||||||
|
and coalesce(u.raw_user_meta_data->>'registration_source','') in ('caterium_public_signup','caterium_invite_signup')
|
||||||
|
) then
|
||||||
|
new.identity_data := jsonb_set(coalesce(new.identity_data,'{}'::jsonb),'{email_verified}','true'::jsonb,true);
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_create_invite_v27(
|
||||||
|
p_workspace uuid,
|
||||||
|
p_email text,
|
||||||
|
p_display_name text default '',
|
||||||
|
p_role text default 'manager'
|
||||||
|
)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_token uuid;
|
||||||
|
v_role text := lower(coalesce(p_role,'manager'));
|
||||||
|
v_email text := lower(trim(coalesce(p_email,'')));
|
||||||
|
v_name text := trim(coalesce(p_display_name,''));
|
||||||
|
v_max integer;
|
||||||
|
v_active integer;
|
||||||
|
v_pending integer;
|
||||||
|
begin
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if;
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для добавления пользователей'; end if;
|
||||||
|
if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Некорректная роль'; end if;
|
||||||
|
if v_email='' or v_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'Введите корректный email сотрудника'; end if;
|
||||||
|
if v_name='' then v_name:=split_part(v_email,'@',1); end if;
|
||||||
|
|
||||||
|
if exists(
|
||||||
|
select 1
|
||||||
|
from public.sun_workspace_members m
|
||||||
|
join auth.users u on u.id=m.user_id
|
||||||
|
where m.workspace_id=p_workspace and m.is_active=true and lower(coalesce(u.email,''))=v_email
|
||||||
|
) then
|
||||||
|
raise exception 'Пользователь с этим email уже добавлен в компанию';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Remove stale invites and replace the previous pending invite for the same email.
|
||||||
|
delete from public.sun_workspace_invites
|
||||||
|
where workspace_id=p_workspace and used_at is null
|
||||||
|
and (expires_at<=now() or lower(coalesce(email,''))=v_email);
|
||||||
|
|
||||||
|
select p.max_members into v_max
|
||||||
|
from public.sun_workspace_subscriptions s
|
||||||
|
join public.sun_plans p on p.id=s.plan_id
|
||||||
|
where s.workspace_id=p_workspace;
|
||||||
|
|
||||||
|
if v_max is not null then
|
||||||
|
select count(*)::int into v_active
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=p_workspace and is_active=true;
|
||||||
|
select count(*)::int into v_pending
|
||||||
|
from public.sun_workspace_invites
|
||||||
|
where workspace_id=p_workspace and used_at is null and expires_at>now();
|
||||||
|
if v_active+v_pending>=v_max then
|
||||||
|
raise exception 'Достигнут лимит пользователей тарифа (%)',v_max;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by,email,display_name)
|
||||||
|
values(p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid(),v_email,left(v_name,120))
|
||||||
|
returning token into v_token;
|
||||||
|
return v_token;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_list_workspace_invites_v27(p_workspace uuid)
|
||||||
|
returns table(
|
||||||
|
token uuid,
|
||||||
|
email text,
|
||||||
|
display_name text,
|
||||||
|
role text,
|
||||||
|
created_at timestamptz,
|
||||||
|
expires_at timestamptz,
|
||||||
|
status text
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для просмотра приглашений'; end if;
|
||||||
|
return query
|
||||||
|
select i.token,i.email,i.display_name,i.role,i.created_at,i.expires_at,
|
||||||
|
case when i.expires_at<=now() then 'expired' else 'pending' end::text
|
||||||
|
from public.sun_workspace_invites i
|
||||||
|
where i.workspace_id=p_workspace and i.used_at is null
|
||||||
|
order by i.created_at desc;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_cancel_invite_v27(p_workspace uuid,p_token uuid)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare v_deleted integer;
|
||||||
|
begin
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для отмены приглашения'; end if;
|
||||||
|
delete from public.sun_workspace_invites
|
||||||
|
where workspace_id=p_workspace and token=p_token and used_at is null;
|
||||||
|
get diagnostics v_deleted = row_count;
|
||||||
|
return v_deleted>0;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Token is the secret. This preview intentionally reveals only the invited company/name/email/role.
|
||||||
|
create or replace function public.sun_invite_preview_v27(p_token uuid)
|
||||||
|
returns table(
|
||||||
|
workspace_id uuid,
|
||||||
|
workspace_name text,
|
||||||
|
email text,
|
||||||
|
display_name text,
|
||||||
|
role text,
|
||||||
|
expires_at timestamptz,
|
||||||
|
is_valid boolean
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
select w.id,w.name,i.email,i.display_name,i.role,i.expires_at,
|
||||||
|
(i.used_at is null and i.expires_at>now()) as is_valid
|
||||||
|
from public.sun_workspace_invites i
|
||||||
|
join public.sun_workspaces w on w.id=i.workspace_id
|
||||||
|
where i.token=p_token
|
||||||
|
limit 1
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Keep the existing RPC name for backward compatibility, but make new email-bound invites safe.
|
||||||
|
create or replace function public.sun_accept_invite(p_token uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public','auth'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_invite public.sun_workspace_invites%rowtype;
|
||||||
|
v_display text;
|
||||||
|
v_user_email text;
|
||||||
|
v_max integer;
|
||||||
|
v_count integer;
|
||||||
|
begin
|
||||||
|
if v_user is null then raise exception 'Сначала войдите в Caterium'; end if;
|
||||||
|
select * into v_invite from public.sun_workspace_invites where token=p_token for update;
|
||||||
|
if not found then raise exception 'Приглашение не найдено'; end if;
|
||||||
|
if v_invite.used_at is not null then raise exception 'Приглашение уже использовано'; end if;
|
||||||
|
if v_invite.expires_at < now() then raise exception 'Срок действия приглашения истёк'; end if;
|
||||||
|
|
||||||
|
select lower(coalesce(email,'')),
|
||||||
|
coalesce(nullif(v_invite.display_name,''),nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1))
|
||||||
|
into v_user_email,v_display
|
||||||
|
from auth.users where id=v_user;
|
||||||
|
|
||||||
|
if nullif(lower(trim(coalesce(v_invite.email,''))),'') is not null
|
||||||
|
and lower(trim(v_invite.email))<>v_user_email then
|
||||||
|
raise exception 'Это приглашение создано для другого email';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if public.sun_subscription_access_mode(v_invite.workspace_id)<>'full' then raise exception 'Подписка компании неактивна'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(v_invite.workspace_id,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if;
|
||||||
|
|
||||||
|
select p.max_members into v_max
|
||||||
|
from public.sun_workspace_subscriptions s
|
||||||
|
join public.sun_plans p on p.id=s.plan_id
|
||||||
|
where s.workspace_id=v_invite.workspace_id;
|
||||||
|
select count(*)::int into v_count
|
||||||
|
from public.sun_workspace_members
|
||||||
|
where workspace_id=v_invite.workspace_id and is_active=true;
|
||||||
|
if not exists(select 1 from public.sun_workspace_members where workspace_id=v_invite.workspace_id and user_id=v_user and is_active=true) then
|
||||||
|
if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at)
|
||||||
|
values(v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now())
|
||||||
|
on conflict(workspace_id,user_id) do update
|
||||||
|
set role=excluded.role,
|
||||||
|
display_name=coalesce(nullif(public.sun_workspace_members.display_name,''),excluded.display_name),
|
||||||
|
is_active=true,
|
||||||
|
permissions=excluded.permissions,
|
||||||
|
updated_at=now();
|
||||||
|
|
||||||
|
update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token;
|
||||||
|
return v_invite.workspace_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_create_invite_v27(uuid,text,text,text) from public,anon;
|
||||||
|
revoke all on function public.sun_list_workspace_invites_v27(uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_cancel_invite_v27(uuid,uuid) from public,anon;
|
||||||
|
revoke all on function public.sun_invite_preview_v27(uuid) from public;
|
||||||
|
revoke all on function public.sun_accept_invite(uuid) from public,anon;
|
||||||
|
|
||||||
|
grant execute on function public.sun_create_invite_v27(uuid,text,text,text) to authenticated;
|
||||||
|
grant execute on function public.sun_list_workspace_invites_v27(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_cancel_invite_v27(uuid,uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_invite_preview_v27(uuid) to anon,authenticated;
|
||||||
|
grant execute on function public.sun_accept_invite(uuid) to authenticated;
|
||||||
134
ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
-- Sun Catering SaaS v16 finalization
|
||||||
|
-- Run after SUPABASE-SAAS-V16.sql on a fresh project.
|
||||||
|
|
||||||
|
create or replace function public.sun_feature_for_read_storage_key(p_key text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
select case
|
||||||
|
when p_key='sunOrders' then 'orders'
|
||||||
|
when p_key in ('sunBoxes','sunCatalogCategoriesV2','sunOfficialCatalogVersion') then 'catalog_view'
|
||||||
|
when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients'
|
||||||
|
when p_key='sunFinanceRecordsV2' then 'money'
|
||||||
|
when p_key in ('sunStock','sunStockMoves') then 'stock'
|
||||||
|
when p_key='sunEmployees' then 'team'
|
||||||
|
when p_key='sunSuppliers' then 'suppliers'
|
||||||
|
when p_key like 'sunRoute%' then 'routes'
|
||||||
|
when p_key like 'sunMarketing%' or p_key='sunPromoCodesV1' then 'mailings'
|
||||||
|
when p_key='sunBrandThemeV1' then 'branding'
|
||||||
|
when p_key='sunClientOfferSettingsV1' then 'client_offers'
|
||||||
|
when p_key='sunOfferTemplateV1' then 'offer_templates'
|
||||||
|
when p_key='sunAuditLogV1' then 'audit'
|
||||||
|
else 'settings'
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_fetch_app_state(p_workspace uuid)
|
||||||
|
returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text)
|
||||||
|
language plpgsql
|
||||||
|
stable security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_row public.sun_app_state%rowtype;
|
||||||
|
v_storage jsonb := '{}'::jsonb;
|
||||||
|
kv record;
|
||||||
|
v_feature text;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)='blocked' then raise exception 'Подписка закончилась. Данные сохранены, продлите подписку для доступа.'; end if;
|
||||||
|
select * into v_row from public.sun_app_state where sun_app_state.workspace_id=p_workspace;
|
||||||
|
if not found then return; end if;
|
||||||
|
for kv in select key,value from jsonb_each(coalesce(v_row.payload->'storage','{}'::jsonb)) loop
|
||||||
|
v_feature := public.sun_feature_for_read_storage_key(kv.key);
|
||||||
|
if public.sun_can_read_storage_key(p_workspace,kv.key)
|
||||||
|
and (v_feature is null or public.sun_workspace_has_feature(p_workspace,v_feature)) then
|
||||||
|
v_storage := v_storage || jsonb_build_object(kv.key,kv.value);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
workspace_id := v_row.workspace_id;
|
||||||
|
payload := jsonb_build_object('format',coalesce(v_row.payload->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(v_row.payload->'version','2'::jsonb),'storage',v_storage);
|
||||||
|
revision := v_row.revision;
|
||||||
|
updated_at := v_row.updated_at;
|
||||||
|
client_id := v_row.client_id;
|
||||||
|
return next;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_admin_update_member(p_workspace uuid, p_user uuid, p_display_name text, p_role text, p_is_active boolean, p_permissions jsonb)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_old_role text;
|
||||||
|
v_old_active boolean;
|
||||||
|
v_admins int;
|
||||||
|
v_role text := lower(coalesce(p_role,''));
|
||||||
|
v_max integer;
|
||||||
|
v_active_count integer;
|
||||||
|
begin
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if;
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if;
|
||||||
|
if p_permissions is null or jsonb_typeof(p_permissions) <> 'object' then raise exception 'Permissions must be an object'; end if;
|
||||||
|
select role,is_active into v_old_role,v_old_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update;
|
||||||
|
if not found then raise exception 'Member not found'; end if;
|
||||||
|
if v_old_role='admin' and coalesce(v_old_active,false) and (v_role<>'admin' or not coalesce(p_is_active,false)) then
|
||||||
|
select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true;
|
||||||
|
if v_admins <= 1 then raise exception 'Нельзя отключить или понизить последнего администратора'; end if;
|
||||||
|
end if;
|
||||||
|
if coalesce(p_is_active,false) and not coalesce(v_old_active,false) then
|
||||||
|
select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace;
|
||||||
|
select count(*)::int into v_active_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true;
|
||||||
|
if v_max is not null and v_active_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if;
|
||||||
|
end if;
|
||||||
|
update public.sun_workspace_members set display_name=nullif(trim(coalesce(p_display_name,'')),''),role=v_role,is_active=coalesce(p_is_active,false),permissions=p_permissions,updated_at=now()
|
||||||
|
where workspace_id=p_workspace and user_id=p_user;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_admin_remove_member(p_workspace uuid, p_user uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_role text;
|
||||||
|
v_active boolean;
|
||||||
|
v_admins int;
|
||||||
|
begin
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Управление сотрудниками недоступно на текущем тарифе'; end if;
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
select role,is_active into v_role,v_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update;
|
||||||
|
if not found then return; end if;
|
||||||
|
if v_role='admin' and coalesce(v_active,false) then
|
||||||
|
select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true;
|
||||||
|
if v_admins <= 1 then raise exception 'Нельзя удалить последнего администратора'; end if;
|
||||||
|
end if;
|
||||||
|
delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- New SaaS helper RPCs are authenticated-only.
|
||||||
|
revoke execute on function public.sun_is_platform_admin() from public, anon;
|
||||||
|
revoke execute on function public.sun_subscription_access_mode(uuid) from public, anon;
|
||||||
|
revoke execute on function public.sun_workspace_has_feature(uuid,text) from public, anon;
|
||||||
|
revoke execute on function public.sun_subscription_snapshot(uuid) from public, anon;
|
||||||
|
revoke execute on function public.sun_platform_list_workspaces() from public, anon;
|
||||||
|
revoke execute on function public.sun_platform_set_subscription(uuid,text,integer,text) from public, anon;
|
||||||
|
revoke execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) from public, anon;
|
||||||
|
|
||||||
|
grant execute on function public.sun_is_platform_admin() to authenticated;
|
||||||
|
grant execute on function public.sun_subscription_access_mode(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_workspace_has_feature(uuid,text) to authenticated;
|
||||||
|
grant execute on function public.sun_subscription_snapshot(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_list_workspaces() to authenticated;
|
||||||
|
grant execute on function public.sun_platform_set_subscription(uuid,text,integer,text) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) to authenticated;
|
||||||
475
ops/sql/SUPABASE-SAAS-V16.sql
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
-- Sun Catering SaaS foundation v16
|
||||||
|
-- Additive subscription/entitlement layer. Existing workspace remains active on Full.
|
||||||
|
|
||||||
|
create table if not exists public.sun_plans (
|
||||||
|
id text primary key,
|
||||||
|
name text not null,
|
||||||
|
description text not null default '',
|
||||||
|
max_members integer null check (max_members is null or max_members >= 1),
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_plan_features (
|
||||||
|
plan_id text not null references public.sun_plans(id) on delete cascade,
|
||||||
|
feature_key text not null,
|
||||||
|
enabled boolean not null default false,
|
||||||
|
primary key (plan_id, feature_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_workspace_subscriptions (
|
||||||
|
workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade,
|
||||||
|
plan_id text not null references public.sun_plans(id),
|
||||||
|
status text not null default 'trialing' check (status in ('trialing','active','past_due','canceled','expired')),
|
||||||
|
trial_started_at timestamptz null,
|
||||||
|
trial_ends_at timestamptz null,
|
||||||
|
current_period_start timestamptz null,
|
||||||
|
current_period_end timestamptz null,
|
||||||
|
grace_until timestamptz null,
|
||||||
|
cancel_at_period_end boolean not null default false,
|
||||||
|
source text not null default 'manual',
|
||||||
|
external_customer_id text null,
|
||||||
|
external_subscription_id text null,
|
||||||
|
note text null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_workspace_feature_overrides (
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
feature_key text not null,
|
||||||
|
enabled boolean not null,
|
||||||
|
expires_at timestamptz null,
|
||||||
|
note text null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (workspace_id, feature_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_platform_admins (
|
||||||
|
user_id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.sun_plans(id,name,description,max_members,sort_order,is_active) values
|
||||||
|
('basic','Базовый','Заказы, календарь, клиенты, просмотр каталога и базовая статистика.',1,10,true),
|
||||||
|
('professional','Профессиональный','Основная работа команды, финансы, склад, поставщики, маршруты, рассылки и фирменное оформление.',3,20,true),
|
||||||
|
('full','Полный','Все функции, включая редактирование боксов и предложения клиентам.',null,30,true)
|
||||||
|
on conflict (id) do update set name=excluded.name,description=excluded.description,max_members=excluded.max_members,sort_order=excluded.sort_order,is_active=excluded.is_active,updated_at=now();
|
||||||
|
|
||||||
|
-- Reset only built-in feature matrix for the three standard plans.
|
||||||
|
delete from public.sun_plan_features where plan_id in ('basic','professional','full');
|
||||||
|
|
||||||
|
insert into public.sun_plan_features(plan_id,feature_key,enabled) values
|
||||||
|
-- Basic
|
||||||
|
('basic','orders',true),('basic','calendar',true),('basic','map',true),('basic','clients',true),('basic','catalog_view',true),
|
||||||
|
('basic','catalog_edit',false),('basic','production',false),('basic','shopping',false),('basic','stock',false),('basic','routes',false),
|
||||||
|
('basic','mailings',false),('basic','money',false),('basic','stats_basic',true),('basic','stats_advanced',false),('basic','team',false),
|
||||||
|
('basic','suppliers',false),('basic','print',true),('basic','settings',true),('basic','branding',false),('basic','client_offers',false),
|
||||||
|
('basic','offer_templates',false),('basic','backups',false),('basic','audit',false),('basic','users_manage',false),
|
||||||
|
-- Professional
|
||||||
|
('professional','orders',true),('professional','calendar',true),('professional','map',true),('professional','clients',true),('professional','catalog_view',true),
|
||||||
|
('professional','catalog_edit',false),('professional','production',true),('professional','shopping',true),('professional','stock',true),('professional','routes',true),
|
||||||
|
('professional','mailings',true),('professional','money',true),('professional','stats_basic',true),('professional','stats_advanced',true),('professional','team',true),
|
||||||
|
('professional','suppliers',true),('professional','print',true),('professional','settings',true),('professional','branding',true),('professional','client_offers',false),
|
||||||
|
('professional','offer_templates',false),('professional','backups',true),('professional','audit',false),('professional','users_manage',true),
|
||||||
|
-- Full
|
||||||
|
('full','orders',true),('full','calendar',true),('full','map',true),('full','clients',true),('full','catalog_view',true),
|
||||||
|
('full','catalog_edit',true),('full','production',true),('full','shopping',true),('full','stock',true),('full','routes',true),
|
||||||
|
('full','mailings',true),('full','money',true),('full','stats_basic',true),('full','stats_advanced',true),('full','team',true),
|
||||||
|
('full','suppliers',true),('full','print',true),('full','settings',true),('full','branding',true),('full','client_offers',true),
|
||||||
|
('full','offer_templates',true),('full','backups',true),('full','audit',true),('full','users_manage',true);
|
||||||
|
|
||||||
|
-- The creator of the oldest/current production workspace is the initial SaaS platform owner.
|
||||||
|
insert into public.sun_platform_admins(user_id)
|
||||||
|
select created_by from public.sun_workspaces where created_by is not null order by created_at asc limit 1
|
||||||
|
on conflict (user_id) do nothing;
|
||||||
|
|
||||||
|
-- Existing workspaces are never disrupted by this migration: they receive Full active access.
|
||||||
|
insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source,note)
|
||||||
|
select id,'full','active',now(),timestamptz '2099-12-31 23:59:59+00',timestamptz '2100-01-07 23:59:59+00','migration','Existing workspace preserved during SaaS migration'
|
||||||
|
from public.sun_workspaces
|
||||||
|
on conflict (workspace_id) do nothing;
|
||||||
|
|
||||||
|
alter table public.sun_plans enable row level security;
|
||||||
|
alter table public.sun_plan_features enable row level security;
|
||||||
|
alter table public.sun_workspace_subscriptions enable row level security;
|
||||||
|
alter table public.sun_workspace_feature_overrides enable row level security;
|
||||||
|
alter table public.sun_platform_admins enable row level security;
|
||||||
|
|
||||||
|
-- Recreate policies idempotently.
|
||||||
|
drop policy if exists sun_plans_read on public.sun_plans;
|
||||||
|
create policy sun_plans_read on public.sun_plans for select to authenticated using (is_active = true);
|
||||||
|
|
||||||
|
drop policy if exists sun_plan_features_read on public.sun_plan_features;
|
||||||
|
create policy sun_plan_features_read on public.sun_plan_features for select to authenticated using (true);
|
||||||
|
|
||||||
|
drop policy if exists sun_workspace_subscriptions_member_read on public.sun_workspace_subscriptions;
|
||||||
|
create policy sun_workspace_subscriptions_member_read on public.sun_workspace_subscriptions for select to authenticated
|
||||||
|
using (exists(select 1 from public.sun_workspace_members m where m.workspace_id=sun_workspace_subscriptions.workspace_id and m.user_id=auth.uid() and m.is_active=true));
|
||||||
|
|
||||||
|
drop policy if exists sun_workspace_overrides_member_read on public.sun_workspace_feature_overrides;
|
||||||
|
create policy sun_workspace_overrides_member_read on public.sun_workspace_feature_overrides for select to authenticated
|
||||||
|
using (exists(select 1 from public.sun_workspace_members m where m.workspace_id=sun_workspace_feature_overrides.workspace_id and m.user_id=auth.uid() and m.is_active=true));
|
||||||
|
|
||||||
|
-- No direct client access to platform-admin rows. Use checked RPCs only.
|
||||||
|
revoke all on public.sun_platform_admins from anon, authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_is_platform_admin()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
select auth.uid() is not null and exists(select 1 from public.sun_platform_admins a where a.user_id=auth.uid());
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_subscription_access_mode(p_workspace uuid)
|
||||||
|
returns text
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
s public.sun_workspace_subscriptions%rowtype;
|
||||||
|
v_end timestamptz;
|
||||||
|
v_grace timestamptz;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then return 'blocked'; end if;
|
||||||
|
select * into s from public.sun_workspace_subscriptions where workspace_id=p_workspace;
|
||||||
|
if not found then return 'blocked'; end if;
|
||||||
|
|
||||||
|
if s.status='trialing' then
|
||||||
|
v_end:=s.trial_ends_at;
|
||||||
|
else
|
||||||
|
v_end:=s.current_period_end;
|
||||||
|
end if;
|
||||||
|
v_grace:=coalesce(s.grace_until, case when v_end is not null then v_end + interval '7 days' else null end);
|
||||||
|
|
||||||
|
if s.status in ('trialing','active','canceled') and (v_end is null or now() <= v_end) then return 'full'; end if;
|
||||||
|
if v_grace is not null and now() <= v_grace then return 'read_only'; end if;
|
||||||
|
return 'blocked';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_workspace_has_feature(p_workspace uuid,p_feature text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_plan text;
|
||||||
|
v_value boolean := false;
|
||||||
|
v_override boolean;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then return false; end if;
|
||||||
|
select plan_id into v_plan from public.sun_workspace_subscriptions where workspace_id=p_workspace;
|
||||||
|
if v_plan is null then return false; end if;
|
||||||
|
select enabled into v_value from public.sun_plan_features where plan_id=v_plan and feature_key=p_feature;
|
||||||
|
select enabled into v_override from public.sun_workspace_feature_overrides
|
||||||
|
where workspace_id=p_workspace and feature_key=p_feature and (expires_at is null or expires_at>now());
|
||||||
|
if found then return coalesce(v_override,false); end if;
|
||||||
|
return coalesce(v_value,false);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_subscription_snapshot(p_workspace uuid)
|
||||||
|
returns table(
|
||||||
|
workspace_id uuid,
|
||||||
|
plan_id text,
|
||||||
|
plan_name text,
|
||||||
|
status text,
|
||||||
|
access_mode text,
|
||||||
|
trial_ends_at timestamptz,
|
||||||
|
current_period_end timestamptz,
|
||||||
|
grace_until timestamptz,
|
||||||
|
max_members integer,
|
||||||
|
member_count integer,
|
||||||
|
features jsonb,
|
||||||
|
platform_admin boolean
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
s public.sun_workspace_subscriptions%rowtype;
|
||||||
|
p public.sun_plans%rowtype;
|
||||||
|
f jsonb := '{}'::jsonb;
|
||||||
|
o jsonb := '{}'::jsonb;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
|
||||||
|
select * into s from public.sun_workspace_subscriptions where sun_workspace_subscriptions.workspace_id=p_workspace;
|
||||||
|
if not found then raise exception 'Subscription not found'; end if;
|
||||||
|
select * into p from public.sun_plans where id=s.plan_id;
|
||||||
|
select coalesce(jsonb_object_agg(feature_key,enabled),'{}'::jsonb) into f from public.sun_plan_features where sun_plan_features.plan_id=s.plan_id;
|
||||||
|
select coalesce(jsonb_object_agg(feature_key,enabled),'{}'::jsonb) into o from public.sun_workspace_feature_overrides
|
||||||
|
where sun_workspace_feature_overrides.workspace_id=p_workspace and (expires_at is null or expires_at>now());
|
||||||
|
workspace_id:=p_workspace; plan_id:=s.plan_id; plan_name:=p.name; status:=s.status; access_mode:=public.sun_subscription_access_mode(p_workspace);
|
||||||
|
trial_ends_at:=s.trial_ends_at; current_period_end:=s.current_period_end; grace_until:=coalesce(s.grace_until,coalesce(s.trial_ends_at,s.current_period_end)+interval '7 days');
|
||||||
|
max_members:=p.max_members;
|
||||||
|
select count(*)::int into member_count from public.sun_workspace_members m where m.workspace_id=p_workspace and m.is_active=true;
|
||||||
|
features:=f||o; platform_admin:=public.sun_is_platform_admin();
|
||||||
|
return next;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_feature_for_storage_key(p_key text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
select case
|
||||||
|
when p_key='sunOrders' then 'orders'
|
||||||
|
when p_key in ('sunBoxes','sunCatalogCategoriesV2','sunOfficialCatalogVersion') then 'catalog_edit'
|
||||||
|
when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients'
|
||||||
|
when p_key='sunFinanceRecordsV2' then 'money'
|
||||||
|
when p_key in ('sunStock','sunStockMoves') then 'stock'
|
||||||
|
when p_key='sunEmployees' then 'team'
|
||||||
|
when p_key='sunSuppliers' then 'suppliers'
|
||||||
|
when p_key like 'sunRoute%' then 'routes'
|
||||||
|
when p_key like 'sunMarketing%' or p_key='sunPromoCodesV1' then 'mailings'
|
||||||
|
when p_key='sunBrandThemeV1' then 'branding'
|
||||||
|
when p_key='sunClientOfferSettingsV1' then 'client_offers'
|
||||||
|
when p_key='sunOfferTemplateV1' then 'offer_templates'
|
||||||
|
else 'settings'
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- New workspaces automatically receive a 14-day Full trial.
|
||||||
|
create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг'::text)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_workspace uuid;
|
||||||
|
v_name text;
|
||||||
|
begin
|
||||||
|
if v_user is null then raise exception 'Authentication required'; end if;
|
||||||
|
v_name := coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг');
|
||||||
|
insert into public.sun_workspaces(name, created_by) values (v_name, v_user) returning id into v_workspace;
|
||||||
|
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
|
||||||
|
values (v_workspace,v_user,'admin',coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce((select email from auth.users where id=v_user),'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin'));
|
||||||
|
insert into public.sun_app_state(workspace_id,payload,client_id)
|
||||||
|
values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap')
|
||||||
|
on conflict (workspace_id) do nothing;
|
||||||
|
insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,trial_started_at,trial_ends_at,grace_until,source,note)
|
||||||
|
values(v_workspace,'full','trialing',now(),now()+interval '14 days',now()+interval '21 days','trial','14-day Full trial')
|
||||||
|
on conflict(workspace_id) do nothing;
|
||||||
|
return v_workspace;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Workspace member limits are enforced server-side.
|
||||||
|
create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager'::text)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_token uuid;
|
||||||
|
v_role text := lower(coalesce(p_role,'manager'));
|
||||||
|
v_max integer;
|
||||||
|
v_count integer;
|
||||||
|
begin
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; end if;
|
||||||
|
if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if;
|
||||||
|
if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if;
|
||||||
|
select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace;
|
||||||
|
select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true;
|
||||||
|
if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if;
|
||||||
|
insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by)
|
||||||
|
values (p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid()) returning token into v_token;
|
||||||
|
return v_token;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_accept_invite(p_token uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_invite public.sun_workspace_invites%rowtype;
|
||||||
|
v_display text;
|
||||||
|
v_max integer;
|
||||||
|
v_count integer;
|
||||||
|
begin
|
||||||
|
if v_user is null then raise exception 'Authentication required'; end if;
|
||||||
|
select * into v_invite from public.sun_workspace_invites where token=p_token for update;
|
||||||
|
if not found then raise exception 'Invite not found'; end if;
|
||||||
|
if v_invite.used_at is not null then raise exception 'Invite already used'; end if;
|
||||||
|
if v_invite.expires_at < now() then raise exception 'Invite expired'; end if;
|
||||||
|
if public.sun_subscription_access_mode(v_invite.workspace_id)<>'full' then raise exception 'Подписка компании неактивна'; end if;
|
||||||
|
if not public.sun_workspace_has_feature(v_invite.workspace_id,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; end if;
|
||||||
|
select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=v_invite.workspace_id;
|
||||||
|
select count(*)::int into v_count from public.sun_workspace_members where workspace_id=v_invite.workspace_id and is_active=true;
|
||||||
|
if not exists(select 1 from public.sun_workspace_members where workspace_id=v_invite.workspace_id and user_id=v_user and is_active=true) then
|
||||||
|
if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if;
|
||||||
|
end if;
|
||||||
|
select coalesce(nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) into v_display from auth.users where id=v_user;
|
||||||
|
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at)
|
||||||
|
values (v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now())
|
||||||
|
on conflict (workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now();
|
||||||
|
update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token;
|
||||||
|
return v_invite.workspace_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Subscription gating is enforced on the cloud save RPC, not only in the browser.
|
||||||
|
create or replace function public.sun_save_app_state(p_workspace uuid, p_payload jsonb, p_client_id text)
|
||||||
|
returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb;
|
||||||
|
v_old_storage jsonb;
|
||||||
|
v_new_storage jsonb;
|
||||||
|
v_final_storage jsonb;
|
||||||
|
v_final_payload jsonb;
|
||||||
|
v_key text;
|
||||||
|
v_perm text;
|
||||||
|
v_feature text;
|
||||||
|
v_old_orders jsonb;
|
||||||
|
v_new_orders jsonb;
|
||||||
|
v_create boolean;
|
||||||
|
v_delete boolean;
|
||||||
|
v_edit boolean;
|
||||||
|
v_saved public.sun_app_state%rowtype;
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
||||||
|
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка истекла: база доступна только для просмотра'; end if;
|
||||||
|
if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if;
|
||||||
|
|
||||||
|
select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace;
|
||||||
|
if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if;
|
||||||
|
v_old_storage := coalesce(v_old->'storage','{}'::jsonb);
|
||||||
|
v_new_storage := coalesce(p_payload->'storage','{}'::jsonb);
|
||||||
|
v_final_storage := v_old_storage;
|
||||||
|
|
||||||
|
for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop
|
||||||
|
if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if;
|
||||||
|
if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if;
|
||||||
|
|
||||||
|
if v_key='sunOrders' then
|
||||||
|
if not public.sun_workspace_has_feature(p_workspace,'orders') then raise exception 'Заказы недоступны на текущем тарифе'; end if;
|
||||||
|
v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb);
|
||||||
|
v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb);
|
||||||
|
if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete;
|
||||||
|
select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit;
|
||||||
|
if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if;
|
||||||
|
if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if;
|
||||||
|
if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if;
|
||||||
|
else
|
||||||
|
v_feature := public.sun_feature_for_storage_key(v_key);
|
||||||
|
if v_feature is not null and not public.sun_workspace_has_feature(p_workspace,v_feature) then raise exception 'Функция недоступна на текущем тарифе: %',v_feature; end if;
|
||||||
|
v_perm := public.sun_required_write_permission(v_key);
|
||||||
|
if v_perm='_member' then null;
|
||||||
|
elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key);
|
||||||
|
else v_final_storage := v_final_storage - v_key; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage);
|
||||||
|
insert into public.sun_app_state as app_state(workspace_id,payload,client_id)
|
||||||
|
values (p_workspace,v_final_payload,p_client_id)
|
||||||
|
on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id
|
||||||
|
returning app_state.* into v_saved;
|
||||||
|
insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id);
|
||||||
|
return query select * from public.sun_fetch_app_state(p_workspace);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Platform owner APIs. Direct table access remains blocked for normal users.
|
||||||
|
create or replace function public.sun_platform_list_workspaces()
|
||||||
|
returns table(workspace_id uuid,workspace_name text,created_at timestamptz,plan_id text,plan_name text,status text,access_mode text,trial_ends_at timestamptz,current_period_end timestamptz,grace_until timestamptz,member_count integer,max_members integer)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
return query
|
||||||
|
select w.id,w.name,w.created_at,s.plan_id,p.name,s.status,public.sun_subscription_access_mode(w.id),s.trial_ends_at,s.current_period_end,
|
||||||
|
coalesce(s.grace_until,coalesce(s.trial_ends_at,s.current_period_end)+interval '7 days'),
|
||||||
|
(select count(*)::int from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active=true),p.max_members
|
||||||
|
from public.sun_workspaces w
|
||||||
|
left join public.sun_workspace_subscriptions s on s.workspace_id=w.id
|
||||||
|
left join public.sun_plans p on p.id=s.plan_id
|
||||||
|
order by w.created_at desc;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_set_subscription(p_workspace uuid,p_plan text,p_days integer default 30,p_status text default 'active')
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_status text:=lower(coalesce(p_status,'active'));
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if not exists(select 1 from public.sun_plans where id=p_plan and is_active=true) then raise exception 'Unknown plan'; end if;
|
||||||
|
if v_status not in ('trialing','active','past_due','canceled','expired') then raise exception 'Invalid status'; end if;
|
||||||
|
if p_days is null or p_days<0 or p_days>3650 then raise exception 'Invalid duration'; end if;
|
||||||
|
insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source,updated_at)
|
||||||
|
values(p_workspace,p_plan,v_status,now(),case when v_status='expired' then now() else now()+make_interval(days=>p_days) end,
|
||||||
|
case when v_status='expired' then now() else now()+make_interval(days=>p_days+7) end,'manual',now())
|
||||||
|
on conflict(workspace_id) do update set plan_id=excluded.plan_id,status=excluded.status,current_period_start=excluded.current_period_start,current_period_end=excluded.current_period_end,grace_until=excluded.grace_until,source='manual',updated_at=now();
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.sun_platform_set_feature_override(p_workspace uuid,p_feature text,p_enabled boolean,p_days integer default null,p_note text default null)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path='public'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
|
||||||
|
if not exists(select 1 from public.sun_plan_features where feature_key=p_feature) then raise exception 'Unknown feature'; end if;
|
||||||
|
insert into public.sun_workspace_feature_overrides(workspace_id,feature_key,enabled,expires_at,note,updated_at)
|
||||||
|
values(p_workspace,p_feature,p_enabled,case when p_days is null then null else now()+make_interval(days=>p_days) end,p_note,now())
|
||||||
|
on conflict(workspace_id,feature_key) do update set enabled=excluded.enabled,expires_at=excluded.expires_at,note=excluded.note,updated_at=now();
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_is_platform_admin() from public;
|
||||||
|
revoke all on function public.sun_subscription_access_mode(uuid) from public;
|
||||||
|
revoke all on function public.sun_workspace_has_feature(uuid,text) from public;
|
||||||
|
revoke all on function public.sun_subscription_snapshot(uuid) from public;
|
||||||
|
revoke all on function public.sun_platform_list_workspaces() from public;
|
||||||
|
revoke all on function public.sun_platform_set_subscription(uuid,text,integer,text) from public;
|
||||||
|
revoke all on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) from public;
|
||||||
|
|
||||||
|
grant execute on function public.sun_is_platform_admin() to authenticated;
|
||||||
|
grant execute on function public.sun_subscription_access_mode(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_workspace_has_feature(uuid,text) to authenticated;
|
||||||
|
grant execute on function public.sun_subscription_snapshot(uuid) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_list_workspaces() to authenticated;
|
||||||
|
grant execute on function public.sun_platform_set_subscription(uuid,text,integer,text) to authenticated;
|
||||||
|
grant execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) to authenticated;
|
||||||
296
ops/sql/SUPABASE-SETUP.sql
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
-- Sun Catering Cloud v2
|
||||||
|
-- Run this entire file once in Supabase -> SQL Editor.
|
||||||
|
-- Safe to re-run: objects are created with IF NOT EXISTS where possible.
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists public.sun_workspaces (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null check (char_length(trim(name)) between 1 and 120),
|
||||||
|
created_by uuid not null references auth.users(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_workspace_members (
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
role text not null default 'manager' check (role in ('owner','manager','viewer')),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (workspace_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_app_state (
|
||||||
|
workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade,
|
||||||
|
payload jsonb not null default '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,
|
||||||
|
revision bigint not null default 0,
|
||||||
|
client_id text,
|
||||||
|
updated_by uuid references auth.users(id) on delete set null,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.sun_workspace_invites (
|
||||||
|
token uuid primary key default gen_random_uuid(),
|
||||||
|
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
|
||||||
|
role text not null default 'manager' check (role in ('manager','viewer')),
|
||||||
|
created_by uuid not null references auth.users(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
expires_at timestamptz not null default (now() + interval '7 days'),
|
||||||
|
used_by uuid references auth.users(id) on delete set null,
|
||||||
|
used_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists sun_workspace_members_user_idx on public.sun_workspace_members(user_id);
|
||||||
|
create index if not exists sun_workspace_invites_workspace_idx on public.sun_workspace_invites(workspace_id);
|
||||||
|
|
||||||
|
create or replace function public.sun_member_role(p_workspace uuid)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select m.role
|
||||||
|
from public.sun_workspace_members m
|
||||||
|
where m.workspace_id = p_workspace
|
||||||
|
and m.user_id = auth.uid()
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_member_role(uuid) from public;
|
||||||
|
grant execute on function public.sun_member_role(uuid) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_touch_app_state()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.updated_at := now();
|
||||||
|
if tg_op = 'UPDATE' then
|
||||||
|
new.revision := old.revision + 1;
|
||||||
|
end if;
|
||||||
|
new.updated_by := auth.uid();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists sun_app_state_touch on public.sun_app_state;
|
||||||
|
create trigger sun_app_state_touch
|
||||||
|
before insert or update on public.sun_app_state
|
||||||
|
for each row execute function public.sun_touch_app_state();
|
||||||
|
|
||||||
|
revoke all on function public.sun_touch_app_state() from public;
|
||||||
|
revoke all on function public.sun_touch_app_state() from anon;
|
||||||
|
revoke all on function public.sun_touch_app_state() from authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг')
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_workspace uuid;
|
||||||
|
begin
|
||||||
|
if v_user is null then
|
||||||
|
raise exception 'Authentication required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.sun_workspaces(name, created_by)
|
||||||
|
values (coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'), v_user)
|
||||||
|
returning id into v_workspace;
|
||||||
|
|
||||||
|
insert into public.sun_workspace_members(workspace_id, user_id, role)
|
||||||
|
values (v_workspace, v_user, 'owner');
|
||||||
|
|
||||||
|
insert into public.sun_app_state(workspace_id, payload, client_id)
|
||||||
|
values (v_workspace, '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb, 'bootstrap')
|
||||||
|
on conflict (workspace_id) do nothing;
|
||||||
|
|
||||||
|
return v_workspace;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_create_workspace(text) from public;
|
||||||
|
grant execute on function public.sun_create_workspace(text) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager')
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_token uuid;
|
||||||
|
v_role text := lower(coalesce(p_role,'manager'));
|
||||||
|
begin
|
||||||
|
if public.sun_member_role(p_workspace) <> 'owner' then
|
||||||
|
raise exception 'Owner role required';
|
||||||
|
end if;
|
||||||
|
if v_role not in ('manager','viewer') then
|
||||||
|
raise exception 'Invalid role';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.sun_workspace_invites(workspace_id, role, created_by)
|
||||||
|
values (p_workspace, v_role, auth.uid())
|
||||||
|
returning token into v_token;
|
||||||
|
|
||||||
|
return v_token;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_create_invite(uuid,text) from public;
|
||||||
|
grant execute on function public.sun_create_invite(uuid,text) to authenticated;
|
||||||
|
|
||||||
|
create or replace function public.sun_accept_invite(p_token uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_invite public.sun_workspace_invites%rowtype;
|
||||||
|
begin
|
||||||
|
if v_user is null then
|
||||||
|
raise exception 'Authentication required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_invite
|
||||||
|
from public.sun_workspace_invites
|
||||||
|
where token = p_token
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then raise exception 'Invite not found'; end if;
|
||||||
|
if v_invite.used_at is not null then raise exception 'Invite already used'; end if;
|
||||||
|
if v_invite.expires_at < now() then raise exception 'Invite expired'; end if;
|
||||||
|
|
||||||
|
insert into public.sun_workspace_members(workspace_id, user_id, role)
|
||||||
|
values (v_invite.workspace_id, v_user, v_invite.role)
|
||||||
|
on conflict (workspace_id, user_id)
|
||||||
|
do update set role = excluded.role;
|
||||||
|
|
||||||
|
update public.sun_workspace_invites
|
||||||
|
set used_by = v_user, used_at = now()
|
||||||
|
where token = p_token;
|
||||||
|
|
||||||
|
return v_invite.workspace_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.sun_accept_invite(uuid) from public;
|
||||||
|
grant execute on function public.sun_accept_invite(uuid) to authenticated;
|
||||||
|
|
||||||
|
alter table public.sun_workspaces enable row level security;
|
||||||
|
alter table public.sun_workspace_members enable row level security;
|
||||||
|
alter table public.sun_app_state enable row level security;
|
||||||
|
alter table public.sun_workspace_invites enable row level security;
|
||||||
|
|
||||||
|
-- Remove overly broad browser grants, then grant only what authenticated clients need.
|
||||||
|
revoke all on public.sun_workspaces from anon;
|
||||||
|
revoke all on public.sun_workspace_members from anon;
|
||||||
|
revoke all on public.sun_app_state from anon;
|
||||||
|
revoke all on public.sun_workspace_invites from anon;
|
||||||
|
|
||||||
|
grant select on public.sun_workspaces to authenticated;
|
||||||
|
grant select on public.sun_workspace_members to authenticated;
|
||||||
|
grant select, insert, update on public.sun_app_state to authenticated;
|
||||||
|
grant select on public.sun_workspace_invites to authenticated;
|
||||||
|
|
||||||
|
-- Recreate policies idempotently.
|
||||||
|
drop policy if exists sun_workspaces_read on public.sun_workspaces;
|
||||||
|
create policy sun_workspaces_read on public.sun_workspaces
|
||||||
|
for select to authenticated
|
||||||
|
using (public.sun_member_role(id) is not null);
|
||||||
|
|
||||||
|
drop policy if exists sun_members_read on public.sun_workspace_members;
|
||||||
|
create policy sun_members_read on public.sun_workspace_members
|
||||||
|
for select to authenticated
|
||||||
|
using (user_id = auth.uid() or public.sun_member_role(workspace_id) = 'owner');
|
||||||
|
|
||||||
|
drop policy if exists sun_state_read on public.sun_app_state;
|
||||||
|
create policy sun_state_read on public.sun_app_state
|
||||||
|
for select to authenticated
|
||||||
|
using (public.sun_member_role(workspace_id) is not null);
|
||||||
|
|
||||||
|
drop policy if exists sun_state_insert on public.sun_app_state;
|
||||||
|
create policy sun_state_insert on public.sun_app_state
|
||||||
|
for insert to authenticated
|
||||||
|
with check (public.sun_member_role(workspace_id) in ('owner','manager'));
|
||||||
|
|
||||||
|
drop policy if exists sun_state_update on public.sun_app_state;
|
||||||
|
create policy sun_state_update on public.sun_app_state
|
||||||
|
for update to authenticated
|
||||||
|
using (public.sun_member_role(workspace_id) in ('owner','manager'))
|
||||||
|
with check (public.sun_member_role(workspace_id) in ('owner','manager'));
|
||||||
|
|
||||||
|
drop policy if exists sun_invites_read on public.sun_workspace_invites;
|
||||||
|
create policy sun_invites_read on public.sun_workspace_invites
|
||||||
|
for select to authenticated
|
||||||
|
using (public.sun_member_role(workspace_id) = 'owner');
|
||||||
|
|
||||||
|
-- Private media bucket. The first folder is always the workspace UUID.
|
||||||
|
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'sun-media',
|
||||||
|
'sun-media',
|
||||||
|
false,
|
||||||
|
15728640,
|
||||||
|
array['image/jpeg','image/png','image/webp','image/gif']
|
||||||
|
)
|
||||||
|
on conflict (id) do update set
|
||||||
|
public = excluded.public,
|
||||||
|
file_size_limit = excluded.file_size_limit,
|
||||||
|
allowed_mime_types = excluded.allowed_mime_types;
|
||||||
|
|
||||||
|
-- Storage policies.
|
||||||
|
drop policy if exists sun_media_read on storage.objects;
|
||||||
|
create policy sun_media_read on storage.objects
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'sun-media'
|
||||||
|
and public.sun_member_role(((storage.foldername(name))[1])::uuid) is not null
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists sun_media_insert on storage.objects;
|
||||||
|
create policy sun_media_insert on storage.objects
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
bucket_id = 'sun-media'
|
||||||
|
and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager')
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists sun_media_update on storage.objects;
|
||||||
|
create policy sun_media_update on storage.objects
|
||||||
|
for update to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'sun-media'
|
||||||
|
and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager')
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'sun-media'
|
||||||
|
and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager')
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists sun_media_delete on storage.objects;
|
||||||
|
create policy sun_media_delete on storage.objects
|
||||||
|
for delete to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'sun-media'
|
||||||
|
and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Realtime publication for the single workspace-state row.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'sun_app_state'
|
||||||
|
) then
|
||||||
|
alter publication supabase_realtime add table public.sun_app_state;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
2263
package-lock.json
generated
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "caterium-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "17.6.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/pdf-engine.js",
|
||||||
|
"test:static": "node tests/static-security.mjs",
|
||||||
|
"check:release": "node tests/release-check.mjs",
|
||||||
|
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release",
|
||||||
|
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
|
||||||
|
"test": "npm run check:deploy",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.51.0",
|
||||||
|
"http-server": "^14.1.1",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5612
public/app-runtime.js
Normal file
BIN
public/catalog/001.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/catalog/002.jpg
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
public/catalog/003.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/catalog/004.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
public/catalog/005.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
public/catalog/006.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/catalog/007.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/catalog/008.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/catalog/009.jpg
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/catalog/010.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/catalog/011.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/catalog/012.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/catalog/013.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/catalog/014.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/catalog/015.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/catalog/016.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/catalog/017.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/catalog/018.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/catalog/019.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/catalog/020.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
public/catalog/021.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/catalog/022.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/catalog/023.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
public/catalog/024.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/catalog/025.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/catalog/026.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
public/catalog/027.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/catalog/028.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/catalog/029.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/catalog/030.jpg
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/catalog/031.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
public/catalog/032.jpg
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/catalog/033.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/catalog/034.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/catalog/035.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/catalog/036.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/catalog/037.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/catalog/038.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/catalog/039.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
public/catalog/040.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/catalog/041.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/catalog/042.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
public/catalog/043.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
public/catalog/044.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/catalog/045.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
public/catalog/046.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/catalog/047.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/catalog/048.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/catalog/049.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
public/catalog/050.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/catalog/051.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/catalog/052.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
public/catalog/053.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/catalog/054.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/catalog/055.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/catalog/056.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/catalog/057.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |