Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ export const IDENTIFIERS_ORG_ID_BASE_URL = env('IDENTIFIERS_ORG_ID_BASE_URL', '

// Email
export const EMAIL_ENABLED = env('EMAIL_ENABLED', false);
export const EMAIL_RELPPRS_CONTACT = env('EMAIL_RELPPRS_CONTACT', true);
export const EMAIL_FROM = env('EMAIL_FROM', 'Biofactoid');
export const EMAIL_FROM_ADDR = env('EMAIL_FROM_ADDR', 'support@biofactoid.org');
export const EMAIL_ADMIN_ADDR = env('EMAIL_ADMIN_ADDR', 'support@biofactoid.org');
export const SMTP_PORT = env('SMTP_PORT', 587);
export const SMTP_HOST = env('SMTP_HOST', 'localhost');
export const SMTP_USER = env('SMTP_USER', 'user');
Expand All @@ -89,9 +91,11 @@ export const EMAIL_VENDOR_MAILJET = env('EMAIL_VENDOR_MAILJET', 'Mailjet');
export const MAILJET_TMPLID_INVITE = env('MAILJET_TMPLID_INVITE', '1330760');
export const MAILJET_TMPLID_FOLLOWUP = env('MAILJET_TMPLID_FOLLOWUP', '988309');
export const MAILJET_TMPLID_REQUEST_ISSUE = env('MAILJET_TMPLID_REQUEST_ISSUE', '1202251');
export const MAILJET_TMPLID_REL_PPR = env('MAILJET_TMPLID_REL_PPR', '1871553');
export const EMAIL_TYPE_INVITE = env('EMAIL_TYPE_INVITE', 'invite');
export const EMAIL_TYPE_FOLLOWUP = env('EMAIL_TYPE_FOLLOWUP', 'followUp');
export const EMAIL_TYPE_REQUEST_ISSUE = env('EMAIL_TYPE_REQUEST_ISSUE', 'requestIssue');
export const EMAIL_TYPE_REL_PPR_NOTIFICATION = env('EMAIL_TYPE_REL_PPR_NOTIFICATION', 'relatedPaperNotification');
export const EMAIL_ADDRESS_INFO = env('EMAIL_ADDRESS_INFO', 'info@biofactoid.org');

// Sharing
Expand Down
15 changes: 15 additions & 0 deletions src/model/document/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,21 @@ class Document {
}
}

referencedPapers( papersData ){
if( papersData ){
let p = this.syncher.update({ 'referencedPapers': papersData });
this.emit( 'referencedPapers', papersData );
return p;
}
else if( !papersData ){
return this.syncher.get( 'referencedPapers' );
}
}

relatedPapersNotified(newVal){
return !!this.rwMeta('relatedPapersNotified', newVal);
}

