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 }, }, { _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", ], 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, }, { _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( { // Основные данные воронки 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 = mongoose.models.Funnel || mongoose.model("Funnel", FunnelSchema); export default FunnelModel;