w-funnel/src/lib/models/Funnel.ts
gofnnp f8305e193a special-offer
add setting to admin
2025-10-11 20:34:04 +04:00

399 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;