status( field ){
if( field && _.includes( _.values( DOCUMENT_STATUS_FIELDS ), field ) ){
let p = this.syncher.update({ 'status': field });
Expand Down
20 changes: 20 additions & 0 deletions src/model/element/interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const makeAssociation = ( type, intn ) => {
const DEFAULTS = Object.freeze({
type: TYPE,
association: INTERACTION_TYPE.INTERACTION.value,
novel: false,
entries: [] // used by elementSet
});

Expand Down Expand Up @@ -177,6 +178,21 @@ class Interaction extends Element {
return this.participantsOf( type, (t1, t2) => t1 !== t2 );
}

setNovel( novel ) {
let currVal = this.isNovel();
if ( currVal == novel ) {
return Promise.resolve();
}

let update = this.update({
novel
});

this.emit( 'novel', novel, currVal );

return update;
}

associate( interactionType ){
let type = makeAssociation( interactionType, this );
let oldType = makeAssociation( this.syncher.get('association'), this );
Expand Down Expand Up @@ -221,6 +237,10 @@ class Interaction extends Element {
return this.syncher.get('association') != null;
}

isNovel(){
return this.syncher.get('novel') == true;
}

json(){
return _.assign( {}, super.json(), _.pick( this.syncher.get(), _.keys(DEFAULTS) ) );
}
Expand Down
109 changes: 98 additions & 11 deletions src/server/routes/api/document/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,18 @@ import { BASE_URL,
MAX_TWEET_LENGTH,
DEMO_CAN_BE_SHARED,
DOCUMENT_IMAGE_PADDING,
EMAIL_ADMIN_ADDR,
EMAIL_RELPPRS_CONTACT,
EMAIL_TYPE_INVITE,
DOCUMENT_IMAGE_CACHE_SIZE,
EMAIL_TYPE_FOLLOWUP,
MIN_RELATED_PAPERS,
SEMANTIC_SEARCH_LIMIT
SEMANTIC_SEARCH_LIMIT,
EMAIL_TYPE_REL_PPR_NOTIFICATION
} from '../../../../config';

import { ENTITY_TYPE } from '../../../../model/element/entity-type';
import { db2pubmed } from './pubmed/linkPubmed';
import { eLink, elink2UidList } from './pubmed/linkPubmed';
import { fetchPubmed } from './pubmed/fetchPubmed';
import { docs2Sitemap } from '../../../sitemap';
const DOCUMENT_STATUS_FIELDS = Document.statusFields();
Expand Down Expand Up @@ -1220,6 +1223,60 @@ const checkApiKey = (apiKey) => {
}
};

const getNovelInteractions = async doc => {
// n.b. will be empty if we haven't yet queried for related papers
return doc.interactions().filter(intn => intn.isNovel());
};

const emailRelatedPaperAuthors = async doc => {
if( doc.relatedPapersNotified() ){ return; } // bail out if already notified

const getContact = paper => _.get(paper, ['pubmed', 'authors', 'contacts', 0]);

const hasContact = paper => getContact(paper) != null;

const sendEmail = async (paper, novelIntns) => {
const contact = getContact(paper);
const email = EMAIL_RELPPRS_CONTACT ? contact.email[0] : EMAIL_ADMIN_ADDR;
const name = contact.name;

// prevent duplicate sending
doc.relatedPapersNotified(true);

const mailOpts = await msgFactory(EMAIL_TYPE_REL_PPR_NOTIFICATION, doc, {
to: EMAIL_RELPPRS_CONTACT ? email : EMAIL_ADMIN_ADDR, //must explicitly turn off
name,
paper,
novelIntns
});

// Sending blocked by default, otherwise set EMAIL_ENABLED=true
await sendMail(mailOpts);

logger.info(`Related paper notification email for doc ${doc.id()} sent to ${name} at ${email} with ${novelIntns.length} novel interactions`);
};

// TODO RPN use semantic search score etc. in future
// just send to all for now, as we already have a small cutoff at 30
const paperIsGoodFit = async paper => true; // eslint-disable-line

const getSendablePapers = async papers => {
const isGoodFit = await Promise.all(papers.map(paperIsGoodFit));

return papers.filter((paper, i) => isGoodFit[i] && hasContact(paper));
};

const novelIntns = await getNovelInteractions(doc);

const papers = await getSendablePapers(doc.referencedPapers());

logger.info(`Sending related paper notifications for doc ${doc.id()} with ${papers.length} papers`);

await Promise.all(papers.map(async paper => sendEmail(paper, novelIntns)));

logger.info(`Related paper notifications complete for ${papers.length} emails`);
};

/**
* @swagger
*
Expand Down Expand Up @@ -1435,7 +1492,16 @@ http.patch('/:id/:secret', function( req, res, next ){
return;
};

const sendFollowUpNotification = async doc => await configureAndSendMail( EMAIL_TYPE_FOLLOWUP, doc.id(), doc.secret() );
const sendFollowUpNotification = async doc => {
await configureAndSendMail( EMAIL_TYPE_FOLLOWUP, doc.id(), doc.secret() );
await emailRelatedPaperAuthors( doc );
};

const onDocPublic = async doc => {
await updateRelatedPapers( doc );
await sendFollowUpNotification( doc );
};

const tryTweetingDoc = async doc => {
if ( !doc.hasTweet() ) {
try {
Expand All @@ -1449,9 +1515,8 @@ http.patch('/:id/:secret', function( req, res, next ){
const handleMakePublicRequest = async doc => {
await tryMakePublic( doc );
if( doc.isPublic() ) {
updateRelatedPapers( doc );
await tryTweetingDoc( doc );
sendFollowUpNotification( doc );
onDocPublic( doc );
}
};

Expand All @@ -1476,6 +1541,7 @@ http.patch('/:id/:secret', function( req, res, next ){
case 'relatedPapers':
if( op === 'replace' ){
await updateRelatedPapers( doc );
await emailRelatedPaperAuthors( doc );
}
break;
}
Expand Down Expand Up @@ -1608,23 +1674,37 @@ const updateRelatedPapers = async doc => {

const getRelPprsForDoc = async doc => {
let papers = [];
let referencedPapers = [];

try {
logger.info(`Updating document-level related papers for doc ${doc.id()}`);
const getUid = result => _.get( result, 'uid' );
const { pmid } = doc.citation();

if( pmid ){
const pmids = await db2pubmed({ uids: [pmid] });
let rankedDocs = await indra.semanticSearch({ query: pmid, documents: pmids });
const elinkResponse = await eLink( { id: [pmid] } );

// For .relatedPapers
const documents = elink2UidList( elinkResponse );
let rankedDocs = await indra.semanticSearch({ query: pmid, documents });
const uids = _.take( rankedDocs, SEMANTIC_SEARCH_LIMIT ).map( getUid );
const { PubmedArticleSet } = await fetchPubmed({ uids });
papers = PubmedArticleSet
const relatedPapersResponse = await fetchPubmed({ uids });
papers = _.get( relatedPapersResponse, 'PubmedArticleSet', [] )
.map( getPubmedCitation )
.map( citation => ({ pmid: citation.pmid, pubmed: citation }) );

// For .referencedPapers
const referencedPaperUids = elink2UidList( elinkResponse, ['pubmed_pubmed_refs'], 100 );
if( referencedPaperUids.length ){
const referencedPapersResponse = await fetchPubmed({ uids: referencedPaperUids });
referencedPapers = _.get( referencedPapersResponse, 'PubmedArticleSet', [] )
.map( getPubmedCitation )
.map( citation => ({ pmid: citation.pmid, pubmed: citation }) );
}
}

doc.relatedPapers( papers );
doc.referencedPapers( referencedPapers );
logger.info(`Finished updating document-level related papers`);
return doc;

Expand Down Expand Up @@ -1652,9 +1732,16 @@ const getRelatedPapersForNetwork = async doc => {
entities: el.isEntity() ? [ template ] : []
};

const indraRes = await indra.searchDocuments({ templates, doc });
let indraRes = await indra.searchDocuments({ templates, doc });
indraRes = indraRes || [];

if ( el.isInteraction() ) {
if ( indraRes.length == 0 ) {
el.setNovel( true );
}
}

el.relatedPapers( indraRes || [] );
el.relatedPapers( indraRes );
};

await Promise.all([ ...els.map(getRelPprsForEl) ]);
Expand Down
31 changes: 12 additions & 19 deletions src/server/routes/api/document/pubmed/linkPubmed.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,17 @@ const DEFAULT_ELINK_PARAMS = {
linkname: undefined
};

const data2UidList = ( json, maxPerLink ) => {
const { ids, linksetdbs = [] } = _.get( json, ['linksets', '0'] );
/**
* elink2UidList
* Retrieve PubMed uids from the ELINK response
* @param {Object} json The ELINK response
* @param {Object} linknames The list linknames to consider
* @param {number} maxPerLink Take this top number of uids for any one subset (dbfrom_db_subset)
* @return {Object} The array of PubMed uids
*/
const elink2UidList = ( json, linknames, maxPerLink = DEFAULT_MAX_PER_LINK ) => {
let { ids, linksetdbs = [] } = _.get( json, ['linksets', '0'] );
if( linknames ) linksetdbs = linksetdbs.filter( linksetdb => _.includes( linknames, linksetdb.linkname ) );
const links = linksetdbs.map( linksetdb => _.take( _.get( linksetdb, ['links'] ), maxPerLink ) );
let uids = _.flatten( links );
uids = _.uniq( uids ); // Remove redundancy
Expand Down Expand Up @@ -50,20 +59,4 @@ const eLink = opts => {
.then( response => response.json() );
};

/**
* db2pubmed
* Retrieve PubMed uids for one or more uids from the source database
* @param {Object} uids Array of strings that represent PubMed uids
* @param {string} dbfrom The database (e.g. gene, protein) from which the uids are derived
* @param {string} reldate Restrict to uids to those with Publication Date within the last n days
* @param {string} term Text query used to limit the set of unique identifiers (UIDs) returned, similar to the search string you would put into an Entrez database’s web interface.
* @param {number} maxPerLink Take this top number of uids for any one subset (dbfrom_db_subset)
* @return {Object} The array of PubMed uids
*/
const db2pubmed = ({ uids, db, reldate, term, maxPerLink = DEFAULT_MAX_PER_LINK }) => {
const id = uids.join(',');
return eLink( { id, dbfrom: db, reldate, term } )
.then( data => data2UidList( data, maxPerLink ) );
};

export { eLink, db2pubmed };
export { eLink, elink2UidList };
24 changes: 21 additions & 3 deletions src/util/email.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/** N.b. this can only be run on the server */

import _ from 'lodash';
import { tryPromise } from './promise';
import logger from '../server/logger';

import {
BASE_URL,
Expand All @@ -11,10 +14,12 @@ import {
MAILJET_TMPLID_REQUEST_ISSUE,
EMAIL_TYPE_INVITE,
EMAIL_TYPE_FOLLOWUP,
EMAIL_TYPE_REQUEST_ISSUE
EMAIL_TYPE_REQUEST_ISSUE,
EMAIL_TYPE_REL_PPR_NOTIFICATION,
MAILJET_TMPLID_REL_PPR
} from '../config' ;

const msgFactory = ( emailType, doc ) => {
const msgFactory = ( emailType, doc, info = {} ) => {
const { authorEmail } = doc.correspondence();

const {
Expand All @@ -25,6 +30,7 @@ const msgFactory = ( emailType, doc ) => {
const privateUrl = `${BASE_URL}${doc.privateUrl()}`;
const publicUrl = `${BASE_URL}${doc.publicUrl()}`;
const imageUrl = `${BASE_URL}/api${doc.publicUrl()}.png`;
const authorsAbbreviation = _.get(doc.citation(), ['authors', 'abbreviation']);

const DEFAULTS = {
from: {
Expand Down Expand Up @@ -62,7 +68,19 @@ const msgFactory = ( emailType, doc ) => {
imageUrl,
});
break;
default:
case EMAIL_TYPE_REL_PPR_NOTIFICATION:
_.set( data, ['to'], _.get(info, ['to']) );
_.set( data, ['template', 'id'], MAILJET_TMPLID_REL_PPR );
_.set( data, ['template', 'vars'], { // TODO RPN this may need to be reconfigured
publicUrl,
name: info.name, // of author of related paper
authorsAbbreviation, // of factoid
paperTitle: _.get(info.paper, ['pubmed', 'title']),
hasNovelInteraction: info.novelIntns.length > 0,
novelInteraction: info.novelIntns.length > 0 ? info.novelIntns[0].toString() : ''
});
logger.info(`Sending related papers email with template (excl. defaults)`, data); // TODO RPN remove
break;
}

const mailOpts = _.defaultsDeep( data, DEFAULTS );
Expand Down