├── .eslintignore ├── test ├── mocha.opts ├── api │ ├── helpers │ │ ├── README.md │ │ └── url-for.js │ ├── controllers │ │ ├── README.md │ │ ├── document_categories.js │ │ ├── sources.js │ │ ├── persons.js │ │ ├── conditions.js │ │ ├── interventions.js │ │ ├── organisations.js │ │ ├── publications.js │ │ ├── fda_applications.js │ │ ├── documents.js │ │ └── trials.js │ └── models │ │ ├── fda_application.js │ │ ├── location.js │ │ ├── person.js │ │ ├── condition.js │ │ ├── organisation.js │ │ ├── intervention.js │ │ ├── publication.js │ │ ├── base.js │ │ ├── file.js │ │ ├── record.js │ │ └── document.js ├── .eslintrc ├── integration │ └── server.js ├── e2e │ ├── sources.js │ └── search.js ├── common.js ├── config │ └── pg_types.js └── factory.js ├── config ├── README.md ├── bluebird.js ├── pg_types.js ├── default.yaml └── index.js ├── api ├── controllers │ ├── README.md │ ├── sources.js │ ├── persons.js │ ├── conditions.js │ ├── document_categories.js │ ├── interventions.js │ ├── organisations.js │ ├── publications.js │ ├── documents.js │ ├── fda_applications.js │ ├── trials.js │ └── search.js ├── mocks │ └── README.md ├── helpers │ ├── index.js │ └── url-for.js └── models │ ├── document_category.js │ ├── source.js │ ├── location.js │ ├── fda_approval.js │ ├── condition.js │ ├── intervention.js │ ├── person.js │ ├── organisation.js │ ├── risk_of_bias_criteria.js │ ├── file.js │ ├── risk_of_bias.js │ ├── fda_application.js │ ├── publication.js │ ├── base.js │ ├── record.js │ ├── document.js │ └── trial.js ├── .istanbul.yml ├── .dockerignore ├── nodemon.json ├── docker-compose.override.yml ├── migrations ├── 20160519105352_rename_trialrecords_to_records.js ├── 20160518155023_remove_conditions_type.js ├── 20161219173747_remove_source_data_from_records.js ├── 20161209163721_add_unique_constraint_to_records_source_url.js ├── 20161213144433_add_is_primary_to_records.js ├── 20170406122708_set_study_phase_arrays_with_null_element_to_null.js ├── 20170419122022_make_is_primary_not_nullable_and_set_current_nulls_to_false.js ├── 20161207171700_add_last_verification_date_to_records.js ├── 20160816113418_add_fda_application_number_to_interventions.js ├── 20170221113629_add_age_range_column_to_trials_and_records.js ├── 20160527115742_fix_records_unique_constraint.js ├── 20160818194716_add_documentcloud_url_and_text_to_documents.js ├── 20160907113052_add_url_and_terms_and_conditions_url_to_sources.js ├── 20160511184804_add_source_id_to_trials.js ├── 20160902123540_documents_trial_id_url_and_fda_approval_id_must_be_unique.js ├── 20160531140605_rename_trials_and_records_source_id_to_primary_source_id.js ├── 20161027182331_add_completion_date_to_trials_and_records.js ├── 20160518152405_rename_problems_to_conditions.js ├── 20161207142951_add_results_exemption_date_to_trials_and_records.js ├── 20161026164912_alter_files_text_to_array_and_rename_to_pages.js ├── 20160908171543_remove_documents_url_documentcloud_id_and_text_and_set_file_id_as_not_nullable.js ├── 20160513191730_rename_trials_secondary_ids_to_identifiers.js ├── 20160501171307_add_source_id.js ├── 20160820171646_clean_data_model_in_trials_and_records.js ├── 20161020094503_add_trials_documents.js ├── 20170407155833_change_pubmed_source_type_to_journal.js ├── 20170125181013_alter_trials_and_records_study_phase_to_text_array.js ├── 20160415172210_remove_trials_trialrecords_table.js ├── 20161001173327_add_url_to_documents_without_files.js ├── 20160708115615_remove_trials_documents_and_documents_slug_and_add_trial_id_type_and_url_to_documents.js ├── 20170216104317_create_audit_log_for_trial_deduplication.js ├── 20170122130426_set_default_for_created_at_and_updated_at.js ├── 20161021092257_add_uniqueness_constraints_to_documents.js ├── 20160501175732_add_slug_and_facts.js ├── 20160523160519_trials_remove_not_null.js ├── 20160907170328_create_files_belonging_to_many_documents.js ├── 20160413110458_add_gender_and_has_published_results_to_trials_and_trialrecords.js ├── 20160510204856_alter_source_id_to_equal_source_name.js ├── 20160407111800_add_meta_fields_to_entities.js ├── 20170122185448_add_trigger_for_updated_at.js ├── 20160826145535_add_document_type_csr_synopsis.js ├── 20160519143233_add_enum_constraint_to_trials_recruitment_status.js ├── 20160901174427_add_document_type_results.js ├── 20160825155641_add_trials_and_records_status_and_change_recruitment_status_enum.js ├── 20160511144353_add_description_and_extra_codes_columns_to_problems_and_interventions.js ├── 20161024144543_rename_documents_files_and_sources_url_to_source_url.js ├── 20160519151639_remove_unused_columns.js ├── 20160501172805_remove_links_facts.js ├── 20170126145822_replace_document_type_with_document_categories.js ├── 20160818182848_create_fda_approvals_and_add_relationship_with_documents.js ├── 20160912132241_create_risks_of_bias.js ├── 20160831135725_create_fda_applications.js ├── 20170125090419_add_status_unknown_to_trials_and_records.js ├── 20160830191612_add_on_delete_cascade_to_trials_relationship_tables.js ├── 20160429190634_update_publications.js ├── 20160407111633_create_trialrecords_entity_drop_records.js ├── 20170125115201_create_document_categories.js └── 20160216120159_create_initial_schema.js ├── tools ├── indexers │ ├── index.js │ ├── autocomplete.js │ ├── helpers.js │ ├── fda_documents.js │ └── trials.js ├── reindex.js └── wait-for-it.sh ├── Dockerfile ├── .eslintrc ├── knexfile.js ├── .env.example ├── docker-compose.yml ├── app.json ├── .gitignore ├── .travis.yml ├── server.js ├── README.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage/** 2 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | --require test/common 3 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | Place configuration files in this directory. 2 | -------------------------------------------------------------------------------- /api/controllers/README.md: -------------------------------------------------------------------------------- 1 | Place your controllers in this directory. 2 | -------------------------------------------------------------------------------- /test/api/helpers/README.md: -------------------------------------------------------------------------------- 1 | Place your helper tests in this directory. 2 | -------------------------------------------------------------------------------- /api/mocks/README.md: -------------------------------------------------------------------------------- 1 | Place controllers for mock mode in this directory. 2 | -------------------------------------------------------------------------------- /test/api/controllers/README.md: -------------------------------------------------------------------------------- 1 | Place your controller tests in this directory. 2 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | instrumentation: 2 | excludes: ["**/migrations/**", "**/seed/**", "**/tools/**"] 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !api/ 3 | !config/ 4 | !migrations/ 5 | !seeds/ 6 | !tools/ 7 | !*.json 8 | !*.js 9 | -------------------------------------------------------------------------------- /api/helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const urlFor = require('./url-for'); 4 | 5 | module.exports = { 6 | urlFor, 7 | }; 8 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "test/*" 4 | ], 5 | "env": { 6 | "NODE_ENV": "development" 7 | }, 8 | "ext": "js yaml" 9 | } 10 | -------------------------------------------------------------------------------- /config/bluebird.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | 'use strict'; 4 | 5 | process.on('unhandledRejection', (reason) => { 6 | console.trace(reason); 7 | }); 8 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | web: 2 | build: . 3 | command: bash -c "./tools/wait-for-it.sh db:5432 && ./tools/wait-for-it.sh elasticsearch:9200 && npm run dev" 4 | volumes: 5 | - .:/app 6 | environment: 7 | NODE_ENV: 'development' 8 | -------------------------------------------------------------------------------- /migrations/20160519105352_rename_trialrecords_to_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema.renameTable('trialrecords', 'records') 5 | ); 6 | 7 | exports.down = (knex) => ( 8 | knex.schema.renameTable('records', 'trialrecords') 9 | ); 10 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: ../.eslintrc 3 | env: 4 | mocha: true 5 | globals: 6 | server: false 7 | factory: false 8 | config: false 9 | clearDB: false 10 | toJSON: false 11 | rules: 12 | import/no-extraneous-dependencies: 13 | - error 14 | - devDependencies: true 15 | -------------------------------------------------------------------------------- /migrations/20160518155023_remove_conditions_type.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('conditions', (table) => { 6 | table.dropColumn('type'); 7 | }) 8 | ); 9 | 10 | exports.down = () => { 11 | throw Error('Destructive migration can\'t be rolled back.'); 12 | }; 13 | -------------------------------------------------------------------------------- /tools/indexers/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | 'use strict'; 4 | 5 | const trials = require('./trials'); 6 | const autocomplete = require('./autocomplete'); 7 | const fdaDocuments = require('./fda_documents'); 8 | 9 | module.exports = { 10 | trials, 11 | autocomplete, 12 | fdaDocuments, 13 | }; 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:6 2 | MAINTAINER Open Knowledge International 3 | 4 | WORKDIR /app 5 | 6 | # FIXME: Copying the package.json before is a workaround for 7 | # https://github.com/npm/npm/issues/9863 8 | COPY package.json ./ 9 | RUN npm install --production 10 | COPY . ./ 11 | 12 | ENV HOST 0.0.0.0 13 | ENV PORT 80 14 | 15 | EXPOSE $PORT 16 | CMD ["npm", "start"] 17 | -------------------------------------------------------------------------------- /migrations/20161219173747_remove_source_data_from_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('records', (table) => { 6 | table.dropColumn('source_data'); 7 | }) 8 | ); 9 | 10 | exports.down = (knex) => ( 11 | knex.schema 12 | .table('records', (table) => { 13 | table.jsonb('source_data'); 14 | }) 15 | ); 16 | -------------------------------------------------------------------------------- /api/controllers/sources.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Source = require('../models/source'); 4 | 5 | function listSources(req, res) { 6 | return new Source().fetchAll() 7 | .then((sources) => { 8 | res.json(sources); 9 | }) 10 | .catch((err) => { 11 | res.finish(); 12 | throw err; 13 | }); 14 | } 15 | 16 | module.exports = { 17 | list: listSources, 18 | }; 19 | -------------------------------------------------------------------------------- /migrations/20161209163721_add_unique_constraint_to_records_source_url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('records', (table) => { 6 | table.unique('source_url'); 7 | }) 8 | ); 9 | 10 | exports.down = (knex) => ( 11 | knex.schema 12 | .table('records', (table) => { 13 | table.dropUnique('source_url'); 14 | }) 15 | ); 16 | 17 | -------------------------------------------------------------------------------- /test/integration/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('server', () => { 4 | it('has CORS enabled', () => ( 5 | server.inject({ 6 | url: '/v1/swagger.yaml', 7 | headers: { 8 | Origin: 'http://foo.com', 9 | }, 10 | }).then((response) => { 11 | response.headers.should.containEql({ 'access-control-allow-origin': '*' }); 12 | }) 13 | )); 14 | }); 15 | -------------------------------------------------------------------------------- /api/models/document_category.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bookshelf = require('../../config').bookshelf; 4 | const BaseModel = require('./base'); 5 | 6 | const DocumentCategory = BaseModel.extend({ 7 | tableName: 'document_categories', 8 | visible: [ 9 | 'id', 10 | 'name', 11 | 'group', 12 | ], 13 | }); 14 | 15 | module.exports = bookshelf.model('DocumentCategory', DocumentCategory); 16 | -------------------------------------------------------------------------------- /migrations/20161213144433_add_is_primary_to_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('records', (table) => { 6 | table.boolean('is_primary') 7 | .defaultTo(false); 8 | }) 9 | ); 10 | 11 | exports.down = (knex) => ( 12 | knex.schema 13 | .table('records', (table) => { 14 | table.dropColumn('is_primary'); 15 | }) 16 | ); 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: airbnb-base 3 | env: 4 | node: true 5 | parserOptions: 6 | sourceType: script 7 | rules: 8 | strict: 9 | - error 10 | - safe 11 | quotes: 12 | - error 13 | - single 14 | - avoid-escape 15 | no-underscore-dangle: 16 | - off 17 | no-use-before-define: 18 | - error 19 | - functions: false 20 | arrow-parens: 21 | - error 22 | - always 23 | -------------------------------------------------------------------------------- /migrations/20170406122708_set_study_phase_arrays_with_null_element_to_null.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw("UPDATE trials SET study_phase = NULL WHERE study_phase = '{null}'") 6 | .raw("UPDATE records SET study_phase = NULL WHERE study_phase = '{null}'") 7 | ); 8 | 9 | exports.down = () => { 10 | throw Error('Destructive migration can\'t be rolled back.'); 11 | }; 12 | -------------------------------------------------------------------------------- /migrations/20170419122022_make_is_primary_not_nullable_and_set_current_nulls_to_false.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('UPDATE records SET is_primary = false where is_primary IS NULL') 6 | .raw('ALTER TABLE records ALTER COLUMN is_primary SET NOT NULL') 7 | ); 8 | 9 | exports.down = () => { 10 | throw Error('Destructive migration can\'t be rolled back.'); 11 | }; 12 | -------------------------------------------------------------------------------- /migrations/20161207171700_add_last_verification_date_to_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('records', (table) => { 6 | table.date('last_verification_date') 7 | .nullable(); 8 | }) 9 | ); 10 | 11 | exports.down = (knex) => ( 12 | knex.schema 13 | .table('records', (table) => { 14 | table.dropColumn('last_verification_date'); 15 | }) 16 | ); 17 | -------------------------------------------------------------------------------- /api/models/source.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./trial'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | 8 | const Source = BaseModel.extend({ 9 | tableName: 'sources', 10 | visible: [ 11 | 'id', 12 | 'name', 13 | 'source_url', 14 | 'terms_and_conditions_url', 15 | 'type', 16 | ], 17 | }); 18 | 19 | module.exports = bookshelf.model('Source', Source); 20 | -------------------------------------------------------------------------------- /test/e2e/sources.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | 5 | describe('(e2e) sources', () => { 6 | it('should be successful', () => ( 7 | server.inject('/v1/sources') 8 | .then((response) => { 9 | should(response.statusCode).eql(200); 10 | return JSON.parse(response.result); 11 | }) 12 | .then((apiResponse) => should(apiResponse.failedValidation).be.undefined()) 13 | )); 14 | }); 15 | -------------------------------------------------------------------------------- /migrations/20160816113418_add_fda_application_number_to_interventions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema.table('interventions', (table) => { 5 | table.text('fda_application_number') 6 | .nullable() 7 | .index(); 8 | }) 9 | ); 10 | 11 | exports.down = (knex) => ( 12 | knex.schema.table('interventions', (table) => { 13 | table.dropColumn('fda_application_number'); 14 | }) 15 | ); 16 | -------------------------------------------------------------------------------- /migrations/20170221113629_add_age_range_column_to_trials_and_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('trials', (table) => table.jsonb('age_range')) 6 | .table('records', (table) => table.jsonb('age_range')) 7 | ); 8 | 9 | exports.down = (knex) => ( 10 | knex.schema 11 | .table('trials', (table) => table.dropColumn('age_range')) 12 | .table('records', (table) => table.dropColumn('age_range')) 13 | ); 14 | -------------------------------------------------------------------------------- /migrations/20160527115742_fix_records_unique_constraint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('records', (table) => { 6 | table.dropUnique(undefined, 'trialrecords_primary_register_primary_id_unique'); 7 | table.unique(['source_id', 'primary_id']); 8 | }) 9 | ); 10 | 11 | exports.down = () => { 12 | // Rollback will fail on a good data 13 | throw Error('Destructive migration can\'t be rolled back.'); 14 | }; 15 | -------------------------------------------------------------------------------- /migrations/20160818194716_add_documentcloud_url_and_text_to_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema.table('documents', (table) => { 5 | table.text('text') 6 | .nullable(); 7 | table.text('documentcloud_url') 8 | .nullable(); 9 | }) 10 | ); 11 | 12 | exports.down = (knex) => ( 13 | knex.schema.table('documents', (table) => { 14 | table.dropColumns([ 15 | 'text', 16 | 'documentcloud_url', 17 | ]); 18 | }) 19 | ); 20 | -------------------------------------------------------------------------------- /migrations/20160907113052_add_url_and_terms_and_conditions_url_to_sources.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('sources', (table) => { 6 | table.text('url') 7 | .nullable(); 8 | table.text('terms_and_conditions_url') 9 | .nullable(); 10 | }) 11 | ); 12 | 13 | exports.down = (knex) => ( 14 | knex.schema 15 | .table('sources', (table) => { 16 | table.dropColumns(['url', 'terms_and_conditions_url']); 17 | }) 18 | ); 19 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('dotenv').config(); 4 | 5 | const db = { 6 | production: { 7 | client: 'pg', 8 | connection: process.env.DATABASE_URL, 9 | }, 10 | development: { 11 | client: 'pg', 12 | connection: process.env.DATABASE_URL, 13 | }, 14 | staging: { 15 | client: 'pg', 16 | connection: process.env.DATABASE_URL, 17 | }, 18 | test: { 19 | client: 'pg', 20 | connection: process.env.TEST_DATABASE_URL, 21 | }, 22 | }; 23 | 24 | module.exports = db; 25 | -------------------------------------------------------------------------------- /migrations/20160511184804_add_source_id_to_trials.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const schema = knex.schema; 5 | 6 | schema.table('trials', (table) => { 7 | table.text('source_id') 8 | .references('sources.id') 9 | .onUpdate('CASCADE'); 10 | }); 11 | 12 | return schema; 13 | }; 14 | 15 | exports.down = (knex) => { 16 | const schema = knex.schema; 17 | 18 | schema.table('trials', (table) => { 19 | table.dropColumn('source_id'); 20 | }); 21 | 22 | return schema; 23 | }; 24 | -------------------------------------------------------------------------------- /api/helpers/url-for.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | 5 | function urlFor(models) { 6 | const modelsArray = (Array.isArray(models)) ? models : [models]; 7 | const hasModelsWithoutId = (modelsArray.find((m) => m.id === undefined) !== undefined); 8 | 9 | if (!hasModelsWithoutId) { 10 | const path = modelsArray.map((model) => `${model.tableName}/${model.id}`); 11 | return `${config.url}/v1/${path.join('/')}`; 12 | } 13 | 14 | return undefined; 15 | } 16 | 17 | module.exports = urlFor; 18 | -------------------------------------------------------------------------------- /api/models/location.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./trial'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | 8 | const Location = BaseModel.extend({ 9 | tableName: 'locations', 10 | visible: [ 11 | 'id', 12 | 'name', 13 | 'type', 14 | ], 15 | trials() { 16 | return this.belongsToMany('Trial', 'trials_locations', 17 | 'location_id', 'trial_id').withPivot(['role']); 18 | }, 19 | }); 20 | 21 | module.exports = bookshelf.model('Location', Location); 22 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://opentrials:password@localhost:5432/opentrials_api_development 2 | TEST_DATABASE_URL=postgres://opentrials:password@localhost:5432/opentrials_api_test 3 | ELASTICSEARCH_URL=http://localhost:9200 4 | # ELASTICSEARCH_AWS_REGION=optional_region 5 | # ELASTICSEARCH_AWS_ACCESS_KEY=optional_access_key 6 | # ELASTICSEARCH_AWS_SECRET_KEY=optional_secret_key 7 | PORT=10010 8 | HOST=localhost 9 | URL=http://localhost:10010 10 | 11 | # Optional 12 | # https://github.com/tgriesser/knex/issues/852 13 | # PGSSLMODE=require 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | web: 2 | image: opentrials/api 3 | command: bash -c "./tools/wait-for-it.sh db:5432 && ./tools/wait-for-it.sh elasticsearch:9200 && npm start" 4 | ports: 5 | - "10010:80" 6 | environment: 7 | DATABASE_URL: postgres://postgres@db:5432/postgres 8 | ELASTICSEARCH_URL: http://elasticsearch:9200 9 | restart: always 10 | links: 11 | - db 12 | - elasticsearch 13 | 14 | db: 15 | image: postgres:9.5 16 | restart: always 17 | 18 | elasticsearch: 19 | image: elasticsearch:2.3 20 | restart: always 21 | -------------------------------------------------------------------------------- /migrations/20160902123540_documents_trial_id_url_and_fda_approval_id_must_be_unique.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('documents', (table) => { 6 | table.dropUnique(['trial_id', 'url']); 7 | table.unique(['trial_id', 'fda_approval_id', 'url']); 8 | }) 9 | ); 10 | 11 | exports.down = (knex) => ( 12 | knex.schema 13 | .table('documents', (table) => { 14 | table.unique(['trial_id', 'url']); 15 | table.dropUnique(['trial_id', 'fda_approval_id', 'url']); 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /api/controllers/persons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Person = require('../models/person'); 4 | 5 | function getPerson(req, res) { 6 | const id = req.swagger.params.id.value; 7 | 8 | return new Person({ id }).fetch({}) 9 | .then((person) => { 10 | if (person) { 11 | res.json(person); 12 | } else { 13 | res.status(404); 14 | res.finish(); 15 | } 16 | }) 17 | .catch((err) => { 18 | res.finish(); 19 | throw err; 20 | }); 21 | } 22 | 23 | module.exports = { 24 | getPerson, 25 | }; 26 | -------------------------------------------------------------------------------- /api/controllers/conditions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Condition = require('../models/condition'); 4 | 5 | function getCondition(req, res) { 6 | const id = req.swagger.params.id.value; 7 | 8 | return new Condition({ id }).fetch({}) 9 | .then((condition) => { 10 | if (condition) { 11 | res.json(condition); 12 | } else { 13 | res.status(404); 14 | res.finish(); 15 | } 16 | }) 17 | .catch((err) => { 18 | res.finish(); 19 | throw err; 20 | }); 21 | } 22 | 23 | module.exports = { 24 | getCondition, 25 | }; 26 | -------------------------------------------------------------------------------- /migrations/20160531140605_rename_trials_and_records_source_id_to_primary_source_id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('trials', (table) => table.renameColumn('source_id', 'primary_source_id')) 6 | .table('records', (table) => table.renameColumn('source_id', 'primary_source_id')) 7 | ); 8 | 9 | exports.down = (knex) => ( 10 | knex.schema 11 | .table('trials', (table) => table.renameColumn('primary_source_id', 'source_id')) 12 | .table('records', (table) => table.renameColumn('primary_source_id', 'source_id')) 13 | ); 14 | -------------------------------------------------------------------------------- /api/models/fda_approval.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./fda_application'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | 8 | const FDAApproval = BaseModel.extend({ 9 | tableName: 'fda_approvals', 10 | visible: [ 11 | 'id', 12 | 'supplement_number', 13 | 'type', 14 | 'action_date', 15 | 'notes', 16 | 'fda_application', 17 | ], 18 | fda_application() { 19 | return this.belongsTo('FDAApplication'); 20 | }, 21 | }); 22 | 23 | module.exports = bookshelf.model('FDAApproval', FDAApproval); 24 | -------------------------------------------------------------------------------- /api/controllers/document_categories.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const DocumentCategory = require('../models/document_category'); 4 | 5 | function listDocumentCategories(req, res) { 6 | return DocumentCategory.fetchAll() 7 | .then((categories) => { 8 | const response = { 9 | total_count: categories.length, 10 | items: categories.models.map((m) => m.toJSON()), 11 | }; 12 | res.json(response); 13 | }) 14 | .catch((err) => { 15 | res.finish(); 16 | throw err; 17 | }); 18 | } 19 | 20 | module.exports = { 21 | listDocumentCategories, 22 | }; 23 | -------------------------------------------------------------------------------- /api/controllers/interventions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Intervention = require('../models/intervention'); 4 | 5 | function getIntervention(req, res) { 6 | const id = req.swagger.params.id.value; 7 | 8 | return new Intervention({ id }).fetch({}) 9 | .then((intervention) => { 10 | if (intervention) { 11 | res.json(intervention); 12 | } else { 13 | res.status(404); 14 | res.finish(); 15 | } 16 | }) 17 | .catch((err) => { 18 | res.finish(); 19 | throw err; 20 | }); 21 | } 22 | 23 | module.exports = { 24 | getIntervention, 25 | }; 26 | -------------------------------------------------------------------------------- /api/controllers/organisations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Organisation = require('../models/organisation'); 4 | 5 | function getOrganisation(req, res) { 6 | const id = req.swagger.params.id.value; 7 | 8 | return new Organisation({ id }).fetch({}) 9 | .then((organisation) => { 10 | if (organisation) { 11 | res.json(organisation); 12 | } else { 13 | res.status(404); 14 | res.finish(); 15 | } 16 | }) 17 | .catch((err) => { 18 | res.finish(); 19 | throw err; 20 | }); 21 | } 22 | 23 | module.exports = { 24 | getOrganisation, 25 | }; 26 | -------------------------------------------------------------------------------- /api/models/condition.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./trial'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | const helpers = require('../helpers'); 8 | 9 | const Condition = BaseModel.extend({ 10 | tableName: 'conditions', 11 | visible: [ 12 | 'id', 13 | 'name', 14 | ], 15 | trials() { 16 | return this.belongsToMany('Trial', 'trials_conditions'); 17 | }, 18 | virtuals: { 19 | url() { 20 | return helpers.urlFor(this); 21 | }, 22 | }, 23 | }); 24 | 25 | module.exports = bookshelf.model('Condition', Condition); 26 | -------------------------------------------------------------------------------- /api/controllers/publications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Publication = require('../models/publication'); 4 | 5 | function getPublication(req, res) { 6 | const id = req.swagger.params.id.value; 7 | return new Publication({ id }).fetch({ withRelated: Publication.relatedModels }) 8 | .then((publication) => { 9 | if (publication) { 10 | res.json(publication); 11 | } else { 12 | res.status(404); 13 | res.finish(); 14 | } 15 | }) 16 | .catch((err) => { 17 | res.finish(); 18 | throw err; 19 | }); 20 | } 21 | 22 | module.exports = { 23 | getPublication, 24 | }; 25 | -------------------------------------------------------------------------------- /migrations/20161027182331_add_completion_date_to_trials_and_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('trials', (table) => { 6 | table.date('completion_date') 7 | .nullable(); 8 | }) 9 | .table('records', (table) => { 10 | table.date('completion_date') 11 | .nullable(); 12 | }) 13 | ); 14 | 15 | exports.down = (knex) => ( 16 | knex.schema 17 | .table('trials', (table) => { 18 | table.dropColumn('completion_date'); 19 | }) 20 | .table('records', (table) => { 21 | table.dropColumn('completion_date'); 22 | }) 23 | ); 24 | -------------------------------------------------------------------------------- /api/models/intervention.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./trial'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | const helpers = require('../helpers'); 8 | 9 | const Intervention = BaseModel.extend({ 10 | tableName: 'interventions', 11 | visible: [ 12 | 'id', 13 | 'name', 14 | 'type', 15 | ], 16 | trials() { 17 | return this.belongsToMany('Trial', 'trials_interventions'); 18 | }, 19 | virtuals: { 20 | url() { 21 | return helpers.urlFor(this); 22 | }, 23 | }, 24 | }); 25 | 26 | module.exports = bookshelf.model('Intervention', Intervention); 27 | -------------------------------------------------------------------------------- /api/models/person.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./trial'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | const helpers = require('../helpers'); 8 | 9 | const Person = BaseModel.extend({ 10 | tableName: 'persons', 11 | visible: [ 12 | 'id', 13 | 'name', 14 | ], 15 | trials() { 16 | return this.belongsToMany('Trial', 'trials_persons', 17 | 'person_id', 'trial_id').withPivot(['role']); 18 | }, 19 | virtuals: { 20 | url() { 21 | return helpers.urlFor(this); 22 | }, 23 | }, 24 | }); 25 | 26 | module.exports = bookshelf.model('Person', Person); 27 | -------------------------------------------------------------------------------- /migrations/20160518152405_rename_problems_to_conditions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('trials_problems', (table) => { 6 | table.renameColumn('problem_id', 'condition_id'); 7 | }) 8 | .renameTable('problems', 'conditions') 9 | .renameTable('trials_problems', 'trials_conditions') 10 | ); 11 | 12 | exports.down = (knex) => ( 13 | knex.schema 14 | .table('trials_conditions', (table) => { 15 | table.renameColumn('condition_id', 'problem_id'); 16 | }) 17 | .renameTable('conditions', 'problems') 18 | .renameTable('trials_conditions', 'trials_problems') 19 | ); 20 | -------------------------------------------------------------------------------- /migrations/20161207142951_add_results_exemption_date_to_trials_and_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('trials', (table) => { 6 | table.date('results_exemption_date') 7 | .nullable(); 8 | }) 9 | .table('records', (table) => { 10 | table.date('results_exemption_date') 11 | .nullable(); 12 | }) 13 | ); 14 | 15 | exports.down = (knex) => ( 16 | knex.schema 17 | .table('trials', (table) => { 18 | table.dropColumn('results_exemption_date'); 19 | }) 20 | .table('records', (table) => { 21 | table.dropColumn('results_exemption_date'); 22 | }) 23 | ); 24 | -------------------------------------------------------------------------------- /migrations/20161026164912_alter_files_text_to_array_and_rename_to_pages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw(` 6 | ALTER TABLE files 7 | RENAME COLUMN text TO pages 8 | `) 9 | .raw(` 10 | ALTER TABLE files 11 | ALTER COLUMN pages TYPE text[] 12 | USING array[pages]::text[] 13 | `) 14 | ); 15 | 16 | exports.down = (knex) => ( 17 | knex.schema 18 | .raw(` 19 | ALTER TABLE files 20 | ALTER COLUMN pages TYPE text 21 | USING array_to_string(pages, ' ') 22 | `) 23 | .raw(` 24 | ALTER TABLE files 25 | RENAME COLUMN pages TO text 26 | `) 27 | ); 28 | -------------------------------------------------------------------------------- /api/models/organisation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./trial'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | const helpers = require('../helpers'); 8 | 9 | const Organisation = BaseModel.extend({ 10 | tableName: 'organisations', 11 | visible: [ 12 | 'id', 13 | 'name', 14 | ], 15 | trials() { 16 | return this.belongsToMany('Trial', 'trials_organisations', 17 | 'organisation_id', 'trial_id').withPivot(['role']); 18 | }, 19 | virtuals: { 20 | url() { 21 | return helpers.urlFor(this); 22 | }, 23 | }, 24 | }); 25 | 26 | module.exports = bookshelf.model('Organisation', Organisation); 27 | -------------------------------------------------------------------------------- /migrations/20160908171543_remove_documents_url_documentcloud_id_and_text_and_set_file_id_as_not_nullable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('ALTER TABLE documents ALTER COLUMN file_id SET NOT NULL') 6 | .table('documents', (table) => { 7 | table.dropColumns([ 8 | 'url', 9 | 'documentcloud_url', 10 | 'text', 11 | ]); 12 | }) 13 | ); 14 | 15 | exports.down = (knex) => ( 16 | knex.schema 17 | .raw('ALTER TABLE documents ALTER COLUMN file_id DROP NOT NULL') 18 | .table('documents', (table) => { 19 | table.text('url'); 20 | table.text('documentcloud_url'); 21 | table.text('text'); 22 | }) 23 | ); 24 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentrials-api", 3 | "description": "API for OpenTrials.net", 4 | "scripts": { 5 | "postdeploy": "npm run migrate && npm run seed && npm run reindex" 6 | }, 7 | "env": { 8 | "URL": { 9 | "description": "Base URL for this app (e.g. http://api.opentrials.net)", 10 | "required": true 11 | } 12 | }, 13 | "addons": [ 14 | { 15 | "plan": "heroku-postgresql", 16 | "options": { 17 | "version": "9.4" 18 | } 19 | }, 20 | { 21 | "plan": "bonsai:sandbox-10", 22 | "options": { 23 | "version": "2.3" 24 | } 25 | } 26 | ], 27 | "buildpacks": [ 28 | { 29 | "url": "heroku/nodejs" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /test/api/models/fda_application.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | 5 | describe('FDAApplication', () => { 6 | before(clearDB); 7 | 8 | afterEach(clearDB); 9 | 10 | describe('virtuals', () => { 11 | describe('type', () => { 12 | it('extracts the type from the ID', () => ( 13 | factory.build('fda_application', { id: 'ANDA000000' }) 14 | .then((fdaApproval) => should(fdaApproval.toJSON().type).equal('ANDA')) 15 | )); 16 | 17 | it('is undefined when could not extract it from the ID', () => { 18 | factory.build('fda_application', { id: '0' }) 19 | .then((fdaApproval) => should(fdaApproval.toJSON().type).be.undefined()); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /migrations/20160513191730_rename_trials_secondary_ids_to_identifiers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const schema = knex.schema; 5 | 6 | schema.table('trials', (table) => { 7 | table.renameColumn('secondary_ids', 'identifiers'); 8 | }); 9 | 10 | schema.table('trialrecords', (table) => { 11 | table.renameColumn('secondary_ids', 'identifiers'); 12 | }); 13 | 14 | return schema; 15 | }; 16 | 17 | exports.down = (knex) => { 18 | const schema = knex.schema; 19 | 20 | schema.table('trials', (table) => { 21 | table.renameColumn('identifiers', 'secondary_ids'); 22 | }); 23 | 24 | schema.table('trialrecords', (table) => { 25 | table.renameColumn('identifiers', 'secondary_ids'); 26 | }); 27 | 28 | return schema; 29 | }; 30 | -------------------------------------------------------------------------------- /api/models/risk_of_bias_criteria.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./risk_of_bias'); 4 | 5 | const bookshelf = require('../../config').bookshelf; 6 | const BaseModel = require('./base'); 7 | 8 | const RiskOfBiasCriteria = BaseModel.extend({ 9 | tableName: 'risk_of_bias_criterias', 10 | hasTimestamps: true, 11 | visible: [ 12 | 'id', 13 | 'name', 14 | 'value', 15 | ], 16 | risk_of_bias() { 17 | return this.belongsToMany('RiskOfBias', 'risk_of_biases_risk_of_bias_criterias', 18 | 'risk_of_bias_id', 'risk_of_bias_criteria_id').withPivot(['value']); 19 | }, 20 | virtuals: { 21 | value() { 22 | return this.pivot.attributes.value; 23 | }, 24 | }, 25 | }); 26 | 27 | module.exports = bookshelf.model('RiskOfBiasCriteria', RiskOfBiasCriteria); 28 | -------------------------------------------------------------------------------- /migrations/20160501171307_add_source_id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableNames = [ 4 | 'interventions', 5 | 'locations', 6 | 'organisations', 7 | 'persons', 8 | 'problems', 9 | ]; 10 | 11 | exports.up = (knex, Promise) => { 12 | const operations = tableNames.map((tableName) => ( 13 | knex.schema.table(tableName, (table) => { 14 | table.uuid('source_id') 15 | .references('sources.id') 16 | .nullable(); 17 | }) 18 | )); 19 | 20 | return Promise.all(operations); 21 | }; 22 | 23 | exports.down = (knex, Promise) => { 24 | const operations = tableNames.map((tableName) => ( 25 | knex.schema.table(tableName, (table) => { 26 | table.dropColumn('source_id'); 27 | }) 28 | )); 29 | 30 | return Promise.all(operations); 31 | }; 32 | -------------------------------------------------------------------------------- /migrations/20160820171646_clean_data_model_in_trials_and_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('trials', (table) => { 6 | table.renameColumn('primary_source_id', 'source_id'); 7 | table.dropColumn('primary_register'); 8 | table.dropColumn('primary_id'); 9 | table.dropColumn('facts'); 10 | table.dropColumn('slug'); 11 | }) 12 | .table('records', (table) => { 13 | table.renameColumn('primary_source_id', 'source_id'); 14 | table.dropColumn('primary_register'); 15 | table.dropColumn('primary_id'); 16 | table.index('identifiers', undefined, 'GIN'); 17 | }) 18 | ); 19 | 20 | exports.down = () => { 21 | throw Error('Destructive migration can\'t be rolled back.'); 22 | }; 23 | -------------------------------------------------------------------------------- /migrations/20161020094503_add_trials_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .createTable('trials_documents', (table) => { 6 | table.uuid('trial_id') 7 | .references('trials.id'); 8 | table.uuid('document_id') 9 | .references('documents.id') 10 | .index(); 11 | 12 | table.primary(['trial_id', 'document_id']); 13 | }) 14 | .raw(` 15 | INSERT INTO trials_documents (trial_id, document_id) 16 | SELECT trial_id, id FROM documents 17 | WHERE trial_id IS NOT NULL 18 | `) 19 | .table('documents', (table) => { 20 | table.dropColumn('trial_id'); 21 | }) 22 | ); 23 | 24 | exports.down = () => { 25 | throw Error('Destructive migration can\'t be rolled back.'); 26 | }; 27 | -------------------------------------------------------------------------------- /migrations/20170407155833_change_pubmed_source_type_to_journal.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('ALTER TABLE sources DROP CONSTRAINT sources_type_check;') 6 | .raw(`ALTER TABLE sources ADD CONSTRAINT sources_type_check 7 | CHECK (type = ANY (ARRAY['register'::text, 'other'::text, 'journal'::text]))`) 8 | .raw('UPDATE sources SET type = \'journal\' WHERE id = \'pubmed\'') 9 | ); 10 | 11 | exports.down = (knex) => ( 12 | knex.schema 13 | .raw('UPDATE sources SET type = \'other\' WHERE id = \'pubmed\'') 14 | .raw('ALTER TABLE sources DROP CONSTRAINT sources_type_check;') 15 | .raw(`ALTER TABLE sources ADD CONSTRAINT sources_type_check 16 | CHECK (type = ANY (ARRAY['register'::text, 'other'::text])) 17 | `) 18 | ); 19 | -------------------------------------------------------------------------------- /migrations/20170125181013_alter_trials_and_records_study_phase_to_text_array.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw(` 6 | ALTER TABLE trials 7 | ALTER COLUMN study_phase TYPE text[] 8 | USING array[study_phase]::text[] 9 | `) 10 | .raw(` 11 | ALTER TABLE records 12 | ALTER COLUMN study_phase TYPE text[] 13 | USING array[study_phase]::text[] 14 | `) 15 | ); 16 | 17 | exports.down = (knex) => ( 18 | knex.schema 19 | .raw(` 20 | ALTER TABLE trials 21 | ALTER COLUMN study_phase TYPE text 22 | USING array_to_string(study_phase, '|') 23 | `) 24 | .raw(` 25 | ALTER TABLE records 26 | ALTER COLUMN study_phase TYPE text 27 | USING array_to_string(study_phase, '|') 28 | `) 29 | ); 30 | -------------------------------------------------------------------------------- /migrations/20160415172210_remove_trials_trialrecords_table.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const query = ` 5 | UPDATE trialrecords 6 | SET trial_id = trials_trialrecords.trial_id 7 | FROM ( 8 | SELECT trial_id, trialrecord_id 9 | FROM trials_trialrecords 10 | ) AS trials_trialrecords 11 | WHERE trialrecords.id = trials_trialrecords.trialrecord_id 12 | `; 13 | function addTrialIdColumn(table) { 14 | table.uuid('trial_id') 15 | .references('trials.id') 16 | .index(); 17 | } 18 | 19 | return knex.schema.table('trialrecords', addTrialIdColumn) 20 | .then(() => knex.raw(query)) 21 | .then(() => knex.schema.dropTable('trials_trialrecords')); 22 | }; 23 | 24 | exports.down = () => { 25 | throw Error('Destructive migration can\'t be rolled back.'); 26 | }; 27 | -------------------------------------------------------------------------------- /test/api/models/location.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const Location = require('../../../api/models/location'); 5 | 6 | describe('Location', () => { 7 | before(clearDB); 8 | 9 | afterEach(clearDB); 10 | 11 | describe('trials', () => { 12 | it('returns trials related to the location', () => { 13 | let trialId; 14 | 15 | return factory.create('trialWithRelated') 16 | .then((trial) => { 17 | trialId = trial.id; 18 | const locationId = toJSON(trial).locations[0].id; 19 | return new Location({ id: locationId }).fetch({ withRelated: 'trials' }); 20 | }).then((loc) => { 21 | const trialsIds = loc.related('trials').models.map((trial) => trial.id); 22 | should(trialsIds).containEql(trialId); 23 | }); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /migrations/20161001173327_add_url_to_documents_without_files.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('documents', (table) => { 6 | table.text('url'); 7 | 8 | table.unique([ 9 | 'trial_id', 10 | 'file_id', 11 | 'fda_approval_id', 12 | 'url', 13 | ]); 14 | }) 15 | .raw('ALTER TABLE documents ALTER COLUMN file_id DROP NOT NULL') 16 | .raw(`ALTER TABLE documents 17 | ADD CONSTRAINT file_id_xor_url_check CHECK ( 18 | (file_id IS NULL AND url IS NOT NULL) OR 19 | (file_id IS NOT NULL AND url IS NULL) 20 | )`) 21 | ); 22 | 23 | exports.down = (knex) => ( 24 | knex.schema 25 | .table('documents', (table) => { 26 | table.dropColumn('url'); 27 | }) 28 | .raw('ALTER TABLE documents ALTER COLUMN file_id SET NOT NULL') 29 | ); 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # Commenting this out is preferred by some people, see 27 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 28 | node_modules 29 | 30 | # Users Environment Variables 31 | .lock-wscript 32 | 33 | # Runtime configuration for swagger app 34 | config/runtime.yaml 35 | 36 | # Database files 37 | *.db 38 | 39 | # Environment file 40 | .env 41 | -------------------------------------------------------------------------------- /api/models/file.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bookshelf = require('../../config').bookshelf; 4 | const BaseModel = require('./base'); 5 | 6 | const File = BaseModel.extend({ 7 | tableName: 'files', 8 | visible: [ 9 | 'id', 10 | 'source_url', 11 | 'documentcloud_id', 12 | 'sha1', 13 | 'pages', 14 | ], 15 | serialize(...args) { 16 | const attributes = Object.assign( 17 | {}, 18 | Object.getPrototypeOf(File.prototype).serialize.call(this, args) 19 | ); 20 | 21 | if (attributes.pages) { 22 | attributes.pages = attributes.pages.map((text, num) => ({ text, num: num + 1 })); 23 | } 24 | 25 | return attributes; 26 | }, 27 | toJSONSummary() { 28 | const attributes = this.toJSON(); 29 | 30 | delete attributes.pages; 31 | 32 | return attributes; 33 | }, 34 | }); 35 | 36 | module.exports = bookshelf.model('File', File); 37 | -------------------------------------------------------------------------------- /migrations/20160708115615_remove_trials_documents_and_documents_slug_and_add_trial_id_type_and_url_to_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .dropTable('trials_documents') 6 | .table('documents', (table) => { 7 | table.dropColumns([ 8 | 'slug', 9 | ]); 10 | table.uuid('trial_id') 11 | .notNullable() 12 | .references('trials.id'); 13 | table.enu('type', [ 14 | 'csr', 15 | 'epar_segment', 16 | 'blank_consent_form', 17 | 'patient_information_sheet', 18 | 'blank_case_report_form', 19 | 'other', 20 | ]).notNullable(); 21 | table.text('url') 22 | .notNullable(); 23 | 24 | table.unique(['trial_id', 'url']); 25 | }) 26 | ); 27 | 28 | exports.down = () => { 29 | throw Error('Destructive migration can\'t be rolled back.'); 30 | }; 31 | -------------------------------------------------------------------------------- /migrations/20170216104317_create_audit_log_for_trial_deduplication.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .createTable('trial_deduplication_logs', (table) => { 6 | table.increments(); 7 | 8 | table.uuid('record_id') 9 | .references('records.id') 10 | .notNullable(); 11 | table.uuid('trial_id') 12 | .references('trials.id') 13 | .notNullable(); 14 | table.text('method') 15 | .notNullable(); 16 | table.text('commit'); 17 | 18 | table.timestamps(true, true); 19 | }) 20 | .raw(` 21 | CREATE TRIGGER trial_deduplication_logs_set_updated_at 22 | BEFORE UPDATE ON trial_deduplication_logs 23 | FOR EACH ROW EXECUTE PROCEDURE set_updated_at() 24 | `) 25 | ); 26 | 27 | exports.down = (knex) => ( 28 | knex.schema.dropTableIfExists('trial_deduplication_logs') 29 | ); 30 | -------------------------------------------------------------------------------- /migrations/20170122130426_set_default_for_created_at_and_updated_at.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | 5 | const tablesWithTimestamps = [ 6 | 'conditions', 7 | 'fda_approvals', 8 | 'interventions', 9 | 'locations', 10 | 'organisations', 11 | 'persons', 12 | 'publications', 13 | 'records', 14 | 'sources', 15 | 'trials', 16 | ]; 17 | 18 | exports.up = (knex) => ( 19 | Promise.map(tablesWithTimestamps, (table) => 20 | knex.schema.raw( 21 | `ALTER TABLE ${table} 22 | ALTER COLUMN created_at SET DEFAULT now(), 23 | ALTER COLUMN updated_at SET DEFAULT now();` 24 | ) 25 | ) 26 | ); 27 | 28 | exports.down = (knex) => ( 29 | Promise.map(tablesWithTimestamps, (table) => 30 | knex.schema.raw( 31 | `ALTER TABLE ${table} 32 | ALTER COLUMN created_at DROP DEFAULT, 33 | ALTER COLUMN updated_at DROP DEFAULT;` 34 | ) 35 | ) 36 | ); 37 | -------------------------------------------------------------------------------- /config/pg_types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Overwrite node-pg-types date parser. See https://github.com/tgriesser/knex/issues/1750 4 | const pgTypes = require('pg').types; 5 | 6 | const DATE_OID = 1082; 7 | const DATE_ARRAY_OID = 1182; 8 | 9 | function parseDate(value) { 10 | if (!value) { 11 | return null; 12 | } 13 | 14 | return new Date(Date.parse(value)); 15 | } 16 | 17 | function parseDateArray(value) { 18 | if (!value) { 19 | return null; 20 | } 21 | 22 | const parser = pgTypes.arrayParser.create(value, (entry) => { 23 | let parsedEntry = entry; 24 | if (String(entry).toLowerCase() !== String(null)) { 25 | parsedEntry = parseDate(entry); 26 | } else { 27 | parsedEntry = null; 28 | } 29 | return parsedEntry; 30 | }); 31 | 32 | return parser.parse(); 33 | } 34 | 35 | pgTypes.setTypeParser(DATE_OID, parseDate); 36 | pgTypes.setTypeParser(DATE_ARRAY_OID, parseDateArray); 37 | -------------------------------------------------------------------------------- /migrations/20161021092257_add_uniqueness_constraints_to_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .alterTable('documents', (table) => { 6 | table.unique(['fda_approval_id', 'file_id', 'name']); 7 | }) 8 | .raw(` 9 | CREATE UNIQUE INDEX 10 | non_fda_documents_type_file_id_unique 11 | ON documents (type, file_id) 12 | WHERE fda_approval_id IS NULL 13 | `) 14 | .raw(` 15 | CREATE UNIQUE INDEX 16 | non_fda_documents_type_url_unique 17 | ON documents (type, url) 18 | WHERE fda_approval_id IS NULL 19 | `) 20 | ); 21 | 22 | exports.down = (knex) => ( 23 | knex.schema 24 | .alterTable('documents', (table) => { 25 | table.dropUnique(['fda_approval_id', 'file_id', 'name']); 26 | }) 27 | .raw('DROP INDEX non_fda_documents_type_file_id_unique') 28 | .raw('DROP INDEX non_fda_documents_type_url_unique') 29 | ); 30 | -------------------------------------------------------------------------------- /api/models/risk_of_bias.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./source'); 4 | require('./trial'); 5 | require('./risk_of_bias_criteria'); 6 | 7 | const bookshelf = require('../../config').bookshelf; 8 | const BaseModel = require('./base'); 9 | 10 | const RiskOfBias = BaseModel.extend({ 11 | tableName: 'risk_of_biases', 12 | hasTimestamps: true, 13 | visible: [ 14 | 'id', 15 | 'trial_id', 16 | 'source_id', 17 | 'source_url', 18 | 'study_id', 19 | 'risk_of_bias_criteria', 20 | 'created_at', 21 | 'updated_at', 22 | 'source', 23 | ], 24 | source() { 25 | return this.belongsTo('Source', 'source_id'); 26 | }, 27 | risk_of_bias_criteria() { 28 | return this.belongsToMany('RiskOfBiasCriteria', 'risk_of_biases_risk_of_bias_criterias', 29 | 'risk_of_bias_id', 'risk_of_bias_criteria_id').withPivot(['value']); 30 | }, 31 | }); 32 | 33 | module.exports = bookshelf.model('RiskOfBias', RiskOfBias); 34 | -------------------------------------------------------------------------------- /migrations/20160501175732_add_slug_and_facts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableNames = [ 4 | 'documents', 5 | 'interventions', 6 | 'locations', 7 | 'organisations', 8 | 'persons', 9 | 'problems', 10 | 'publications', 11 | 'trials', 12 | ]; 13 | 14 | exports.up = (knex) => { 15 | const operations = tableNames.map((tableName) => ( 16 | knex.schema.table(tableName, (table) => { 17 | table.text('slug') 18 | .nullable() 19 | .unique(); 20 | table.specificType('facts', 'text[]') 21 | .nullable() 22 | .index(undefined, 'GIN'); 23 | }) 24 | )); 25 | 26 | return Promise.all(operations); 27 | }; 28 | 29 | exports.down = (knex) => { 30 | const operations = tableNames.map((tableName) => ( 31 | knex.schema.table(tableName, (table) => { 32 | table.dropColumns([ 33 | 'slug', 34 | 'facts', 35 | ]); 36 | }) 37 | )); 38 | 39 | return Promise.all(operations); 40 | }; 41 | -------------------------------------------------------------------------------- /migrations/20160523160519_trials_remove_not_null.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableNames = [ 4 | 'trials', 5 | 'records', 6 | ]; 7 | 8 | const fieldNames = [ 9 | 'registration_date', 10 | 'brief_summary', 11 | 'recruitment_status', 12 | 'eligibility_criteria', 13 | 'study_type', 14 | 'study_design', 15 | 'study_phase', 16 | ]; 17 | 18 | exports.up = (knex) => { 19 | const schema = knex.schema; 20 | 21 | tableNames.forEach((tableName) => { 22 | fieldNames.forEach((fieldName) => { 23 | schema.raw(`ALTER TABLE ${tableName} ALTER COLUMN ${fieldName} DROP NOT NULL`); 24 | }); 25 | }); 26 | 27 | return schema; 28 | }; 29 | 30 | exports.down = (knex) => { 31 | const schema = knex.schema; 32 | 33 | tableNames.forEach((tableName) => { 34 | fieldNames.forEach((fieldName) => { 35 | schema.raw(`ALTER TABLE ${tableName} ALTER COLUMN ${fieldName} SET NOT NULL`); 36 | }); 37 | }); 38 | 39 | return schema; 40 | }; 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6.9.5 4 | sudo: required 5 | cache: 6 | directories: 7 | - node_modules 8 | - .nvm 9 | env: 10 | global: 11 | - NODE_ENV=test 12 | - TEST_DATABASE_URL=postgres://postgres@localhost:5432/opentrials_api_test 13 | - ELASTICSEARCH_URL=http://localhost:9200 14 | - URL=http://localhost:10010 15 | 16 | addons: 17 | postgresql: "9.4" 18 | services: 19 | - postgresql 20 | before_install: 21 | - curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.6.8.deb && sudo dpkg -i --force-confnew elasticsearch-5.6.8.deb && sudo service elasticsearch restart 22 | before_script: 23 | - touch .env 24 | - psql -c 'create database opentrials_api_test;' -U postgres 25 | - npm run migrate 26 | # Run seed twice to make sure we're cleaning the DB correctly 27 | - npm run seed 28 | - npm run seed 29 | - npm run reindex 30 | script: 31 | - npm run e2e 32 | - npm run coveralls 33 | -------------------------------------------------------------------------------- /migrations/20160907170328_create_files_belonging_to_many_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .createTable('files', (table) => { 6 | table.uuid('id') 7 | .primary(); 8 | 9 | table.text('documentcloud_id') 10 | .nullable() 11 | .unique(); 12 | table.text('sha1') 13 | .notNullable() 14 | .unique(); 15 | table.text('url') 16 | .notNullable() 17 | .unique(); 18 | table.text('text') 19 | .nullable(); 20 | }) 21 | .table('documents', (table) => { 22 | table.uuid('file_id') 23 | .references('files.id'); 24 | }) 25 | .raw('ALTER TABLE documents ALTER COLUMN url DROP NOT NULL') 26 | ); 27 | 28 | exports.down = (knex) => ( 29 | knex.schema 30 | .table('documents', (table) => table.dropColumn('file_id')) 31 | .raw('ALTER TABLE documents ALTER COLUMN url SET NOT NULL') 32 | .dropTableIfExists('files') 33 | ); 34 | -------------------------------------------------------------------------------- /migrations/20160413110458_add_gender_and_has_published_results_to_trials_and_trialrecords.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex, Promise) => { 4 | function addHasPublishedResultsAndGender(table) { 5 | table.boolean('has_published_results') 6 | .nullable(); 7 | 8 | table.enu('gender', [ 9 | 'both', 10 | 'male', 11 | 'female', 12 | ]).nullable(); 13 | } 14 | 15 | return Promise.all([ 16 | knex.schema.table('trials', addHasPublishedResultsAndGender), 17 | knex.schema.table('trialrecords', addHasPublishedResultsAndGender), 18 | ]); 19 | }; 20 | 21 | exports.down = (knex, Promise) => { 22 | function dropHasPublishedResultsAndGender(table) { 23 | table.dropColumns([ 24 | 'has_published_results', 25 | 'gender', 26 | ]); 27 | } 28 | 29 | return Promise.all([ 30 | knex.schema.table('trials', dropHasPublishedResultsAndGender), 31 | knex.schema.table('trialrecords', dropHasPublishedResultsAndGender), 32 | ]); 33 | }; 34 | -------------------------------------------------------------------------------- /test/api/controllers/document_categories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | 'use strict'; 4 | 5 | const DocumentCategory = require('../../../api/models/document_category'); 6 | 7 | describe('Document category', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('GET /v1/document_categories', () => { 13 | it('returns the document categories', () => { 14 | let cat; 15 | 16 | return factory.create('document_category') 17 | .then((_cat) => new DocumentCategory({ id: _cat.attributes.id }).fetch()) 18 | .then((_cat) => (cat = _cat)) 19 | .then(() => server.inject('/v1/document_categories')) 20 | .then((response) => { 21 | response.statusCode.should.equal(200); 22 | 23 | const expectedResult = { 24 | total_count: 1, 25 | items: [cat.toJSON()], 26 | }; 27 | const result = JSON.parse(response.result); 28 | result.should.deepEqual(toJSON(expectedResult)); 29 | }); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /migrations/20160510204856_alter_source_id_to_equal_source_name.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableNames = [ 4 | 'documents', 5 | 'interventions', 6 | 'locations', 7 | 'organisations', 8 | 'persons', 9 | 'problems', 10 | 'publications', 11 | 'trialrecords', 12 | ]; 13 | 14 | exports.up = (knex) => { 15 | const schema = knex.schema; 16 | 17 | tableNames.forEach((tableName) => { 18 | schema.table(tableName, (table) => { 19 | table.dropForeign('source_id'); 20 | }); 21 | schema.raw(`ALTER TABLE ${tableName} ALTER COLUMN source_id TYPE text`); 22 | }); 23 | 24 | schema.raw('ALTER TABLE sources ALTER COLUMN id TYPE text'); 25 | 26 | tableNames.forEach((tableName) => { 27 | schema.table(tableName, (table) => { 28 | table.foreign('source_id') 29 | .references('sources.id') 30 | .onUpdate('CASCADE'); 31 | }); 32 | }); 33 | 34 | schema.raw('UPDATE sources SET id = name'); 35 | 36 | return schema; 37 | }; 38 | 39 | exports.down = () => { 40 | throw Error('Destructive migration can\'t be rolled back.'); 41 | }; 42 | -------------------------------------------------------------------------------- /migrations/20160407111800_add_meta_fields_to_entities.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableNames = [ 4 | 'interventions', 5 | 'locations', 6 | 'organisations', 7 | 'persons', 8 | 'problems', 9 | 'sources', 10 | 'trials', 11 | 'trialrecords', 12 | ]; 13 | 14 | exports.up = (knex) => { 15 | const operations = tableNames.map((tableName) => ( 16 | knex.schema.table(tableName, (table) => { 17 | table.timestamps(); 18 | table.specificType('links', 'text[]') 19 | .nullable() 20 | .index(undefined, 'GIN'); 21 | table.specificType('facts', 'text[]') 22 | .nullable() 23 | .index(undefined, 'GIN'); 24 | }) 25 | )); 26 | 27 | return Promise.all(operations); 28 | }; 29 | 30 | exports.down = (knex) => { 31 | const operations = tableNames.map((tableName) => ( 32 | knex.schema.table(tableName, (table) => { 33 | table.dropColumns([ 34 | 'created_at', 35 | 'updated_at', 36 | 'links', 37 | 'facts', 38 | ]); 39 | }) 40 | )); 41 | 42 | return Promise.all(operations); 43 | }; 44 | -------------------------------------------------------------------------------- /test/api/models/person.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const helpers = require('../../../api/helpers'); 5 | const Person = require('../../../api/models/person'); 6 | 7 | describe('Person', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('trials', () => { 13 | it('returns trials related to the person', () => { 14 | let trialId; 15 | 16 | return factory.create('trialWithRelated') 17 | .then((trial) => { 18 | trialId = trial.id; 19 | const personId = toJSON(trial).persons[0].id; 20 | return new Person({ id: personId }).fetch({ withRelated: 'trials' }); 21 | }).then((person) => { 22 | const trialsIds = person.related('trials').models.map((trial) => trial.id); 23 | should(trialsIds).containEql(trialId); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('url', () => { 29 | it('returns the url', () => ( 30 | factory.build('person') 31 | .then((person) => should(person.toJSON().url).eql(helpers.urlFor(person))) 32 | )); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /api/models/fda_application.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./fda_approval'); 4 | require('./organisation'); 5 | 6 | const helpers = require('../helpers'); 7 | const bookshelf = require('../../config').bookshelf; 8 | const BaseModel = require('./base'); 9 | 10 | const relatedModels = [ 11 | 'fda_approvals', 12 | 'organisation', 13 | ]; 14 | 15 | const FDAApplication = BaseModel.extend({ 16 | tableName: 'fda_applications', 17 | visible: [ 18 | 'id', 19 | 'drug_name', 20 | 'active_ingredients', 21 | 'fda_approvals', 22 | 'organisation', 23 | ], 24 | fda_approvals() { 25 | return this.hasMany('FDAApproval'); 26 | }, 27 | organisation() { 28 | return this.belongsTo('Organisation'); 29 | }, 30 | virtuals: { 31 | url() { 32 | return helpers.urlFor(this); 33 | }, 34 | type() { 35 | const matches = this.id.match(/^[A-Z]+/i); 36 | if (matches) { 37 | return matches[0]; 38 | } 39 | return undefined; 40 | }, 41 | }, 42 | }, { 43 | relatedModels, 44 | }); 45 | 46 | module.exports = bookshelf.model('FDAApplication', FDAApplication); 47 | -------------------------------------------------------------------------------- /migrations/20170122185448_add_trigger_for_updated_at.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | 5 | const tablesWithTimestamps = ['conditions', 'fda_applications', 'fda_approvals', 6 | 'interventions', 'locations', 'organisations', 'persons', 'publications', 7 | 'records', 'risk_of_bias_criterias', 'risk_of_biases', 'sources', 'trials']; 8 | 9 | exports.up = (knex) => ( 10 | knex.raw( 11 | `CREATE FUNCTION set_updated_at() 12 | RETURNS TRIGGER 13 | LANGUAGE plpgsql 14 | AS $$ 15 | BEGIN 16 | NEW.updated_at := now(); 17 | RETURN NEW; 18 | END; 19 | $$;` 20 | ).then(() => 21 | Promise.map(tablesWithTimestamps, (table) => 22 | knex.raw( 23 | `CREATE TRIGGER ${table}_set_updated_at 24 | BEFORE UPDATE ON ${table} 25 | FOR EACH ROW EXECUTE PROCEDURE set_updated_at();` 26 | ) 27 | ) 28 | ) 29 | ); 30 | 31 | exports.down = (knex) => ( 32 | Promise.map(tablesWithTimestamps, (table) => 33 | knex.raw(`DROP TRIGGER ${table}_set_updated_at ON ${table};`) 34 | ).then(() => 35 | knex.raw('DROP FUNCTION set_updated_at();') 36 | ) 37 | ); 38 | -------------------------------------------------------------------------------- /test/api/controllers/sources.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Sources', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/sources', () => { 9 | it('returns all the sources', () => ( 10 | factory.createMany('source', 2).then((models) => ( 11 | server.inject('/v1/sources') 12 | .then((response) => { 13 | response.statusCode.should.equal(200); 14 | 15 | const expectedResult = JSON.parse(JSON.stringify(models)); 16 | const result = JSON.parse(response.result); 17 | 18 | result.should.deepEqual(expectedResult); 19 | }) 20 | )) 21 | )); 22 | 23 | it('returns all the attributes', () => { 24 | const attributes = [ 25 | 'id', 26 | 'name', 27 | 'source_url', 28 | 'terms_and_conditions_url', 29 | 'type', 30 | ]; 31 | factory.create('source').then(() => ( 32 | server.inject('/v1/sources') 33 | .then((response) => JSON.parse(response.payload)[0] 34 | .should.have.keys(...attributes)) 35 | )); 36 | }); 37 | }); 38 | }); 39 | 40 | -------------------------------------------------------------------------------- /test/api/models/condition.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const helpers = require('../../../api/helpers'); 5 | const Condition = require('../../../api/models/condition'); 6 | 7 | describe('Condition', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('trials', () => { 13 | it('returns trials related to the condition', () => { 14 | let trialId; 15 | 16 | return factory.create('trialWithRelated') 17 | .then((trial) => { 18 | trialId = trial.id; 19 | const conditionId = toJSON(trial).conditions[0].id; 20 | return new Condition({ id: conditionId }).fetch({ withRelated: 'trials' }); 21 | }).then((condition) => { 22 | const trialsIds = condition.related('trials').models.map((trial) => trial.id); 23 | should(trialsIds).containEql(trialId); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('url', () => { 29 | it('returns the url', () => ( 30 | factory.build('condition') 31 | .then((condition) => should(condition.toJSON().url).eql(helpers.urlFor(condition))) 32 | )); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /api/models/publication.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bookshelf = require('../../config').bookshelf; 4 | const BaseModel = require('./base'); 5 | const helpers = require('../helpers'); 6 | require('./source'); 7 | 8 | const relatedModels = [ 9 | 'source', 10 | ]; 11 | 12 | const Publication = BaseModel.extend({ 13 | tableName: 'publications', 14 | hasTimestamps: true, 15 | visible: [ 16 | 'id', 17 | 'source', 18 | 'source_url', 19 | 'title', 20 | 'abstract', 21 | 'created_at', 22 | 'updated_at', 23 | 'authors', 24 | ], 25 | source() { 26 | return this.belongsTo('Source'); 27 | }, 28 | toJSONSummary() { 29 | const attributes = this.toJSON(); 30 | const result = { 31 | id: attributes.id, 32 | url: attributes.url, 33 | title: attributes.title, 34 | source_url: attributes.source_url, 35 | source_id: this.attributes.source_id, 36 | }; 37 | 38 | return result; 39 | }, 40 | virtuals: { 41 | url() { 42 | return helpers.urlFor(this); 43 | }, 44 | }, 45 | }, { 46 | relatedModels, 47 | }); 48 | 49 | module.exports = bookshelf.model('Publication', Publication); 50 | -------------------------------------------------------------------------------- /migrations/20160826145535_add_document_type_csr_synopsis.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('ALTER TABLE documents DROP CONSTRAINT documents_type_check') 6 | .raw(`ALTER TABLE documents ADD CONSTRAINT documents_type_check 7 | CHECK (type = ANY(ARRAY[ 8 | 'csr'::text, 9 | 'csr_synopsis'::text, 10 | 'epar_segment'::text, 11 | 'blank_consent_form'::text, 12 | 'patient_information_sheet'::text, 13 | 'blank_case_report_form'::text, 14 | 'other'::text 15 | ])) 16 | `) 17 | ); 18 | 19 | exports.down = (knex) => ( 20 | knex.schema 21 | .raw('ALTER TABLE documents DROP CONSTRAINT documents_type_check') 22 | .raw(`ALTER TABLE documents ADD CONSTRAINT documents_type_check 23 | CHECK (type = ANY(ARRAY[ 24 | 'csr'::text, 25 | 'epar_segment'::text, 26 | 'blank_consent_form'::text, 27 | 'patient_information_sheet'::text, 28 | 'blank_case_report_form'::text, 29 | 'other'::text 30 | ])) 31 | `) 32 | ); 33 | -------------------------------------------------------------------------------- /test/api/models/organisation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const helpers = require('../../../api/helpers'); 5 | const Organisation = require('../../../api/models/organisation'); 6 | 7 | describe('Organisation', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('trials', () => { 13 | it('returns trials related to the organisation', () => { 14 | let trialId; 15 | 16 | return factory.create('trialWithRelated') 17 | .then((trial) => { 18 | trialId = trial.id; 19 | const organisationId = toJSON(trial).organisations[0].id; 20 | return new Organisation({ id: organisationId }).fetch({ withRelated: 'trials' }); 21 | }).then((organisation) => { 22 | const trialsIds = organisation.related('trials').models.map((trial) => trial.id); 23 | should(trialsIds).containEql(trialId); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('url', () => { 29 | it('returns the url', () => ( 30 | factory.build('organisation') 31 | .then((org) => should(org.toJSON().url).eql(helpers.urlFor(org))) 32 | )); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /migrations/20160519143233_add_enum_constraint_to_trials_recruitment_status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('UPDATE trials SET recruitment_status = LOWER(recruitment_status)') 6 | .raw('UPDATE records SET recruitment_status = LOWER(recruitment_status)') 7 | .raw(`ALTER TABLE trials ADD CONSTRAINT trials_recruitment_status_check 8 | CHECK (recruitment_status = ANY(ARRAY['pending'::text, 'recruiting'::text, 9 | 'suspended'::text, 'complete'::text, 10 | 'other'::text]))`) 11 | .raw(`ALTER TABLE records ADD CONSTRAINT records_recruitment_status_check 12 | CHECK (recruitment_status = ANY(ARRAY['pending'::text, 'recruiting'::text, 13 | 'suspended'::text, 'complete'::text, 14 | 'other'::text]))`) 15 | ); 16 | 17 | exports.down = (knex) => ( 18 | knex.schema 19 | .raw('ALTER TABLE trials DROP CONSTRAINT trials_recruitment_status_check') 20 | .raw('ALTER TABLE records DROP CONSTRAINT records_recruitment_status_check') 21 | ); 22 | -------------------------------------------------------------------------------- /test/api/models/intervention.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const helpers = require('../../../api/helpers'); 5 | const Intervention = require('../../../api/models/intervention'); 6 | 7 | describe('Intervention', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('trials', () => { 13 | it('returns trials related to the intervention', () => { 14 | let trialId; 15 | 16 | return factory.create('trialWithRelated') 17 | .then((trial) => { 18 | trialId = trial.id; 19 | const interventionId = toJSON(trial).interventions[0].id; 20 | return new Intervention({ id: interventionId }).fetch({ withRelated: 'trials' }); 21 | }).then((intervention) => { 22 | const trialsIds = intervention.related('trials').models.map((trial) => trial.id); 23 | should(trialsIds).containEql(trialId); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('url', () => { 29 | it('returns the url', () => ( 30 | factory.build('intervention') 31 | .then((interven) => should(interven.toJSON().url).eql(helpers.urlFor(interven))) 32 | )); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/api/models/publication.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const helpers = require('../../../api/helpers'); 5 | const Publication = require('../../../api/models/publication'); 6 | 7 | describe('Publication', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | it('should define the relatedModels', () => { 13 | should(Publication.relatedModels).deepEqual([ 14 | 'source', 15 | ]); 16 | }); 17 | 18 | it('#toJSONSummary returns simplified record representation', () => ( 19 | factory.create('publication') 20 | .then((publication) => { 21 | publication.toJSONSummary().should.deepEqual({ 22 | id: publication.attributes.id, 23 | url: publication.url, 24 | title: publication.attributes.title, 25 | source_id: publication.attributes.source_id, 26 | source_url: publication.attributes.source_url, 27 | }); 28 | }) 29 | )); 30 | 31 | describe('url', () => { 32 | it('returns the url', () => ( 33 | factory.build('publication') 34 | .then((publication) => should(publication.toJSON().url).eql(helpers.urlFor(publication))) 35 | )); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /config/default.yaml: -------------------------------------------------------------------------------- 1 | # swagger configuration file 2 | 3 | # values in the swagger hash are system configuration for swagger-node 4 | swagger: 5 | 6 | fittingsDirs: [ api/fittings ] 7 | defaultPipe: null 8 | swaggerControllerPipe: swagger_controllers # defines the standard processing pipe for controllers 9 | 10 | # values defined in the bagpipes key are the bagpipes pipes and fittings definitions 11 | # (see https://github.com/apigee-127/bagpipes) 12 | bagpipes: 13 | 14 | _router: 15 | name: swagger_router 16 | mockMode: false 17 | mockControllersDirs: [ api/mocks ] 18 | controllersDirs: [ api/controllers ] 19 | 20 | _swagger_validate: 21 | name: swagger_validator 22 | validateResponse: true 23 | 24 | # pipe for all swagger-node controllers 25 | swagger_controllers: 26 | - onError: json_error_handler 27 | - cors 28 | - swagger_security 29 | - _swagger_validate 30 | - express_compatibility 31 | - _router 32 | 33 | # pipe to serve swagger (endpoint is in swagger.yaml) 34 | swagger_raw: 35 | - cors 36 | - swagger_raw 37 | 38 | # any other values in this file are just loaded into the config for application access... 39 | -------------------------------------------------------------------------------- /test/api/helpers/url-for.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const Trial = require('../../../api/models/trial'); 5 | const config = require('../../../config'); 6 | const urlFor = require('../../../api/helpers').urlFor; 7 | 8 | describe('urlFor', () => { 9 | it('should return the trial URL', () => ( 10 | factory.build('trial') 11 | .then((trial) => { 12 | should(urlFor(trial)).equal(`${config.url}/v1/trials/${trial.id}`); 13 | }) 14 | )); 15 | 16 | it('should accept an array of models', () => ( 17 | Promise.all([ 18 | factory.build('trial'), 19 | factory.build('source'), 20 | ]).then((models) => { 21 | const trial = models[0]; 22 | const source = models[1]; 23 | 24 | should(urlFor([trial, source])).equal(`${config.url}/v1/trials/${trial.id}/sources/${source.id}`); 25 | }) 26 | )); 27 | 28 | it('should return undefined if any of the models is new', () => { 29 | Promise.all([ 30 | new Trial(), 31 | factory.build('source'), 32 | ]).then((models) => { 33 | const trial = models[0]; 34 | const source = models[1]; 35 | 36 | should(urlFor([trial, source])).be.undefined(); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/api/models/base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseModel = require('../../../api/models/base'); 4 | const should = require('should'); 5 | 6 | describe('BaseModel', () => { 7 | it('sets the ID on saving', () => { 8 | const base = new BaseModel(); 9 | should.not.exist(base.attributes.id); 10 | base.trigger('saving', base); 11 | should.exist(base.attributes.id); 12 | }); 13 | 14 | it('removes null attributes when serializing', () => { 15 | const base = new BaseModel({ foo: null }); 16 | 17 | base.toJSON().should.not.have.ownProperty('foo'); 18 | }); 19 | 20 | it('removes empty plain objects when serializing', () => { 21 | const base = new BaseModel({ 22 | plainObj: {}, 23 | nonPlainObj: [], 24 | }); 25 | 26 | base.toJSON().should.not.have.ownProperty('plainObj'); 27 | base.toJSON().should.have.ownProperty('nonPlainObj'); 28 | }); 29 | 30 | it('removes undefined virtual attributes', () => { 31 | const Model = BaseModel.extend({ 32 | visible: ['data'], 33 | virtuals: { 34 | data: () => undefined, 35 | }, 36 | }); 37 | const model = new Model(); 38 | 39 | should(model.toJSON()).not.have.ownProperty('data'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /migrations/20160901174427_add_document_type_results.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('ALTER TABLE documents DROP CONSTRAINT documents_type_check') 6 | .raw(`ALTER TABLE documents ADD CONSTRAINT documents_type_check 7 | CHECK (type = ANY(ARRAY[ 8 | 'csr'::text, 9 | 'csr_synopsis'::text, 10 | 'epar_segment'::text, 11 | 'blank_consent_form'::text, 12 | 'patient_information_sheet'::text, 13 | 'blank_case_report_form'::text, 14 | 'results'::text, 15 | 'other'::text 16 | ])) 17 | `) 18 | ); 19 | 20 | exports.down = (knex) => ( 21 | knex.schema 22 | .raw('ALTER TABLE documents DROP CONSTRAINT documents_type_check') 23 | .raw(`ALTER TABLE documents ADD CONSTRAINT documents_type_check 24 | CHECK (type = ANY(ARRAY[ 25 | 'csr'::text, 26 | 'csr_synopsis'::text, 27 | 'epar_segment'::text, 28 | 'blank_consent_form'::text, 29 | 'patient_information_sheet'::text, 30 | 'blank_case_report_form'::text, 31 | 'other'::text 32 | ])) 33 | `) 34 | ); 35 | -------------------------------------------------------------------------------- /api/controllers/documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Document = require('../models/document'); 4 | 5 | function getDocument(req, res) { 6 | const id = req.swagger.params.id.value; 7 | 8 | return new Document({ id }).fetch({ 9 | withRelated: Document.relatedModels, 10 | }) 11 | .then((doc) => { 12 | if (doc) { 13 | res.json(doc); 14 | } else { 15 | res.status(404); 16 | res.finish(); 17 | } 18 | }) 19 | .catch((err) => { 20 | res.finish(); 21 | throw err; 22 | }); 23 | } 24 | 25 | function listDocuments(req, res) { 26 | const params = req.swagger.params; 27 | const page = params.page.value; 28 | const perPage = params.per_page.value; 29 | 30 | return Document.fetchPage({ 31 | page, 32 | pageSize: perPage, 33 | withRelated: Document.relatedModels, 34 | }) 35 | .then((documents) => { 36 | const response = { 37 | total_count: documents.pagination.rowCount, 38 | items: documents.models.map((m) => m.toJSONSummary()), 39 | }; 40 | res.json(response); 41 | }) 42 | .catch((err) => { 43 | res.finish(); 44 | throw err; 45 | }); 46 | } 47 | 48 | module.exports = { 49 | getDocument, 50 | listDocuments, 51 | }; 52 | -------------------------------------------------------------------------------- /migrations/20160825155641_add_trials_and_records_status_and_change_recruitment_status_enum.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | function doSchemaModifications(schema, tableName) { 5 | return schema 6 | .table(tableName, (table) => { 7 | table.enu('status', [ 8 | 'ongoing', 9 | 'withdrawn', 10 | 'suspended', 11 | 'terminated', 12 | 'complete', 13 | 'other', 14 | ]); 15 | }) 16 | .raw(`UPDATE "${tableName}" SET recruitment_status = null`) 17 | .raw(`ALTER TABLE "${tableName}" DROP CONSTRAINT ${tableName}_recruitment_status_check`) 18 | .raw(`ALTER TABLE "${tableName}" ADD CONSTRAINT ${tableName}_recruitment_status_check 19 | CHECK (recruitment_status = ANY(ARRAY[ 20 | 'recruiting'::text, 21 | 'not_recruiting'::text, 22 | 'unknown'::text, 23 | 'other'::text 24 | ])) 25 | `); 26 | } 27 | 28 | const schema = knex.schema; 29 | 30 | doSchemaModifications(schema, 'trials'); 31 | doSchemaModifications(schema, 'records'); 32 | 33 | return schema; 34 | }; 35 | 36 | exports.down = () => { 37 | throw Error('Destructive migration can\'t be rolled back.'); 38 | }; 39 | -------------------------------------------------------------------------------- /migrations/20160511144353_add_description_and_extra_codes_columns_to_problems_and_interventions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const schema = knex.schema; 5 | 6 | // knex don't use native Posgres emum 7 | schema.raw('ALTER TABLE interventions ' + 8 | 'DROP CONSTRAINT interventions_type_check;'); 9 | schema.raw('ALTER TABLE interventions ' + 10 | 'ADD CONSTRAINT interventions_type_check ' + 11 | 'CHECK (type = ANY(ARRAY[\'drug\'::text, \'procedure\'::text, \'other\'::text]));'); 12 | 13 | schema.table('problems', (table) => { 14 | table.text('description'); 15 | table.text('icdcm_code'); 16 | }); 17 | 18 | schema.table('interventions', (table) => { 19 | table.text('description'); 20 | table.text('icdpcs_code'); 21 | table.text('ndc_code'); 22 | }); 23 | 24 | return schema; 25 | }; 26 | 27 | exports.down = (knex) => { 28 | const schema = knex.schema; 29 | 30 | schema.table('problems', (table) => { 31 | table.dropColumn('description'); 32 | table.dropColumn('icdcm_code'); 33 | }); 34 | 35 | schema.table('interventions', (table) => { 36 | table.dropColumn('description'); 37 | table.dropColumn('icdpcs_code'); 38 | table.dropColumn('ndc_code'); 39 | }); 40 | 41 | return schema; 42 | }; 43 | -------------------------------------------------------------------------------- /migrations/20161024144543_rename_documents_files_and_sources_url_to_source_url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('documents', (table) => table.renameColumn('url', 'source_url')) 6 | .raw('ALTER INDEX non_fda_documents_type_url_unique RENAME TO non_fda_documents_type_source_url_unique') 7 | .raw('ALTER TABLE documents RENAME CONSTRAINT file_id_xor_url_check TO file_id_xor_source_url_check') 8 | 9 | .table('files', (table) => table.renameColumn('url', 'source_url')) 10 | .raw('ALTER TABLE files RENAME CONSTRAINT files_url_unique TO files_source_url_unique') 11 | 12 | .table('sources', (table) => table.renameColumn('url', 'source_url')) 13 | ); 14 | 15 | exports.down = (knex) => ( 16 | knex.schema 17 | .table('documents', (table) => table.renameColumn('source_url', 'url')) 18 | .raw('ALTER INDEX non_fda_documents_type_source_url_unique RENAME TO non_fda_documents_type_url_unique') 19 | .raw('ALTER TABLE documents RENAME CONSTRAINT file_id_xor_source_url_check TO file_id_xor_url_check') 20 | 21 | .table('files', (table) => table.renameColumn('source_url', 'url')) 22 | .raw('ALTER TABLE files RENAME CONSTRAINT files_source_url_unique TO files_url_unique') 23 | 24 | .table('sources', (table) => table.renameColumn('source_url', 'url')) 25 | ); 26 | -------------------------------------------------------------------------------- /migrations/20160519151639_remove_unused_columns.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .table('interventions', (table) => table.dropColumns(['data', 'facts'])) 6 | .table('locations', (table) => table.dropColumns(['data', 'facts'])) 7 | .table('documents', (table) => table.dropColumns(['type', 'data', 'facts'])) 8 | .table('organisations', (table) => table.dropColumns(['type', 'data', 'facts'])) 9 | .table('persons', (table) => table.dropColumns(['type', 'data', 'facts'])) 10 | .table('conditions', (table) => table.dropColumns(['data', 'facts'])) 11 | .table('publications', (table) => table.dropColumns(['facts'])) 12 | .table('sources', (table) => table.dropColumns(['data'])) 13 | .table('trials_documents', (table) => table.dropColumns(['role', 'context'])) 14 | .table('trials_interventions', (table) => table.dropColumns(['role', 'context'])) 15 | .table('trials_conditions', (table) => table.dropColumns(['role', 'context'])) 16 | .table('trials_locations', (table) => table.dropColumns(['context'])) 17 | .table('trials_organisations', (table) => table.dropColumns(['context'])) 18 | .table('trials_persons', (table) => table.dropColumns(['context'])) 19 | ); 20 | 21 | exports.down = () => { 22 | throw Error('Destructive migration can\'t be rolled back.'); 23 | }; 24 | -------------------------------------------------------------------------------- /api/controllers/fda_applications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const FDAApplication = require('../models/fda_application'); 4 | 5 | function getFDAApplication(req, res) { 6 | const id = req.swagger.params.id.value; 7 | 8 | return new FDAApplication({ id }).fetch({ 9 | withRelated: FDAApplication.relatedModels, 10 | }) 11 | .then((fdaApplication) => { 12 | if (fdaApplication) { 13 | res.json(fdaApplication); 14 | } else { 15 | res.status(404); 16 | res.finish(); 17 | } 18 | }) 19 | .catch((err) => { 20 | res.finish(); 21 | throw err; 22 | }); 23 | } 24 | 25 | function listFDAApplications(req, res) { 26 | const params = req.swagger.params; 27 | const page = params.page.value; 28 | const perPage = params.per_page.value; 29 | 30 | return FDAApplication.fetchPage({ 31 | page, 32 | pageSize: perPage, 33 | withRelated: FDAApplication.relatedModels, 34 | }) 35 | .then((fdaApplications) => { 36 | const response = { 37 | total_count: fdaApplications.pagination.rowCount, 38 | items: fdaApplications.models, 39 | }; 40 | res.json(response); 41 | }) 42 | .catch((err) => { 43 | res.finish(); 44 | throw err; 45 | }); 46 | } 47 | 48 | module.exports = { 49 | getFDAApplication, 50 | listFDAApplications, 51 | }; 52 | -------------------------------------------------------------------------------- /test/api/controllers/persons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Persons', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/persons/{id}', () => { 9 | it('returns 404 if there\'s no person with the received ID', () => ( 10 | server.inject('/v1/persons/eb1af997-3e9c-4f31-895f-002ec1cfa196') 11 | .then((response) => { 12 | response.statusCode.should.equal(404); 13 | }) 14 | )); 15 | 16 | it('returns the Person', () => ( 17 | factory.create('person').then((model) => ( 18 | server.inject(`/v1/persons/${model.attributes.id}`) 19 | .then((response) => { 20 | response.statusCode.should.equal(200); 21 | 22 | const expectedResult = JSON.parse(JSON.stringify(model.toJSON())); 23 | const result = JSON.parse(response.result); 24 | 25 | result.should.deepEqual(expectedResult); 26 | }) 27 | )) 28 | )); 29 | 30 | it('returns all the attributes', () => { 31 | const attributes = [ 32 | 'id', 33 | 'name', 34 | 'url', 35 | ]; 36 | factory.create('person').then((model) => ( 37 | server.inject(`/v1/persons/${model.attributes.id}`) 38 | .then((response) => JSON.parse(response.payload) 39 | .should.have.keys(...attributes)) 40 | )); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/api/controllers/conditions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Conditions', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/conditions/{id}', () => { 9 | it('returns 404 if there\'s no condition with the received ID', () => ( 10 | server.inject('/v1/conditions/eb1af997-3e9c-4f31-895f-002ec1cfa196') 11 | .then((response) => { 12 | response.statusCode.should.equal(404); 13 | }) 14 | )); 15 | 16 | it('returns the Condition', () => ( 17 | factory.create('condition').then((model) => ( 18 | server.inject(`/v1/conditions/${model.attributes.id}`) 19 | .then((response) => { 20 | response.statusCode.should.equal(200); 21 | 22 | const expectedResult = JSON.parse(JSON.stringify(model.toJSON())); 23 | const result = JSON.parse(response.result); 24 | 25 | result.should.deepEqual(expectedResult); 26 | }) 27 | )) 28 | )); 29 | 30 | it('returns all the attributes', () => { 31 | const attributes = [ 32 | 'id', 33 | 'name', 34 | 'url', 35 | ]; 36 | factory.create('condition').then((model) => ( 37 | server.inject(`/v1/conditions/${model.attributes.id}`) 38 | .then((response) => JSON.parse(response.payload) 39 | .should.have.keys(...attributes)) 40 | )); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /api/controllers/trials.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Trial = require('../models/trial'); 4 | const Record = require('../models/record'); 5 | 6 | function getTrial(req, res) { 7 | const id = req.swagger.params.id.value; 8 | 9 | return new Trial({ id }).fetch({ withRelated: Trial.relatedModels }) 10 | .then((trial) => { 11 | if (trial) { 12 | res.json(trial); 13 | } else { 14 | res.status(404); 15 | res.finish(); 16 | } 17 | }) 18 | .catch((err) => { 19 | res.finish(); 20 | throw err; 21 | }); 22 | } 23 | 24 | function getRecord(req, res) { 25 | const id = req.swagger.params.id.value; 26 | 27 | return new Record({ id }).fetch({ withRelated: Record.relatedModels }) 28 | .then((record) => { 29 | if (record) { 30 | res.json(record); 31 | } else { 32 | res.status(404); 33 | res.finish(); 34 | } 35 | }) 36 | .catch((err) => { 37 | res.finish(); 38 | throw err; 39 | }); 40 | } 41 | 42 | function getRecords(req, res) { 43 | const id = req.swagger.params.id.value; 44 | 45 | return Record.query({ where: { trial_id: id } }).fetchAll({ withRelated: ['source'] }) 46 | .then((records) => { 47 | res.json(records); 48 | }) 49 | .catch((err) => { 50 | res.finish(); 51 | throw err; 52 | }); 53 | } 54 | 55 | module.exports = { 56 | getTrial, 57 | getRecord, 58 | getRecords, 59 | }; 60 | -------------------------------------------------------------------------------- /test/api/controllers/interventions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Interventions', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/interventions/{id}', () => { 9 | it('returns 404 if there\'s no intervention with the received ID', () => ( 10 | server.inject('/v1/interventions/eb1af997-3e9c-4f31-895f-002ec1cfa196') 11 | .then((response) => { 12 | response.statusCode.should.equal(404); 13 | }) 14 | )); 15 | 16 | it('returns the Intervention', () => ( 17 | factory.create('intervention').then((model) => ( 18 | server.inject(`/v1/interventions/${model.attributes.id}`) 19 | .then((response) => { 20 | response.statusCode.should.equal(200); 21 | 22 | const expectedResult = JSON.parse(JSON.stringify(model.toJSON())); 23 | const result = JSON.parse(response.result); 24 | 25 | result.should.deepEqual(expectedResult); 26 | }) 27 | )) 28 | )); 29 | 30 | it('returns all the attributes', () => { 31 | const attributes = [ 32 | 'id', 33 | 'name', 34 | 'url', 35 | ]; 36 | factory.create('intervention').then((model) => ( 37 | server.inject(`/v1/interventions/${model.attributes.id}`) 38 | .then((response) => JSON.parse(response.payload) 39 | .should.have.keys(...attributes)) 40 | )); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/api/controllers/organisations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Organisations', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/organisations/{id}', () => { 9 | it('returns 404 if there\'s no organisation with the received ID', () => ( 10 | server.inject('/v1/organisations/eb1af997-3e9c-4f31-895f-002ec1cfa196') 11 | .then((response) => { 12 | response.statusCode.should.equal(404); 13 | }) 14 | )); 15 | 16 | it('returns the Organisation', () => ( 17 | factory.create('organisation').then((model) => ( 18 | server.inject(`/v1/organisations/${model.attributes.id}`) 19 | .then((response) => { 20 | response.statusCode.should.equal(200); 21 | 22 | const expectedResult = JSON.parse(JSON.stringify(model.toJSON())); 23 | const result = JSON.parse(response.result); 24 | 25 | result.should.deepEqual(expectedResult); 26 | }) 27 | )) 28 | )); 29 | 30 | it('returns all the attributes', () => { 31 | const attributes = [ 32 | 'id', 33 | 'name', 34 | 'url', 35 | ]; 36 | factory.create('organisation').then((model) => ( 37 | server.inject(`/v1/organisations/${model.attributes.id}`) 38 | .then((response) => JSON.parse(response.payload) 39 | .should.have.keys(...attributes)) 40 | )); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /migrations/20160501172805_remove_links_facts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableNames = [ 4 | 'interventions', 5 | 'locations', 6 | 'organisations', 7 | 'persons', 8 | 'problems', 9 | 'publications', 10 | 'sources', 11 | 'trials', 12 | 'trialrecords', 13 | ]; 14 | 15 | exports.up = (knex) => { 16 | const operations = tableNames.map((tableName) => ( 17 | knex.schema.table(tableName, (table) => { 18 | let linksColumn = 'links'; 19 | let factsColumn = 'facts'; 20 | if (tableName === 'publications') { 21 | linksColumn = 'primary_facts'; 22 | factsColumn = 'secondary_facts'; 23 | } 24 | table.dropColumn(linksColumn); 25 | table.dropColumn(factsColumn); 26 | }) 27 | )); 28 | 29 | return Promise.all(operations); 30 | }; 31 | 32 | exports.down = (knex) => { 33 | const operations = tableNames.map((tableName) => ( 34 | knex.schema.table(tableName, (table) => { 35 | let linksColumn = 'links'; 36 | let factsColumn = 'facts'; 37 | if (tableName === 'publications') { 38 | linksColumn = 'primary_facts'; 39 | factsColumn = 'secondary_facts'; 40 | } 41 | table.specificType(linksColumn, 'text[]') 42 | .nullable() 43 | .index(undefined, 'GIN'); 44 | table.specificType(factsColumn, 'text[]') 45 | .nullable() 46 | .index(undefined, 'GIN'); 47 | }) 48 | )); 49 | 50 | return Promise.all(operations); 51 | }; 52 | -------------------------------------------------------------------------------- /test/api/models/file.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const File = require('../../../api/models/file'); 5 | 6 | describe('File', () => { 7 | before(clearDB); 8 | 9 | afterEach(clearDB); 10 | 11 | describe('toJSONSummary', () => { 12 | it('returns simplified file representation', () => ( 13 | factory.create('file') 14 | .then((file) => file.toJSONSummary().should.deepEqual({ 15 | id: file.attributes.id, 16 | sha1: file.attributes.sha1, 17 | source_url: file.attributes.source_url, 18 | documentcloud_id: file.attributes.documentcloud_id, 19 | })) 20 | )); 21 | 22 | it('is an empty object if file is empty', () => { 23 | const file = new File(); 24 | 25 | should(file.toJSONSummary()).deepEqual({}); 26 | }); 27 | }); 28 | 29 | describe('toJSON', () => { 30 | it('returns the pages as object with "num" and "text" fields', () => ( 31 | factory.create('file', { pages: ['foo', 'bar'] }) 32 | .then((file) => { 33 | file.toJSON().pages.should.deepEqual([ 34 | { num: 1, text: 'foo' }, 35 | { num: 2, text: 'bar' }, 36 | ]); 37 | }) 38 | )); 39 | 40 | it('returns the pages undefined when there are no pages', () => ( 41 | factory.create('file', { pages: null }) 42 | .then((file) => { 43 | should(file.toJSON().pages).be.undefined(); 44 | }) 45 | )); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.NODE_ENV = 'test'; 4 | 5 | const config = require('../config'); 6 | const server = require('../server'); 7 | const factory = require('./factory'); 8 | 9 | function clearDB() { 10 | const tables = [ 11 | 'trials_publications', 12 | 'publications', 13 | 'trials_locations', 14 | 'locations', 15 | 'trials_interventions', 16 | 'interventions', 17 | 'trials_conditions', 18 | 'conditions', 19 | 'trials_persons', 20 | 'persons', 21 | 'trials_organisations', 22 | 'trials_documents', 23 | 'documents', 24 | 'document_categories', 25 | 'fda_approvals', 26 | 'fda_applications', 27 | 'organisations', 28 | 'files', 29 | 'publications', 30 | 'trials_publications', 31 | 'records', 32 | 'risk_of_biases_risk_of_bias_criterias', 33 | 'risk_of_bias_criterias', 34 | 'risk_of_biases', 35 | 'trials', 36 | 'sources', 37 | ]; 38 | let deferred = config.bookshelf.knex.migrate.latest(); 39 | 40 | for (const tableName of tables) { 41 | // eslint-disable-next-line no-loop-func 42 | deferred = deferred.then(() => config.bookshelf.knex(tableName).select().del()); 43 | } 44 | 45 | return deferred; 46 | } 47 | 48 | function toJSON(object) { 49 | return JSON.parse(JSON.stringify(object)); 50 | } 51 | 52 | 53 | global.config = config; 54 | global.server = server; 55 | global.factory = factory; 56 | global.clearDB = clearDB; 57 | global.toJSON = toJSON; 58 | -------------------------------------------------------------------------------- /test/api/models/record.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const helpers = require('../../../api/helpers'); 5 | const Record = require('../../../api/models/record'); 6 | 7 | describe('Record', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | it('should define the relatedModels', () => { 13 | should(Record.relatedModels).deepEqual([ 14 | 'trial', 15 | 'source', 16 | ]); 17 | }); 18 | 19 | it('defines url and trial_url', () => ( 20 | factory.create('record') 21 | .then((record) => { 22 | const trial = record.related('trial'); 23 | const fakeRecord = { id: record.id, tableName: 'records' }; 24 | 25 | record.toJSON().should.containEql({ 26 | url: helpers.urlFor([trial, fakeRecord]), 27 | trial_url: helpers.urlFor(trial), 28 | }); 29 | }) 30 | )); 31 | 32 | it('#toJSONSummary returns simplified record representation', () => ( 33 | factory.create('record').then((record) => { 34 | const recordJSON = record.toJSON(); 35 | 36 | record.toJSONSummary().should.deepEqual({ 37 | source_id: record.attributes.source_id, 38 | id: recordJSON.id, 39 | url: recordJSON.url, 40 | is_primary: recordJSON.is_primary, 41 | source_url: recordJSON.source_url, 42 | updated_at: recordJSON.updated_at, 43 | last_verification_date: recordJSON.last_verification_date, 44 | }); 45 | }) 46 | )); 47 | }); 48 | -------------------------------------------------------------------------------- /migrations/20170126145822_replace_document_type_with_document_categories.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const Promise = require('bluebird'); 5 | 6 | const typeToCategoryMapping = { 7 | blank_consent_form: 33, 8 | patient_information_sheet: 33, 9 | blank_case_report_form: 29, 10 | csr: 22, 11 | csr_synopsis: 23, 12 | epar_segment: 24, 13 | other: 20, 14 | }; 15 | 16 | 17 | exports.up = (knex) => ( 18 | knex.schema.table('documents', (table) => 19 | table.integer('document_category_id') 20 | .references('document_categories.id') 21 | ) 22 | .then(() => 23 | Promise.map(_.keys(typeToCategoryMapping), (type) => 24 | knex('documents').where('type', type) 25 | .update('document_category_id', typeToCategoryMapping[type]) 26 | ) 27 | ) 28 | .then(() => 29 | knex('documents').where({ type: 'results', source_id: 'euctr' }) 30 | .update('document_category_id', typeToCategoryMapping.epar_segment) 31 | ) 32 | .then(() => 33 | knex('documents').where({ type: 'results', source_id: 'nct' }) 34 | .update('document_category_id', typeToCategoryMapping.csr) 35 | ) 36 | .then(() => 37 | knex.schema.table('documents', (table) => 38 | table.dropColumn('type') 39 | ) 40 | ) 41 | .then(() => 42 | knex.schema.raw('ALTER TABLE documents ALTER COLUMN document_category_id SET NOT NULL') 43 | ) 44 | ); 45 | 46 | exports.down = () => { 47 | throw Error('Destructive migration can\'t be rolled back.'); 48 | }; 49 | -------------------------------------------------------------------------------- /migrations/20160818182848_create_fda_approvals_and_add_relationship_with_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .createTable('fda_approvals', (table) => { 6 | table.text('id').primary(); 7 | 8 | table.uuid('intervention_id') 9 | .notNullable() 10 | .references('interventions.id'); 11 | table.integer('supplement_number') 12 | .notNullable(); 13 | table.text('type') 14 | .notNullable() 15 | .index(); 16 | table.date('action_date') 17 | .notNullable(); 18 | table.text('notes') 19 | .nullable(); 20 | table.timestamps(); 21 | 22 | table.unique(['intervention_id', 'supplement_number']); 23 | }) 24 | .table('documents', (table) => { 25 | table.text('fda_approval_id') 26 | .nullable() 27 | .references('fda_approvals.id'); 28 | }) 29 | .raw('ALTER TABLE documents ALTER COLUMN trial_id DROP NOT NULL') 30 | .raw(`ALTER TABLE documents 31 | ADD CONSTRAINT trial_id_xor_fda_approval_id_check CHECK ( 32 | (trial_id IS NULL AND fda_approval_id IS NOT NULL) OR 33 | (trial_id IS NOT NULL AND fda_approval_id IS NULL) 34 | )`) 35 | ); 36 | 37 | exports.down = (knex) => ( 38 | knex.schema 39 | .raw('ALTER TABLE documents ALTER COLUMN trial_id SET NOT NULL') 40 | .table('documents', (table) => { 41 | table.dropColumn('fda_approval_id'); 42 | }) 43 | .dropTableIfExists('fda_approvals') 44 | ); 45 | -------------------------------------------------------------------------------- /migrations/20160912132241_create_risks_of_bias.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const schema = knex.schema; 5 | 6 | schema.createTable('risk_of_biases', (table) => { 7 | table.uuid('id').primary(); 8 | 9 | table.uuid('trial_id') 10 | .notNullable() 11 | .references('trials.id'); 12 | table.text('source_id') 13 | .notNullable() 14 | .references('sources.id'); 15 | table.text('source_url') 16 | .notNullable(); 17 | table.text('study_id') 18 | .notNullable(); 19 | table.timestamps(true, true); 20 | 21 | table.unique(['study_id', 'source_url']); 22 | }); 23 | 24 | schema.createTable('risk_of_bias_criterias', (table) => { 25 | table.uuid('id').primary(); 26 | 27 | table.text('name') 28 | .unique() 29 | .notNullable(); 30 | table.timestamps(true, true); 31 | }); 32 | 33 | schema.createTable('risk_of_biases_risk_of_bias_criterias', (table) => { 34 | table.uuid('risk_of_bias_id') 35 | .references('risk_of_biases.id'); 36 | table.uuid('risk_of_bias_criteria_id') 37 | .references('risk_of_bias_criterias.id'); 38 | 39 | table.enu('value', [ 40 | 'yes', 41 | 'no', 42 | 'unknown', 43 | ]).notNullable(); 44 | 45 | table.primary(['risk_of_bias_id', 'risk_of_bias_criteria_id']); 46 | }); 47 | 48 | return schema; 49 | }; 50 | 51 | exports.down = (knex) => ( 52 | knex.schema 53 | .dropTableIfExists('risk_of_biases_risk_of_bias_criterias') 54 | .dropTableIfExists('risk_of_bias_criterias') 55 | .dropTableIfExists('risk_of_biases') 56 | ); 57 | -------------------------------------------------------------------------------- /test/api/controllers/publications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Publication', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/publications/{id}', () => { 9 | it('returns 404 if there\'s no publication with the received ID', () => ( 10 | server.inject('/v1/publications/eb1af997-3e9c-4f31-895f-002ec1cfa196') 11 | .then((response) => { 12 | response.statusCode.should.equal(404); 13 | }) 14 | )); 15 | 16 | it('returns the Publication', () => { 17 | let publication; 18 | 19 | return factory.create('publication') 20 | .then((_publication) => (publication = _publication)) 21 | .then(() => server.inject(`/v1/publications/${publication.attributes.id}`)) 22 | .then((response) => { 23 | response.statusCode.should.equal(200); 24 | 25 | const expectedResult = JSON.parse(JSON.stringify(publication.toJSON())); 26 | const result = JSON.parse(response.result); 27 | result.should.deepEqual(expectedResult); 28 | }); 29 | }); 30 | 31 | it('returns all the attributes', () => { 32 | const attributes = [ 33 | 'id', 34 | 'source', 35 | 'source_url', 36 | 'title', 37 | 'abstract', 38 | 'created_at', 39 | 'updated_at', 40 | 'authors', 41 | 'url', 42 | ]; 43 | factory.create('publication').then((model) => ( 44 | server.inject(`/v1/publications/${model.attributes.id}`) 45 | .then((response) => JSON.parse(response.payload) 46 | .should.have.keys(...attributes)) 47 | )); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/config/pg_types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const pgTypes = require('pg').types; 5 | require('../../config/pg_types'); 6 | 7 | describe('parseDate', () => { 8 | const DATE_OID = 1082; 9 | const parseDate = pgTypes.getTypeParser(DATE_OID); 10 | 11 | it('does not offset time', () => { 12 | const testDate = '2016-12-01'; 13 | const expectedDate = new Date(testDate).toISOString(); 14 | const resultedDate = parseDate(testDate); 15 | resultedDate.toISOString().should.equal(expectedDate); 16 | }); 17 | 18 | it('returns null when value is null', () => { 19 | const testDate = null; 20 | should(parseDate(testDate)).equal(null); 21 | }); 22 | }); 23 | 24 | describe('parseDateArray', () => { 25 | const DATE_ARRAY_OID = 1182; 26 | const parseDateArray = pgTypes.getTypeParser(DATE_ARRAY_OID); 27 | 28 | it('parses array correctly', () => { 29 | const testDate = '2014-01-12'; 30 | const testPgArray = `{${testDate}}`; 31 | const expectedDate = new Date(testDate).toISOString(); 32 | const resultedArray = parseDateArray(testPgArray); 33 | resultedArray.map((date) => date.toISOString()).should.deepEqual([ 34 | expectedDate, 35 | ]); 36 | }); 37 | 38 | it('returns null when value is null', () => { 39 | const testPgArray = null; 40 | should(parseDateArray(testPgArray)).equal(null); 41 | }); 42 | 43 | it('returns empty array when there are no values', () => { 44 | const testPgArray = '{}'; 45 | parseDateArray(testPgArray).should.be.empty(); 46 | }); 47 | 48 | it('returns null for the null values', () => { 49 | const testPgArray = '{null}'; 50 | parseDateArray(testPgArray).should.deepEqual([ 51 | null, 52 | ]); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /api/models/base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const uuid = require('node-uuid'); 5 | const bookshelf = require('../../config').bookshelf; 6 | 7 | const BaseModel = bookshelf.Model.extend({ 8 | serialize(...args) { 9 | const attributes = Object.assign( 10 | {}, 11 | Object.getPrototypeOf(BaseModel.prototype).serialize.call(this, args) 12 | ); 13 | 14 | // FIXME: We don't want empty objects to be added to the resulting JSON. 15 | // This is the default behaviour of Bookshelf when using single entities, 16 | // but for collections it isn't. Looks like a bug in their side (see 17 | // https://github.com/tgriesser/bookshelf/issues/753) 18 | const isEmptyPlainObject = (value) => _.isPlainObject(value) && _.isEmpty(value); 19 | 20 | // FIXME: This is a workaround because Swagger doesn't allow nullable 21 | // fields. Check https://github.com/OAI/OpenAPI-Specification/issues/229. 22 | const isNullOrEmptyPlainObject = (value) => (value === null) || isEmptyPlainObject(value); 23 | 24 | return _.omitBy(attributes, isNullOrEmptyPlainObject); 25 | }, 26 | toJSON(...args) { 27 | // FIXME: Bookshelf's virtuals plugin adds the virtual attributes 28 | // regardless of their value. We can't change this behaviour on 29 | // `serialize()`, because the plugin overwrittes it, so we need to do it 30 | // here. 31 | const json = Object.getPrototypeOf(BaseModel.prototype).toJSON.call(this, args); 32 | 33 | return _.omitBy(json, _.isUndefined); 34 | }, 35 | initialize() { 36 | this.on('saving', this.addIdIfNeeded); 37 | }, 38 | addIdIfNeeded: (model) => { 39 | if (!model.attributes.id) { 40 | // eslint-disable-next-line no-param-reassign 41 | model.attributes.id = uuid.v1(); 42 | } 43 | }, 44 | }); 45 | 46 | module.exports = BaseModel; 47 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('./config'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const SwaggerHapi = require('swagger-hapi'); 7 | const Hapi = require('hapi'); 8 | 9 | function setupSwaggerUi(server) { 10 | const swaggerUiPath = path.join(__dirname, './node_modules/swagger-ui/dist'); 11 | const indexPath = path.join(swaggerUiPath, 'index.html'); 12 | 13 | // Configure the Swagger file URL 14 | const swaggerUiIndex = fs.readFileSync(indexPath, 'utf8'); 15 | const contents = swaggerUiIndex.replace(/url = ".+";/, 16 | `url = "${config.url}/v1/swagger.yaml";`); 17 | fs.writeFileSync(indexPath, contents, 'utf8'); 18 | 19 | server.route({ 20 | method: 'GET', 21 | path: '/v1/docs/{param*}', 22 | handler: { 23 | directory: { 24 | path: swaggerUiPath, 25 | }, 26 | }, 27 | config: { 28 | cache: { 29 | expiresIn: 7 * 24 * 60 * 60 * 1000, 30 | }, 31 | }, 32 | }); 33 | } 34 | 35 | function startServer() { 36 | const server = new Hapi.Server(); 37 | 38 | SwaggerHapi.create(config.swaggerHapi, (err, swaggerHapi) => { 39 | if (err) { throw err; } 40 | 41 | const port = config.port; 42 | const plugins = [ 43 | swaggerHapi.plugin, 44 | ...config.hapi.plugins, 45 | ]; 46 | 47 | server.connection({ 48 | host: config.host, 49 | port, 50 | routes: { 51 | cors: true, 52 | }, 53 | }); 54 | server.address = () => ({ port }); 55 | 56 | server.register(plugins, (_err) => { 57 | if (_err) { throw _err; } 58 | 59 | setupSwaggerUi(server); 60 | server.start(() => { 61 | console.info('Server started at', server.info.uri); // eslint-disable-line no-console 62 | }); 63 | }); 64 | }); 65 | 66 | return server; 67 | } 68 | 69 | module.exports = startServer(); // for testing 70 | -------------------------------------------------------------------------------- /migrations/20160831135725_create_fda_applications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .createTable('fda_applications', (table) => { 6 | table.text('id') 7 | .primary(); 8 | table.uuid('organisation_id') 9 | .nullable() 10 | .references('organisations.id'); 11 | table.text('drug_name') 12 | .nullable(); 13 | table.text('active_ingredients') 14 | .nullable(); 15 | 16 | table.timestamps(true, true); 17 | }) 18 | // Create initial fda_applications using IDs from interventions and 19 | // fda_approvals. This way, we can add the relationships during this 20 | // migration. 21 | .raw(`INSERT INTO fda_applications (id) ( 22 | SELECT fda_application_number 23 | FROM interventions 24 | WHERE fda_application_number IS NOT NULL 25 | UNION 26 | SELECT regexp_replace(id, '-\\d+$', '') 27 | FROM fda_approvals 28 | ) 29 | `) 30 | .table('interventions', (table) => { 31 | table.renameColumn('fda_application_number', 'fda_application_id'); 32 | table.foreign('fda_application_id') 33 | .references('fda_applications.id') 34 | .onUpdate('CASCADE'); 35 | }) 36 | .table('fda_approvals', (table) => { 37 | table.dropColumn('intervention_id'); 38 | table.text('fda_application_id') 39 | .nullable() 40 | .references('fda_applications.id') 41 | .onUpdate('CASCADE'); 42 | 43 | table.unique(['fda_application_id', 'supplement_number']); 44 | }) 45 | .raw(`UPDATE fda_approvals 46 | SET fda_application_id = regexp_replace(id, '-\\d+$', '') 47 | `) 48 | .raw(`ALTER TABLE fda_approvals 49 | ALTER COLUMN fda_application_id SET NOT NULL 50 | `) 51 | ); 52 | 53 | exports.down = () => { 54 | throw Error('Destructive migration can\'t be rolled back.'); 55 | }; 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # api 2 | 3 | [![Gitter](https://img.shields.io/gitter/room/opentrials/chat.svg)](https://gitter.im/opentrials/chat) 4 | [![Travis Build Status](https://travis-ci.org/opentrials/api.svg?branch=master)](https://travis-ci.org/opentrials/api) 5 | [![Coveralls](http://img.shields.io/coveralls/opentrials/api.svg?branch=master)](https://coveralls.io/r/opentrials/api?branch=master) 6 | [![Dependency Status](https://david-dm.org/opentrials/api.svg)](https://david-dm.org/opentrials/api) 7 | [![Issues](https://img.shields.io/badge/issue-tracker-orange.svg)](https://github.com/opentrials/opentrials/issues) 8 | [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](http://docs.opentrials.net/en/latest/developers/) 9 | 10 | The OpenTrials API service. 11 | 12 | ## Developer notes 13 | 14 | ### Requirements 15 | 16 | * Node 6.9 17 | * PostgreSQL 9.4 18 | * ElasticSearch 2.3.5 19 | 20 | ### Installing 21 | 22 | 1. Copy the `.env.example` file to `.env` and alter its contents as needed. 23 | At minimum, you should set the `DATABASE_URL` and `ELASTICSEARCH_URL`. The 24 | `TEST_DATABASE_URL` is needed to run the tests. You could leave the 25 | `ELASTICSEARCH_AWS_*` as is if you're not using ElasticSearch on AWS; 26 | 2. Run `npm install`; 27 | 3. Run `npm run migrate`; 28 | 4. (Optional) If you want, you can add some seed data using `npm run seed`; 29 | 5. Run `npm run reindex`; 30 | 31 | After the install and migrations ran successfully, you can run `npm run dev` to 32 | run the project. If you haven't changed the default `PORT`, it should be 33 | available at `http://localhost:5000` 34 | 35 | ### Reindexing 36 | 37 | Currently, there's no way to automatically reindex the data. Every time you 38 | change the database, you'd need to run `npm run reindex` to keep ElasticSearch 39 | in sync. 40 | 41 | ### Testing 42 | 43 | You can run the test suite and linting with `npm test`. 44 | 45 | ### Interacting with the API 46 | 47 | You can find and interact [here](https://api.opentrials.net/v1/docs/) with the available endpoints. 48 | -------------------------------------------------------------------------------- /migrations/20170125090419_add_status_unknown_to_trials_and_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => ( 4 | knex.schema 5 | .raw('ALTER TABLE trials DROP CONSTRAINT trials_status_check') 6 | .raw(`ALTER TABLE trials ADD CONSTRAINT trials_status_check 7 | CHECK (status = ANY(ARRAY[ 8 | 'ongoing'::text, 9 | 'withdrawn'::text, 10 | 'suspended'::text, 11 | 'terminated'::text, 12 | 'complete'::text, 13 | 'unknown'::text, 14 | 'other'::text 15 | ])) 16 | `) 17 | .raw('ALTER TABLE records DROP CONSTRAINT records_status_check') 18 | .raw(`ALTER TABLE records ADD CONSTRAINT records_status_check 19 | CHECK (status = ANY(ARRAY[ 20 | 'ongoing'::text, 21 | 'withdrawn'::text, 22 | 'suspended'::text, 23 | 'terminated'::text, 24 | 'complete'::text, 25 | 'unknown'::text, 26 | 'other'::text 27 | ])) 28 | `) 29 | ); 30 | 31 | exports.down = (knex) => ( 32 | knex.schema 33 | .raw('ALTER TABLE trials DROP CONSTRAINT trials_status_check') 34 | .raw(`ALTER TABLE trials ADD CONSTRAINT trials_status_check 35 | CHECK (status = ANY(ARRAY[ 36 | 'ongoing'::text, 37 | 'withdrawn'::text, 38 | 'suspended'::text, 39 | 'terminated'::text, 40 | 'complete'::text, 41 | 'other'::text 42 | ])) 43 | `) 44 | .raw('ALTER TABLE records DROP CONSTRAINT records_status_check') 45 | .raw(`ALTER TABLE records ADD CONSTRAINT records_status_check 46 | CHECK (status = ANY(ARRAY[ 47 | 'ongoing'::text, 48 | 'withdrawn'::text, 49 | 'suspended'::text, 50 | 'terminated'::text, 51 | 'complete'::text, 52 | 'other'::text 53 | ])) 54 | `) 55 | ); 56 | -------------------------------------------------------------------------------- /tools/indexers/autocomplete.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const esHelpers = require('./helpers'); 4 | const Location = require('../../api/models/location'); 5 | 6 | 7 | const autocompleteModelMapping = { 8 | dynamic: 'strict', 9 | properties: { 10 | id: { 11 | type: 'string', 12 | index: 'not_analyzed', 13 | }, 14 | name: { 15 | type: 'string', 16 | analyzer: 'autocomplete', 17 | search_analyzer: 'standard', 18 | }, 19 | url: { 20 | type: 'string', 21 | index: 'not_analyzed', 22 | }, 23 | }, 24 | }; 25 | 26 | 27 | const autocompleteIndex = { 28 | body: { 29 | settings: { 30 | analysis: { 31 | filter: { 32 | autocomplete_filter: { 33 | type: 'edge_ngram', 34 | min_gram: 1, 35 | max_gram: 20, 36 | }, 37 | }, 38 | analyzer: { 39 | autocomplete: { 40 | type: 'custom', 41 | tokenizer: 'standard', 42 | filter: [ 43 | 'lowercase', 44 | 'autocomplete_filter', 45 | ], 46 | }, 47 | }, 48 | }, 49 | }, 50 | mappings: { 51 | location: autocompleteModelMapping, 52 | }, 53 | }, 54 | }; 55 | 56 | 57 | function indexAutocompleteModel(model, index, indexType) { 58 | return esHelpers.indexModel( 59 | model, 60 | index, 61 | indexType, 62 | { 63 | // Filter out entities without trials 64 | innerJoin: [ 65 | `trials_${indexType}s`, 66 | `${indexType}s.id`, 67 | `trials_${indexType}s.${indexType}_id`, 68 | ], 69 | // Remove duplicates 70 | groupBy: 'id', 71 | }, 72 | { 73 | columns: ['id', 'name'], 74 | } 75 | ); 76 | } 77 | 78 | 79 | function indexer(indexName) { 80 | return Promise.resolve() 81 | .then(() => indexAutocompleteModel(Location, indexName, 'location')); 82 | } 83 | 84 | 85 | module.exports = { 86 | alias: 'autocomplete', 87 | index: autocompleteIndex, 88 | indexer, 89 | }; 90 | -------------------------------------------------------------------------------- /migrations/20160830191612_add_on_delete_cascade_to_trials_relationship_tables.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | function addOnDeleteCascade(schema, tableName, originalConstraintName) { 5 | const constraint = `${tableName}_trial_id_foreign`; 6 | const originalConstraint = originalConstraintName || constraint; 7 | 8 | return schema.raw(`ALTER TABLE ${tableName} 9 | DROP CONSTRAINT ${originalConstraint}, 10 | ADD CONSTRAINT ${constraint} 11 | FOREIGN KEY (trial_id) 12 | REFERENCES trials(id) 13 | ON DELETE CASCADE 14 | `); 15 | } 16 | 17 | const schema = knex.schema; 18 | 19 | addOnDeleteCascade(schema, 'trials_conditions', 'trials_problems_trial_id_foreign'); 20 | addOnDeleteCascade(schema, 'trials_interventions'); 21 | addOnDeleteCascade(schema, 'trials_locations'); 22 | addOnDeleteCascade(schema, 'trials_organisations'); 23 | addOnDeleteCascade(schema, 'trials_persons'); 24 | addOnDeleteCascade(schema, 'trials_publications'); 25 | 26 | return schema; 27 | }; 28 | 29 | exports.down = (knex) => { 30 | function removeOnDeleteCascade(schema, tableName, originalConstraintName) { 31 | const constraint = `${tableName}_trial_id_foreign`; 32 | const originalConstraint = originalConstraintName || constraint; 33 | 34 | return schema.raw(`ALTER TABLE ${tableName} 35 | DROP CONSTRAINT ${constraint}, 36 | ADD CONSTRAINT ${originalConstraint} 37 | FOREIGN KEY (trial_id) 38 | REFERENCES trials(id) 39 | `); 40 | } 41 | 42 | const schema = knex.schema; 43 | 44 | removeOnDeleteCascade(schema, 'trials_conditions', 'trials_problems_trial_id_foreign'); 45 | removeOnDeleteCascade(schema, 'trials_interventions'); 46 | removeOnDeleteCascade(schema, 'trials_locations'); 47 | removeOnDeleteCascade(schema, 'trials_organisations'); 48 | removeOnDeleteCascade(schema, 'trials_persons'); 49 | removeOnDeleteCascade(schema, 'trials_publications'); 50 | 51 | return schema; 52 | }; 53 | -------------------------------------------------------------------------------- /api/models/record.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bookshelf = require('../../config').bookshelf; 4 | const BaseModel = require('./base'); 5 | const helpers = require('../helpers'); 6 | require('./trial'); 7 | require('./source'); 8 | 9 | const relatedModels = [ 10 | 'trial', 11 | 'source', 12 | ]; 13 | 14 | const Record = BaseModel.extend({ 15 | tableName: 'records', 16 | hasTimestamps: true, 17 | visible: [ 18 | 'id', 19 | 'trial_id', 20 | 'source', 21 | 'source_url', 22 | 'public_title', 23 | 'brief_summary', 24 | 'target_sample_size', 25 | 'gender', 26 | 'status', 27 | 'recruitment_status', 28 | 'registration_date', 29 | 'completion_date', 30 | 'results_exemption_date', 31 | 'last_verification_date', 32 | 'has_published_results', 33 | 'is_primary', 34 | 'created_at', 35 | 'updated_at', 36 | ], 37 | trial() { 38 | return this.belongsTo('Trial'); 39 | }, 40 | source() { 41 | return this.belongsTo('Source', 'source_id'); 42 | }, 43 | toJSONSummary() { 44 | const attributes = this.toJSON(); 45 | const result = { 46 | id: attributes.id, 47 | source_id: this.attributes.source_id, 48 | is_primary: this.attributes.is_primary, 49 | }; 50 | 51 | if (attributes.last_verification_date) { 52 | result.last_verification_date = attributes.last_verification_date; 53 | } 54 | 55 | if (attributes.url) { 56 | result.url = attributes.url; 57 | } 58 | if (attributes.source_url) { 59 | result.source_url = attributes.source_url; 60 | } 61 | if (attributes.updated_at) { 62 | result.updated_at = attributes.updated_at; 63 | } 64 | 65 | return result; 66 | }, 67 | virtuals: { 68 | url() { 69 | const fakeTrial = { id: this.attributes.trial_id, tableName: 'trials' }; 70 | const fakeRecord = { id: this.id, tableName: 'records' }; 71 | return helpers.urlFor([fakeTrial, fakeRecord]); 72 | }, 73 | trial_url() { 74 | const fakeTrial = { id: this.attributes.trial_id, tableName: 'trials' }; 75 | return helpers.urlFor(fakeTrial); 76 | }, 77 | }, 78 | }, { 79 | relatedModels, 80 | }); 81 | 82 | module.exports = bookshelf.model('Record', Record); 83 | -------------------------------------------------------------------------------- /migrations/20160429190634_update_publications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex, Promise) => { 4 | const updatePublications = knex.schema.table('publications', (table) => { 5 | // Add columns 6 | table.timestamps(); 7 | table.specificType('primary_facts', 'text[]') 8 | .nullable() 9 | .index(undefined, 'GIN'); 10 | table.specificType('secondary_facts', 'text[]') 11 | .nullable() 12 | .index(undefined, 'GIN'); 13 | table.text('source_url') 14 | .notNullable(); 15 | table.text('title') 16 | .notNullable(); 17 | table.text('abstract') 18 | .notNullable(); 19 | table.specificType('authors', 'text[]') 20 | .nullable(); 21 | table.text('journal') 22 | .nullable(); 23 | table.date('date') 24 | .nullable(); 25 | 26 | // Remove columns 27 | table.dropColumn('name'); 28 | table.dropColumn('type'); 29 | table.dropColumn('data'); 30 | }); 31 | 32 | const updateTrialsPublications = knex.schema.table('trials_publications', (table) => { 33 | // Remove columns 34 | table.dropColumn('role'); 35 | table.dropColumn('context'); 36 | }); 37 | 38 | return Promise.all([ 39 | updatePublications, 40 | updateTrialsPublications, 41 | ]); 42 | }; 43 | 44 | exports.down = (knex, Promise) => { 45 | const updatePublications = knex.schema.table('publications', (table) => { 46 | // Add columns 47 | table.text('name'); 48 | table.enu('type', [ 49 | 'other', 50 | ]).nullable(); 51 | table.jsonb('data') 52 | .notNullable(); 53 | 54 | // Remove columns 55 | table.dropColumn('created_at'); 56 | table.dropColumn('updated_at'); 57 | table.dropColumn('primary_facts'); 58 | table.dropColumn('secondary_facts'); 59 | table.dropColumn('source_url'); 60 | table.dropColumn('title'); 61 | table.dropColumn('abstract'); 62 | table.dropColumn('authors'); 63 | table.dropColumn('journal'); 64 | table.dropColumn('date'); 65 | }); 66 | 67 | const updateTrialsPublications = knex.schema.table('trials_publications', (table) => { 68 | // Add columns 69 | table.enu('role', [ 70 | 'other', 71 | ]).nullable(); 72 | table.jsonb('context') 73 | .notNullable(); 74 | }); 75 | 76 | return Promise.all([ 77 | updatePublications, 78 | updateTrialsPublications, 79 | ]); 80 | }; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentrials-api", 3 | "version": "1.1.1", 4 | "private": true, 5 | "description": "API for OpenTrials.net.", 6 | "license": "MIT", 7 | "keywords": [ 8 | "opentrials" 9 | ], 10 | "homepage": "https://github.com/opentrials/api", 11 | "bugs": "https://github.com/opentrials/api/issues", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/opentrials/api.git" 15 | }, 16 | "author": "Open Knowledge (https://okfn.org)", 17 | "contributors": [ 18 | "Vitor Baptista (http://vitorbaptista.com)" 19 | ], 20 | "engines": { 21 | "node": "6.9.5" 22 | }, 23 | "dependencies": { 24 | "bluebird": "^3.4.6", 25 | "bookshelf": "^0.10.1", 26 | "dotenv": "^2.0.0", 27 | "elasticsearch": "^15.0.0", 28 | "good": "^7.0.2", 29 | "good-console": "^6.1.2", 30 | "hapi": "^15.0.3", 31 | "http-aws-es": "^1.1.3", 32 | "inert": "^4.0.2", 33 | "knex": "^0.12.0", 34 | "lodash": "^4.15.0", 35 | "node-uuid": "^1.4.7", 36 | "pg": "^6.1.0", 37 | "swagger-hapi": "^0.1.0", 38 | "swagger-ui": "^2.2.3" 39 | }, 40 | "main": "server.js", 41 | "devDependencies": { 42 | "coveralls": "^2.11.12", 43 | "eslint": "^3.5.0", 44 | "eslint-config-airbnb-base": "^8.0.0", 45 | "eslint-plugin-import": "^1.15.0", 46 | "factory-girl": "^3.0.1", 47 | "factory-girl-bookshelf": "^1.0.3", 48 | "istanbul": "^0.4.5", 49 | "mocha": "^3.0.2", 50 | "nodemon": "^1.11.0", 51 | "should": "^11.1.0", 52 | "sinon": "^1.17.3", 53 | "supertest": "^1.0.0" 54 | }, 55 | "scripts": { 56 | "e2e": "mocha --grep e2e", 57 | "test": "node ./node_modules/.bin/istanbul cover _mocha -- --grep e2e --invert", 58 | "posttest": "npm run lint", 59 | "lint": "eslint .", 60 | "precoveralls": "npm test", 61 | "coveralls": "cat ./coverage/lcov.info | coveralls", 62 | "migrate": "knex migrate:latest", 63 | "rollback": "knex migrate:rollback", 64 | "seed": "[ \"$NODE_ENV\" != \"production\" ] && knex seed:run || echo Can\\'t run seed in production", 65 | "start": "node --optimize_for_size --max_old_space_size=460 --gc_interval=100 server.js", 66 | "dev": "nodemon server.js", 67 | "reindex": "node --optimize_for_size --max_old_space_size=460 --gc_interval=100 ./tools/reindex.js" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/api/controllers/fda_applications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('FDAApplication', () => { 4 | before(clearDB); 5 | 6 | afterEach(clearDB); 7 | 8 | describe('GET /v1/fda_applications/{id}', () => { 9 | it('returns 404 if there\'s no FDA application with the received ID', () => ( 10 | server.inject('/v1/fda_applications/00000000-0000-0000-0000-000000000000') 11 | .then((response) => { 12 | response.statusCode.should.equal(404); 13 | }) 14 | )); 15 | 16 | it('returns the FDA application', () => ( 17 | factory.create('fda_application').then((model) => ( 18 | server.inject(`/v1/fda_applications/${model.attributes.id}`) 19 | .then((response) => { 20 | response.statusCode.should.equal(200); 21 | 22 | const expectedResult = toJSON(model); 23 | const result = JSON.parse(response.result); 24 | 25 | result.should.deepEqual(expectedResult); 26 | }) 27 | )) 28 | )); 29 | 30 | it('returns all the attributes', () => { 31 | const attributes = [ 32 | 'id', 33 | 'drug_name', 34 | 'active_ingredients', 35 | 'fda_approvals', 36 | 'url', 37 | 'type', 38 | ]; 39 | factory.create('fda_application').then((model) => ( 40 | server.inject(`/v1/fda_applications/${model.attributes.id}`) 41 | .then((response) => JSON.parse(response.payload) 42 | .should.have.keys(...attributes)) 43 | )); 44 | }); 45 | }); 46 | 47 | describe('GET /v1/fda_applications', () => { 48 | it('returns the FDA applications in pages', () => ( 49 | factory.create('fda_application').then((model) => ( 50 | server.inject('/v1/fda_applications') 51 | .then((response) => { 52 | response.statusCode.should.equal(200); 53 | 54 | const expectedResult = { 55 | total_count: 1, 56 | items: [toJSON(model)], 57 | }; 58 | const result = JSON.parse(response.result); 59 | result.should.deepEqual(expectedResult); 60 | }) 61 | )) 62 | )); 63 | 64 | it('returns empty items array when there are no more results', () => ( 65 | factory.create('fda_application').then(() => ( 66 | server.inject('/v1/fda_applications?page=2') 67 | .then((response) => { 68 | response.statusCode.should.equal(200); 69 | 70 | const expectedResult = { 71 | total_count: 1, 72 | items: [], 73 | }; 74 | const result = JSON.parse(response.result); 75 | result.should.deepEqual(expectedResult); 76 | }) 77 | )) 78 | )); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('dotenv').config(); 4 | require('./pg_types'); 5 | 6 | if (!process.env.ELASTICSEARCH_URL) { 7 | // Fallback to BONSAI_URL if ELASTICSEARCH_URL isn't set. 8 | process.env.ELASTICSEARCH_URL = process.env.BONSAI_URL; 9 | } 10 | 11 | const elasticsearch = require('elasticsearch'); 12 | const path = require('path'); 13 | const good = require('good'); 14 | const inert = require('inert'); 15 | const httpAwsEs = require('http-aws-es'); 16 | const Promise = require('bluebird'); 17 | require('./bluebird'); 18 | 19 | const config = { 20 | host: process.env.HOST || '0.0.0.0', 21 | port: process.env.PORT || 10010, 22 | url: process.env.URL, 23 | 24 | swaggerHapi: { 25 | appRoot: path.join(__dirname, '..'), 26 | }, 27 | 28 | hapi: { 29 | plugins: [{ 30 | register: good, 31 | options: { 32 | reporters: { 33 | console: [ 34 | { 35 | module: 'good-console', 36 | args: [{ 37 | log: '*', 38 | response: '*', 39 | error: '*', 40 | }], 41 | }, 42 | 'stdout', 43 | ], 44 | }, 45 | }, 46 | }, { 47 | register: inert, 48 | }], 49 | }, 50 | }; 51 | 52 | if (!config.url) { 53 | throw Error('Please set the URL environment variable to a URL like "http://www.foo.com:10010".'); 54 | } 55 | 56 | const env = process.env.NODE_ENV || 'development'; 57 | const knexConfig = require(path.join(__dirname, '..', './knexfile'))[env]; // eslint-disable-line import/no-dynamic-require 58 | const knex = require('knex')(knexConfig); 59 | const bookshelf = require('bookshelf')(knex); 60 | 61 | bookshelf.plugin('registry'); 62 | bookshelf.plugin('visibility'); 63 | bookshelf.plugin('virtuals'); 64 | bookshelf.plugin('pagination'); 65 | config.bookshelf = bookshelf; 66 | 67 | // ElasticSearch 68 | const elasticsearchConfig = { 69 | host: process.env.ELASTICSEARCH_URL, 70 | apiVersion: '5.6', 71 | defer: () => { 72 | const defer = {}; 73 | defer.promise = new Promise((resolve, reject) => { 74 | defer.resolve = resolve; 75 | defer.reject = reject; 76 | }); 77 | return defer; 78 | }, 79 | }; 80 | if (process.env.ELASTICSEARCH_AWS_REGION && 81 | process.env.ELASTICSEARCH_AWS_ACCESS_KEY && 82 | process.env.ELASTICSEARCH_AWS_SECRET_KEY) { 83 | elasticsearchConfig.connectionClass = httpAwsEs; 84 | elasticsearchConfig.amazonES = { 85 | region: process.env.ELASTICSEARCH_AWS_REGION, 86 | accessKey: process.env.ELASTICSEARCH_AWS_ACCESS_KEY, 87 | secretKey: process.env.ELASTICSEARCH_AWS_SECRET_KEY, 88 | }; 89 | } 90 | config.elasticsearch = new elasticsearch.Client(elasticsearchConfig); 91 | 92 | module.exports = config; 93 | -------------------------------------------------------------------------------- /api/models/document.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./file'); 4 | require('./trial'); 5 | require('./source'); 6 | require('./fda_approval'); 7 | require('./document_category'); 8 | 9 | const _ = require('lodash'); 10 | const helpers = require('../helpers'); 11 | const bookshelf = require('../../config').bookshelf; 12 | const BaseModel = require('./base'); 13 | 14 | const relatedModels = [ 15 | 'file', 16 | 'trials', 17 | 'source', 18 | 'fda_approval', 19 | 'fda_approval.fda_application', 20 | 'document_category', 21 | ]; 22 | 23 | const Document = BaseModel.extend({ 24 | tableName: 'documents', 25 | visible: [ 26 | 'id', 27 | 'name', 28 | 'file', 29 | 'trials', 30 | 'source', 31 | 'source_url', 32 | 'fda_approval', 33 | 'document_category', 34 | ], 35 | serialize(...args) { 36 | const attributes = Object.assign( 37 | {}, 38 | Object.getPrototypeOf(Document.prototype).serialize.call(this, args), 39 | { 40 | trials: this.related('trials').map((trial) => trial.toJSONSummary()), 41 | } 42 | ); 43 | 44 | if (attributes.file !== undefined) { 45 | attributes.source_url = attributes.file.source_url; 46 | } 47 | 48 | return attributes; 49 | }, 50 | file() { 51 | return this.belongsTo('File'); 52 | }, 53 | trials() { 54 | return this.belongsToMany('Trial', 'trials_documents'); 55 | }, 56 | source() { 57 | return this.belongsTo('Source'); 58 | }, 59 | fda_approval() { 60 | return this.belongsTo('FDAApproval'); 61 | }, 62 | document_category() { 63 | return this.belongsTo('DocumentCategory'); 64 | }, 65 | toJSONSummary() { 66 | const isEmptyPlainObject = (value) => _.isPlainObject(value) && _.isEmpty(value); 67 | const isNilOrEmptyPlainObject = (value) => _.isNil(value) || isEmptyPlainObject(value); 68 | const attributes = Object.assign( 69 | this.toJSON(), 70 | { 71 | file: this.related('file').toJSONSummary(), 72 | fda_approval: this.related('fda_approval').toJSON(), 73 | trials: this.related('trials').map((t) => t.toJSONSummary()), 74 | source_id: this.attributes.source_id, 75 | document_category: this.related('document_category').toJSON(), 76 | } 77 | ); 78 | 79 | delete attributes.source; 80 | 81 | return _.omitBy(attributes, isNilOrEmptyPlainObject); 82 | }, 83 | toJSONWithoutPages() { 84 | const attributes = this.toJSON(); 85 | 86 | if (attributes.file !== undefined) { 87 | delete attributes.file.pages; 88 | } 89 | 90 | return attributes; 91 | }, 92 | virtuals: { 93 | url() { 94 | return helpers.urlFor(this); 95 | }, 96 | }, 97 | }, { 98 | relatedModels, 99 | }); 100 | 101 | module.exports = bookshelf.model('Document', Document); 102 | -------------------------------------------------------------------------------- /tools/indexers/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, max-len */ 2 | 3 | 'use strict'; 4 | 5 | const _ = require('lodash'); 6 | const Promise = require('bluebird'); 7 | const client = require('../../config').elasticsearch; 8 | 9 | const defaultBatchSize = 1000; 10 | 11 | function indexModel(model, index, indexType, _queryParams, fetchOptions, entitiesConverter, _batchSize) { 12 | const batchSize = _batchSize || defaultBatchSize; 13 | let converter = entitiesConverter; 14 | if (converter === undefined) { 15 | converter = (entities) => entities; 16 | } 17 | 18 | return model.query(_queryParams).count().then((modelCount) => { 19 | console.info( 20 | `${modelCount} entities being indexed in "${index}/${indexType}" (${batchSize} at a time).` 21 | ); 22 | let offset = 0; 23 | let chain = Promise.resolve(); 24 | let numReindexedModels = 0; 25 | 26 | do { 27 | const queryParams = Object.assign( 28 | {}, 29 | { 30 | orderBy: 'id', 31 | limit: batchSize, 32 | offset, 33 | }, 34 | _queryParams 35 | ); 36 | 37 | chain = chain 38 | .then(() => model.query(queryParams).fetchAll(fetchOptions)) 39 | .then(converter) 40 | .then((entities) => _bulkIndexEntities(entities, index, indexType)) 41 | // eslint-disable-next-line no-loop-func 42 | .then((resp) => { 43 | if (resp && resp.errors) { 44 | const failedModels = _.filter(resp.items, 'index.error'); 45 | console.error(`${failedModels.length} failed to reindex.`); 46 | throw _.map(failedModels, (item) => item.index); 47 | } 48 | const count = (resp) ? resp.items.length : 0; 49 | numReindexedModels += count; 50 | console.info(`${numReindexedModels} successfully reindexed, ${modelCount - numReindexedModels} remaining.`); 51 | }); 52 | 53 | offset += batchSize; 54 | } while (offset <= modelCount); 55 | 56 | return chain; 57 | }); 58 | } 59 | 60 | function _bulkIndexEntities(entities, index, indexType) { 61 | if (entities.length === 0) { 62 | return undefined; 63 | } 64 | 65 | const bulkBody = entities.reduce((result, entity) => { 66 | const action = { 67 | index: { 68 | _index: index, 69 | _type: indexType, 70 | _id: entity.id, 71 | }, 72 | }; 73 | 74 | if (entity._parent !== undefined) { 75 | action.index._parent = entity._parent; 76 | delete entity._parent; // eslint-disable-line no-param-reassign 77 | } 78 | 79 | return result.concat([ 80 | action, 81 | entity, 82 | ]); 83 | }, []); 84 | 85 | let result; 86 | if (bulkBody.length > 0) { 87 | result = client.bulk({ 88 | body: bulkBody, 89 | }); 90 | } 91 | 92 | return result; 93 | } 94 | 95 | module.exports = { 96 | indexModel, 97 | }; 98 | -------------------------------------------------------------------------------- /tools/reindex.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | 'use strict'; 4 | 5 | const Promise = require('bluebird'); 6 | const assert = require('assert'); 7 | const client = require('../config').elasticsearch; 8 | const indexers = require('./indexers'); 9 | 10 | 11 | function runIndexer(indexDefinition, alias, indexer) { 12 | const index = uniqueIndexName(alias); 13 | const mapping = Object.assign( 14 | {}, 15 | indexDefinition, 16 | { index } 17 | ); 18 | 19 | return Promise.resolve() 20 | .then(() => client.indices.create(mapping)) 21 | .then(() => indexer(index)) 22 | .catch((err) => removeIndexes([index]).then(() => { throw err; })) 23 | .then(() => updateAlias(index, alias)) 24 | .catch((err) => { 25 | console.error(err); 26 | process.exit(-1); 27 | }); 28 | } 29 | 30 | function uniqueIndexName(alias) { 31 | const date = new Date(); 32 | const indexSuffix = `${date.toISOString().slice(0, 10)}_${date.getTime()}`; 33 | return `${alias}_${indexSuffix}`; 34 | } 35 | 36 | function updateAlias(index, alias) { 37 | let indexesToRemove; 38 | 39 | return Promise.resolve() 40 | .then(() => client.indices.getAlias({ name: alias })) 41 | .catch(ignoreESError(404)) 42 | .then((oldAliases) => { 43 | if (oldAliases) { 44 | indexesToRemove = Object.keys(oldAliases); 45 | } 46 | }) 47 | .then(() => client.indices.updateAliases({ 48 | body: { 49 | actions: [ 50 | { remove: { index: '*', alias } }, 51 | { add: { index, alias } }, 52 | ], 53 | }, 54 | })) 55 | .then(() => removeIndexes(indexesToRemove)); 56 | } 57 | 58 | function ignoreESError(statusCode) { 59 | return (err) => { 60 | if (err.status !== statusCode) { 61 | throw err; 62 | } 63 | }; 64 | } 65 | 66 | function removeIndexes(indexes) { 67 | let result; 68 | 69 | if (indexes) { 70 | console.log('Removing indexes:', indexes.join(', ')); 71 | result = client.indices.delete({ index: indexes, ignore: 404 }); 72 | } 73 | 74 | return result; 75 | } 76 | 77 | function indexersToRun() { 78 | const argv = process.argv; 79 | const knownIndexers = Object.keys(indexers); 80 | let result = knownIndexers; 81 | 82 | if (argv.length > 2) { 83 | const potentialIndexers = argv.slice(2); 84 | 85 | const unknownIndexers = potentialIndexers.filter((idx) => knownIndexers.indexOf(idx) === -1); 86 | const msg = `Unknown indexers: ${unknownIndexers.join(', ')}. Valid indexers are: ${knownIndexers.join(', ')}.`; 87 | assert.deepEqual(unknownIndexers.length, 0, msg); 88 | 89 | result = potentialIndexers; 90 | } 91 | 92 | return result; 93 | } 94 | 95 | Promise.each(indexersToRun(), (key) => { 96 | const indexer = indexers[key]; 97 | return runIndexer( 98 | indexer.index, 99 | indexer.alias, 100 | indexer.indexer 101 | ); 102 | }) 103 | .then(() => process.exit()); 104 | -------------------------------------------------------------------------------- /test/api/controllers/documents.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | 'use strict'; 4 | 5 | const Document = require('../../../api/models/document'); 6 | 7 | describe('Document', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('GET /v1/documents/{id}', () => { 13 | it('returns 404 if there\'s no document with the received ID', () => ( 14 | server.inject('/v1/documents/00000000-0000-0000-0000-000000000000') 15 | .then((response) => { 16 | response.statusCode.should.equal(404); 17 | }) 18 | )); 19 | 20 | it('returns the document', () => { 21 | let doc; 22 | 23 | return factory.create('document') 24 | .then((_doc) => new Document({ id: _doc.attributes.id }).fetch({ withRelated: Document.relatedModels })) 25 | .then((_doc) => (doc = _doc)) 26 | .then(() => server.inject(`/v1/documents/${doc.attributes.id}`)) 27 | .then((response) => { 28 | response.statusCode.should.equal(200); 29 | 30 | const expectedResult = doc.toJSON(); 31 | const result = JSON.parse(response.result); 32 | 33 | result.should.deepEqual(expectedResult); 34 | }); 35 | }); 36 | 37 | it('returns all the attributes', () => { 38 | const attributes = [ 39 | 'id', 40 | 'name', 41 | 'trials', 42 | 'source', 43 | 'source_url', 44 | 'document_category', 45 | 'url', 46 | ]; 47 | factory.create('document').then((model) => ( 48 | server.inject(`/v1/documents/${model.attributes.id}`) 49 | .then((response) => JSON.parse(response.payload) 50 | .should.have.keys(...attributes)) 51 | )); 52 | }); 53 | }); 54 | 55 | describe('GET /v1/documents', () => { 56 | it('returns the documents JSON Summaries in pages', () => { 57 | let doc; 58 | 59 | return factory.create('documentWithFile') 60 | .then((_doc) => new Document({ id: _doc.attributes.id }).fetch({ withRelated: Document.relatedModels })) 61 | .then((_doc) => (doc = _doc)) 62 | .then(() => server.inject('/v1/documents')) 63 | .then((response) => { 64 | response.statusCode.should.equal(200); 65 | 66 | const expectedResult = { 67 | total_count: 1, 68 | items: [doc.toJSONSummary()], 69 | }; 70 | const result = JSON.parse(response.result); 71 | result.should.deepEqual(toJSON(expectedResult)); 72 | }); 73 | }); 74 | 75 | it('returns empty items array when there are no more documents', () => ( 76 | factory.create('document').then(() => ( 77 | server.inject('/v1/documents?page=2') 78 | .then((response) => { 79 | response.statusCode.should.equal(200); 80 | 81 | const expectedResult = { 82 | total_count: 1, 83 | items: [], 84 | }; 85 | const result = JSON.parse(response.result); 86 | result.should.deepEqual(expectedResult); 87 | }) 88 | )) 89 | )); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /migrations/20160407111633_create_trialrecords_entity_drop_records.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const schema = knex.schema; 5 | 6 | schema.createTable('trialrecords', (table) => { 7 | table.uuid('id').primary(); 8 | 9 | table.uuid('source_id') 10 | .notNullable() 11 | .references('sources.id'); 12 | table.text('source_url') 13 | .notNullable(); 14 | table.jsonb('source_data') 15 | .notNullable(); 16 | 17 | table.text('primary_register') 18 | .notNullable(); 19 | table.text('primary_id') 20 | .notNullable(); 21 | table.jsonb('secondary_ids') 22 | .notNullable(); 23 | table.date('registration_date') 24 | .notNullable(); 25 | table.text('public_title') 26 | .notNullable(); 27 | table.text('brief_summary') 28 | .notNullable(); 29 | table.text('scientific_title') 30 | .nullable(); 31 | table.text('description') 32 | .nullable(); 33 | 34 | table.text('recruitment_status') 35 | .notNullable(); 36 | table.jsonb('eligibility_criteria') 37 | .notNullable(); 38 | table.integer('target_sample_size') 39 | .nullable(); 40 | table.date('first_enrollment_date') 41 | .nullable(); 42 | 43 | table.text('study_type') 44 | .notNullable(); 45 | table.text('study_design') 46 | .notNullable(); 47 | table.text('study_phase') 48 | .notNullable(); 49 | 50 | table.jsonb('primary_outcomes') 51 | .nullable(); 52 | table.jsonb('secondary_outcomes') 53 | .nullable(); 54 | 55 | table.unique(['primary_register', 'primary_id']); 56 | }); 57 | 58 | schema.createTable('trials_trialrecords', (table) => { 59 | table.uuid('trial_id') 60 | .references('trials.id'); 61 | table.uuid('trialrecord_id') 62 | .references('trialrecords.id'); 63 | 64 | table.enu('role', [ 65 | 'primary', 66 | 'secondary', 67 | 'other', 68 | ]).nullable(); 69 | table.jsonb('context') 70 | .notNullable(); 71 | 72 | table.primary(['trial_id', 'trialrecord_id']); 73 | }); 74 | 75 | schema.dropTableIfExists('trials_records'); 76 | schema.dropTableIfExists('records'); 77 | 78 | return schema; 79 | }; 80 | 81 | exports.down = (knex) => { 82 | const schema = knex.schema; 83 | 84 | schema.createTable('records', (table) => { 85 | table.uuid('id').primary(); 86 | 87 | table.uuid('source_id') 88 | .notNullable() 89 | .references('sources.id'); 90 | table.enu('type', [ 91 | 'trial', 92 | 'other', 93 | ]).nullable(); 94 | table.jsonb('data') 95 | .notNullable(); 96 | }); 97 | 98 | schema.createTable('trials_records', (table) => { 99 | table.uuid('trial_id') 100 | .references('trials.id'); 101 | table.uuid('record_id') 102 | .references('records.id'); 103 | 104 | table.enu('role', [ 105 | 'primary', 106 | 'secondary', 107 | 'other', 108 | ]).nullable(); 109 | table.jsonb('context') 110 | .notNullable(); 111 | 112 | table.primary(['trial_id', 'record_id']); 113 | }); 114 | 115 | schema.dropTableIfExists('trials_trialrecords'); 116 | schema.dropTableIfExists('trialrecords'); 117 | 118 | return schema; 119 | }; 120 | -------------------------------------------------------------------------------- /test/api/controllers/trials.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | const Record = require('../../../api/models/record'); 5 | 6 | describe('Trials', () => { 7 | before(clearDB); 8 | 9 | afterEach(clearDB); 10 | 11 | describe('GET /v1/trials/{id}', () => { 12 | it('returns 404 if there\'s no trial with the received ID', () => ( 13 | server.inject('/v1/trials/eb1af997-3e9c-4f31-895f-002ec1cfa196') 14 | .then((response) => { 15 | response.statusCode.should.equal(404); 16 | }) 17 | )); 18 | 19 | it('returns the Trial', () => ( 20 | factory.create('trialWithRelated') 21 | .then((trial) => ( 22 | server.inject(`/v1/trials/${trial.attributes.id}`) 23 | .then((response) => { 24 | response.statusCode.should.equal(200); 25 | 26 | // Convert and load from JSON so things like Dates get 27 | // properly stringified and we can compare with the API's output. 28 | const expectedResult = JSON.parse(JSON.stringify(trial)); 29 | const result = JSON.parse(response.result); 30 | 31 | result.should.deepEqual(expectedResult); 32 | }) 33 | )) 34 | )); 35 | }); 36 | 37 | describe('GET /v1/trials/{id}/records/{id}', () => { 38 | it('returns 404 if there\'s no trial with the received ID', () => ( 39 | server.inject('/v1/trials/eb1af997-3e9c-4f31-895f-002ec1cfa196/records/eb1af997-3e9c-4f31-895f-002ec1cfa196') 40 | .then((response) => { 41 | response.statusCode.should.equal(404); 42 | }) 43 | )); 44 | 45 | it('returns the records', () => ( 46 | factory.create('record') 47 | .then((record) => ( 48 | server.inject(`/v1/trials/${record.attributes.trial_id}/records/${record.attributes.id}`) 49 | .then((response) => { 50 | response.statusCode.should.equal(200); 51 | 52 | // Convert and load from JSON so things like Dates get 53 | // properly stringified and we can compare with the API's output. 54 | const expectedResult = JSON.parse(JSON.stringify(record)); 55 | const result = JSON.parse(response.result); 56 | 57 | result.should.deepEqual(expectedResult); 58 | }) 59 | )) 60 | )); 61 | }); 62 | 63 | describe('GET /v1/trials/{id}/records', () => { 64 | it('returns an array with all trial\'s records', () => { 65 | let trialId; 66 | let records; 67 | 68 | return factory.create('trial') 69 | .then((trial) => (trialId = trial.attributes.id)) 70 | .then(() => factory.createMany('record', { trial_id: trialId }, 2)) 71 | .then(() => Record.query({ where: { trial_id: trialId } }).fetchAll({ withRelated: ['source'] })) 72 | .then((_records) => (records = _records)) 73 | .then(() => server.inject(`/v1/trials/${trialId}/records`)) 74 | .then((response) => { 75 | response.statusCode.should.equal(200); 76 | 77 | const result = JSON.parse(response.result); 78 | should(result).deepEqual(records.map((record) => toJSON(record))); 79 | }); 80 | }); 81 | 82 | it('returns empty array if the trial has no records', () => ( 83 | server.inject('/v1/trials/00000000-0000-0000-0000-000000000000/records') 84 | .then((response) => { 85 | response.statusCode.should.equal(200); 86 | 87 | const result = JSON.parse(response.result); 88 | should(result).deepEqual([]); 89 | }) 90 | )); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/e2e/search.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const should = require('should'); 4 | 5 | describe('(e2e) search', () => { 6 | it('should be successful', () => ( 7 | server.inject('/v1/search') 8 | .then((response) => { 9 | should(response.statusCode).eql(200); 10 | return JSON.parse(response.result); 11 | }) 12 | .then((apiResponse) => should(apiResponse.failedValidation).be.undefined()) 13 | )); 14 | }); 15 | 16 | describe('(e2e) search FDA documents', () => { 17 | it('should be successful', () => ( 18 | server.inject('/v1/search/fda_documents') 19 | .then((response) => { 20 | should(response.statusCode).eql(200); 21 | return JSON.parse(response.result); 22 | }) 23 | .then((apiResponse) => should(apiResponse.failedValidation).be.undefined()) 24 | )); 25 | 26 | it('returns only documents that contain the "q" query string', () => { 27 | let sampleDocumentId; 28 | 29 | return server.inject('/v1/search/fda_documents') 30 | .then((response) => { 31 | // Extract a sample document file's page 32 | const result = JSON.parse(response.result); 33 | 34 | // Make sure we have at least 2 documents indexed 35 | should(result.items.length).be.above(1); 36 | 37 | sampleDocumentId = result.items[0].id; 38 | }) 39 | .then(() => server.inject(`/v1/search/fda_documents?q=${sampleDocumentId}`)) 40 | .then((response) => { 41 | const result = JSON.parse(response.result); 42 | 43 | should(result.items.length).eql(1); 44 | should(result.items[0].id).eql(sampleDocumentId); 45 | }); 46 | }); 47 | 48 | it('returns only pages that contain the "text" query param, with terms highlighted', () => { 49 | let samplePage; 50 | 51 | return server.inject('/v1/search/fda_documents') 52 | .then((response) => { 53 | // Extract a sample document file's page 54 | const result = JSON.parse(response.result); 55 | 56 | for (const doc of result.items) { 57 | // Only consider files with multiple pages to be able to test that 58 | // the other pages weren't returned 59 | if (doc.file !== undefined && doc.file.pages.length >= 2) { 60 | samplePage = doc.file.pages[0]; 61 | break; 62 | } 63 | } 64 | 65 | // Safety net for the case we can't find any page in the DB 66 | should(samplePage).not.be.undefined(); 67 | }) 68 | .then(() => server.inject(`/v1/search/fda_documents?text=${encodeURIComponent(`"${samplePage.text}"`)}`)) 69 | .then((response) => { 70 | const result = JSON.parse(response.result); 71 | const pages = result.items.reduce((items, doc) => { 72 | if (doc.file === undefined) { 73 | return items; 74 | } 75 | 76 | return [ 77 | ...items, 78 | ...doc.file.pages, 79 | ]; 80 | }, []); 81 | const highlightedTerms = samplePage.text 82 | .split(' ') 83 | .map((term) => `${term}`) 84 | .join(' '); 85 | 86 | pages.forEach((page) => should(page.text).eql(highlightedTerms)); 87 | }); 88 | }); 89 | 90 | it('returns all documents when called without parameters', () => ( 91 | // This assumes that there're FDA Documents indexed 92 | server.inject('/v1/search/fda_documents') 93 | .then((response) => { 94 | const result = JSON.parse(response.result); 95 | should(result.total_count).above(0); 96 | should(result.items.length).above(0); 97 | }) 98 | )); 99 | }); 100 | -------------------------------------------------------------------------------- /test/api/models/document.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const should = require('should'); 5 | const helpers = require('../../../api/helpers'); 6 | 7 | describe('Document', () => { 8 | before(clearDB); 9 | 10 | afterEach(clearDB); 11 | 12 | describe('toJSONSummary', () => { 13 | it('returns simplified document representation', () => ( 14 | factory.create('documentWithRelated') 15 | .then((doc) => { 16 | const attributes = doc.toJSON(); 17 | const expected = { 18 | id: attributes.id, 19 | name: attributes.name, 20 | url: attributes.url, 21 | source_id: doc.attributes.source_id, 22 | source_url: doc.related('file').attributes.source_url, 23 | file: doc.related('file').toJSONSummary(), 24 | trials: doc.related('trials').map((t) => t.toJSONSummary()), 25 | fda_approval: doc.related('fda_approval').toJSON(), 26 | document_category: doc.related('document_category').toJSON(), 27 | }; 28 | 29 | doc.toJSONSummary().should.deepEqual(expected); 30 | }) 31 | )); 32 | 33 | it('doesn\'t return null or undefined values for documents without files', () => ( 34 | factory.create('document', { source_id: null, fda_approval_id: null, file_id: null }) 35 | .then((doc) => { 36 | const jsonSummary = doc.toJSONSummary(); 37 | const values = _.values(jsonSummary); 38 | 39 | should(values).not.containEql(null); 40 | should(values).not.containEql(undefined); 41 | }) 42 | )); 43 | 44 | it('doesn\'t return null or undefined values for documents with files', () => ( 45 | factory.create('documentWithFile', { source_id: null, fda_approval_id: null, source_url: null }) 46 | .then((doc) => { 47 | const jsonSummary = doc.toJSONSummary(); 48 | const values = _.values(jsonSummary); 49 | 50 | should(values).not.containEql(null); 51 | should(values).not.containEql(undefined); 52 | }) 53 | )); 54 | }); 55 | 56 | describe('toJSONWithoutPages', () => { 57 | it('returns JSON removing files.pages', () => ( 58 | factory.create('documentWithRelated') 59 | .then((doc) => { 60 | const json = doc.toJSON(); 61 | const jsonWithoutPages = doc.toJSONWithoutPages(); 62 | 63 | should(json.file.pages).not.be.undefined(); 64 | should(jsonWithoutPages.pages).be.undefined(); 65 | 66 | delete json.file.pages; 67 | 68 | should(jsonWithoutPages).deepEqual(json); 69 | }) 70 | )); 71 | 72 | it('returns same as toJSON() if document has no file', () => ( 73 | factory.create('document', { file_id: null }) 74 | .then((doc) => { 75 | should(doc.toJSONWithoutPages()).deepEqual(doc.toJSON()); 76 | }) 77 | )); 78 | }); 79 | 80 | describe('virtuals', () => { 81 | describe('url', () => { 82 | it('returns the url', () => ( 83 | factory.build('document') 84 | .then((doc) => should(doc.toJSON().url).eql(helpers.urlFor(doc))) 85 | )); 86 | }); 87 | }); 88 | 89 | describe('serialize', () => { 90 | describe('trial', () => { 91 | it('is the trial\'s JSON summary', () => ( 92 | factory.create('documentWithRelated') 93 | .then((doc) => { 94 | const trialsJSONSummary = doc.related('trials').map((trial) => trial.toJSONSummary()); 95 | should(doc.toJSON().trials).deepEqual(trialsJSONSummary); 96 | }) 97 | )); 98 | }); 99 | 100 | it('returns source_url as file.source_url if it has a file', () => ( 101 | factory.create('documentWithRelated') 102 | .then((doc) => { 103 | const fileSourceUrl = doc.related('file').attributes.source_url; 104 | should(doc.toJSON().source_url).eql(fileSourceUrl); 105 | }) 106 | )); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /migrations/20170125115201_create_document_categories.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const documentCategories = [ 4 | { 5 | id: 19, 6 | name: 'Registry entry', 7 | group: null, 8 | }, 9 | { 10 | id: 20, 11 | name: 'Other', 12 | group: null, 13 | }, 14 | { 15 | id: 21, 16 | name: 'Journal article', 17 | group: 'Results', 18 | }, 19 | { 20 | id: 22, 21 | name: 'Clinical study report', 22 | group: 'Results', 23 | }, 24 | { 25 | id: 23, 26 | name: 'Clinical study report synopsis', 27 | group: 'Results', 28 | }, 29 | { 30 | id: 24, 31 | name: 'European Public Assessment Report (EPAR) document section', 32 | group: 'Results', 33 | }, 34 | { 35 | id: 25, 36 | name: 'U.S. Food and Drug Administration (FDA) document segment', 37 | group: 'Results', 38 | }, 39 | { 40 | id: 26, 41 | name: 'Press release describing results', 42 | group: 'Results', 43 | }, 44 | { 45 | id: 27, 46 | name: 'Conference abstract or proceedings describing results', 47 | group: 'Results', 48 | }, 49 | { 50 | id: 28, 51 | name: 'Report to funder', 52 | group: 'Results', 53 | }, 54 | { 55 | id: 29, 56 | name: 'Case report form', 57 | group: 'Study documents', 58 | }, 59 | { 60 | id: 30, 61 | name: 'Grant application', 62 | group: 'Study documents', 63 | }, 64 | { 65 | id: 31, 66 | name: 'IRB/HREC approval documents', 67 | group: 'Study documents', 68 | }, 69 | { 70 | id: 32, 71 | name: 'Investigator\'\'s Brochure', 72 | group: 'Study documents', 73 | }, 74 | { 75 | id: 33, 76 | name: 'Patient information sheet / Consent form', 77 | group: 'Study documents', 78 | }, 79 | { 80 | id: 34, 81 | name: 'Statistical analysis plan', 82 | group: 'Study documents', 83 | }, 84 | { 85 | id: 35, 86 | name: 'Trial protocol', 87 | group: 'Study documents', 88 | }, 89 | { 90 | id: 36, 91 | name: 'Analytic code', 92 | group: 'Study documents', 93 | }, 94 | { 95 | id: 37, 96 | name: 'Trialists\'\' webpage', 97 | group: 'Study documents', 98 | }, 99 | { 100 | id: 38, 101 | name: 'Lay summary, design of ongoing study', 102 | group: 'Lay summaries', 103 | }, 104 | { 105 | id: 39, 106 | name: 'Lay summary, results of completed study', 107 | group: 'Lay summaries', 108 | }, 109 | { 110 | id: 40, 111 | name: 'Link to individual patient data for trial', 112 | group: 'Data', 113 | }, 114 | { 115 | id: 41, 116 | name: 'Structured data about trial extracted for systematic review', 117 | group: 'Data', 118 | }, 119 | { 120 | id: 42, 121 | name: 'Blog about trial design or results', 122 | group: 'Miscellaneous', 123 | }, 124 | { 125 | id: 43, 126 | name: 'Journal article critiquing trial design or results', 127 | group: 'Miscellaneous', 128 | }, 129 | { 130 | id: 44, 131 | name: 'Systematic review including trial', 132 | group: 'Miscellaneous', 133 | }, 134 | { 135 | id: 45, 136 | name: 'Review article citing trial', 137 | group: 'Miscellaneous', 138 | }, 139 | { 140 | id: 46, 141 | name: 'News article about trial or results', 142 | group: 'Miscellaneous', 143 | }, 144 | { 145 | id: 47, 146 | name: 'Press release about trial', 147 | group: 'Miscellaneous', 148 | }, 149 | { 150 | id: 48, 151 | name: 'Report from sponsor describing trial or results', 152 | group: 'Miscellaneous', 153 | }, 154 | ]; 155 | 156 | exports.up = (knex) => ( 157 | knex.schema 158 | .createTable('document_categories', (table) => { 159 | table.integer('id') 160 | .primary() 161 | .notNullable(); 162 | table.text('name') 163 | .notNullable(); 164 | table.text('group') 165 | .nullable(); 166 | table.unique(['name', 'group']); 167 | }) 168 | .then(() => knex.batchInsert('document_categories', documentCategories, 50)) 169 | ); 170 | 171 | exports.down = (knex) => ( 172 | knex.schema.dropTable('document_categories') 173 | ); 174 | -------------------------------------------------------------------------------- /tools/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | cmdname=$(basename $0) 5 | 6 | echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $TIMEOUT -gt 0 ]]; then 28 | echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT" 29 | else 30 | echoerr "$cmdname: waiting for $HOST:$PORT without a timeout" 31 | fi 32 | start_ts=$(date +%s) 33 | while : 34 | do 35 | (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1 36 | result=$? 37 | if [[ $result -eq 0 ]]; then 38 | end_ts=$(date +%s) 39 | echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds" 40 | break 41 | fi 42 | sleep 1 43 | done 44 | return $result 45 | } 46 | 47 | wait_for_wrapper() 48 | { 49 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 50 | if [[ $QUIET -eq 1 ]]; then 51 | timeout $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 52 | else 53 | timeout $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 54 | fi 55 | PID=$! 56 | trap "kill -INT -$PID" INT 57 | wait $PID 58 | RESULT=$? 59 | if [[ $RESULT -ne 0 ]]; then 60 | echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT" 61 | fi 62 | return $RESULT 63 | } 64 | 65 | # process arguments 66 | while [[ $# -gt 0 ]] 67 | do 68 | case "$1" in 69 | *:* ) 70 | hostport=(${1//:/ }) 71 | HOST=${hostport[0]} 72 | PORT=${hostport[1]} 73 | shift 1 74 | ;; 75 | --child) 76 | CHILD=1 77 | shift 1 78 | ;; 79 | -q | --quiet) 80 | QUIET=1 81 | shift 1 82 | ;; 83 | -s | --strict) 84 | STRICT=1 85 | shift 1 86 | ;; 87 | -h) 88 | HOST="$2" 89 | if [[ $HOST == "" ]]; then break; fi 90 | shift 2 91 | ;; 92 | --host=*) 93 | HOST="${1#*=}" 94 | shift 1 95 | ;; 96 | -p) 97 | PORT="$2" 98 | if [[ $PORT == "" ]]; then break; fi 99 | shift 2 100 | ;; 101 | --port=*) 102 | PORT="${1#*=}" 103 | shift 1 104 | ;; 105 | -t) 106 | TIMEOUT="$2" 107 | if [[ $TIMEOUT == "" ]]; then break; fi 108 | shift 2 109 | ;; 110 | --timeout=*) 111 | TIMEOUT="${1#*=}" 112 | shift 1 113 | ;; 114 | --) 115 | shift 116 | CLI="$@" 117 | break 118 | ;; 119 | --help) 120 | usage 121 | ;; 122 | *) 123 | echoerr "Unknown argument: $1" 124 | usage 125 | ;; 126 | esac 127 | done 128 | 129 | if [[ "$HOST" == "" || "$PORT" == "" ]]; then 130 | echoerr "Error: you need to provide a host and port to test." 131 | usage 132 | fi 133 | 134 | TIMEOUT=${TIMEOUT:-15} 135 | STRICT=${STRICT:-0} 136 | CHILD=${CHILD:-0} 137 | QUIET=${QUIET:-0} 138 | 139 | if [[ $CHILD -gt 0 ]]; then 140 | RESULT=$(wait_for) 141 | exit $RESULT 142 | else 143 | if [[ $TIMEOUT -gt 0 ]]; then 144 | wait_for_wrapper 145 | RESULT=$? 146 | else 147 | RESULT=$(wait_for) 148 | fi 149 | fi 150 | 151 | if [[ $CLI != "" ]]; then 152 | if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then 153 | echoerr "$cmdname: strict mode, refusing to execute subprocess" 154 | exit $RESULT 155 | fi 156 | exec $CLI 157 | else 158 | exit $RESULT 159 | fi 160 | -------------------------------------------------------------------------------- /api/controllers/search.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const client = require('../../config').elasticsearch; 4 | 5 | function _convertElasticSearchResult(esResult) { 6 | return { 7 | total_count: esResult.hits.total, 8 | items: esResult.hits.hits.map((hit) => hit._source), 9 | }; 10 | } 11 | 12 | function searchTrials(req, res) { 13 | const params = req.swagger.params; 14 | const page = params.page.value; 15 | const perPage = params.per_page.value; 16 | const searchQuery = { 17 | index: 'trials', 18 | type: 'trial', 19 | q: params.q.value || '*', 20 | from: (page - 1) * perPage, 21 | size: perPage, 22 | defaultOperator: 'AND', 23 | sort: [ 24 | 'registration_date:desc', 25 | '_score:desc', 26 | ], 27 | }; 28 | 29 | return client.search(searchQuery) 30 | .then(_convertElasticSearchResult) 31 | .then((data) => { 32 | res.status(200); 33 | res.setHeader('Content-Type', 'application/json'); 34 | return res.end(data); 35 | }) 36 | .catch((err) => { 37 | // FIXME: Log error and return 500 HTTP code 38 | res.finish(); 39 | throw err; 40 | }); 41 | } 42 | 43 | function _convertFDADocumentsElasticSearchResult(esResult) { 44 | return { 45 | total_count: esResult.hits.total, 46 | items: esResult.hits.hits.map((hit) => { 47 | const doc = hit._source; 48 | 49 | if (hit.inner_hits.page !== undefined) { 50 | const pages = hit.inner_hits.page.hits.hits; 51 | doc.file.pages = pages.map((page) => { 52 | let text = page._source.text; 53 | if (page.highlight !== undefined && page.highlight.text.length > 0) { 54 | text = page.highlight.text[0]; 55 | } 56 | 57 | return { 58 | text, 59 | num: page._source.num, 60 | }; 61 | }); 62 | } 63 | 64 | return doc; 65 | }), 66 | }; 67 | } 68 | 69 | function searchFDADocuments(req, res) { 70 | const params = req.swagger.params; 71 | const page = params.page.value; 72 | const perPage = params.per_page.value; 73 | const queryString = params.q.value || '*'; 74 | const textQuery = params.text.value || '*'; 75 | 76 | const defaultQueryString = { 77 | default_operator: 'AND', 78 | }; 79 | 80 | let documentQuery = { 81 | match_all: {}, 82 | }; 83 | if (queryString !== '*') { 84 | const documentQueryString = Object.assign( 85 | {}, 86 | defaultQueryString, 87 | { 88 | query: queryString, 89 | } 90 | ); 91 | 92 | documentQuery = { 93 | query_string: documentQueryString, 94 | }; 95 | } 96 | 97 | let pageQuery = { 98 | match_all: {}, 99 | }; 100 | if (textQuery !== '*') { 101 | const pageQueryString = Object.assign( 102 | {}, 103 | defaultQueryString, 104 | { 105 | query: textQuery, 106 | default_field: 'text', 107 | } 108 | ); 109 | pageQuery = { 110 | query_string: pageQueryString, 111 | }; 112 | } 113 | 114 | const searchQuery = { 115 | index: 'fda_documents', 116 | type: 'document', 117 | from: (page - 1) * perPage, 118 | size: perPage, 119 | sort: [ 120 | 'application_id:desc', 121 | 'fda_approval.supplement_number:desc', 122 | 'name:asc', 123 | ], 124 | body: { 125 | query: { 126 | bool: { 127 | minimum_should_match: 2, 128 | should: [ 129 | documentQuery, 130 | { 131 | has_child: { 132 | type: 'page', 133 | query: pageQuery, 134 | inner_hits: { 135 | size: 2, 136 | sort: [ 137 | { num: 'asc' }, 138 | ], 139 | highlight: { 140 | require_field_match: false, 141 | fields: { 142 | text: { 143 | fragment_size: 150, 144 | no_match_size: 150, 145 | number_of_fragments: 1, 146 | }, 147 | }, 148 | }, 149 | }, 150 | }, 151 | }, 152 | ], 153 | }, 154 | }, 155 | }, 156 | }; 157 | 158 | return client.search(searchQuery) 159 | .then(_convertFDADocumentsElasticSearchResult) 160 | .then((data) => { 161 | res.status(200); 162 | res.setHeader('Content-Type', 'application/json'); 163 | return res.end(data); 164 | }) 165 | .catch((err) => { 166 | // FIXME: Log error and return 500 HTTP code 167 | res.finish(); 168 | throw err; 169 | }); 170 | } 171 | 172 | function autocomplete(req, res) { 173 | const params = req.swagger.params; 174 | const page = params.page.value; 175 | const perPage = params.per_page.value; 176 | const searchQuery = { 177 | index: 'autocomplete', 178 | type: params.in.value, 179 | from: (page - 1) * perPage, 180 | size: perPage, 181 | defaultOperator: 'AND', 182 | }; 183 | 184 | if (params.q.value) { 185 | searchQuery.body = { 186 | query: { 187 | match: { 188 | name: params.q.value, 189 | }, 190 | }, 191 | }; 192 | } else { 193 | // Return all results 194 | searchQuery.q = '*'; 195 | } 196 | 197 | return client.search(searchQuery) 198 | .then(_convertElasticSearchResult) 199 | .then(res.json) 200 | .catch((err) => { 201 | // FIXME: Log error and return 500 HTTP code 202 | res.finish(); 203 | throw err; 204 | }); 205 | } 206 | 207 | module.exports = { 208 | searchTrials, 209 | searchFDADocuments, 210 | autocomplete, 211 | }; 212 | -------------------------------------------------------------------------------- /tools/indexers/fda_documents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const esHelpers = require('./helpers'); 4 | const Document = require('../../api/models/document'); 5 | 6 | 7 | const fdaDocumentMapping = { 8 | dynamic: 'strict', 9 | properties: { 10 | id: { 11 | type: 'string', 12 | index: 'not_analyzed', 13 | }, 14 | name: { 15 | type: 'string', 16 | fielddata: true, 17 | }, 18 | source_id: { 19 | type: 'string', 20 | index: 'not_analyzed', 21 | }, 22 | source_url: { 23 | type: 'string', 24 | index: 'not_analyzed', 25 | }, 26 | url: { 27 | type: 'string', 28 | index: 'not_analyzed', 29 | }, 30 | document_category: { 31 | properties: { 32 | id: { 33 | type: 'integer', 34 | }, 35 | name: { 36 | type: 'string', 37 | index: 'not_analyzed', 38 | }, 39 | group: { 40 | type: 'string', 41 | index: 'not_analyzed', 42 | }, 43 | }, 44 | }, 45 | fda_approval: { 46 | properties: { 47 | id: { 48 | type: 'string', 49 | index: 'not_analyzed', 50 | }, 51 | notes: { 52 | type: 'string', 53 | }, 54 | supplement_number: { 55 | type: 'integer', 56 | }, 57 | type: { 58 | type: 'string', 59 | }, 60 | action_date: { 61 | type: 'date', 62 | format: 'dateOptionalTime', 63 | copy_to: 'action_date', 64 | }, 65 | fda_application: { 66 | properties: { 67 | id: { 68 | type: 'string', 69 | index: 'not_analyzed', 70 | copy_to: 'application_id', 71 | }, 72 | active_ingredients: { 73 | type: 'string', 74 | copy_to: 'active_ingredients', 75 | }, 76 | drug_name: { 77 | type: 'string', 78 | copy_to: 'drug', 79 | }, 80 | type: { 81 | type: 'string', 82 | index: 'not_analyzed', 83 | copy_to: 'application_type', 84 | }, 85 | url: { 86 | type: 'string', 87 | index: 'not_analyzed', 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | action_date: { 94 | type: 'date', 95 | format: 'dateOptionalTime', 96 | }, 97 | application_id: { 98 | type: 'string', 99 | index: 'not_analyzed', 100 | }, 101 | active_ingredients: { 102 | type: 'string', 103 | }, 104 | drug: { 105 | type: 'string', 106 | }, 107 | application_type: { 108 | type: 'string', 109 | index: 'not_analyzed', 110 | }, 111 | file: { 112 | properties: { 113 | id: { 114 | type: 'string', 115 | index: 'not_analyzed', 116 | }, 117 | documentcloud_id: { 118 | type: 'string', 119 | index: 'not_analyzed', 120 | }, 121 | sha1: { 122 | type: 'string', 123 | index: 'not_analyzed', 124 | }, 125 | source_url: { 126 | type: 'string', 127 | index: 'not_analyzed', 128 | }, 129 | pages: { 130 | type: 'string', 131 | analyzer: 'english', 132 | }, 133 | }, 134 | }, 135 | trials: { 136 | properties: { 137 | id: { 138 | type: 'string', 139 | index: 'not_analyzed', 140 | }, 141 | url: { 142 | type: 'string', 143 | index: 'not_analyzed', 144 | }, 145 | public_title: { 146 | type: 'string', 147 | }, 148 | }, 149 | }, 150 | }, 151 | }; 152 | 153 | const pageMapping = { 154 | _parent: { 155 | type: 'document', 156 | }, 157 | properties: { 158 | id: { 159 | type: 'string', 160 | index: 'not_analyzed', 161 | }, 162 | text: { 163 | type: 'string', 164 | analyzer: 'english', 165 | }, 166 | num: { 167 | type: 'integer', 168 | }, 169 | }, 170 | }; 171 | 172 | const index = { 173 | body: { 174 | mappings: { 175 | document: fdaDocumentMapping, 176 | page: pageMapping, 177 | }, 178 | }, 179 | }; 180 | 181 | function indexer(name) { 182 | return indexerFDADocuments(name) 183 | .then(() => indexerPages(name)); 184 | } 185 | 186 | function indexerFDADocuments(name) { 187 | return esHelpers.indexModel( 188 | Document, 189 | name, 190 | 'document', 191 | { 192 | where: { 193 | source_id: 'fda', 194 | }, 195 | }, 196 | { 197 | withRelated: Document.relatedModels, 198 | }, 199 | (entities) => entities.models.map((entity) => entity.toJSONSummary()) 200 | ); 201 | } 202 | 203 | function indexerPages(name) { 204 | return esHelpers.indexModel( 205 | Document, 206 | name, 207 | 'page', 208 | { 209 | where: { 210 | source_id: 'fda', 211 | }, 212 | whereNotNull: 'file_id', 213 | }, 214 | { 215 | withRelated: ['file'], 216 | }, 217 | _convertDocumentsFilesPages, 218 | 1 219 | ); 220 | } 221 | 222 | function _convertDocumentsFilesPages(docs) { 223 | return docs.models.reduce((result, doc) => { 224 | const docJSON = doc.toJSON(); 225 | const pages = (docJSON.file.pages || []).map((page, i) => (Object.assign( 226 | {}, 227 | page, 228 | { 229 | _parent: docJSON.id, 230 | // Must add parent's ID because multiple documents can point to the 231 | // same file. This means we'll be indexing the same file multiple 232 | // times. 233 | id: `${docJSON.id}_${docJSON.file.id}_${i + 1}`, 234 | } 235 | ))); 236 | 237 | return result.concat(pages); 238 | }, []); 239 | } 240 | 241 | module.exports = { 242 | alias: 'fda_documents', 243 | index, 244 | indexer, 245 | }; 246 | -------------------------------------------------------------------------------- /api/models/trial.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./location'); 4 | require('./intervention'); 5 | require('./condition'); 6 | require('./person'); 7 | require('./organisation'); 8 | require('./source'); 9 | require('./document'); 10 | require('./record'); 11 | require('./publication'); 12 | require('./risk_of_bias'); 13 | 14 | const _ = require('lodash'); 15 | const helpers = require('../helpers'); 16 | const bookshelf = require('../../config').bookshelf; 17 | const BaseModel = require('./base'); 18 | 19 | const relatedModels = [ 20 | 'locations', 21 | 'interventions', 22 | 'conditions', 23 | 'persons', 24 | 'organisations', 25 | 'records', 26 | 'records.source', 27 | 'publications', 28 | 'publications.source', 29 | 'documents', 30 | 'documents.file', 31 | 'documents.source', 32 | 'documents.fda_approval', 33 | 'documents.fda_approval.fda_application', 34 | 'documents.document_category', 35 | 'risks_of_bias', 36 | 'risks_of_bias.source', 37 | 'risks_of_bias.risk_of_bias_criteria', 38 | 'source', 39 | ]; 40 | 41 | const Trial = BaseModel.extend({ 42 | tableName: 'trials', 43 | visible: [ 44 | 'id', 45 | 'source_id', 46 | 'identifiers', 47 | 'public_title', 48 | 'brief_summary', 49 | 'target_sample_size', 50 | 'gender', 51 | 'age_range', 52 | 'has_published_results', 53 | 'status', 54 | 'recruitment_status', 55 | 'registration_date', 56 | 'completion_date', 57 | 'results_exemption_date', 58 | 'study_phase', 59 | ].concat(relatedModels), 60 | serialize(...args) { 61 | const attributes = Object.assign( 62 | {}, 63 | Object.getPrototypeOf(Trial.prototype).serialize.call(this, args) 64 | ); 65 | const relations = this.relations; 66 | const ignoredRelations = ['source']; 67 | const serializedRelations = _.difference(Object.keys(relations), ignoredRelations); 68 | 69 | attributes.locations = []; 70 | attributes.interventions = []; 71 | attributes.conditions = []; 72 | attributes.persons = []; 73 | attributes.organisations = []; 74 | attributes.risks_of_bias = []; 75 | 76 | for (const relationName of serializedRelations) { 77 | attributes[relationName] = relations[relationName].map((model) => { 78 | const attrs = model.toJSON(); 79 | 80 | if (model.pivot) { 81 | Object.keys(model.pivot.attributes).forEach((key) => { 82 | const value = model.pivot.attributes[key]; 83 | if (!key.endsWith('_id') && value) { 84 | attrs[key] = value; 85 | } 86 | }); 87 | } 88 | 89 | return attrs; 90 | }); 91 | } 92 | 93 | attributes.records = (relations.records || []).map((record) => record.toJSONSummary()); 94 | attributes.publications = (relations.publications || []).map((pub) => pub.toJSONSummary()); 95 | attributes.documents = (relations.documents || []).map((doc) => doc.toJSONSummary()); 96 | 97 | return attributes; 98 | }, 99 | locations() { 100 | return this.belongsToMany('Location', 'trials_locations', 101 | 'trial_id', 'location_id').withPivot(['role']); 102 | }, 103 | interventions() { 104 | return this.belongsToMany('Intervention', 'trials_interventions'); 105 | }, 106 | conditions() { 107 | return this.belongsToMany('Condition', 'trials_conditions'); 108 | }, 109 | persons() { 110 | return this.belongsToMany('Person', 'trials_persons', 111 | 'trial_id', 'person_id').withPivot(['role']); 112 | }, 113 | organisations() { 114 | return this.belongsToMany('Organisation', 'trials_organisations', 115 | 'trial_id', 'organisation_id').withPivot(['role']); 116 | }, 117 | publications() { 118 | return this.belongsToMany('Publication', 'trials_publications', 119 | 'trial_id', 'publication_id'); 120 | }, 121 | documents() { 122 | return this.belongsToMany('Document', 'trials_documents'); 123 | }, 124 | records() { 125 | return this.hasMany('Record'); 126 | }, 127 | risks_of_bias() { 128 | return this.hasMany('RiskOfBias'); 129 | }, 130 | source() { 131 | return this.belongsTo('Source', 'source_id'); 132 | }, 133 | toJSONSummary() { 134 | const attributes = this.toJSON(); 135 | 136 | return { 137 | id: attributes.id, 138 | public_title: attributes.public_title, 139 | url: attributes.url, 140 | }; 141 | }, 142 | virtuals: { 143 | url() { 144 | return helpers.urlFor(this); 145 | }, 146 | sources() { 147 | const publicationsSources = this.related('publications') 148 | .toJSON() 149 | .map((publication) => publication.source); 150 | const documentsSources = this.related('documents') 151 | .toJSON() 152 | .map((doc) => doc.source); 153 | const recordsSources = this.related('records') 154 | .toJSON() 155 | .map((record) => record.source); 156 | const robSources = this.related('risks_of_bias') 157 | .toJSON() 158 | .map((rob) => rob.source); 159 | const trialSource = this.related('source').toJSON().id !== undefined ? 160 | this.related('source').toJSON() : 161 | undefined; 162 | 163 | const sources = [ 164 | trialSource, 165 | ...publicationsSources, 166 | ...documentsSources, 167 | ...recordsSources, 168 | ...robSources, 169 | ]; 170 | 171 | const result = sources.reduce((data, source) => { 172 | if (source !== undefined) { 173 | // eslint-disable-next-line no-param-reassign 174 | data[source.id] = { 175 | id: source.id, 176 | name: source.name, 177 | type: source.type, 178 | source_url: source.source_url, 179 | }; 180 | } 181 | 182 | return data; 183 | }, {}); 184 | 185 | return result; 186 | }, 187 | discrepancies() { 188 | const discrepancyFields = [ 189 | 'target_sample_size', 190 | 'gender', 191 | 'status', 192 | 'recruitment_status', 193 | 'has_published_results', 194 | ]; 195 | const ignoredSources = ['euctr', 'ictrp']; 196 | const records = this.related('records') 197 | .toJSON() 198 | .filter((record) => ignoredSources.indexOf(record.source.id) === -1); 199 | let discrepancies; 200 | 201 | for (const field of discrepancyFields) { 202 | const values = records.reduce((result, record) => { 203 | if (record[field] !== undefined) { 204 | result.push({ 205 | record_id: record.id, 206 | source_name: record.source.name, 207 | value: record[field], 208 | }); 209 | } 210 | 211 | return result; 212 | }, []); 213 | 214 | // Have to convert to JSON to handle values that normally aren't 215 | // comparable like dates. 216 | const cleanValues = JSON.parse(JSON.stringify(values)); 217 | 218 | const hasDiscrepantValues = (_.uniqBy(cleanValues, 'value').length > 1); 219 | if (hasDiscrepantValues) { 220 | discrepancies = discrepancies || {}; 221 | discrepancies[field] = values; 222 | } 223 | } 224 | 225 | return discrepancies; 226 | }, 227 | }, 228 | }, { 229 | relatedModels, 230 | }); 231 | 232 | module.exports = bookshelf.model('Trial', Trial); 233 | -------------------------------------------------------------------------------- /migrations/20160216120159_create_initial_schema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = (knex) => { 4 | const schema = knex.schema; 5 | 6 | schema.createTable('sources', (table) => { 7 | table.uuid('id') 8 | .primary(); 9 | table.text('name') 10 | .notNullable(); 11 | table.enu('type', [ 12 | 'register', 13 | 'other', 14 | ]).nullable(); 15 | table.jsonb('data') 16 | .notNullable(); 17 | 18 | table.unique(['name', 'type']); 19 | }); 20 | 21 | schema.createTable('trials', (table) => { 22 | table.uuid('id').primary(); 23 | 24 | table.text('primary_register') 25 | .notNullable(); 26 | table.text('primary_id') 27 | .notNullable(); 28 | table.jsonb('secondary_ids') 29 | .notNullable(); 30 | table.date('registration_date') 31 | .notNullable(); 32 | table.text('public_title') 33 | .notNullable(); 34 | table.text('brief_summary') 35 | .notNullable(); 36 | table.text('scientific_title') 37 | .nullable(); 38 | table.text('description') 39 | .nullable(); 40 | 41 | table.text('recruitment_status') 42 | .notNullable(); 43 | table.jsonb('eligibility_criteria') 44 | .notNullable(); 45 | table.integer('target_sample_size') 46 | .nullable(); 47 | table.date('first_enrollment_date') 48 | .nullable(); 49 | 50 | table.text('study_type') 51 | .notNullable(); 52 | table.text('study_design') 53 | .notNullable(); 54 | table.text('study_phase') 55 | .notNullable(); 56 | 57 | table.jsonb('primary_outcomes') 58 | .nullable(); 59 | table.jsonb('secondary_outcomes') 60 | .nullable(); 61 | 62 | table.unique(['primary_register', 'primary_id']); 63 | }); 64 | 65 | schema.createTable('records', (table) => { 66 | table.uuid('id').primary(); 67 | 68 | table.uuid('source_id') 69 | .notNullable() 70 | .references('sources.id'); 71 | table.enu('type', [ 72 | 'trial', 73 | 'other', 74 | ]).nullable(); 75 | table.jsonb('data') 76 | .notNullable(); 77 | }); 78 | 79 | schema.createTable('trials_records', (table) => { 80 | table.uuid('trial_id') 81 | .references('trials.id'); 82 | table.uuid('record_id') 83 | .references('records.id'); 84 | 85 | table.enu('role', [ 86 | 'primary', 87 | 'secondary', 88 | 'other', 89 | ]).nullable(); 90 | table.jsonb('context') 91 | .notNullable(); 92 | 93 | table.primary(['trial_id', 'record_id']); 94 | }); 95 | 96 | schema.createTable('publications', (table) => { 97 | table.uuid('id').primary(); 98 | 99 | table.uuid('source_id') 100 | .notNullable() 101 | .references('sources.id'); 102 | table.text('name'); 103 | table.enu('type', [ 104 | 'other', 105 | ]).nullable(); 106 | table.jsonb('data') 107 | .notNullable(); 108 | 109 | table.unique(['name', 'type']); 110 | }); 111 | 112 | schema.createTable('trials_publications', (table) => { 113 | table.uuid('trial_id') 114 | .references('trials.id'); 115 | table.uuid('publication_id') 116 | .references('publications.id'); 117 | 118 | table.enu('role', [ 119 | 'other', 120 | ]).nullable(); 121 | table.jsonb('context') 122 | .notNullable(); 123 | 124 | table.primary(['trial_id', 'publication_id']); 125 | }); 126 | 127 | schema.createTable('documents', (table) => { 128 | table.uuid('id').primary(); 129 | table.uuid('source_id') 130 | .references('sources.id'); 131 | 132 | table.text('name') 133 | .notNullable(); 134 | table.enu('type', [ 135 | 'other', 136 | ]).nullable(); 137 | table.jsonb('data') 138 | .notNullable(); 139 | 140 | table.unique(['name', 'type']); 141 | }); 142 | 143 | schema.createTable('trials_documents', (table) => { 144 | table.uuid('trial_id') 145 | .references('trials.id'); 146 | table.uuid('document_id') 147 | .references('documents.id'); 148 | 149 | table.enu('role', [ 150 | 'other', 151 | ]).nullable(); 152 | table.jsonb('context') 153 | .notNullable(); 154 | 155 | table.primary(['trial_id', 'document_id']); 156 | }); 157 | 158 | schema.createTable('problems', (table) => { 159 | table.uuid('id').primary(); 160 | 161 | table.text('name') 162 | .notNullable(); 163 | table.enu('type', [ 164 | 'condition', 165 | 'other', 166 | ]).nullable(); 167 | table.jsonb('data') 168 | .notNullable(); 169 | 170 | table.unique(['name', 'type']); 171 | }); 172 | 173 | schema.createTable('trials_problems', (table) => { 174 | table.uuid('trial_id') 175 | .references('trials.id'); 176 | table.uuid('problem_id') 177 | .references('problems.id'); 178 | 179 | table.enu('role', [ 180 | 'other', 181 | ]).nullable(); 182 | table.jsonb('context') 183 | .notNullable(); 184 | 185 | table.primary(['trial_id', 'problem_id']); 186 | }); 187 | 188 | schema.createTable('interventions', (table) => { 189 | table.uuid('id').primary(); 190 | 191 | table.text('name') 192 | .notNullable(); 193 | table.enu('type', [ 194 | 'drug', 195 | 'other', 196 | ]).nullable(); 197 | table.jsonb('data') 198 | .notNullable(); 199 | 200 | table.unique(['name', 'type']); 201 | }); 202 | 203 | schema.createTable('trials_interventions', (table) => { 204 | table.uuid('trial_id') 205 | .references('trials.id'); 206 | table.uuid('intervention_id') 207 | .references('interventions.id'); 208 | 209 | table.enu('role', [ 210 | 'other', 211 | ]).nullable(); 212 | table.jsonb('context') 213 | .notNullable(); 214 | 215 | table.primary(['trial_id', 'intervention_id']); 216 | }); 217 | 218 | schema.createTable('locations', (table) => { 219 | table.uuid('id').primary(); 220 | 221 | table.text('name') 222 | .notNullable(); 223 | table.enu('type', [ 224 | 'country', 225 | 'city', 226 | 'other', 227 | ]).nullable(); 228 | table.jsonb('data') 229 | .notNullable(); 230 | 231 | table.unique(['name', 'type']); 232 | }); 233 | 234 | schema.createTable('trials_locations', (table) => { 235 | table.uuid('trial_id') 236 | .references('trials.id'); 237 | table.uuid('location_id') 238 | .references('locations.id'); 239 | 240 | table.enu('role', [ 241 | 'recruitment_countries', 242 | 'other', 243 | ]).nullable(); 244 | table.jsonb('context') 245 | .notNullable(); 246 | 247 | table.primary(['trial_id', 'location_id']); 248 | }); 249 | 250 | schema.createTable('organisations', (table) => { 251 | table.uuid('id').primary(); 252 | 253 | table.text('name') 254 | .notNullable() 255 | .unique(); 256 | table.enu('type', [ 257 | 'other', 258 | ]).nullable(); 259 | table.jsonb('data') 260 | .notNullable(); 261 | }); 262 | 263 | schema.createTable('trials_organisations', (table) => { 264 | table.uuid('trial_id') 265 | .references('trials.id'); 266 | table.uuid('organisation_id') 267 | .references('organisations.id'); 268 | 269 | table.enu('role', [ 270 | 'primary_sponsor', 271 | 'sponsor', 272 | 'funder', 273 | 'other', 274 | ]).nullable(); 275 | table.jsonb('context') 276 | .notNullable(); 277 | 278 | table.primary(['trial_id', 'organisation_id']); 279 | }); 280 | 281 | schema.createTable('persons', (table) => { 282 | table.uuid('id').primary(); 283 | 284 | table.text('name') 285 | .notNullable(); 286 | table.enu('type', [ 287 | 'other', 288 | ]).nullable(); 289 | table.jsonb('data') 290 | .notNullable(); 291 | }); 292 | 293 | schema.createTable('trials_persons', (table) => { 294 | table.uuid('trial_id') 295 | .references('trials.id'); 296 | table.uuid('person_id') 297 | .references('persons.id'); 298 | 299 | table.enu('role', [ 300 | 'principal_investigator', 301 | 'public_queries', 302 | 'scientific_queries', 303 | 'other', 304 | ]).nullable(); 305 | table.jsonb('context') 306 | .notNullable(); 307 | 308 | table.primary(['trial_id', 'person_id']); 309 | }); 310 | 311 | return schema; 312 | }; 313 | 314 | exports.down = (knex) => ( 315 | knex.schema 316 | .dropTableIfExists('trials_persons') 317 | .dropTableIfExists('persons') 318 | .dropTableIfExists('trials_organisations') 319 | .dropTableIfExists('organisations') 320 | .dropTableIfExists('trials_locations') 321 | .dropTableIfExists('locations') 322 | .dropTableIfExists('trials_interventions') 323 | .dropTableIfExists('interventions') 324 | .dropTableIfExists('trials_problems') 325 | .dropTableIfExists('problems') 326 | .dropTableIfExists('trials_documents') 327 | .dropTableIfExists('documents') 328 | .dropTableIfExists('trials_publications') 329 | .dropTableIfExists('publications') 330 | .dropTableIfExists('trials_records') 331 | .dropTableIfExists('records') 332 | .dropTableIfExists('trials') 333 | .dropTableIfExists('sources') 334 | ); 335 | -------------------------------------------------------------------------------- /test/factory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const factory = require('factory-girl').promisify(require('bluebird')); 4 | require('factory-girl-bookshelf')(); 5 | const uuid = require('node-uuid'); 6 | const Trial = require('../api/models/trial'); 7 | const Location = require('../api/models/location'); 8 | const Intervention = require('../api/models/intervention'); 9 | const Condition = require('../api/models/condition'); 10 | const Person = require('../api/models/person'); 11 | const Organisation = require('../api/models/organisation'); 12 | const Source = require('../api/models/source'); 13 | const Record = require('../api/models/record'); 14 | const Publication = require('../api/models/publication'); 15 | const Document = require('../api/models/document'); 16 | const File = require('../api/models/file'); 17 | const RiskOfBias = require('../api/models/risk_of_bias'); 18 | const RiskOfBiasCriteria = require('../api/models/risk_of_bias_criteria'); 19 | const FDAApplication = require('../api/models/fda_application'); 20 | const FDAApproval = require('../api/models/fda_approval'); 21 | const DocumentCategory = require('../api/models/document_category'); 22 | 23 | factory.define('publication', Publication, { 24 | id: () => uuid.v1(), 25 | source_id: factory.assoc('source', 'id'), 26 | source_url: factory.sequence((n) => `http://source.com/trial/${n}`), 27 | title: 'some title', 28 | abstract: 'abstract', 29 | journal: 'some journal', 30 | date: new Date('2016-01-02'), 31 | slug: factory.sequence((n) => `slug-${n}`), 32 | }, { 33 | afterCreate: (publication, attrs, callback) => { 34 | new Publication({ id: publication.id }) 35 | .fetch({ withRelated: Publication.relatedModels }) 36 | .then((instance) => callback(null, instance)) 37 | .catch((err) => callback(err)); 38 | }, 39 | }); 40 | 41 | factory.define('location', Location, { 42 | id: () => uuid.v1(), 43 | name: factory.sequence((n) => `location${n}`), 44 | type: 'country', 45 | }); 46 | 47 | factory.define('intervention', Intervention, { 48 | id: () => uuid.v1(), 49 | name: factory.sequence((n) => `intervention${n}`), 50 | type: 'drug', 51 | }); 52 | 53 | factory.define('condition', Condition, { 54 | id: () => uuid.v1(), 55 | name: factory.sequence((n) => `condition${n}`), 56 | }); 57 | 58 | factory.define('person', Person, { 59 | id: () => uuid.v1(), 60 | name: factory.sequence((n) => `person${n}`), 61 | }); 62 | 63 | factory.define('organisation', Organisation, { 64 | id: () => uuid.v1(), 65 | name: factory.sequence((n) => `organisation${n}`), 66 | }); 67 | 68 | factory.define('source', Source, { 69 | id: () => uuid.v1(), 70 | name: factory.sequence((n) => `source${n}`), 71 | source_url: factory.sequence((n) => `http://example.org/source/${n}`), 72 | terms_and_conditions_url: factory.sequence((n) => `http://example.org/source/${n}/terms`), 73 | type: 'register', 74 | }); 75 | 76 | factory.define('file', File, { 77 | id: () => uuid.v1(), 78 | source_url: factory.sequence((n) => `http://example.org/file${n}.pdf`), 79 | sha1: factory.sequence(), 80 | documentcloud_id: factory.sequence((n) => `${n}-file`), 81 | pages: [ 82 | 'Lorem ipsum dolor sit amet', 83 | 'consectetur adipiscing elit', 84 | ], 85 | }); 86 | 87 | factory.define('document_category', DocumentCategory, { 88 | id: factory.sequence(), 89 | name: factory.sequence((n) => `Subcategory ${n}`), 90 | group: factory.sequence((n) => `Category ${n}`), 91 | }); 92 | 93 | const documentAttributes = { 94 | id: () => uuid.v1(), 95 | source_id: factory.assoc('source', 'id'), 96 | name: factory.sequence((n) => `Document ${n}`), 97 | document_category_id: factory.assoc('document_category', 'id'), 98 | }; 99 | 100 | factory.define('document', Document, Object.assign( 101 | {}, 102 | documentAttributes, 103 | { 104 | source_url: factory.sequence((n) => `http://example.org/document${n}`), 105 | } 106 | )); 107 | 108 | const documentWithFileAttrs = Object.assign( 109 | {}, 110 | documentAttributes, 111 | { 112 | file_id: factory.assoc('file', 'id'), 113 | fda_approval_id: factory.assoc('fda_approval', 'id'), 114 | } 115 | ); 116 | 117 | factory.define('documentWithFile', Document, documentWithFileAttrs); 118 | 119 | factory.define('documentWithRelated', Document, documentWithFileAttrs, { 120 | afterCreate: (doc, options, callback) => { 121 | factory.create('trial').then((trial) => ( 122 | doc.trials().attach({ 123 | trial_id: trial.id, 124 | }) 125 | )) 126 | .then(() => new Document({ id: doc.id }).fetch({ withRelated: Document.relatedModels })) 127 | .then((instance) => callback(null, instance)) 128 | .catch((err) => callback(err)); 129 | }, 130 | }); 131 | 132 | factory.define('risk_of_bias', RiskOfBias, { 133 | id: () => uuid.v1(), 134 | trial_id: factory.assoc('trial', 'id'), 135 | source_id: factory.assoc('source', 'id'), 136 | study_id: factory.sequence((n) => `study-${n}`), 137 | source_url: factory.sequence((n) => `http://source.com/trial/${n}`), 138 | }); 139 | 140 | factory.define('risk_of_bias_criteria', RiskOfBiasCriteria, { 141 | id: () => uuid.v1(), 142 | name: 'blinding', 143 | value: 'unknown', 144 | risk_of_bias: factory.assoc('risk_of_bias', 'id'), 145 | }); 146 | 147 | const trialAttributes = { 148 | id: () => uuid.v1(), 149 | identifiers: {}, 150 | registration_date: new Date('2016-01-01'), 151 | completion_date: new Date('2016-12-12'), 152 | target_sample_size: 1000, 153 | gender: 'both', 154 | age_range: { 155 | min_age: '18 Years', 156 | max_age: '60 Years', 157 | }, 158 | has_published_results: true, 159 | public_title: 'public_title', 160 | brief_summary: 'brief_summary', 161 | status: 'complete', 162 | recruitment_status: 'not_recruiting', 163 | eligibility_criteria: [], 164 | study_type: 'study_type', 165 | study_design: 'study_design', 166 | study_phase: ['study_phase'], 167 | }; 168 | 169 | factory.define('trial', Trial, trialAttributes); 170 | 171 | factory.define('trialWithRecord', Trial, trialAttributes, { 172 | afterCreate: (trial, options, callback) => { 173 | factory.create('record', { trial_id: trial.id }) 174 | .then(() => new Trial({ id: trial.id }).fetch({ withRelated: Trial.relatedModels })) 175 | .then((instance) => callback(null, instance)) 176 | .catch((err) => callback(err)); 177 | }, 178 | }); 179 | 180 | factory.define('trialWithRelated', Trial, trialAttributes, { 181 | afterCreate: (trial, options, callback) => { 182 | Promise.all([ 183 | factory.create('intervention').then((intervention) => ( 184 | trial.interventions().attach({ 185 | intervention_id: intervention.id, 186 | }) 187 | )), 188 | factory.create('condition').then((condition) => ( 189 | trial.conditions().attach({ 190 | condition_id: condition.id, 191 | }) 192 | )), 193 | factory.create('location').then((loc) => ( 194 | trial.locations().attach({ 195 | location_id: loc.id, 196 | role: 'other', 197 | }) 198 | )), 199 | factory.create('person').then((person) => ( 200 | trial.persons().attach({ 201 | person_id: person.id, 202 | role: 'other', 203 | }) 204 | )), 205 | factory.create('organisation').then((organisation) => ( 206 | trial.organisations().attach({ 207 | organisation_id: organisation.id, 208 | role: 'other', 209 | }) 210 | )), 211 | factory.create('publication').then((publication) => ( 212 | trial.publications().attach({ 213 | publication_id: publication.id, 214 | }) 215 | )), 216 | factory.create('document').then((doc) => ( 217 | trial.documents().attach({ 218 | document_id: doc.id, 219 | }) 220 | )), 221 | ]) 222 | .then(() => new Trial({ id: trial.id }).fetch({ withRelated: Trial.relatedModels })) 223 | .then((instance) => callback(null, instance)) 224 | .catch((err) => callback(err)); 225 | }, 226 | }); 227 | 228 | factory.define('record', Record, Object.assign({}, trialAttributes, { 229 | id: () => uuid.v1(), 230 | trial_id: factory.assoc('trial', 'id'), 231 | last_verification_date: new Date('2016-12-12'), 232 | source_id: factory.assoc('source', 'id'), 233 | source_url: factory.sequence((n) => `http://source.com/trial/${n}`), 234 | }), { 235 | afterCreate: (record, attrs, callback) => { 236 | new Record({ id: record.id }) 237 | .fetch({ withRelated: Record.relatedModels }) 238 | .then((instance) => callback(null, instance)) 239 | .catch((err) => callback(err)); 240 | }, 241 | }); 242 | 243 | 244 | factory.define('sourceRelatedToSeveralRecords', Source, { 245 | id: () => uuid.v1(), 246 | name: 'test_source', 247 | type: 'register', 248 | }, { 249 | afterCreate: (source, attrs, callback) => { 250 | const records = [ 251 | { 252 | source_id: source.id, 253 | }, 254 | { 255 | source_id: source.id, 256 | }, 257 | ]; 258 | 259 | factory.createMany('record', records) 260 | .then(() => callback(null, source)) 261 | .catch((err) => callback(err)); 262 | }, 263 | }); 264 | 265 | factory.define('fda_application', FDAApplication, { 266 | id: factory.sequence((n) => `NDA${n}`), 267 | drug_name: 'Healer', 268 | active_ingredients: 'healing', 269 | organisation_id: factory.assoc('organisation', 'id'), 270 | }, { 271 | afterCreate: (fdaApplication, attrs, callback) => { 272 | factory.create('fda_approval', { fda_application_id: fdaApplication.id }) 273 | .then(() => new FDAApplication({ id: fdaApplication.id }) 274 | .fetch({ withRelated: FDAApplication.relatedModels })) 275 | .then((instance) => callback(null, instance)) 276 | .catch((err) => callback(err)); 277 | }, 278 | }); 279 | 280 | factory.define('fda_approval', FDAApproval, { 281 | id: factory.sequence((n) => `NDA${n}-${n}`), 282 | supplement_number: factory.sequence((n) => n), 283 | type: 'Approval', 284 | action_date: new Date('2016-01-01'), 285 | notes: 'Healing heals', 286 | fda_application_id: factory.assoc('fda_application', 'id'), 287 | }); 288 | 289 | module.exports = factory; 290 | -------------------------------------------------------------------------------- /tools/indexers/trials.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const esHelpers = require('./helpers'); 4 | const Trial = require('../../api/models/trial'); 5 | 6 | const sourceMapping = { 7 | properties: { 8 | id: { 9 | type: 'string', 10 | index: 'not_analyzed', 11 | }, 12 | name: { 13 | type: 'string', 14 | index: 'not_analyzed', 15 | }, 16 | source_url: { 17 | type: 'string', 18 | index: 'not_analyzed', 19 | }, 20 | terms_and_conditions_url: { 21 | type: 'string', 22 | index: 'not_analyzed', 23 | }, 24 | type: { 25 | type: 'string', 26 | index: 'not_analyzed', 27 | }, 28 | }, 29 | }; 30 | 31 | const trialMapping = { 32 | dynamic_templates: [ 33 | { 34 | identifiers_values_arent_analyzed: { 35 | path_match: 'identifiers.*', 36 | mapping: { 37 | type: 'string', 38 | index: 'not_analyzed', 39 | }, 40 | }, 41 | }, 42 | { 43 | sources_consists_of_source_objects: { 44 | path_match: 'sources.*', 45 | mapping: sourceMapping, 46 | }, 47 | }, 48 | ], 49 | dynamic: 'strict', 50 | properties: { 51 | identifiers: { 52 | type: 'object', 53 | dynamic: true, 54 | }, 55 | sources: { 56 | type: 'object', 57 | dynamic: true, 58 | }, 59 | brief_summary: { 60 | type: 'string', 61 | }, 62 | url: { 63 | type: 'string', 64 | index: 'not_analyzed', 65 | }, 66 | id: { 67 | type: 'string', 68 | index: 'not_analyzed', 69 | }, 70 | source_id: { 71 | type: 'string', 72 | index: 'not_analyzed', 73 | }, 74 | interventions: { 75 | properties: { 76 | id: { 77 | type: 'string', 78 | index: 'not_analyzed', 79 | }, 80 | name: { 81 | type: 'string', 82 | copy_to: 'intervention', 83 | }, 84 | url: { 85 | type: 'string', 86 | index: 'not_analyzed', 87 | }, 88 | type: { 89 | type: 'string', 90 | index: 'not_analyzed', 91 | }, 92 | }, 93 | }, 94 | intervention: { 95 | type: 'string', 96 | }, 97 | locations: { 98 | properties: { 99 | id: { 100 | type: 'string', 101 | index: 'not_analyzed', 102 | }, 103 | name: { 104 | type: 'string', 105 | copy_to: 'location', 106 | }, 107 | url: { 108 | type: 'string', 109 | index: 'not_analyzed', 110 | }, 111 | type: { 112 | type: 'string', 113 | index: 'not_analyzed', 114 | }, 115 | role: { 116 | type: 'string', 117 | index: 'not_analyzed', 118 | }, 119 | }, 120 | }, 121 | location: { 122 | type: 'string', 123 | }, 124 | conditions: { 125 | properties: { 126 | id: { 127 | type: 'string', 128 | index: 'not_analyzed', 129 | }, 130 | name: { 131 | type: 'string', 132 | copy_to: 'condition', 133 | }, 134 | url: { 135 | type: 'string', 136 | index: 'not_analyzed', 137 | }, 138 | }, 139 | }, 140 | condition: { 141 | type: 'string', 142 | }, 143 | persons: { 144 | properties: { 145 | id: { 146 | type: 'string', 147 | index: 'not_analyzed', 148 | }, 149 | name: { 150 | type: 'string', 151 | copy_to: 'person', 152 | }, 153 | url: { 154 | type: 'string', 155 | index: 'not_analyzed', 156 | }, 157 | role: { 158 | type: 'string', 159 | index: 'not_analyzed', 160 | }, 161 | }, 162 | }, 163 | person: { 164 | type: 'string', 165 | }, 166 | organisations: { 167 | properties: { 168 | id: { 169 | type: 'string', 170 | index: 'not_analyzed', 171 | }, 172 | name: { 173 | type: 'string', 174 | copy_to: 'organisation', 175 | }, 176 | url: { 177 | type: 'string', 178 | index: 'not_analyzed', 179 | }, 180 | role: { 181 | type: 'string', 182 | index: 'not_analyzed', 183 | }, 184 | }, 185 | }, 186 | organisation: { 187 | type: 'string', 188 | }, 189 | publications: { 190 | properties: { 191 | id: { 192 | type: 'string', 193 | index: 'not_analyzed', 194 | }, 195 | url: { 196 | type: 'string', 197 | index: 'not_analyzed', 198 | }, 199 | title: { 200 | type: 'string', 201 | copy_to: 'publication', 202 | }, 203 | source_url: { 204 | type: 'string', 205 | index: 'not_analyzed', 206 | }, 207 | source_id: { 208 | type: 'string', 209 | index: 'not_analyzed', 210 | }, 211 | }, 212 | }, 213 | publication: { 214 | type: 'string', 215 | }, 216 | documents: { 217 | properties: { 218 | id: { 219 | type: 'string', 220 | index: 'not_analyzed', 221 | }, 222 | name: { 223 | type: 'string', 224 | }, 225 | source_id: { 226 | type: 'string', 227 | index: 'not_analyzed', 228 | }, 229 | source_url: { 230 | type: 'string', 231 | index: 'not_analyzed', 232 | }, 233 | trials: { 234 | properties: { 235 | id: { 236 | type: 'string', 237 | index: 'not_analyzed', 238 | }, 239 | url: { 240 | type: 'string', 241 | index: 'not_analyzed', 242 | }, 243 | public_title: { 244 | type: 'string', 245 | }, 246 | }, 247 | }, 248 | document_category: { 249 | properties: { 250 | id: { 251 | type: 'integer', 252 | }, 253 | name: { 254 | type: 'string', 255 | index: 'not_analyzed', 256 | }, 257 | group: { 258 | type: 'string', 259 | index: 'not_analyzed', 260 | }, 261 | }, 262 | }, 263 | url: { 264 | type: 'string', 265 | index: 'not_analyzed', 266 | }, 267 | fda_approval: { 268 | properties: { 269 | action_date: { 270 | type: 'date', 271 | format: 'dateOptionalTime', 272 | }, 273 | id: { 274 | type: 'string', 275 | index: 'not_analyzed', 276 | }, 277 | notes: { 278 | type: 'string', 279 | }, 280 | supplement_number: { 281 | type: 'long', 282 | }, 283 | type: { 284 | type: 'string', 285 | index: 'not_analyzed', 286 | }, 287 | fda_application: { 288 | properties: { 289 | id: { 290 | type: 'string', 291 | index: 'not_analyzed', 292 | }, 293 | type: { 294 | type: 'string', 295 | index: 'not_analyzed', 296 | }, 297 | url: { 298 | type: 'string', 299 | index: 'not_analyzed', 300 | }, 301 | drug_name: { 302 | type: 'string', 303 | }, 304 | active_ingredients: { 305 | type: 'string', 306 | }, 307 | }, 308 | }, 309 | }, 310 | }, 311 | file: { 312 | properties: { 313 | id: { 314 | type: 'string', 315 | index: 'not_analyzed', 316 | }, 317 | documentcloud_id: { 318 | type: 'string', 319 | index: 'not_analyzed', 320 | }, 321 | sha1: { 322 | type: 'string', 323 | index: 'not_analyzed', 324 | }, 325 | source_url: { 326 | type: 'string', 327 | index: 'not_analyzed', 328 | }, 329 | }, 330 | }, 331 | }, 332 | }, 333 | discrepancies: { 334 | properties: { 335 | status: getDiscrepancyRecordMapping({ 336 | type: 'string', 337 | index: 'not_analyzed', 338 | }), 339 | recruitment_status: getDiscrepancyRecordMapping({ 340 | type: 'string', 341 | index: 'not_analyzed', 342 | }), 343 | has_published_results: getDiscrepancyRecordMapping({ 344 | type: 'boolean', 345 | }), 346 | gender: getDiscrepancyRecordMapping({ 347 | type: 'string', 348 | index: 'not_analyzed', 349 | }), 350 | target_sample_size: getDiscrepancyRecordMapping({ 351 | type: 'integer', 352 | }), 353 | }, 354 | }, 355 | records: { 356 | properties: { 357 | id: { 358 | type: 'string', 359 | index: 'not_analyzed', 360 | }, 361 | source_id: { 362 | type: 'string', 363 | index: 'not_analyzed', 364 | }, 365 | url: { 366 | type: 'string', 367 | index: 'not_analyzed', 368 | }, 369 | is_primary: { 370 | type: 'boolean', 371 | }, 372 | last_verification_date: { 373 | type: 'date', 374 | format: 'dateOptionalTime', 375 | }, 376 | source_url: { 377 | type: 'string', 378 | index: 'not_analyzed', 379 | }, 380 | updated_at: { 381 | type: 'date', 382 | format: 'date_time', 383 | }, 384 | }, 385 | }, 386 | risks_of_bias: { 387 | properties: { 388 | id: { 389 | type: 'string', 390 | index: 'not_analyzed', 391 | }, 392 | source_id: { 393 | type: 'string', 394 | index: 'not_analyzed', 395 | }, 396 | source_url: { 397 | type: 'string', 398 | index: 'not_analyzed', 399 | }, 400 | updated_at: { 401 | type: 'date', 402 | format: 'date_time', 403 | }, 404 | created_at: { 405 | type: 'date', 406 | format: 'date_time', 407 | }, 408 | study_id: { 409 | type: 'string', 410 | index: 'not_analyzed', 411 | }, 412 | trial_id: { 413 | type: 'string', 414 | index: 'not_analyzed', 415 | }, 416 | source: sourceMapping, 417 | risk_of_bias_criteria: { 418 | properties: { 419 | id: { 420 | type: 'string', 421 | index: 'not_analyzed', 422 | }, 423 | name: { 424 | type: 'string', 425 | index: 'not_analyzed', 426 | }, 427 | value: { 428 | type: 'string', 429 | index: 'not_analyzed', 430 | }, 431 | }, 432 | }, 433 | }, 434 | }, 435 | public_title: { 436 | type: 'string', 437 | }, 438 | target_sample_size: { 439 | type: 'integer', 440 | }, 441 | gender: { 442 | type: 'string', 443 | index: 'not_analyzed', 444 | }, 445 | age_range: { 446 | properties: { 447 | min_age: { 448 | type: 'string', 449 | }, 450 | max_age: { 451 | type: 'string', 452 | }, 453 | }, 454 | }, 455 | has_published_results: { 456 | type: 'boolean', 457 | }, 458 | recruitment_status: { 459 | type: 'string', 460 | index: 'not_analyzed', 461 | }, 462 | registration_date: { 463 | type: 'date', 464 | format: 'dateOptionalTime', 465 | }, 466 | completion_date: { 467 | type: 'date', 468 | format: 'dateOptionalTime', 469 | }, 470 | study_phase: { 471 | type: 'string', 472 | index: 'not_analyzed', 473 | }, 474 | status: { 475 | type: 'string', 476 | index: 'not_analyzed', 477 | }, 478 | results_exemption_date: { 479 | type: 'date', 480 | format: 'dateOptionalTime', 481 | }, 482 | source: sourceMapping, 483 | }, 484 | }; 485 | 486 | 487 | function getDiscrepancyRecordMapping(valueMapping) { 488 | return { 489 | properties: { 490 | field: { 491 | enabled: 'false', 492 | }, 493 | record_id: { 494 | type: 'string', 495 | index: 'not_analyzed', 496 | }, 497 | source_name: { 498 | type: 'string', 499 | index: 'not_analyzed', 500 | }, 501 | value: valueMapping, 502 | }, 503 | }; 504 | } 505 | 506 | 507 | module.exports = { 508 | alias: 'trials', 509 | index: { 510 | body: { 511 | mappings: { 512 | trial: trialMapping, 513 | }, 514 | }, 515 | }, 516 | indexer: (indexName) => ( 517 | esHelpers.indexModel(Trial, indexName, 'trial', {}, { withRelated: Trial.relatedModels }) 518 | ), 519 | }; 520 | --------------------------------------------------------------------------------