diff --git a/src/controllers/blog.controller.js b/src/controllers/blog.controller.js index f1757318..533b9b2d 100644 --- a/src/controllers/blog.controller.js +++ b/src/controllers/blog.controller.js @@ -913,7 +913,7 @@ async function generateRSSFeed(req, res) { ${escapeXml(description)} ${escapeXml(author)} ${pubDate} -${categories ? categories + '\n' : ''} +${categories ? `${categories}\n` : ''} `; } diff --git a/src/models/SubmissionTracking.model.js b/src/models/SubmissionTracking.model.js new file mode 100644 index 00000000..e6fc690e --- /dev/null +++ b/src/models/SubmissionTracking.model.js @@ -0,0 +1,318 @@ +/** + * Submission Tracking Model + * Track submission lifecycle for publications + */ + +const mongoose = require('mongoose'); + +const SubmissionTrackingSchema = new mongoose.Schema({ + // Submission identification + blogPostId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'BlogPost', + required: true, + index: true + }, + publicationId: { + type: String, + required: true, + index: true + }, + publicationName: { + type: String, + required: true + }, + + // Content details + title: { type: String, required: true }, + wordCount: { type: Number }, + contentType: { + type: String, + enum: ['letter', 'oped', 'essay', 'social'], + required: true + }, + + // Submission lifecycle + status: { + type: String, + enum: [ + 'drafted', // Content created, not yet submitted + 'ready', // Ready to submit + 'submitted', // Submitted to publication + 'under_review', // Acknowledged by publication + 'revision_requested', // Publication requested changes + 'revised', // Changes made, awaiting re-submission + 'accepted', // Accepted for publication + 'rejected', // Rejected by publication + 'published', // Successfully published + 'withdrawn' // Submission withdrawn + ], + default: 'drafted', + index: true + }, + + // Timeline + draftedAt: { type: Date, default: Date.now }, + submittedAt: { type: Date }, + reviewStartedAt: { type: Date }, + acceptedAt: { type: Date }, + rejectedAt: { type: Date }, + publishedAt: { type: Date }, + + // Publication details + publishedUrl: { type: String }, + publishedTitle: { type: String }, // May differ from submitted title + edits: [{ + type: { + type: String, + enum: ['minor', 'moderate', 'major'] + }, + description: String, + date: { type: Date, default: Date.now } + }], + + // Submission method + submissionMethod: { + type: String, + enum: ['email', 'form', 'website', 'self-publish'] + }, + submissionEmail: { type: String }, + submissionUrl: { type: String }, + + // Response tracking + responseTimeHours: { type: Number }, // Time from submission to response + expectedResponseDays: { type: Number }, + + // Editorial feedback + editorName: { type: String }, + editorContact: { type: String }, + feedback: { type: String }, + reasonForRejection: { type: String }, + + // Internal notes + notes: [{ + content: String, + author: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + createdAt: { type: Date, default: Date.now } + }], + + // Submission package checklist + submissionPackage: { + coverLetter: { + completed: { type: Boolean, default: false }, + content: String, + lastUpdated: Date + }, + notesToEditor: { + completed: { type: Boolean, default: false }, + content: String, + lastUpdated: Date + }, + authorBio: { + completed: { type: Boolean, default: false }, + content: String, + lastUpdated: Date + }, + pitchEmail: { + completed: { type: Boolean, default: false }, + content: String, + lastUpdated: Date + }, + supportingMaterials: [{ + name: String, + description: String, + url: String, + completed: { type: Boolean, default: false } + }] + }, + + // Metadata + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + lastUpdatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}, { + timestamps: true +}); + +// Indexes for performance +SubmissionTrackingSchema.index({ status: 1, submittedAt: -1 }); +SubmissionTrackingSchema.index({ publicationId: 1, status: 1 }); +SubmissionTrackingSchema.index({ createdBy: 1, status: 1 }); + +// Virtual for submission duration +SubmissionTrackingSchema.virtual('submissionDurationDays').get(function() { + if (!this.submittedAt) return null; + const endDate = this.publishedAt || this.rejectedAt || new Date(); + return Math.floor((endDate - this.submittedAt) / (1000 * 60 * 60 * 24)); +}); + +// Static methods + +/** + * Get submissions by status + */ +SubmissionTrackingSchema.statics.getByStatus = async function(status) { + return await this.find({ status }) + .populate('blogPostId', 'title slug') + .populate('createdBy', 'email') + .sort({ submittedAt: -1 }); +}; + +/** + * Get submissions for a specific publication + */ +SubmissionTrackingSchema.statics.getByPublication = async function(publicationId) { + return await this.find({ publicationId }) + .populate('blogPostId', 'title slug') + .sort({ submittedAt: -1 }); +}; + +/** + * Get acceptance rate for a publication + */ +SubmissionTrackingSchema.statics.getAcceptanceRate = async function(publicationId) { + const total = await this.countDocuments({ + publicationId, + status: { $in: ['accepted', 'rejected', 'published'] } + }); + + const accepted = await this.countDocuments({ + publicationId, + status: { $in: ['accepted', 'published'] } + }); + + return total > 0 ? (accepted / total) * 100 : 0; +}; + +/** + * Get average response time for a publication + */ +SubmissionTrackingSchema.statics.getAverageResponseTime = async function(publicationId) { + const result = await this.aggregate([ + { + $match: { + publicationId, + responseTimeHours: { $exists: true, $gt: 0 } + } + }, + { + $group: { + _id: null, + avgResponseTime: { $avg: '$responseTimeHours' } + } + } + ]); + + return result.length > 0 ? Math.round(result[0].avgResponseTime) : null; +}; + +/** + * Get submission statistics + */ +SubmissionTrackingSchema.statics.getStatistics = async function() { + const total = await this.countDocuments(); + const byStatus = await this.aggregate([ + { + $group: { + _id: '$status', + count: { $sum: 1 } + } + } + ]); + + const byPublication = await this.aggregate([ + { + $group: { + _id: '$publicationId', + count: { $sum: 1 }, + accepted: { + $sum: { + $cond: [{ $in: ['$status', ['accepted', 'published']] }, 1, 0] + } + } + } + }, + { + $sort: { count: -1 } + }, + { + $limit: 10 + } + ]); + + return { + total, + byStatus: byStatus.reduce((acc, item) => { + acc[item._id] = item.count; + return acc; + }, {}), + topPublications: byPublication + }; +}; + +// Instance methods + +/** + * Update status with automatic timestamp management + */ +SubmissionTrackingSchema.methods.updateStatus = async function(newStatus, userId) { + this.status = newStatus; + this.lastUpdatedBy = userId; + + // Set appropriate timestamps + const now = new Date(); + switch (newStatus) { + case 'submitted': + if (!this.submittedAt) this.submittedAt = now; + break; + case 'under_review': + if (!this.reviewStartedAt) this.reviewStartedAt = now; + // Calculate response time if not already set + if (this.submittedAt && !this.responseTimeHours) { + this.responseTimeHours = Math.round((now - this.submittedAt) / (1000 * 60 * 60)); + } + break; + case 'accepted': + if (!this.acceptedAt) this.acceptedAt = now; + if (this.submittedAt && !this.responseTimeHours) { + this.responseTimeHours = Math.round((now - this.submittedAt) / (1000 * 60 * 60)); + } + break; + case 'rejected': + if (!this.rejectedAt) this.rejectedAt = now; + if (this.submittedAt && !this.responseTimeHours) { + this.responseTimeHours = Math.round((now - this.submittedAt) / (1000 * 60 * 60)); + } + break; + case 'published': + if (!this.publishedAt) this.publishedAt = now; + break; + } + + return await this.save(); +}; + +/** + * Add note + */ +SubmissionTrackingSchema.methods.addNote = async function(content, authorId) { + this.notes.push({ + content, + author: authorId, + createdAt: new Date() + }); + return await this.save(); +}; + +const SubmissionTracking = mongoose.model('SubmissionTracking', SubmissionTrackingSchema); + +module.exports = SubmissionTracking;