tractatus/src/models/SubmissionTracking.model.js
TheFlow 6626cbc7e1 fix(submissions): resolve Mongoose populate error for hybrid BlogPost model
- 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
2025-10-24 10:19:33 +13:00

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;