initial commit
This commit is contained in:
222
models/topic.ts
Normal file
222
models/topic.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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<typeof topicSchema>
|
||||
|
||||
// Type for Mongoose document with ITopic
|
||||
type TopicDocument = Document & ITopic
|
||||
|
||||
// Type for Mongoose model
|
||||
type TopicModel = Model<ITopic>
|
||||
|
||||
// Generic utility function to transform and validate Mongoose document to any type
|
||||
export const transformToType = <T>(
|
||||
doc: Document | Record<string, unknown> | null,
|
||||
schema: z.ZodType<T>
|
||||
): 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<string, unknown> | 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<string, unknown>)[]): ITopic[] => {
|
||||
return docs
|
||||
.map((doc) => transformToType(doc, topicSchema))
|
||||
.filter((topic): topic is ITopic => topic !== null)
|
||||
}
|
||||
|
||||
const TopicSchema = new Schema<ITopic>(
|
||||
{
|
||||
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<ITopic>('topics', TopicSchema)
|
||||
|
||||
export default TopicModel
|
||||
Reference in New Issue
Block a user