399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
import mongoose, { Schema, Document, Model } from "mongoose";
|
||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||
|
||
// Extend FunnelDefinition with MongoDB specific fields
|
||
export interface IFunnel extends Document {
|
||
// Основные данные воронки
|
||
funnelData: FunnelDefinition;
|
||
|
||
// Метаданные для админки
|
||
name: string; // Человеко-читаемое имя для каталога
|
||
description?: string;
|
||
status: "draft" | "published" | "archived";
|
||
|
||
// Система версий и истории
|
||
version: number;
|
||
parentFunnelId?: string; // Для создания копий
|
||
|
||
// Timestamps
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
publishedAt?: Date;
|
||
|
||
// Пользовательские данные
|
||
createdBy?: string; // User ID in future
|
||
lastModifiedBy?: string;
|
||
|
||
// Статистика использования
|
||
usage: {
|
||
totalViews: number;
|
||
totalCompletions: number;
|
||
lastUsed?: Date;
|
||
};
|
||
}
|
||
|
||
// Вложенные схемы для валидации структуры данных воронки
|
||
const TypographyVariantSchema = new Schema(
|
||
{
|
||
text: {
|
||
type: String,
|
||
// НЕ required — позволяет { show: false } без текста, но если указан — не пустой
|
||
validate: {
|
||
validator: function (v: string | undefined): boolean {
|
||
if (v === undefined || v === null) return true;
|
||
return v.trim().length > 0;
|
||
},
|
||
message: "Text field cannot be empty if provided",
|
||
},
|
||
},
|
||
show: { type: Boolean, default: true }, // поддержка флага видимости
|
||
font: {
|
||
type: String,
|
||
enum: ["manrope", "inter", "geistSans", "geistMono"],
|
||
default: "manrope",
|
||
},
|
||
weight: {
|
||
type: String,
|
||
enum: ["regular", "medium", "semiBold", "bold", "extraBold", "black"],
|
||
default: "regular",
|
||
},
|
||
size: {
|
||
type: String,
|
||
enum: ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"],
|
||
default: "md",
|
||
},
|
||
align: {
|
||
type: String,
|
||
enum: ["center", "left", "right"],
|
||
default: "center",
|
||
},
|
||
color: {
|
||
type: String,
|
||
enum: [
|
||
"default",
|
||
"primary",
|
||
"secondary",
|
||
"destructive",
|
||
"success",
|
||
"card",
|
||
"accent",
|
||
"muted",
|
||
],
|
||
default: "default",
|
||
},
|
||
className: String,
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const HeaderDefinitionSchema = new Schema(
|
||
{
|
||
progress: {
|
||
current: Number,
|
||
total: Number,
|
||
value: Number,
|
||
label: String,
|
||
className: String,
|
||
},
|
||
showBackButton: { type: Boolean, default: true },
|
||
show: { type: Boolean, default: true },
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const ListOptionDefinitionSchema = new Schema(
|
||
{
|
||
id: { type: String, required: true },
|
||
label: { type: String, required: true },
|
||
description: String,
|
||
emoji: String,
|
||
value: String,
|
||
disabled: { type: Boolean, default: false },
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const NavigationConditionSchema = new Schema(
|
||
{
|
||
screenId: { type: String, required: true },
|
||
conditionType: {
|
||
type: String,
|
||
enum: ["options", "values"],
|
||
default: "options",
|
||
},
|
||
operator: {
|
||
type: String,
|
||
enum: ["includesAny", "includesAll", "includesExactly", "equals"],
|
||
default: "includesAny",
|
||
},
|
||
optionIds: [{ type: String }],
|
||
values: [{ type: String }],
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const NavigationRuleSchema = new Schema(
|
||
{
|
||
conditions: [NavigationConditionSchema],
|
||
nextScreenId: { type: String, required: true },
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const NavigationDefinitionSchema = new Schema(
|
||
{
|
||
rules: [NavigationRuleSchema],
|
||
defaultNextScreenId: String,
|
||
isEndScreen: { type: Boolean, default: false },
|
||
onBackScreenId: String,
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const BottomActionButtonSchema = new Schema(
|
||
{
|
||
show: { type: Boolean, default: true },
|
||
text: String,
|
||
cornerRadius: {
|
||
type: String,
|
||
enum: ["3xl", "full"],
|
||
default: "3xl",
|
||
},
|
||
showPrivacyTermsConsent: { type: Boolean, default: false },
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
// Схемы для различных типов экранов (используем Mixed для гибкости)
|
||
const ScreenDefinitionSchema = new Schema(
|
||
{
|
||
id: { type: String, required: true },
|
||
template: {
|
||
type: String,
|
||
enum: [
|
||
"info",
|
||
"date",
|
||
"coupon",
|
||
"form",
|
||
"list",
|
||
"email",
|
||
"loaders",
|
||
"soulmate",
|
||
"trialPayment",
|
||
"specialOffer",
|
||
],
|
||
required: true,
|
||
},
|
||
header: HeaderDefinitionSchema,
|
||
title: { type: TypographyVariantSchema, required: true },
|
||
subtitle: TypographyVariantSchema,
|
||
bottomActionButton: BottomActionButtonSchema,
|
||
navigation: NavigationDefinitionSchema,
|
||
|
||
// Специфичные для template поля (используем Mixed для максимальной гибкости)
|
||
description: TypographyVariantSchema, // info, soulmate
|
||
icon: Schema.Types.Mixed, // info
|
||
variables: [Schema.Types.Mixed], // info - динамические переменные для подстановки в текст
|
||
dateInput: Schema.Types.Mixed, // date
|
||
infoMessage: Schema.Types.Mixed, // date
|
||
coupon: Schema.Types.Mixed, // coupon
|
||
copiedMessage: String, // coupon
|
||
fields: [Schema.Types.Mixed], // form
|
||
validationMessages: Schema.Types.Mixed, // form
|
||
list: {
|
||
// list
|
||
selectionType: {
|
||
type: String,
|
||
enum: ["single", "multi"],
|
||
},
|
||
options: [ListOptionDefinitionSchema],
|
||
},
|
||
emailInput: Schema.Types.Mixed, // email
|
||
image: Schema.Types.Mixed, // email, soulmate
|
||
// loaders
|
||
progressbars: Schema.Types.Mixed, // preferred key used by runtime/templates
|
||
variants: [Schema.Types.Mixed], // variants для всех типов
|
||
},
|
||
{ _id: false, strict: false }
|
||
);
|
||
|
||
const FunnelMetaSchema = new Schema(
|
||
{
|
||
id: { type: String, required: true },
|
||
version: String,
|
||
title: String,
|
||
description: String,
|
||
firstScreenId: String,
|
||
googleAnalyticsId: String,
|
||
yandexMetrikaId: String,
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const DefaultTextsSchema = new Schema(
|
||
{
|
||
nextButton: { type: String, default: "Next" },
|
||
privacyBanner: { type: String },
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const FunnelDataSchema = new Schema(
|
||
{
|
||
meta: { type: FunnelMetaSchema, required: true },
|
||
defaultTexts: DefaultTextsSchema,
|
||
screens: [ScreenDefinitionSchema],
|
||
},
|
||
{ _id: false }
|
||
);
|
||
|
||
const FunnelSchema = new Schema<IFunnel>(
|
||
{
|
||
// Основные данные воронки
|
||
funnelData: {
|
||
type: FunnelDataSchema,
|
||
required: true,
|
||
validate: {
|
||
validator: function (v: FunnelDefinition): boolean {
|
||
// Базовая валидация структуры
|
||
return Boolean(v?.meta && v.meta.id && Array.isArray(v.screens));
|
||
},
|
||
message: "Invalid funnel data structure",
|
||
},
|
||
},
|
||
|
||
// Метаданные для админки
|
||
name: {
|
||
type: String,
|
||
required: true,
|
||
trim: true,
|
||
maxlength: 200,
|
||
},
|
||
description: {
|
||
type: String,
|
||
trim: true,
|
||
maxlength: 1000,
|
||
},
|
||
status: {
|
||
type: String,
|
||
enum: ["draft", "published", "archived"],
|
||
default: "draft",
|
||
required: true,
|
||
},
|
||
|
||
// Система версий
|
||
version: {
|
||
type: Number,
|
||
default: 1,
|
||
min: 1,
|
||
},
|
||
parentFunnelId: {
|
||
type: Schema.Types.ObjectId,
|
||
ref: "Funnel",
|
||
},
|
||
|
||
// Пользовательские данные
|
||
createdBy: String, // В будущем можно заменить на ObjectId ref на User
|
||
lastModifiedBy: String,
|
||
|
||
// Статистика
|
||
usage: {
|
||
totalViews: { type: Number, default: 0, min: 0 },
|
||
totalCompletions: { type: Number, default: 0, min: 0 },
|
||
lastUsed: Date,
|
||
},
|
||
|
||
// Timestamps
|
||
publishedAt: Date,
|
||
},
|
||
{
|
||
timestamps: true, // Автоматически добавляет createdAt и updatedAt
|
||
collection: "funnels",
|
||
}
|
||
);
|
||
|
||
// Индексы для производительности
|
||
FunnelSchema.index({ "funnelData.meta.id": 1 }); // Для поиска по ID воронки
|
||
FunnelSchema.index({ status: 1, updatedAt: -1 }); // Для каталога воронок
|
||
FunnelSchema.index({ name: "text", description: "text" }); // Для поиска по тексту
|
||
FunnelSchema.index({ createdBy: 1 }); // Для фильтра по автору
|
||
FunnelSchema.index({ "usage.lastUsed": -1 }); // Для сортировки по использованию
|
||
|
||
// Методы модели
|
||
FunnelSchema.methods.toPublicJSON = function (this: IFunnel) {
|
||
return {
|
||
_id: this._id,
|
||
name: this.name,
|
||
description: this.description,
|
||
status: this.status,
|
||
version: this.version,
|
||
createdAt: this.createdAt,
|
||
updatedAt: this.updatedAt,
|
||
publishedAt: this.publishedAt,
|
||
usage: this.usage,
|
||
funnelData: this.funnelData,
|
||
};
|
||
};
|
||
|
||
FunnelSchema.methods.incrementUsage = function (
|
||
this: IFunnel,
|
||
type: "view" | "completion"
|
||
) {
|
||
if (type === "view") {
|
||
this.usage.totalViews += 1;
|
||
} else if (type === "completion") {
|
||
this.usage.totalCompletions += 1;
|
||
}
|
||
this.usage.lastUsed = new Date();
|
||
return this.save();
|
||
};
|
||
|
||
// Статические методы
|
||
FunnelSchema.statics.findPublished = function () {
|
||
return this.find({ status: "published" }).sort({ publishedAt: -1 });
|
||
};
|
||
|
||
FunnelSchema.statics.findByFunnelId = function (funnelId: string) {
|
||
return this.findOne({ "funnelData.meta.id": funnelId });
|
||
};
|
||
|
||
// Pre-save хуки
|
||
FunnelSchema.pre("save", function (next) {
|
||
// Автоматически устанавливаем publishedAt при первой публикации
|
||
if (this.status === "published" && !this.publishedAt) {
|
||
this.publishedAt = new Date();
|
||
}
|
||
|
||
// Валидация: firstScreenId должен существовать в screens
|
||
if (this.funnelData.meta.firstScreenId) {
|
||
const firstScreenExists = this.funnelData.screens.some(
|
||
(screen) => screen.id === this.funnelData.meta.firstScreenId
|
||
);
|
||
if (!firstScreenExists) {
|
||
return next(new Error("firstScreenId must reference an existing screen"));
|
||
}
|
||
}
|
||
|
||
next();
|
||
});
|
||
|
||
// Экспорт модели с проверкой на существование
|
||
// В dev окружении пересоздаём модель, чтобы подтянуть изменения схемы (enums и т.п.)
|
||
if (
|
||
process.env.NODE_ENV !== "production" &&
|
||
typeof mongoose.models.Funnel !== "undefined"
|
||
) {
|
||
try {
|
||
(
|
||
mongoose as unknown as { deleteModel: (name: string) => void }
|
||
).deleteModel("Funnel");
|
||
} catch {
|
||
// no-op
|
||
}
|
||
}
|
||
|
||
const FunnelModel: Model<IFunnel> =
|
||
mongoose.models.Funnel || mongoose.model<IFunnel>("Funnel", FunnelSchema);
|
||
|
||
export default FunnelModel;
|