ai-wpa/models/topic.ts

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