06
Модуль 3 · Занятие 3
На место тестового приёмника встаёт функция, которая проверяет ключ, валидирует данные и пишет заявку в таблицу
Выбор платформы
| Supabase | Firebase | |
|---|---|---|
| База данных | PostgreSQL: таблицы, SQL, связи | Firestore: документы, NoSQL |
| Серверный код | Edge Functions (TypeScript / Deno) | Cloud Functions (Node.js) |
| Права доступа | Row Level Security — политики на SQL | Security Rules |
| Удобство для курса | ER-модель из урока 2 ложится один в один | Связи придётся переводить в документы |
В курсе берём Supabase: таблицы и связи, которые вы нарисовали на уроке 2, создаются в нём напрямую, без перепроектирования.
Ключи доступа
| Ключ | Что даёт | Где может лежать |
|---|---|---|
| anon (publishable) | Публичный доступ, ограниченный политиками RLS | Во frontend допустим — но только при строгом RLS |
| service_role (secret) | Полный доступ, игнорирует RLS | Только на сервере, в секретах функции |
| TILDA_WEBHOOK_SECRET | Наш собственный ключ: подтверждает, что запрос пришёл от нас | В секретах функции и в настройках webhook в Tilda |
Архитектура
Форма и страница. Отправляет POST с данными и ключом
→
Проверяет ключ, валидирует, обращается к базе с service_role
→
Таблицы с RLS: снаружи закрыто, пишет только функция
Показать форму, проверить заполнение, подставить source. Всё это видно посетителю, и это нормально.
Проверить ключ, провалидировать данные, найти услугу, создать клиента и заявку, отсечь дубль, записать журнал.
Таблицы и права
Ниже — весь файл схемы. Скопируйте его отсюда, откройте в своём проекте Supabase SQL Editor → New query, вставьте и нажмите Run. Ответ Success. No rows returned значит, что четыре таблицы созданы.
-- Учебный кейс «Запись на услугу»: схема данных MVP.
-- ─── Справочник услуг ───────────────────────────────────────────────
create table public.services (
id bigint generated always as identity primary key,
code text not null unique, -- машинное имя: 'consult'
title text not null, -- как в выпадающем списке Tilda
price numeric(10, 2),
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- ─── Клиенты (пользователи, оставившие заявку) ─────────────────────
create table public.clients (
id bigint generated always as identity primary key,
email text not null unique,
name text not null,
phone text,
created_at timestamptz not null default now()
);
-- ─── Статусы заявки ────────────────────────────────────────────────
create type public.request_status as enum ('new', 'confirmed', 'done', 'cancelled');
-- ─── Заявки ────────────────────────────────────────────────────────
create table public.requests (
id bigint generated always as identity primary key,
client_id bigint not null references public.clients (id),
service_id bigint references public.services (id), -- null, если услуга не распознана
preferred_date date,
comment text,
status public.request_status not null default 'new',
source text, -- скрытое поле / UTM
tilda_tranid text unique, -- защита от дублей
created_at timestamptz not null default now()
);
create index requests_client_id_idx on public.requests (client_id);
create index requests_status_idx on public.requests (status);
-- ─── Журнал входящих webhook-запросов ──────────────────────────────
create table public.webhook_log (
id bigint generated always as identity primary key,
received_at timestamptz not null default now(),
result text not null check (result in ('ok', 'duplicate', 'invalid', 'unauthorized', 'error', 'test')),
http_status int not null,
message text,
payload jsonb, -- тело запроса без секретного ключа
request_id bigint references public.requests (id)
);
-- ─── Безопасность ──────────────────────────────────────────────────
-- RLS включён и политик для anon/authenticated нет. Значит, через публичный
-- anon-ключ ни прочитать, ни записать ничего нельзя. Пишет только Edge
-- Function с service_role-ключом, а он хранится в секретах Supabase,
-- а не на странице Tilda.
alter table public.services enable row level security;
alter table public.clients enable row level security;
alter table public.requests enable row level security;
alter table public.webhook_log enable row level security;
-- Справочник услуг можно показывать публично (например, в каталоге).
create policy "services are public" on public.services
for select to anon, authenticated using (is_active);
-- ─── Стартовые данные ──────────────────────────────────────────────
insert into public.services (code, title, price) values
('consult', 'Консультация', 2000),
('audit', 'Аудит сайта', 7500),
('setup', 'Настройка под ключ', 15000);В документации такой файл называют миграцией: это просто SQL с изменением схемы, который лежит в репозитории проекта, чтобы базу можно было собрать заново одной командой. Никакого отдельного инструмента для него не нужно — достаточно SQL Editor.
Демонстрация. Прочитать requests через anon-ключ из консоли браузера. В ответ придёт пустой массив — RLS работает.
Своя схема
Наша схема — пример учебного кейса. Для своего MVP порядок такой же, и начинается он не с SQL, а с диаграммы из урока 2: прямоугольник становится таблицей, строчка внутри — полем, стрелка — внешним ключом.
01
Выписать сущности. Существительные из сценария, у которых есть свои свойства и которые живут дольше одного действия
02
На каждую — болванка таблицы с двумя служебными полями: id и created_at
03
Добавить поля. Текст → text, цена → numeric, дата → date, да/нет → boolean, фиксированный список → enum
04
Связи. Внешний ключ ставится на стороне «много»: у заявки есть клиент, значит client_id лежит в заявках
05
Уникальность. Что не должно повторяться — email, номер заказа — помечаем unique. Это защита от дублей на уровне базы, а не проверка в коде
06
Права. enable row level security на каждую таблицу, политика — только там, где нужно публичное чтение
Заготовка на одну таблицу: скопируйте, переименуйте, оставьте нужные поля.
create table public.zayavki ( id bigint generated always as identity primary key, -- служебный номер строки created_at timestamptz not null default now(), -- когда появилась name text not null, -- текст, заполнять обязательно email text not null unique, -- обязательно и без повторов price numeric(10, 2), -- число с копейками, можно не заполнять visit_date date, -- дата без времени is_paid boolean not null default false, -- да/нет со значением по умолчанию client_id bigint not null references public.clients (id) -- связь: у заявки один клиент ); alter table public.zayavki enable row level security; -- закрыть от публичного ключа
Если схему пишет ИИ — а это нормальный способ, — прочитайте результат по этим шести пунктам. Чаще всего забывают последний: таблицы создаются без row level security, и данные оказываются открыты всем, у кого есть публичный ключ.
Прослойка
01
Не POST → 405
02
Разобрать тело: urlencoded или JSON
03
test=test → 200 ok: это Tilda подключает webhook
04
Проверить ключ → иначе 401
05
Валидация и нормализация → иначе 400 со списком ошибок
06
upsert клиента по email: тот же человек — та же строка
07
insert заявки. Код ошибки 23505 — нарушен unique на tranid, значит дубль → 200 ok
08
Записать результат в webhook_log и ответить
Ключ проверяется до валидации данных: посторонний запрос не должен доходить до разбора полей.
Деплой
00
supabase.com → Start your project: аккаунт по почте и паролю, письмо Confirm your signup. Организация создаётся сама. Затем New project: имя, Generate a password для базы (сохраните себе), Region — Europe. Три галочки Security оставляем как есть. Проект поднимается пару минут
01
Edge Functions → Secrets: TILDA_WEBHOOK_SECRET = длинная случайная строка (генерация — ниже). Сохраните её себе — дашборд потом покажет только дайджест
02
Deploy a new function → Via Editor, имя tilda-webhook: из имени собирается URL
03
Вставить index.ts, вторым файлом logic.ts → Deploy function
04
Settings функции → выключить Verify JWT
05
Скопировать URL: https://<ref>.supabase.co/functions/v1/tilda-webhook
Где взять строку. В консоли браузера (F12 → Console) на любой странице: [...crypto.getRandomValues(new Uint8Array(24))].map(b => b.toString(16).padStart(2, '0')).join('') — получится 48 символов. Альтернатива — менеджер паролей, 32+ символа латиницы и цифр. Онлайн-генераторы «random string» не используйте: секрет уйдёт на чужой сервер.
Шаг 4 обязателен. С включённой проверкой Supabase ждёт свой токен и отбивает запрос до вашего кода: Tilda получит 401, а в webhook_log не появится ни строки. Доступ закрывает не JWT, а ключ из шага 1. Терминал, CLI и Docker не нужны.
Подключение
01
В Tilda заменить URL тестового приёмника на URL функции и указать ключ
02
Отправить заявку с опубликованной страницы
03
Table Editor → requests: новая строка со статусом new, заполнены client_id и service_id
04
Table Editor → clients: клиент с нормализованным телефоном +7…
05
Отправить форму ещё раз с тем же email: клиент остался один, заявок стало две
Шаг 5 — проверка upsert. Если клиентов стало двое, ищите расхождение в email: пробел по краям или заглавные буквы.
Журнал
select received_at, result, http_status, message from webhook_log order by received_at desc limit 20;
webhook_log отвечает на вопрос «куда делась заявка», не открывая код. Смоделируйте сбои скриптом send-test.sh: без ключа, с ошибкой в полях, дубль — и найдите каждый в журнале. Технические детали смотрите в Edge Functions → Logs.
Фиксируем
| # | Условие | Ответ | Что в базе |
|---|---|---|---|
| 1 | Проверка от Tilda test=test | 200 | лог test |
| 2 | Корректная заявка | 200 | клиент + заявка + лог ok |
| 3 | Повтор того же tranid | 200 | лог duplicate, новой заявки нет |
| 4 | Нет ключа или ключ неверный | 401 | лог unauthorized |
| 5 | Ошибки в полях | 400 | лог invalid + текст ошибок |
| 6 | Сбой базы | 500 | лог error |
Заполнять эту таблицу нужно не по памяти: пять строк из шести воспроизводятся за минуту скриптом send-test.sh.
Чего нельзя делать
| Ошибка | Последствие |
|---|---|
| service_role-ключ в T123 «для быстроты» | Любой посетитель может прочитать и удалить всю базу |
| RLS выключен | Anon-ключ даёт чтение персональных данных всех клиентов |
| Нет уникальности tranid | Дубли заявок при повторной доставке |
| Ключ записан в журнал | Утечка секрета через логи — в проекте ключ маскируется функцией redact() |
| Не выключен Verify JWT | Все запросы от Tilda получают 401, журнал пуст |
Мини-артефакт урока 6
Задание: assignments/module-3.md, задание 3.2 · Итог курса: форма → webhook → прослойка → БД работает на вашем проекте