Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8e72cd94 | |||
| d20e277535 | |||
| 47e301a5b8 |
@@ -0,0 +1,133 @@
|
|||||||
|
-- ⚠️ СПРАВОЧНЫЙ ФАЙЛ — НЕ запускать `psql -f` на проде!
|
||||||
|
-- CUID здесь синтетические (могут не пройти валидацию схемы Prisma).
|
||||||
|
-- Создавать курсы biz-brain-base/premium + module-* ТОЛЬКО через LMS Admin UI.
|
||||||
|
-- Этот SQL — справка по составу/связям курсов.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- WS6: Создание курсов-носителей «Второй мозг для бизнеса»
|
||||||
|
-- ============================================================
|
||||||
|
-- ВАЖНО: Выполнять НА ПРОДЕ только после проверки на staging.
|
||||||
|
-- Запускать через SSH: psql <db_url> -f create_biz_brain_courses.sql
|
||||||
|
-- Идемпотентно: INSERT IGNORE по slug (уникальный ключ).
|
||||||
|
--
|
||||||
|
-- Курсы-носители (5 штук):
|
||||||
|
-- biz-brain-base — основной курс (ZIP хранилища + видео-онбординг)
|
||||||
|
-- biz-brain-premium — премиум-раздел (консультации, инструкции по расширениям)
|
||||||
|
-- module-caldav — расширение CalDAV-календарь
|
||||||
|
-- module-ai-pdf — расширение AI-разбор PDF
|
||||||
|
-- module-legacy — расширение Legacy-миграция
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Функция для генерации cuid-подобного ID (через gen_random_uuid как fallback)
|
||||||
|
-- В реальности Prisma генерирует cuid автоматически — используем это свойство.
|
||||||
|
-- Если у вас Postgres 14+: gen_random_uuid() дает UUID, но Prisma ожидает cuid.
|
||||||
|
-- Безопаснее создать через Prisma API или LMS-Admin.
|
||||||
|
|
||||||
|
-- Проверяем что slug ещё не существует перед вставкой
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- biz-brain-base
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'biz-brain-base') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clbizbrainbase0000001',
|
||||||
|
'biz-brain-base',
|
||||||
|
'Второй мозг для бизнеса — Базовый',
|
||||||
|
'Готовое хранилище Obsidian для собственника: CRM-воронка, финансовый кокпит, журнал решений, витрина команды. Разовый платёж, бессрочный доступ.',
|
||||||
|
false, -- опубликовать вручную после наполнения контентом
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс biz-brain-base';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс biz-brain-base уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- biz-brain-premium
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'biz-brain-premium') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clbizbrainprem0000002',
|
||||||
|
'biz-brain-premium',
|
||||||
|
'Второй мозг для бизнеса — Премиум (инструкции и консультации)',
|
||||||
|
'Раздел для премиум-покупателей: инструкции по установке расширений, запись на 2 персональные консультации, ответы на частые вопросы.',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
101,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс biz-brain-premium';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс biz-brain-premium уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- module-caldav
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-caldav') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clmodulecaldav00000003',
|
||||||
|
'module-caldav',
|
||||||
|
'Расширение: CalDAV-календарь',
|
||||||
|
'Двусторонняя синхронизация задач и событий Obsidian с Google Calendar / Apple Calendar через CalDAV. Плагин Full Calendar Remastered v0.13.2.',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс module-caldav';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс module-caldav уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- module-ai-pdf
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-ai-pdf') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clmoduleaipdf000000004',
|
||||||
|
'module-ai-pdf',
|
||||||
|
'Расширение: AI-разбор PDF и исследований',
|
||||||
|
'Промпты А1/А2/А3 для структурированного разбора PDF-документов, статей и исследований с помощью AI. Включает эталонный прогон и демо-PDF.',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
201,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс module-ai-pdf';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс module-ai-pdf уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- module-legacy
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-legacy') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clmodulelegacy00000005',
|
||||||
|
'module-legacy',
|
||||||
|
'Расширение: Legacy-миграция из Evernote / Notion',
|
||||||
|
'Пошаговые инструкции и чеклист пост-обработки для переноса данных из Evernote и Notion в хранилище «Второй мозг для бизнеса».',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
202,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс module-legacy';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс module-legacy уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Проверка результата
|
||||||
|
SELECT slug, title, published, "order" FROM "Course"
|
||||||
|
WHERE slug IN ('biz-brain-base', 'biz-brain-premium', 'module-caldav', 'module-ai-pdf', 'module-legacy')
|
||||||
|
ORDER BY "order";
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# WS6: Проверка /api/internal/grant для biz-brain-* курсов
|
||||||
|
# ============================================================
|
||||||
|
# Использование:
|
||||||
|
# LMS_URL=https://school.second-brain.ru \
|
||||||
|
# LMS_SECRET=<secret_из_env> \
|
||||||
|
# TEST_EMAIL=laukhin@dementiy.com \
|
||||||
|
# bash verify_biz_brain_grant.sh
|
||||||
|
#
|
||||||
|
# Проверяет:
|
||||||
|
# 1. Grant base-курса (biz-brain-base)
|
||||||
|
# 2. Grant premium-бандла (все 5 курсов)
|
||||||
|
# 3. Идемпотентность (повтор того же order_id)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LMS_URL="${LMS_URL:-https://school.second-brain.ru}"
|
||||||
|
LMS_SECRET="${LMS_SECRET:?Задай переменную LMS_SECRET}"
|
||||||
|
TEST_EMAIL="${TEST_EMAIL:-laukhin@dementiy.com}"
|
||||||
|
|
||||||
|
echo "=== WS6: Проверка grant-эндпоинта LMS ==="
|
||||||
|
echo " LMS: $LMS_URL"
|
||||||
|
echo " Email: $TEST_EMAIL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Тест 1: Базовый бандл ────────────────────────────────────────────────────
|
||||||
|
echo "[1] Grant biz-brain-base..."
|
||||||
|
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-internal-secret: $LMS_SECRET" \
|
||||||
|
-d '{
|
||||||
|
"email": "'"$TEST_EMAIL"'",
|
||||||
|
"name": "WS6 Test",
|
||||||
|
"course_slugs": ["biz-brain-base"],
|
||||||
|
"order_id": "ws6-test-base-001",
|
||||||
|
"payment_system": "robokassa"
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo " Ответ: $RESPONSE"
|
||||||
|
|
||||||
|
GRANTED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('granted',''))")
|
||||||
|
if [[ "$GRANTED" == *"biz-brain-base"* ]]; then
|
||||||
|
echo " ✅ biz-brain-base выдан"
|
||||||
|
else
|
||||||
|
IDEMPOTENT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('idempotent',''))")
|
||||||
|
if [[ "$IDEMPOTENT" == "True" ]]; then
|
||||||
|
echo " ℹ️ Идемпотентный ответ (уже выдан ранее)"
|
||||||
|
else
|
||||||
|
echo " ❌ ОШИБКА: biz-brain-base не выдан. Ответ: $RESPONSE"
|
||||||
|
echo " Убедись что курс biz-brain-base создан в LMS!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Тест 2: Премиум-бандл ────────────────────────────────────────────────────
|
||||||
|
echo "[2] Grant biz-brain-premium (5 курсов)..."
|
||||||
|
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-internal-secret: $LMS_SECRET" \
|
||||||
|
-d '{
|
||||||
|
"email": "'"$TEST_EMAIL"'",
|
||||||
|
"name": "WS6 Test",
|
||||||
|
"course_slugs": ["biz-brain-base", "biz-brain-premium", "module-caldav", "module-ai-pdf", "module-legacy"],
|
||||||
|
"order_id": "ws6-test-premium-001",
|
||||||
|
"payment_system": "robokassa"
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo " Ответ: $RESPONSE"
|
||||||
|
|
||||||
|
UNRESOLVED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('unresolved',[]))")
|
||||||
|
if [[ "$UNRESOLVED" == "[]" ]]; then
|
||||||
|
echo " ✅ Все 5 курсов разрешены и выданы"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Не разрешены: $UNRESOLVED"
|
||||||
|
echo " Создай недостающие курсы в LMS (slug'и должны точно совпадать)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Тест 3: Идемпотентность ──────────────────────────────────────────────────
|
||||||
|
echo "[3] Повторный grant того же order_id (должен вернуть idempotent=true)..."
|
||||||
|
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-internal-secret: $LMS_SECRET" \
|
||||||
|
-d '{
|
||||||
|
"email": "'"$TEST_EMAIL"'",
|
||||||
|
"name": "WS6 Test",
|
||||||
|
"course_slugs": ["biz-brain-base"],
|
||||||
|
"order_id": "ws6-test-base-001",
|
||||||
|
"payment_system": "robokassa"
|
||||||
|
}')
|
||||||
|
|
||||||
|
IDEMPOTENT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('idempotent',''))" 2>/dev/null || echo "")
|
||||||
|
if [[ "$IDEMPOTENT" == "True" ]]; then
|
||||||
|
echo " ✅ Идемпотентность работает (повторный вызов вернул idempotent=true)"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Ожидался idempotent=true, получено: $RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка завершена ==="
|
||||||
|
echo ""
|
||||||
|
echo "Следующий шаг: опубликовать курсы в admin (published=true) после наполнения контентом."
|
||||||
|
echo " school.second-brain.ru/admin/courses"
|
||||||
@@ -274,6 +274,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
|||||||
checked={bool("notifyOnHomework")}
|
checked={bool("notifyOnHomework")}
|
||||||
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
|
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Уведомлять о новом комментарии под уроком"
|
||||||
|
checked={bool("notifyOnComment")}
|
||||||
|
onChange={(v) => set("notifyOnComment", v ? "true" : "false")}
|
||||||
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Уведомлять о новой регистрации ученика"
|
label="Уведомлять о новой регистрации ученика"
|
||||||
checked={bool("notifyOnRegistration")}
|
checked={bool("notifyOnRegistration")}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sendHomeworkSubmittedEmail } from "@/lib/email";
|
import { sendHomeworkSubmittedEmail, sendCommentNotificationEmail } from "@/lib/email";
|
||||||
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
||||||
|
|
||||||
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
||||||
@@ -156,7 +156,7 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
|
|
||||||
const lesson = await prisma.lesson.findUnique({
|
const lesson = await prisma.lesson.findUnique({
|
||||||
where: { id: lessonId },
|
where: { id: lessonId },
|
||||||
select: { module: { select: { course: { select: { id: true } } } } },
|
select: { title: true, module: { select: { course: { select: { id: true, slug: true } } } } },
|
||||||
});
|
});
|
||||||
if (!lesson) throw new Error("Lesson not found");
|
if (!lesson) throw new Error("Lesson not found");
|
||||||
|
|
||||||
@@ -185,6 +185,26 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
||||||
|
if (!isAdmin && !isCurator) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (asBool(settings.notifyOnComment)) {
|
||||||
|
const configured = parseNotificationEmails(settings.notificationEmails);
|
||||||
|
const recipients = configured.length > 0
|
||||||
|
? configured.map((email) => ({ email, name: "" }))
|
||||||
|
: await prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
});
|
||||||
|
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
|
||||||
|
await Promise.all(
|
||||||
|
recipients.map((r) =>
|
||||||
|
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,31 @@ export async function sendHomeworkSubmittedEmail(
|
|||||||
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
|
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendCommentNotificationEmail(
|
||||||
|
to: string,
|
||||||
|
recipientName: string,
|
||||||
|
studentName: string,
|
||||||
|
lessonTitle: string,
|
||||||
|
commentText: string,
|
||||||
|
lessonUrl: string
|
||||||
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
|
const esc = (t: string) => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
const preview = commentText.length > 400 ? commentText.slice(0, 400) + "…" : commentText;
|
||||||
|
await getResend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: `Новый комментарий под уроком — ${lessonTitle}`,
|
||||||
|
html: base(`
|
||||||
|
<p ${p}>Привет${recipientName ? `, ${esc(recipientName)}` : ""}!</p>
|
||||||
|
<p ${p}><strong>${esc(studentName)}</strong> оставил комментарий под уроком <strong>«${lessonTitle}»</strong>:</p>
|
||||||
|
${quote(esc(preview))}
|
||||||
|
<p ${pLast}>Открыть урок и ответить:</p>
|
||||||
|
${btn(lessonUrl, "Открыть урок")}
|
||||||
|
`, school),
|
||||||
|
}).catch((e) => console.error("[email] sendCommentNotificationEmail:", e));
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendFeedbackReceivedEmail(
|
export async function sendFeedbackReceivedEmail(
|
||||||
to: string,
|
to: string,
|
||||||
studentName: string,
|
studentName: string,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const SETTINGS_DEFAULTS = {
|
|||||||
// Notifications
|
// Notifications
|
||||||
notificationEmails: "", // newline-separated list
|
notificationEmails: "", // newline-separated list
|
||||||
notifyOnHomework: "true",
|
notifyOnHomework: "true",
|
||||||
|
notifyOnComment: "true",
|
||||||
notifyOnRegistration: "true",
|
notifyOnRegistration: "true",
|
||||||
notifyStudentOnFeedback: "true",
|
notifyStudentOnFeedback: "true",
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user