import mongoose, { Schema, models, Document, Model } from 'mongoose' import { z } from 'zod' import readingTime from 'reading-time' // Define tag schema const tagSchema = z.object({ id: z.string().optional(), name: z.string(), }) // Zod schema for topic validation and transformation export const topicSchema = z.object({ id: z.string(), publishedAt: z.number(), title: z.string(), excerpt: z.string(), coverImage: z.string(), coverImageKey: z.string().optional(), author: z.string(), authorId: z.string(), // NEW: User ownership field slug: z.string(), content: z.string(), contentRTE: z.unknown(), contentImages: z.array(z.string()).optional(), tags: z.array(tagSchema), featured: z.boolean(), isDraft: z.boolean().optional(), views: z.number().optional().default(0), // NEW: View tracking viewHistory: z.array(z.object({ date: z.string(), count: z.number() })).optional().default([]), // NEW: Daily analytics readingTime: z.object({ text: z.string(), minutes: z.number(), time: z.number(), words: z.number(), }).optional(), }) // Type inference from Zod schema export type ITopic = z.infer // Type for Mongoose document with ITopic type TopicDocument = Document & ITopic // Type for Mongoose model type TopicModel = Model // Generic utility function to transform and validate Mongoose document to any type export const transformToType = ( doc: Document | Record | null, schema: z.ZodType ): T | null => { if (!doc) return null // Handle both Mongoose documents and plain objects const data = 'toObject' in doc && typeof doc.toObject === 'function' ? doc.toObject() : doc return schema.parse(data) } // Utility function to transform and validate Mongoose document to ITopic export const transformToTopic = (doc: Document | Record | null): ITopic | null => { return transformToType(doc, topicSchema) } // Utility function to transform and validate multiple Mongoose documents to ITopic array export const transformToTopics = (docs: (TopicDocument | Record)[]): ITopic[] => { return docs .map((doc) => transformToType(doc, topicSchema)) .filter((topic): topic is ITopic => topic !== null) } const TopicSchema = new Schema( { id: { type: String, required: true, unique: true, }, publishedAt: { type: Number, required: true, }, coverImage: { type: String, required: [true, 'Please provide a cover image for this topic'], }, coverImageKey: { type: String, required: false, }, title: { type: String, required: [true, 'Please provide a title for this topic'], maxlength: [100, 'Title cannot be more than 100 characters'], }, author: { type: String, required: [true, 'Please provide an author for this topic'], }, authorId: { type: String, required: [true, 'Please provide an author ID for this topic'], index: true, // NEW: Index for efficient queries by user }, featured: { type: Boolean, default: false, }, isDraft: { type: Boolean, default: false, }, excerpt: { type: String, required: [true, 'Please provide a mini description for this topic'], maxlength: [200, 'Mini description cannot be more than 200 characters'], }, tags: { type: [ { id: { type: String }, name: { type: String, required: true }, }, ], required: [true, 'Please provide tags for this topic'], }, slug: { type: String, unique: true, sparse: true, // This allows the field to be unique only if it exists }, content: { type: String, required: [true, 'Please provide content for this topic'], }, contentRTE: { type: Schema.Types.Mixed, default: [], }, contentImages: { type: [String], default: [], }, readingTime: { text: { type: String }, minutes: { type: Number }, time: { type: Number }, words: { type: Number }, }, views: { type: Number, default: 0, index: true, // Index for efficient sorting by popularity }, viewHistory: [{ date: { type: String, required: true }, // YYYY-MM-DD format count: { type: Number, required: true, default: 1 }, _id: false, // Disable automatic _id generation for subdocuments }], }, { timestamps: true, minimize: false, } ) // Auto-generate slug from title if not provided TopicSchema.pre('save', async function (next) { if (!this.slug && this.title) { // Create base slug from title const baseSlug = this.title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') // Replace any sequence of non-alphanumeric chars with a single dash .replace(/^-|-$/g, '') // Remove leading and trailing dashes // First try using the base slug let slug = baseSlug // Check if an article with this slug already exists const TopicModel = this.constructor as TopicModel const existingTopic = await TopicModel.findOne({ slug: slug }) // If the slug already exists, append a timestamp to make it unique if (existingTopic) { // Generate a timestamp suffix (last 6 digits for brevity) const timestamp = Date.now().toString().slice(-6) slug = `${baseSlug}-${timestamp}` } this.slug = slug } // Auto-calculate reading time from content if (this.content && this.isModified('content')) { const stats = readingTime(this.content) this.readingTime = { text: stats.text, minutes: Math.ceil(stats.minutes), time: stats.time, words: stats.words, } } next() }) // Index for efficient queries by user ownership TopicSchema.index({ authorId: 1, publishedAt: -1 }) TopicSchema.index({ publishedAt: -1 }) // For public topic listing TopicSchema.index({ slug: 1 }) // For individual topic lookup TopicSchema.index({ 'tags.name': 1 }) // For tag-based filtering TopicSchema.index({ isDraft: 1, authorId: 1 }) // For user's draft management TopicSchema.index({ views: -1 }) // For sorting by popularity TopicSchema.index({ authorId: 1, views: -1 }) // For user's most viewed posts // Check if the model exists before creating a new one // This prevents "Cannot overwrite model once compiled" errors const TopicModel = (models.topics as TopicModel) || mongoose.model('topics', TopicSchema) export default TopicModel