- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
369 lines
9.6 KiB
JavaScript
369 lines
9.6 KiB
JavaScript
/**
|
|
* Publication Relationship Model
|
|
* CRM for tracking editorial relationships with publications
|
|
*/
|
|
|
|
const mongoose = require('mongoose');
|
|
|
|
const PublicationRelationshipSchema = new mongoose.Schema({
|
|
// Publication identification
|
|
publicationId: {
|
|
type: String,
|
|
required: true,
|
|
unique: true,
|
|
index: true
|
|
},
|
|
publicationName: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
|
|
// Relationship stage
|
|
relationshipStage: {
|
|
type: String,
|
|
enum: [
|
|
'cold', // No prior relationship
|
|
'introduced', // First submission made
|
|
'engaged', // Multiple submissions, some responses
|
|
'established', // Regular submissions, predictable responses
|
|
'partnership' // Strong relationship, high acceptance rate
|
|
],
|
|
default: 'cold'
|
|
},
|
|
|
|
// Editorial contacts
|
|
editors: [{
|
|
name: { type: String, required: true },
|
|
email: { type: String },
|
|
role: { type: String },
|
|
notes: { type: String },
|
|
lastContact: { type: Date },
|
|
responsive: { type: Boolean, default: true }
|
|
}],
|
|
|
|
// Submission statistics (calculated from SubmissionTracking)
|
|
totalSubmissions: { type: Number, default: 0 },
|
|
acceptedSubmissions: { type: Number, default: 0 },
|
|
rejectedSubmissions: { type: Number, default: 0 },
|
|
acceptanceRate: { type: Number, default: 0 }, // Percentage
|
|
|
|
// Response patterns
|
|
averageResponseTimeHours: { type: Number },
|
|
fastestResponseHours: { type: Number },
|
|
slowestResponseHours: { type: Number },
|
|
lastResponseAt: { type: Date },
|
|
|
|
// Publication preferences (learned over time)
|
|
preferredTopics: [{ type: String }],
|
|
avoidedTopics: [{ type: String }],
|
|
stylePreferences: { type: String },
|
|
wordCountPreference: {
|
|
min: { type: Number },
|
|
max: { type: Number }
|
|
},
|
|
|
|
// Editorial feedback patterns
|
|
commonFeedback: [{ type: String }],
|
|
editorialStyle: {
|
|
type: String,
|
|
enum: ['hands-off', 'light-touch', 'collaborative', 'heavy-editing'],
|
|
default: 'light-touch'
|
|
},
|
|
|
|
// Relationship quality indicators
|
|
qualityScore: {
|
|
type: Number,
|
|
min: 0,
|
|
max: 100,
|
|
default: 50
|
|
},
|
|
lastSuccessfulPublication: { type: Date },
|
|
daysSinceLastSuccess: { type: Number },
|
|
|
|
// Interaction history
|
|
interactions: [{
|
|
type: {
|
|
type: String,
|
|
enum: ['submission', 'response', 'publication', 'rejection', 'meeting', 'email', 'call', 'note']
|
|
},
|
|
date: { type: Date, default: Date.now },
|
|
description: { type: String },
|
|
outcome: {
|
|
type: String,
|
|
enum: ['positive', 'neutral', 'negative']
|
|
},
|
|
recordedBy: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: 'User'
|
|
}
|
|
}],
|
|
|
|
// Strategic notes
|
|
strategy: { type: String },
|
|
nextSteps: [{ type: String }],
|
|
priorities: {
|
|
type: String,
|
|
enum: ['high', 'medium', 'low'],
|
|
default: 'medium'
|
|
},
|
|
|
|
// Metadata
|
|
createdBy: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: 'User'
|
|
},
|
|
lastUpdatedBy: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: 'User'
|
|
}
|
|
}, {
|
|
timestamps: true
|
|
});
|
|
|
|
// Indexes for performance
|
|
PublicationRelationshipSchema.index({ relationshipStage: 1, qualityScore: -1 });
|
|
PublicationRelationshipSchema.index({ acceptanceRate: -1 });
|
|
PublicationRelationshipSchema.index({ priorities: 1, qualityScore: -1 });
|
|
|
|
// Virtual for relationship health
|
|
PublicationRelationshipSchema.virtual('relationshipHealth').get(function() {
|
|
let health = 'unknown';
|
|
|
|
if (this.totalSubmissions === 0) {
|
|
health = 'new';
|
|
} else if (this.acceptanceRate >= 50) {
|
|
health = 'excellent';
|
|
} else if (this.acceptanceRate >= 30) {
|
|
health = 'good';
|
|
} else if (this.acceptanceRate >= 15) {
|
|
health = 'fair';
|
|
} else {
|
|
health = 'poor';
|
|
}
|
|
|
|
return health;
|
|
});
|
|
|
|
// Static methods
|
|
|
|
/**
|
|
* Get relationships by stage
|
|
*/
|
|
PublicationRelationshipSchema.statics.getByStage = async function(stage) {
|
|
return await this.find({ relationshipStage: stage })
|
|
.sort({ qualityScore: -1, acceptanceRate: -1 });
|
|
};
|
|
|
|
/**
|
|
* Get top performing relationships
|
|
*/
|
|
PublicationRelationshipSchema.statics.getTopPerformers = async function(limit = 10) {
|
|
return await this.find({
|
|
totalSubmissions: { $gte: 3 }
|
|
})
|
|
.sort({ acceptanceRate: -1, qualityScore: -1 })
|
|
.limit(limit);
|
|
};
|
|
|
|
/**
|
|
* Get relationships needing attention
|
|
*/
|
|
PublicationRelationshipSchema.statics.getNeedingAttention = async function() {
|
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
return await this.find({
|
|
$or: [
|
|
{ qualityScore: { $lt: 30 } },
|
|
{ acceptanceRate: { $lt: 10 }, totalSubmissions: { $gte: 5 } },
|
|
{ lastSuccessfulPublication: { $lt: thirtyDaysAgo } }
|
|
]
|
|
})
|
|
.sort({ priorities: -1, qualityScore: 1 });
|
|
};
|
|
|
|
/**
|
|
* Get CRM summary statistics
|
|
*/
|
|
PublicationRelationshipSchema.statics.getSummary = async function() {
|
|
const total = await this.countDocuments();
|
|
|
|
const byStage = await this.aggregate([
|
|
{
|
|
$group: {
|
|
_id: '$relationshipStage',
|
|
count: { $sum: 1 }
|
|
}
|
|
}
|
|
]);
|
|
|
|
const averageAcceptanceRate = await this.aggregate([
|
|
{
|
|
$match: {
|
|
totalSubmissions: { $gte: 3 }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
avgAcceptanceRate: { $avg: '$acceptanceRate' },
|
|
avgQualityScore: { $avg: '$qualityScore' }
|
|
}
|
|
}
|
|
]);
|
|
|
|
return {
|
|
total,
|
|
byStage: byStage.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
averageAcceptanceRate: averageAcceptanceRate.length > 0
|
|
? Math.round(averageAcceptanceRate[0].avgAcceptanceRate * 10) / 10
|
|
: 0,
|
|
averageQualityScore: averageAcceptanceRate.length > 0
|
|
? Math.round(averageAcceptanceRate[0].avgQualityScore)
|
|
: 0
|
|
};
|
|
};
|
|
|
|
// Instance methods
|
|
|
|
/**
|
|
* Update statistics from submission data
|
|
*/
|
|
PublicationRelationshipSchema.methods.updateStatistics = async function(submissionData) {
|
|
this.totalSubmissions = submissionData.total;
|
|
this.acceptedSubmissions = submissionData.accepted;
|
|
this.rejectedSubmissions = submissionData.rejected;
|
|
|
|
// Calculate acceptance rate
|
|
if (this.totalSubmissions > 0) {
|
|
this.acceptanceRate = (this.acceptedSubmissions / this.totalSubmissions) * 100;
|
|
}
|
|
|
|
// Update response times
|
|
if (submissionData.avgResponseTime) {
|
|
this.averageResponseTimeHours = submissionData.avgResponseTime;
|
|
}
|
|
if (submissionData.fastestResponse) {
|
|
this.fastestResponseHours = submissionData.fastestResponse;
|
|
}
|
|
if (submissionData.slowestResponse) {
|
|
this.slowestResponseHours = submissionData.slowestResponse;
|
|
}
|
|
|
|
// Update days since last success
|
|
if (this.lastSuccessfulPublication) {
|
|
const daysSince = Math.floor((new Date() - this.lastSuccessfulPublication) / (1000 * 60 * 60 * 24));
|
|
this.daysSinceLastSuccess = daysSince;
|
|
}
|
|
|
|
// Calculate quality score
|
|
await this.calculateQualityScore();
|
|
|
|
// Update relationship stage based on statistics
|
|
await this.updateRelationshipStage();
|
|
|
|
return await this.save();
|
|
};
|
|
|
|
/**
|
|
* Calculate quality score (0-100)
|
|
*/
|
|
PublicationRelationshipSchema.methods.calculateQualityScore = async function() {
|
|
let score = 50; // Base score
|
|
|
|
// Acceptance rate component (40 points max)
|
|
score += (this.acceptanceRate * 0.4);
|
|
|
|
// Submission volume component (10 points max)
|
|
score += Math.min(this.totalSubmissions * 2, 10);
|
|
|
|
// Response time component (15 points max)
|
|
if (this.averageResponseTimeHours) {
|
|
const expectedHours = 168; // 7 days
|
|
if (this.averageResponseTimeHours <= expectedHours) {
|
|
score += 15;
|
|
} else if (this.averageResponseTimeHours <= expectedHours * 2) {
|
|
score += 10;
|
|
} else {
|
|
score += 5;
|
|
}
|
|
}
|
|
|
|
// Recency component (15 points max)
|
|
if (this.lastSuccessfulPublication) {
|
|
if (this.daysSinceLastSuccess <= 30) {
|
|
score += 15;
|
|
} else if (this.daysSinceLastSuccess <= 90) {
|
|
score += 10;
|
|
} else if (this.daysSinceLastSuccess <= 180) {
|
|
score += 5;
|
|
}
|
|
}
|
|
|
|
// Editor relationship component (10 points max)
|
|
const responsiveEditors = this.editors.filter(e => e.responsive).length;
|
|
score += Math.min(responsiveEditors * 5, 10);
|
|
|
|
// Cap at 100
|
|
this.qualityScore = Math.min(Math.round(score), 100);
|
|
};
|
|
|
|
/**
|
|
* Update relationship stage based on statistics
|
|
*/
|
|
PublicationRelationshipSchema.methods.updateRelationshipStage = async function() {
|
|
if (this.totalSubmissions === 0) {
|
|
this.relationshipStage = 'cold';
|
|
} else if (this.acceptanceRate >= 40 && this.totalSubmissions >= 5) {
|
|
this.relationshipStage = 'partnership';
|
|
} else if (this.acceptanceRate >= 25 && this.totalSubmissions >= 3) {
|
|
this.relationshipStage = 'established';
|
|
} else if (this.totalSubmissions >= 3) {
|
|
this.relationshipStage = 'engaged';
|
|
} else if (this.totalSubmissions >= 1) {
|
|
this.relationshipStage = 'introduced';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Add interaction
|
|
*/
|
|
PublicationRelationshipSchema.methods.addInteraction = async function(type, description, outcome, userId) {
|
|
this.interactions.push({
|
|
type,
|
|
description,
|
|
outcome,
|
|
date: new Date(),
|
|
recordedBy: userId
|
|
});
|
|
|
|
// Update last contact for relevant interaction types
|
|
if (['response', 'email', 'call', 'meeting'].includes(type)) {
|
|
this.lastResponseAt = new Date();
|
|
}
|
|
|
|
return await this.save();
|
|
};
|
|
|
|
/**
|
|
* Add or update editor contact
|
|
*/
|
|
PublicationRelationshipSchema.methods.addEditor = async function(editorData) {
|
|
const existingEditor = this.editors.find(e => e.email === editorData.email);
|
|
|
|
if (existingEditor) {
|
|
// Update existing editor
|
|
Object.assign(existingEditor, editorData);
|
|
} else {
|
|
// Add new editor
|
|
this.editors.push(editorData);
|
|
}
|
|
|
|
return await this.save();
|
|
};
|
|
|
|
const PublicationRelationship = mongoose.model('PublicationRelationship', PublicationRelationshipSchema);
|
|
|
|
module.exports = PublicationRelationship;
|