- BlogPost uses native MongoDB (not Mongoose), causing MissingSchemaError
- Removed all .populate('blogPostId') calls that tried to reference non-existent Mongoose model
- Manually fetch blog post data in controllers when needed
- Updated getSubmissions, getSubmissionById, getSubmissionByBlogPost, exportSubmission
- Updated SubmissionTracking static methods: getByStatus, getByPublication
- Standalone submissions (like Le Monde) now display without errors
488 lines
12 KiB
JavaScript
488 lines
12 KiB
JavaScript
/**
|
|
* 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: false, // Optional for standalone submission packages
|
|
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 }
|
|
}]
|
|
},
|
|
|
|
// Multilingual document storage for complete submission packages
|
|
documents: {
|
|
coverLetter: {
|
|
primaryLanguage: { type: String, default: 'en' },
|
|
versions: [{
|
|
language: {
|
|
type: String,
|
|
required: true,
|
|
enum: ['en', 'fr', 'de', 'es', 'pt', 'zh', 'ja', 'ar', 'mi']
|
|
},
|
|
content: String,
|
|
wordCount: Number,
|
|
lastUpdated: { type: Date, default: Date.now },
|
|
translatedBy: {
|
|
type: String,
|
|
enum: ['human', 'deepl', 'claude', 'google', 'manual']
|
|
},
|
|
approved: { type: Boolean, default: false }
|
|
}],
|
|
completed: { type: Boolean, default: false }
|
|
},
|
|
mainArticle: {
|
|
primaryLanguage: { type: String, default: 'en' },
|
|
versions: [{
|
|
language: String,
|
|
content: String,
|
|
wordCount: Number,
|
|
lastUpdated: { type: Date, default: Date.now },
|
|
translatedBy: String,
|
|
approved: { type: Boolean, default: false }
|
|
}],
|
|
completed: { type: Boolean, default: false }
|
|
},
|
|
authorBio: {
|
|
primaryLanguage: { type: String, default: 'en' },
|
|
versions: [{
|
|
language: String,
|
|
content: String,
|
|
wordCount: Number,
|
|
lastUpdated: { type: Date, default: Date.now },
|
|
translatedBy: String,
|
|
approved: { type: Boolean, default: false }
|
|
}],
|
|
completed: { type: Boolean, default: false }
|
|
},
|
|
technicalBrief: {
|
|
primaryLanguage: { type: String, default: 'en' },
|
|
versions: [{
|
|
language: String,
|
|
content: String,
|
|
wordCount: Number,
|
|
lastUpdated: { type: Date, default: Date.now },
|
|
translatedBy: String,
|
|
approved: { type: Boolean, default: false }
|
|
}],
|
|
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
|
|
* NOTE: BlogPost is a native MongoDB class, not Mongoose model, so we can't populate it
|
|
*/
|
|
SubmissionTrackingSchema.statics.getByStatus = async function(status) {
|
|
return await this.find({ status })
|
|
.populate('createdBy', 'email')
|
|
.sort({ submittedAt: -1 });
|
|
};
|
|
|
|
/**
|
|
* Get submissions for a specific publication
|
|
* NOTE: BlogPost is a native MongoDB class, not Mongoose model, so we can't populate it
|
|
*/
|
|
SubmissionTrackingSchema.statics.getByPublication = async function(publicationId) {
|
|
return await this.find({ publicationId })
|
|
.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();
|
|
};
|
|
|
|
/**
|
|
* Get document in specific language (with fallback)
|
|
*/
|
|
SubmissionTrackingSchema.methods.getDocument = function(docType, language, fallbackToDefault = true) {
|
|
const doc = this.documents?.[docType];
|
|
if (!doc) return null;
|
|
|
|
// Find version in requested language
|
|
let version = doc.versions.find(v => v.language === language);
|
|
|
|
// Fallback to primary language if requested
|
|
if (!version && fallbackToDefault) {
|
|
version = doc.versions.find(v => v.language === doc.primaryLanguage);
|
|
}
|
|
|
|
return version;
|
|
};
|
|
|
|
/**
|
|
* Add or update document version
|
|
*/
|
|
SubmissionTrackingSchema.methods.setDocumentVersion = async function(docType, language, content, metadata = {}) {
|
|
if (!this.documents) {
|
|
this.documents = {};
|
|
}
|
|
|
|
if (!this.documents[docType]) {
|
|
this.documents[docType] = {
|
|
primaryLanguage: language,
|
|
versions: [],
|
|
completed: false
|
|
};
|
|
}
|
|
|
|
// Find existing version or create new
|
|
const existingIndex = this.documents[docType].versions.findIndex(v => v.language === language);
|
|
|
|
const versionData = {
|
|
language,
|
|
content,
|
|
wordCount: content.split(/\s+/).length,
|
|
lastUpdated: new Date(),
|
|
...metadata
|
|
};
|
|
|
|
if (existingIndex >= 0) {
|
|
this.documents[docType].versions[existingIndex] = {
|
|
...this.documents[docType].versions[existingIndex],
|
|
...versionData
|
|
};
|
|
} else {
|
|
this.documents[docType].versions.push(versionData);
|
|
}
|
|
|
|
return await this.save();
|
|
};
|
|
|
|
/**
|
|
* Get all available languages for a document type
|
|
*/
|
|
SubmissionTrackingSchema.methods.getAvailableLanguages = function(docType) {
|
|
const doc = this.documents?.[docType];
|
|
if (!doc) return [];
|
|
return doc.versions.map(v => v.language);
|
|
};
|
|
|
|
/**
|
|
* Check if submission package is complete for a language
|
|
*/
|
|
SubmissionTrackingSchema.methods.isPackageComplete = function(language) {
|
|
const requiredDocs = ['coverLetter', 'mainArticle', 'authorBio'];
|
|
|
|
return requiredDocs.every(docType => {
|
|
const doc = this.documents?.[docType];
|
|
if (!doc) return false;
|
|
return doc.versions.some(v => v.language === language);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Export package for submission
|
|
*/
|
|
SubmissionTrackingSchema.methods.exportPackage = function(language) {
|
|
const packageData = {
|
|
publication: {
|
|
id: this.publicationId,
|
|
name: this.publicationName
|
|
},
|
|
metadata: {
|
|
title: this.title,
|
|
wordCount: this.wordCount,
|
|
language,
|
|
exportedAt: new Date()
|
|
},
|
|
documents: {}
|
|
};
|
|
|
|
['coverLetter', 'mainArticle', 'authorBio', 'technicalBrief'].forEach(docType => {
|
|
const version = this.getDocument(docType, language, true);
|
|
if (version) {
|
|
packageData.documents[docType] = {
|
|
content: version.content,
|
|
wordCount: version.wordCount,
|
|
language: version.language
|
|
};
|
|
}
|
|
});
|
|
|
|
return packageData;
|
|
};
|
|
|
|
const SubmissionTracking = mongoose.model('SubmissionTracking', SubmissionTrackingSchema);
|
|
|
|
module.exports = SubmissionTracking;
|