tractatus/src/models/PublicationRelationship.model.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- 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>
2025-10-24 08:47:42 +13:00

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;