Caterium v17.6.0 - GitHub Cloudflare autodeploy

This commit is contained in:
pavlov346346-source 2026-09-07 15:29:20 +03:00
commit 2cd5c3404f
245 changed files with 22706 additions and 0 deletions

16
.github/workflows/qa.yml vendored Normal file
View 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
View File

@ -0,0 +1,12 @@
node_modules/
.playwright/
playwright-report/
test-results/
.wrangler/
.dev.vars
.env
.env.*
!.env.example
*.log
.DS_Store
Thumbs.db

View 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
View 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
View 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/`.

View 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

View 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
View 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.
- Финальный блок «Как это выглядит на вашем столе» заполняет свободное место 13 фотографиями.
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 шаблонах предложения убрано ограничение на первые 23 составляющие бокса.
- Теперь выводится полный состав бокса: все строки/виды, сохранённые в 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 г · 1525 гостей · Всего: 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. Позиции 6970 (Морс, Компот) находятся во вкладке «Напитки».
4. Позиции 85113 находятся во вкладке «Премиум». Вкладка «Премиум» расположена между «Боксы» и «Напитки».
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.jpg060.jpg упакованы побайтово в legacy-images.bin без перекодирования и без потери JPEG-качества; catalog/001.jpg003.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.jpgcurrent-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
View 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.jpg060.jpg находится там полностью, а актуальный каталог использует current-001.jpgcurrent-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.jpgcurrent-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; высота строк/карточек рассчитывается динамически.

View 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
View 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
View 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
}

View 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 использует ту же цепочку источников изображений, что и каталог.
- Финальный блок «Как это выглядит на вашем столе» использует 13 фотографии в зависимости от свободного места на последней странице.
- Если места достаточно, фотографии заполняют остаток страницы; отдельная пустая фотостраница больше не создаётся без необходимости.
- В экранном предложении показываются максимум 3 финальных фотографии.
Облачная архитектура, заказы и схема данных не менялись.

View 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.

View File

@ -0,0 +1,11 @@
Sun Catering v17.5.16
- Все 4 шаблона предложения клиенту: предпросмотр теперь показывает те же Canvas-страницы, из которых формируется PDF.
- Убрана отдельная iframe/HTML-версия предпросмотра, из-за которой раньше могли расходиться переносы, размеры и фото.
- В окне «Предложение клиенту» добавлена кнопка «Редактировать» и локальный редактор прямо перед скачиванием PDF.
- Можно менять название мероприятия, клиента, дату, гостей, заголовки и продающие тексты конкретного предложения.
- Изменения сохраняются в snapshot конкретного заказа и используются и в просмотре, и в PDF.
- «Как это выглядит на вашем столе»: максимум 2 фотографии.
- Финальные фотографии никогда не создают новую страницу. Если свободного места недостаточно, блок полностью пропускается.
- Если свободного места немного — 1 фото; если достаточно — 2 фото.
- PDF при скачивании собирается из уже показанных страниц, без повторной независимой верстки.

View 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.

View 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.

View 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.

View File

@ -0,0 +1,8 @@
Sun Catering v17.5.20
- В Настройках профиль/вход/создание аккаунта/выход перенесены в самый верх.
- Блок обычного резервного копирования удалён из Настроек.
- Диагностический блок «Стабильность и резервирование» скрыт из обычных Настроек.
- Фоновые защитные механизмы данных оставлены без изменений.
- Техническое подключение Supabase перенесено вниз блока профиля.
- На основном экране входа добавлена кнопка «Создать аккаунт», чтобы регистрация была доступна сразу после выхода.

View File

@ -0,0 +1,17 @@
Sun Catering v17.5.21
Настройки разделены на 6 смысловых вкладок:
- Аккаунт — профиль и компания, тариф, пользователи и роли, история изменений.
- Оформление — цвета интерфейса и боковой панели, готовые цветовые варианты и дополнительные цвета.
- Предложение — шаблон предложения клиенту и настройки содержимого предложения.
- Заказы — цвета оплаты и статусы заказов.
- Справочники — типы мероприятий, источники клиентов и управление вкладками каталога.
- Документы — реквизиты товарного чека, QR-код и настройки бланка заказа.
Дополнительно:
- вкладки горизонтальные и прокручиваются на мобильных устройствах;
- последняя открытая вкладка запоминается;
- существующие обработчики настроек, localStorage и Supabase не переписаны;
- добавлена карточка быстрого перехода к настройке вкладок каталога;
- резервные копии и блок стабильности по-прежнему скрыты из обычных настроек;
- обновлён PWA-кэш.

View 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.

View 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.

View 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.

View File

@ -0,0 +1,12 @@
v17.5.27 · Registration & Users
- Полностью упрощена регистрация Caterium.
- Добавлено подтверждение пароля и кнопки показа/скрытия пароля.
- Убрана рекламная строка «14 дней бесплатно · Полный тариф» с формы регистрации.
- Подтверждение email не блокирует регистрацию; после signup приложение автоматически получает сессию и создаёт компанию.
- Повторная регистрация существующего email с верным паролем продолжает существующий аккаунт.
- «Пользователи и права»: добавлена кнопка «+ Добавить пользователя».
- Приглашение теперь привязано к email, имени и роли.
- Добавлены персональные ссылки приглашений, список ожидающих приглашений и отмена.
- Сотрудник по ссылке входит в существующий аккаунт или создаёт пароль и автоматически присоединяется к компании.
- Исправлен переход между tenant-рабочими копиями при принятии приглашения без промежуточной перезагрузки.

View File

@ -0,0 +1,9 @@
CATERIUM v17.5.28
- Прямое создание сотрудников без ссылок-приглашений.
- Новый сотрудник получает временный пароль.
- При первом входе сотрудник обязан задать новый пароль.
- Существующий аккаунт Caterium просто добавляется в компанию.
- Исправлена проверка подписки при добавлении нового участника: проверка больше не требует предварительного membership.
- Старый блок «Ожидают приглашения» убран из интерфейса.
- Защищённая Edge Function caterium-create-employee хранит service role только на сервере.

View 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.

View 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.

View 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. Архитектурно следующими контекстами для чатов считаются Производство, Маршруты/доставка, Закупки и внутренние заметки по клиенту; в этой версии они не включены.

View 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.

View 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
View 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(/&nbsp;|&#160;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, '<').replace(/&gt;/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);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

BIN
public/catalog/001.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
public/catalog/002.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
public/catalog/003.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
public/catalog/004.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
public/catalog/005.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
public/catalog/006.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
public/catalog/007.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
public/catalog/008.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public/catalog/009.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
public/catalog/010.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
public/catalog/011.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
public/catalog/012.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
public/catalog/013.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
public/catalog/014.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
public/catalog/015.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
public/catalog/016.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
public/catalog/017.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/catalog/018.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
public/catalog/019.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
public/catalog/020.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
public/catalog/021.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
public/catalog/022.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
public/catalog/023.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
public/catalog/024.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
public/catalog/025.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
public/catalog/026.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
public/catalog/027.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
public/catalog/028.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
public/catalog/029.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
public/catalog/030.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
public/catalog/031.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
public/catalog/032.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
public/catalog/033.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
public/catalog/034.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
public/catalog/035.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
public/catalog/036.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
public/catalog/037.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
public/catalog/038.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
public/catalog/039.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
public/catalog/040.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
public/catalog/041.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
public/catalog/042.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
public/catalog/043.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
public/catalog/044.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
public/catalog/045.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
public/catalog/046.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
public/catalog/047.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
public/catalog/048.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
public/catalog/049.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
public/catalog/050.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
public/catalog/051.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
public/catalog/052.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
public/catalog/053.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
public/catalog/054.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
public/catalog/055.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
public/catalog/056.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
public/catalog/057.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Some files were not shown because too many files have changed in this diff Show More