222 lines
6.4 KiB
TypeScript
222 lines
6.4 KiB
TypeScript
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 |