From ba3decd0d5b7abe21b60bf4e8f46808b611a147f Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Thu, 4 Jun 2020 13:59:40 -0400 Subject: [PATCH 1/7] Issue 180 Adding contact-direct check-in This provides a variety of bug fixes as defined by the resolved tickets. Primarily this PR will add contact-direct check-in capabilities. This allows Authorized contacts to check-in entities direclty without a user account by using an auth token. resolves #131 resolves #132 resolves #102 resolves #162 resolves #173 resolves #145 resolves #158 resolves #180 --- mail_templates/contact_check_in_html.njk | 19 ++++++ mail_templates/contact_check_in_text.njk | 5 ++ package-lock.json | 5 ++ package.json | 3 +- publiccode.yml | 2 +- src/email/index.js | 65 ++++++++++++++----- src/models/entity-contact.js | 8 +++ src/models/user.js | 29 +-------- src/routes/contact.js | 49 ++++++++++++++ src/routes/csv.js | 3 +- src/routes/entity.js | 6 +- src/routes/user.js | 5 +- src/utils/index.js | 71 +++++++++++++++++--- swagger.json | 82 ++---------------------- 14 files changed, 214 insertions(+), 138 deletions(-) create mode 100644 mail_templates/contact_check_in_html.njk create mode 100644 mail_templates/contact_check_in_text.njk diff --git a/mail_templates/contact_check_in_html.njk b/mail_templates/contact_check_in_html.njk new file mode 100644 index 00000000..973756af --- /dev/null +++ b/mail_templates/contact_check_in_html.njk @@ -0,0 +1,19 @@ +

{{ emailTitle }}

+ +

{{ emailContents }}

+ + + + + +
+ + + + +
+ + Check In + +
+
\ No newline at end of file diff --git a/mail_templates/contact_check_in_text.njk b/mail_templates/contact_check_in_text.njk new file mode 100644 index 00000000..0887cafe --- /dev/null +++ b/mail_templates/contact_check_in_text.njk @@ -0,0 +1,5 @@ +{{ emailTitle }} + +{{ emailContents }} + +{{ entityLink }} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 39dce106..32dc4c52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3043,6 +3043,11 @@ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, + "complexity": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/complexity/-/complexity-0.0.6.tgz", + "integrity": "sha1-pW7g4D9hz0pKeyh6i/HlTYdb/oM=" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", diff --git a/package.json b/package.json index c81ecfd8..0dea65f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bmore-responsive", - "version": "1.1.2", + "version": "1.2.0", "description": "An API-driven CRM (Civic Relationship Management) system.", "main": "src/index.js", "directories": { @@ -40,6 +40,7 @@ "casbin": "4.5.0", "casbin-sequelize-adapter": "2.1.0", "chai": "4.2.0", + "complexity": "0.0.6", "cors": "2.8.5", "crypto": "1.0.1", "dotenv": "8.2.0", diff --git a/publiccode.yml b/publiccode.yml index 4229974e..bca8fe43 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -3,7 +3,7 @@ publiccodeYmlVersion: "0.2" name: Bmore-Responsive url: "https://github.com/CodeForBaltimore/Bmore-Responsive.git" landingUrl: "https://github.com/CodeForBaltimore/Bmore-Responsive" -softwareVersion: "1.1.2" +softwareVersion: "1.2.0" releaseDate: "2020-04-06" platforms: - web diff --git a/src/email/index.js b/src/email/index.js index 05cb8e0f..88e1c56a 100644 --- a/src/email/index.js +++ b/src/email/index.js @@ -19,30 +19,61 @@ const transporter = nodemailer.createTransport({ * @param {string} text plain text of the email */ const sendMail = async (to, subject, html, text) => { - let info = await transporter.sendMail({ - from: `"Healthcare Roll Call" <${process.env.SMTP_USER}>`, // sender address - to, // list of receivers - subject, // Subject line - text, // plain text body - html // html body - }); - console.log("Email sent: %s", info.messageId); + try { + let info = await transporter.sendMail({ + from: `"Healthcare Roll Call" <${process.env.SMTP_USER}>`, // sender address + to, // list of receivers + subject, // Subject line + text, // plain text body + html // html body + }); + console.log("Email sent: %s", info.messageId); + } catch (e) { + console.error(e); + } }; /** * Send a forgot password email. * @param {string} userEmail email address of the user we're sending to * @param {string} resetPasswordToken temporary token for the reset password link + * + * @returns {Boolean} */ const sendForgotPassword = async (userEmail, resetPasswordToken) => { - const emailResetLink = `https://healthcarerollcall.org/reset/${resetPasswordToken}`; - await sendMail( - userEmail, - "Password Reset - Healthcare Roll Call", - nunjucks.render("forgot_password_html.njk", { emailResetLink }), - nunjucks.render("forgot_password_text.njk", { emailResetLink }) - ); - return true; + try { + const emailResetLink = `https://healthcarerollcall.org/reset/${resetPasswordToken}`; + await sendMail( + userEmail, + "Password Reset - Healthcare Roll Call", + nunjucks.render("forgot_password_html.njk", { emailResetLink }), + nunjucks.render("forgot_password_text.njk", { emailResetLink }) + ); + return true; + } catch (e) { + console.error(e); + return false; + } }; -export default { sendForgotPassword }; +const sendContactCheckInEmail = async (info) => { + try { + if (process.env.NODE_ENV === 'production' || process.env.TEST_EMAIL !== undefined && process.env.TEST_EMAIL === info.email) { + const entityLink = `${process.env.URL}/checkin/${info.entityId}?token=${info.token}`; + const emailTitle = `${info.entityName} Check In`; + const emailContents = `Hello ${info.name}! It is time to update the status of ${info.entityName}. Please click the link below to check in.` + await sendMail( + info.email, + emailTitle, + nunjucks.render("contact_check_in_html.njk", { emailTitle, emailContents, entityLink }), + nunjucks.render("contact_check_in_text.njk", { emailTitle, emailContents, entityLink }) + ); + console.log(info.email) + return true; + } + } catch (e) { + console.error(e); + } +} + +export default { sendForgotPassword, sendContactCheckInEmail }; diff --git a/src/models/entity-contact.js b/src/models/entity-contact.js index 1293c7bc..4d3b979b 100644 --- a/src/models/entity-contact.js +++ b/src/models/entity-contact.js @@ -48,6 +48,14 @@ const entityContact = (sequelize, DataTypes) => { } } + EntityContact.findByEntityId = async (entityId) => { + const entries = await EntityContact.findAll({ + where: {entityId} + }); + + return entries; + } + return EntityContact; }; diff --git a/src/models/user.js b/src/models/user.js index 44652812..230adfa9 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -58,39 +58,14 @@ const user = (sequelize, DataTypes) => { } }; - /** - * Validates a login token. - * - * @param {String} token The login token from the user. - * - * @return {Boolean} - */ - User.validateToken = async token => { - /** @todo check if it is a token at all */ - if (token) { - try { - const decoded = jwt.verify(token, process.env.JWT_KEY); - const now = new Date(); - if (now.getTime() < decoded.exp * 1000) { - const user = await User.findByPk(decoded.userId); - if (user) { - return user; - } - } - } catch (e) { - console.error(e); - } - } - return false; - }; - User.decodeToken = async token => { return jwt.verify(token, process.env.JWT_KEY); } + /** @todo deprecate this */ User.getToken = async (userId, email, expiresIn = '1d') => { const token = jwt.sign( - {userId, email}, + {userId, email, type: 'user'}, process.env.JWT_KEY, {expiresIn} ); diff --git a/src/routes/contact.js b/src/routes/contact.js index 8a859bd1..7f29a8d6 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -1,5 +1,6 @@ import { Router } from 'express'; import validator from 'validator'; +import email from '../email'; import utils from '../utils'; const router = new Router(); @@ -92,6 +93,54 @@ router.post('/', async (req, res) => { return utils.response(res, code, message); }); +// Sends emails to contacts based on body +router.post('/send', async (req, res) => { + let code; + let message; + const emails = []; + + try { + /** @todo allow for passing entity and contact arrays */ + const { entityIds, contactIds, relationshipTitle } = req.body; + + if (entityIds === undefined && contactIds === undefined) { + const whereClause = (relationshipTitle !== undefined) ? {where: {relationshipTitle}} : {}; + const associations = await req.context.models.EntityContact.findAll(whereClause); + + for (const association of associations) { + const contact = await req.context.models.Contact.findById(association.contactId); + + if (contact.email !== null) { + const entity = await req.context.models.Entity.findById(association.entityId); + // short-lived temporary token that only lasts one hour + const temporaryToken = await utils.getToken(contact.id, contact.email[0].address, 'contact'); + + emails.push({ + email: contact.email[0].address, + name: contact.name, + entityName: entity.name, + entityId: association.entityId, + relationshipTitle: association.relationshipTitle, + token: temporaryToken + }); + } + } + } + + emails.forEach(async (e) => { + email.sendContactCheckInEmail(e); + }) + + code = 200; + message = 'contacts emailed'; + } catch (e) { + console.error(e); + code = 500; + } + + return utils.response(res, code, message); +}); + // Updates any contact. router.put('/', async (req, res) => { let code; diff --git a/src/routes/csv.js b/src/routes/csv.js index afa249e3..8443e5e5 100644 --- a/src/routes/csv.js +++ b/src/routes/csv.js @@ -12,9 +12,10 @@ router.get('/:model_type', async (req, res) => { let message; const modelType = req.params.model_type; try { - if(req.context.models.hasOwnProperty(modelType)){ + if(req.context.models.hasOwnProperty(modelType) && modelType !== 'User' && modelType !== 'UserRole'){ //todo add filtering const results = await req.context.models[modelType].findAll({raw:true}); + console.log(results) const processedResults = await utils.processResults(results, modelType); diff --git a/src/routes/entity.js b/src/routes/entity.js index 5992ebf0..6f3c4636 100644 --- a/src/routes/entity.js +++ b/src/routes/entity.js @@ -53,8 +53,8 @@ router.post('/', async (req, res) => { let code; let message; try { - if (req.body.name !== undefined && req.body.name !== '') { - let { name, address, phone, email, checkIn, contacts } = req.body; + if (req.body.name !== undefined && req.body.name !== '' && req.body.type !== undefined && req.body.type !== '') { + let { name, type, address, phone, email, checkIn, contacts } = req.body; if (!checkIn) { checkIn = { @@ -65,7 +65,7 @@ router.post('/', async (req, res) => { } } - const entity = await req.context.models.Entity.create({ name, address, email, phone, checkIn }); + const entity = await req.context.models.Entity.create({ name, type, address, email, phone, checkIn }); if (contacts) { for(const contact of contacts) { const ec = { diff --git a/src/routes/user.js b/src/routes/user.js index 886d05a5..f5e381a1 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -37,6 +37,7 @@ router.post('/login', loginLimiter, async (req, res) => { return utils.response(res, code, message); }); +// Password reset router.post('/reset/:email', loginLimiter, async(req, res) => { let code; let message; @@ -146,7 +147,7 @@ router.post('/', utils.authMiddleware, async (req, res) => { let code; let message; try { - if (validator.isEmail(req.body.email)) { + if (validator.isEmail(req.body.email) && utils.validatePassword(req.body.password)) { const { email, password, roles } = req.body; const user = await req.context.models.User.create({ email: email.toLowerCase(), password }); @@ -192,7 +193,7 @@ router.put('/', utils.authMiddleware, async (req, res) => { const roles = await e.getRolesForUser(req.context.me.email); if (password) { - if (req.context.me.email === email || roles.includes('admin')) { + if (req.context.me.email === email || roles.includes('admin') && utils.validatePassword(password)) { user.password = password; } } diff --git a/src/utils/index.js b/src/utils/index.js index 1c1258f6..ebb955e9 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -3,6 +3,8 @@ import crypto from 'crypto'; import validator from 'validator'; import fs from 'fs'; +import jwt from 'jsonwebtoken'; +import complexity from 'complexity' import { newEnforcer } from 'casbin'; import { SequelizeAdapter } from 'casbin-sequelize-adapter'; @@ -61,7 +63,7 @@ const loadCasbin = async () => { } /** - * Validates a user login token. + * Checks a user login token. * * This validates a user token. If the token is invalid the request will immediately be rejected back with a 401. * @@ -70,13 +72,25 @@ const loadCasbin = async () => { * * @return {Boolean} */ -const validateToken = async (req) => { - const authorized = await req.context.models.User.validateToken(req.headers.token); - if (authorized) { - req.context.me = authorized; // add user object to context - return true; - } +const validateToken = async req => { + /** @todo check if it is a token at all */ + if (req.headers.token) { + try { + const decoded = jwt.verify(req.headers.token, process.env.JWT_KEY); + console.log(decoded) + const now = new Date(); + if (now.getTime() < decoded.exp * 1000) { + const user = (decoded.type === 'contact') ? await req.context.models.Contact.findById(decoded.userId) : await req.context.models.User.findByPk(decoded.userId); + if (user) { + req.context.me = user; + return true; + } + } + } catch (e) { + console.error(e); + } + } return false; }; @@ -91,7 +105,9 @@ const validateRoles = async (req) => { const e = await loadCasbin(); const { originalUrl: path, method } = req; - const isAllowed = await e.enforce(req.context.me.email, path, method); + /** @todo refactor this... */ + const email = (req.context.me.email[0].address !== undefined) ? req.context.me.email[0].address : req.context.me.email; + const isAllowed = await e.enforce(email, path, method); return isAllowed; } @@ -156,6 +172,24 @@ const encryptPassword = (password, salt) => { .digest('hex'); }; +/** + * Generates a JWT + * + * @param {int} userId + * @param {String} email + * @param {String} expiresIn + * + * @returns {String} + */ +const getToken = async (userId, email, type, expiresIn = '1d') => { + const token = jwt.sign( + {userId, email, type}, + process.env.JWT_KEY, + {expiresIn} + ); + return token; +}; + /** * Checks array of emails for validitiy * @@ -171,6 +205,25 @@ const validateEmails = async emails => { return true; } +/** + * Checks the user's new password for complexity + * + * @param {String} pass + * + * @return {Boolean} + */ +const validatePassword = pass => { + const options = { + uppercase : 1, // A through Z + lowercase : 1, // a through z + special : 1, // ! @ # $ & * + digit : 1, // 0 through 9 + min : 8, // minumum number of characters + } + + return complexity.check(pass, options) +} + /** * Processes model results based on type * @@ -210,7 +263,9 @@ export default { authMiddleware, response, encryptPassword, + getToken, validateEmails, + validatePassword, processResults, dbUrl }; diff --git a/swagger.json b/swagger.json index 7b68b8b7..8867b7ca 100644 --- a/swagger.json +++ b/swagger.json @@ -6,7 +6,7 @@ } ], "info" : { "description" : "An emergency response and contact management API.", - "version" : "1.1.2", + "version" : "1.2.0", "title" : "Bmore Responsive", "contact" : { "email" : "hello@codeforbaltimore.org" @@ -510,7 +510,7 @@ "application/json" : { "schema" : { "type" : "object", - "required" : [ "name" ], + "required" : [ "name", "type" ], "properties" : { "name" : { "type" : "string", @@ -544,63 +544,6 @@ } } }, - "checkIn" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "updatedAt" : { - "type" : "string", - "format" : "date-time", - "example" : "2020-01-21T13:45:52.348Z" - }, - "status" : { - "type" : "string", - "example" : "Safe" - }, - "UserId" : { - "type" : "string", - "format" : "uuid", - "example" : "4d9721a2-07f8-45ac-9570-682f4774cfa5" - }, - "ContactId" : { - "type" : "string", - "format" : "uuid", - "example" : "abafa852-ecd0-4d57-9083-85f4dfd9c402" - }, - "questionnaire" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "number", - "example" : 1 - }, - "question1" : { - "type" : "string", - "example" : "They have left handed can openers" - }, - "question2" : { - "type" : "boolean", - "example" : false - } - } - }, - "notes" : { - "type" : "string", - "example" : "Everything is okilly dokilly" - } - } - } - }, - "contacts" : { - "type" : "string", - "example" : [ { - "id" : "" - }, { - "id" : "", - "title" : "" - } ] - }, "description" : { "type" : "string", "example" : "Everything for the left handed man, woman, and child!" @@ -753,15 +696,6 @@ } } }, - "contacts" : { - "type" : "string", - "example" : [ { - "id" : "" - }, { - "id" : "", - "title" : "" - } ] - }, "description" : { "type" : "string", "example" : "Everything for the left handed man, woman, and child!" @@ -1487,7 +1421,7 @@ "get" : { "tags" : [ "csv" ], "summary" : "returns a comma separated list of the model_type requested", - "description" : "By passing the model_type, you are returned a comma separated list of that model_type. Valid model types are Entity, EntityContact, Contact, User, UserRole.", + "description" : "By passing the model_type, you are returned a comma separated list of that model_type. Valid model types are Entity, EntityContact, and Contact.", "parameters" : [ { "in" : "path", "name" : "model_type", @@ -1495,7 +1429,7 @@ "type" : "string" }, "required" : true, - "description" : "type of model you want a csv data dump for. Options are Contact, Entity, EntityContact, User, and UserRole." + "description" : "type of model you want a csv data dump for. Options are Contact, Entity, and EntityContact." }, { "in" : "header", "name" : "token", @@ -1688,14 +1622,6 @@ } } }, - "contacts" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : null, - "$ref" : "#/components/schemas/ContactItem" - } - }, "description" : { "type" : "string", "example" : "Everything for the left handed man, woman, and child!" From 05ae1b6c0df21f92ef22477c08b933d671e70a8b Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Thu, 4 Jun 2020 16:01:18 -0400 Subject: [PATCH 2/7] Fixed tests to account for pw complexity and other changes --- src/routes/csv.js | 1 - src/routes/user.js | 1 + src/tests/csv.routes.spec.js | 13 ------------- src/tests/entity.routes.spec.js | 1 + src/tests/user.routes.spec.js | 2 +- src/tests/utils.spec.js | 4 ++++ src/utils/index.js | 32 +++++++++++++++----------------- src/utils/login.js | 2 +- 8 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/routes/csv.js b/src/routes/csv.js index 8443e5e5..c6ad4517 100644 --- a/src/routes/csv.js +++ b/src/routes/csv.js @@ -15,7 +15,6 @@ router.get('/:model_type', async (req, res) => { if(req.context.models.hasOwnProperty(modelType) && modelType !== 'User' && modelType !== 'UserRole'){ //todo add filtering const results = await req.context.models[modelType].findAll({raw:true}); - console.log(results) const processedResults = await utils.processResults(results, modelType); diff --git a/src/routes/user.js b/src/routes/user.js index f5e381a1..6534203d 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -147,6 +147,7 @@ router.post('/', utils.authMiddleware, async (req, res) => { let code; let message; try { + console.log(req.body.password) if (validator.isEmail(req.body.email) && utils.validatePassword(req.body.password)) { const { email, password, roles } = req.body; const user = await req.context.models.User.create({ email: email.toLowerCase(), password }); diff --git a/src/tests/csv.routes.spec.js b/src/tests/csv.routes.spec.js index 1bcf6c52..9dcd73d6 100644 --- a/src/tests/csv.routes.spec.js +++ b/src/tests/csv.routes.spec.js @@ -14,19 +14,6 @@ describe('CSV Dump Positive Tests', () => { await authed.destroyToken(); }); - it('Positive Test for CSV Dump on User ', (done) => { - request(app) - .get('/csv/User') - .set('Accept', 'application/json') - .set('token', token) - .expect('Content-Type', 'text/html; charset=utf-8') - .expect(200) - .end((err, res) => { - if (err) return done(err); - done(); - }); - }); - it('Positive Test for CSV Dump on Entity', (done) => { request(app) .get('/csv/Entity') diff --git a/src/tests/entity.routes.spec.js b/src/tests/entity.routes.spec.js index d2c12650..dcc8a996 100644 --- a/src/tests/entity.routes.spec.js +++ b/src/tests/entity.routes.spec.js @@ -12,6 +12,7 @@ const entity = { number: (Math.floor(Math.random() * Math.floor(100000000000))).toString() } ], + type: 'Test', email: [ { address: `${randomWords()}@test.test` diff --git a/src/tests/user.routes.spec.js b/src/tests/user.routes.spec.js index e1131ab3..2853ccb4 100644 --- a/src/tests/user.routes.spec.js +++ b/src/tests/user.routes.spec.js @@ -5,7 +5,7 @@ import { Login } from '../utils/login'; import app from '..'; const { expect } = chai; -const user = { email: `${randomWords()}@test.test`, password: randomWords(), roles: ["admin"] }; +const user = { email: `${randomWords()}@test.test`, password: `Abcdefg42!`, roles: ["admin"] }; describe('User positive tests', () => { const authed = new Login(); diff --git a/src/tests/utils.spec.js b/src/tests/utils.spec.js index b54d3b05..7eef8a0e 100644 --- a/src/tests/utils.spec.js +++ b/src/tests/utils.spec.js @@ -9,6 +9,10 @@ describe('Utils Tests', () => { expect(utils.formatTime(1)).to.equal('00:00:01'); done(); }); + it('should return a role', async () => { + const e = await utils.loadCasbin(); + assert.isNotNull(await e.getRolesForUser('homer.simpson@sfpp.com'),'returns roles') + }); }); describe('Utils Login Tests', () => { diff --git a/src/utils/index.js b/src/utils/index.js index ebb955e9..b7e1f0cd 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -35,9 +35,9 @@ const formatTime = seconds => { * @returns {Object} */ const loadCasbin = async () => { - let dialectOptions; - if (process.env.NODE_ENV === 'production') { - dialectOptions = { + const dialectOptions = (process.env.NODE_ENV === 'production') ? + { + logging: false, ssl: { rejectUnauthorized: true, ca: [rdsCa], @@ -48,8 +48,7 @@ const loadCasbin = async () => { } } } - }; - } + } : { logging: false }; const a = await SequelizeAdapter.newAdapter( dbUrl(), { @@ -78,7 +77,6 @@ const validateToken = async req => { if (req.headers.token) { try { const decoded = jwt.verify(req.headers.token, process.env.JWT_KEY); - console.log(decoded) const now = new Date(); if (now.getTime() < decoded.exp * 1000) { const user = (decoded.type === 'contact') ? await req.context.models.Contact.findById(decoded.userId) : await req.context.models.User.findByPk(decoded.userId); @@ -118,9 +116,9 @@ const validateRoles = async (req) => { * @param {*} res the response object * @param {*} next the next handler in the chain */ -const authMiddleware = async (req, res, next) => { +const authMiddleware = async (req, res, next) => { let authed = false; - + if (process.env.BYPASS_LOGIN) { authed = process.env.BYPASS_LOGIN; } else { @@ -128,7 +126,7 @@ const authMiddleware = async (req, res, next) => { if (authed) { authed = await validateRoles(req); } - } + } if (authed) { next(); @@ -183,9 +181,9 @@ const encryptPassword = (password, salt) => { */ const getToken = async (userId, email, type, expiresIn = '1d') => { const token = jwt.sign( - {userId, email, type}, + { userId, email, type }, process.env.JWT_KEY, - {expiresIn} + { expiresIn } ); return token; }; @@ -214,13 +212,13 @@ const validateEmails = async emails => { */ const validatePassword = pass => { const options = { - uppercase : 1, // A through Z - lowercase : 1, // a through z - special : 1, // ! @ # $ & * - digit : 1, // 0 through 9 - min : 8, // minumum number of characters + uppercase: 1, // A through Z + lowercase: 1, // a through z + special: 1, // ! @ # $ & * + digit: 1, // 0 through 9 + min: 8, // minumum number of characters } - + console.log(complexity.check(pass, options)) return complexity.check(pass, options) } diff --git a/src/utils/login.js b/src/utils/login.js index c484be92..4df65c84 100644 --- a/src/utils/login.js +++ b/src/utils/login.js @@ -7,7 +7,7 @@ import app from ".."; class Login { constructor() { this.role = randomWords(); - this.user = { email: `${randomWords()}@test.test`, password: randomWords(), roles: [this.role] }; + this.user = { email: `${randomWords()}@test.test`, password: `Abcdefg42!`, roles: [this.role] }; this.methods = [ `GET`, `POST`, From b1a49589a21918ece4beadf31c9b9ba7bb537fcf Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Thu, 4 Jun 2020 16:03:29 -0400 Subject: [PATCH 3/7] Adding new env vars to README --- README.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b6852b7f..04937062 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,22 @@ An API to drive disaster and emergency response systems. - [Bmore Responsive](#bmore-responsive) - - [Documentation](#documentation) - - [API Spec](#api-spec) - - [Database Documentation](#database-documentation) - - [Infrastructure and Deployment](#infrastructure-and-deployment) + - [Documentation](#documentation) + - [API Spec](#api-spec) + - [Database Documentation](#database-documentation) + - [Infrastructure and Deployment](#infrastructure-and-deployment) - [Setup](#setup) - - [Node and Express setup](#node-and-express-setup) - - [Environment variables](#environment-variables) - - [Example .env](#example-env) - - [PostgreSQL](#postgresql) - - [Sequelize](#sequelize) - - [Docker](#docker) - - [docker-compose](#docker-compose) + - [Node and Express setup](#node-and-express-setup) + - [Environment variables](#environment-variables) + - [Example .env](#example-env) + - [PostgreSQL](#postgresql) + - [Sequelize](#sequelize) + - [Docker](#docker) + - [docker-compose](#docker-compose) - [Using this product](#using-this-product) - - [Testing](#testing) + - [Testing](#testing) - [Sources and Links](#sources-and-links) - - [Contributors ✨](#contributors-) + - [Contributors ✨](#contributors-) @@ -86,6 +86,8 @@ The various variables are defined as follows: - `SMTP_PORT` = _optional_ port number for the SMTP server used to send notification emails - `SMTP_USER` = _optional_ username for the SMTP server used to send notification emails - `SMTP_PASSWORD` = _optional_ password for the SMTP server used to send notification emails +- `URL` = _optional_ the URL for your front-end application +- `TEST_EMAIL` = _optional_ the email you wish to send tests to - `BYPASS_LOGIN` = _optional_ Allows you to hit the endpoints locally without having to login. If you wish to bypass the login process during local dev, set this to `true`. _We do not recommend using the default options for PostgreSQL. The above values are provided as examples. It is more secure to create your own credentials._ @@ -104,6 +106,8 @@ JWT_KEY=test123 DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres DATABASE_SCHEMA=public BYPASS_LOGIN=true +URL=http://localhost:8080 +TEST_EMAIL=jason@codeforbaltimore.org ``` ## PostgreSQL From 2f98df0d76fba61e96c932369b83f8e7607a0736 Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Thu, 4 Jun 2020 19:10:57 -0400 Subject: [PATCH 4/7] removing debug comment --- src/routes/user.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/user.js b/src/routes/user.js index 6534203d..f5e381a1 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -147,7 +147,6 @@ router.post('/', utils.authMiddleware, async (req, res) => { let code; let message; try { - console.log(req.body.password) if (validator.isEmail(req.body.email) && utils.validatePassword(req.body.password)) { const { email, password, roles } = req.body; const user = await req.context.models.User.create({ email: email.toLowerCase(), password }); From 5a5ef25fec417faa4dc028690d6f01dc7049c7f5 Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Thu, 4 Jun 2020 19:15:20 -0400 Subject: [PATCH 5/7] Updating swagger and postman --- Bmore-Responsive.postman_collection.json | 215 ++++++++++++++++------- swagger.json | 66 +++++++ 2 files changed, 217 insertions(+), 64 deletions(-) diff --git a/Bmore-Responsive.postman_collection.json b/Bmore-Responsive.postman_collection.json index 7eefd32a..508eaa07 100644 --- a/Bmore-Responsive.postman_collection.json +++ b/Bmore-Responsive.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "c0693306-4e20-4fda-b114-cb59b9057a5c", + "_postman_id": "3be53e97-52ac-405f-b40f-23073f619002", "name": "Bmore-Responsive", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -14,7 +14,7 @@ { "listen": "test", "script": { - "id": "19989e9e-11ee-476a-b986-5be021e89a45", + "id": "eefedb7b-ba34-4e05-8cc4-550978d36b42", "exec": [ "//grab the token and save it into \"token\" env variable", "pm.environment.set(\"token\",pm.response.text());", @@ -68,7 +68,7 @@ { "listen": "test", "script": { - "id": "19a429d8-ad47-4502-ab20-9fda4c54ea87", + "id": "98e1706d-e8ba-4f25-9b3a-28e3477a3bb2", "exec": [ "// grab the id of the first contatc returned for use in subsequent transactions", "var jsonData = pm.response.json();", @@ -110,7 +110,7 @@ { "listen": "test", "script": { - "id": "3908e851-991d-4e9a-8255-ba21abdef273", + "id": "bb50b172-b46b-4767-b477-cb8adf0bdda6", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -156,7 +156,7 @@ { "listen": "test", "script": { - "id": "e6385331-050e-4ae5-94ff-3388f161bb55", + "id": "e7b60b04-969a-4216-9e43-f80967078377", "exec": [ "//get the email of the new user from the request and save it to env variable", "//this will allow deletion of this in the Delete transaction", @@ -214,7 +214,7 @@ { "listen": "test", "script": { - "id": "9d93e390-dc13-42a1-8b78-c683b6c31e58", + "id": "d5de1aa5-dbc8-4d35-a450-786aa0eb23d4", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -261,7 +261,7 @@ { "listen": "test", "script": { - "id": "13bf75ad-caf0-4fa9-afd7-f6d2a23dae15", + "id": "2498d8d3-1234-4047-88f4-6aee2605d8e5", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -306,7 +306,7 @@ { "listen": "test", "script": { - "id": "822834c8-3772-4bd1-9ec8-65e3f441455d", + "id": "df88b7d3-46c0-4a2b-942d-8b5ea908d3b2", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -344,7 +344,7 @@ { "listen": "test", "script": { - "id": "041e221d-c931-4b22-884a-73fefd1c1b80", + "id": "c79b4c2d-6a47-4214-923c-27f3ea5659e6", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -391,7 +391,7 @@ { "listen": "test", "script": { - "id": "db7d2227-79fe-4dc9-bfee-a97248e53e12", + "id": "b66ebdbd-bbce-4b2b-a4e5-853d2c8fae85", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -440,7 +440,7 @@ { "listen": "test", "script": { - "id": "376871ec-6bdf-4315-af89-90886bff9317", + "id": "d64ec855-28e6-4eef-983b-e58d2402c451", "exec": [ "// grab the id of the first contatc returned for use in subsequent transactions", "var jsonData = pm.response.json();", @@ -484,7 +484,7 @@ { "listen": "test", "script": { - "id": "075e31f9-3255-460a-9fa4-b112db37f39a", + "id": "7d3b66de-29cb-4a97-9937-8042d23ec44b", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -523,7 +523,7 @@ { "listen": "test", "script": { - "id": "fa562b7d-4783-4cbb-a06b-e7b774023591", + "id": "21bec72c-9fe8-4afc-b11d-cd837f3f9a36", "exec": [ "//get the 36 character id of the new contact and save it to env variable", "//this will allow deletion of this in the Delete transaction", @@ -568,13 +568,65 @@ }, "response": [] }, + { + "name": "Contact Send", + "event": [ + { + "listen": "test", + "script": { + "id": "b38d3eef-47f0-4f9e-a710-d82c4fef5492", + "exec": [ + "//get the 36 character id of the new contact and save it to env variable", + "//this will allow deletion of this in the Delete transaction", + "pm.environment.set(\"newContactId\", pm.response.text().slice(0,36));", + "", + "//confirm that request returns a success code of 200", + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "token", + "type": "text", + "value": "{{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"relationshipTitle\": [\"Primary Contact\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/contact/send", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "contact", + "send" + ] + } + }, + "response": [] + }, { "name": "Update Contact", "event": [ { "listen": "test", "script": { - "id": "6f1765bb-101a-40d0-904f-37af9aa96558", + "id": "fc1c434b-cc59-4e91-a823-6860602bd128", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -621,7 +673,7 @@ { "listen": "test", "script": { - "id": "7e59699f-e52f-47ac-b2aa-05fedf731131", + "id": "788ddd37-f714-440f-9103-97fecceec6af", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -660,7 +712,7 @@ { "listen": "prerequest", "script": { - "id": "e50ba8bc-02c6-40ba-ae51-57fa584d8aee", + "id": "a9a6fb59-1c5c-4d20-b3ca-9c3d84a3f4dc", "type": "text/javascript", "exec": [ "" @@ -670,7 +722,7 @@ { "listen": "test", "script": { - "id": "194a84e5-96bd-4efa-afa6-a7f770d99d85", + "id": "50e1126f-2406-420f-b6a1-96e43971b85f", "type": "text/javascript", "exec": [ "" @@ -689,7 +741,7 @@ { "listen": "test", "script": { - "id": "64a0b1b0-b208-4ad5-a1cd-2bed78440b07", + "id": "d5a5284c-53a9-43ca-ba8d-832c9c98be4d", "exec": [ "// grab the id of the first two entities returned for use in subsequent transactions", "var jsonData = pm.response.json();", @@ -732,7 +784,7 @@ { "listen": "test", "script": { - "id": "126b51f5-5c6a-40eb-95b6-b0316f2e2e2e", + "id": "fe787d2f-da5e-4cef-bbbd-9b0104bb9dfc", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -771,7 +823,7 @@ { "listen": "test", "script": { - "id": "76ea05ba-36d2-45b2-acfa-425f1029860d", + "id": "4ff4b107-c439-4432-86b6-b0b5e79ee991", "exec": [ "//get the 36 character id of the new entity and save it to env variable", "//this will allow deletion of this in the Delete transaction", @@ -822,7 +874,7 @@ { "listen": "test", "script": { - "id": "2393125b-9337-436e-ad40-816eed71e98a", + "id": "8cbc3938-6cf0-4faa-8dac-c899288cd72f", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -869,7 +921,7 @@ { "listen": "test", "script": { - "id": "0c54a785-ee1e-4cb0-b9bb-b290937b50ff", + "id": "6ba7b41b-83bf-457e-939c-ec65c809ebc9", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -920,7 +972,7 @@ { "listen": "test", "script": { - "id": "1831285f-51de-45fb-9a68-e3c281951a44", + "id": "c7df8851-5ddf-4567-8caf-16207b1f83f3", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -969,7 +1021,7 @@ { "listen": "test", "script": { - "id": "63c5ec2a-d368-4014-9ffa-2ac4a4bce1a6", + "id": "45cb9cec-1aab-4fa8-9851-62138204c03e", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -1018,7 +1070,7 @@ { "listen": "prerequest", "script": { - "id": "79b9e2be-9773-4ed5-87d9-46fa27c2d92e", + "id": "c5f74aa2-191d-442c-b57f-f6d75fa8b78a", "type": "text/javascript", "exec": [ "" @@ -1028,7 +1080,7 @@ { "listen": "test", "script": { - "id": "5c697ba8-81eb-487b-99f6-ab624dc9d589", + "id": "dab3358a-6e1e-49c5-8177-134315247a9a", "type": "text/javascript", "exec": [ "" @@ -1039,15 +1091,15 @@ "protocolProfileBehavior": {} }, { - "name": "csv", + "name": "unlinks", "item": [ { - "name": "Contact CSV", + "name": "Unlink Contact to Entities", "event": [ { "listen": "test", "script": { - "id": "737b2c37-bff3-45b8-b01a-02fdcca9e012", + "id": "339e72dd-0d08-48d0-ab59-7a0cc3b16799", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -1058,42 +1110,45 @@ } } ], - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "token", - "value": "{{token}}", - "type": "text" + "type": "text", + "value": "{{token}}" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n \"entities\": [\n {\n \"id\": \"{{firstEntityId}}\"\n },\n {\n \"id\": \"{{secondEntityId}}\",\n \"title\": \"Owner\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } }, "url": { - "raw": "{{baseUrl}}/csv/Contact", + "raw": "{{baseUrl}}/contact/unlink/{{firstContactId}}", "host": [ "{{baseUrl}}" ], "path": [ - "csv", - "Contact" + "contact", + "unlink", + "{{firstContactId}}" ] } }, "response": [] }, { - "name": "Entity CSV", + "name": "Unlink Entity to Contacts", "event": [ { "listen": "test", "script": { - "id": "8f8f78ba-eb6f-4c06-9939-7e0e7d2af5af", + "id": "8ec628f0-3fb6-4a0c-88eb-7325dbfb6cfc", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -1104,42 +1159,74 @@ } } ], - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "token", - "value": "{{token}}", - "type": "text" + "type": "text", + "value": "{{token}}" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n \"contacts\": [\n {\n \"id\": \"{{firstContactId}}\"\n },\n {\n \"id\": \"{{secondContactId}}\",\n \"title\": \"Owner\"\n }\n ]\n}\n\n", + "options": { + "raw": { + "language": "json" + } + } }, "url": { - "raw": "{{baseUrl}}/csv/Entity", + "raw": "{{baseUrl}}/entity/unlink/{{firstEntityId}}", "host": [ "{{baseUrl}}" ], "path": [ - "csv", - "Entity" + "entity", + "unlink", + "{{firstEntityId}}" ] } }, "response": [] + } + ], + "description": "These calls are exampled on how to create relationships between contacts and entities. These are put in their own folder in postman as they two sample calls require that Get All Contacts and Get All Entities have already been run.", + "event": [ + { + "listen": "prerequest", + "script": { + "id": "1043a0a5-3f65-4f0c-87b4-5cac16ff9bd7", + "type": "text/javascript", + "exec": [ + "" + ] + } }, { - "name": "User CSV", + "listen": "test", + "script": { + "id": "30492179-c737-494a-8db0-97e28436d888", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "csv", + "item": [ + { + "name": "Contact CSV", "event": [ { "listen": "test", "script": { - "id": "5b84cbe0-f036-45bf-b973-f9bf869b5cb8", + "id": "21a85667-7ad4-4a68-a2cf-281da224806a", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -1167,25 +1254,25 @@ "raw": "" }, "url": { - "raw": "{{baseUrl}}/csv/User", + "raw": "{{baseUrl}}/csv/Contact", "host": [ "{{baseUrl}}" ], "path": [ "csv", - "User" + "Contact" ] } }, "response": [] }, { - "name": "UserRole CSV", + "name": "Entity CSV", "event": [ { "listen": "test", "script": { - "id": "7781e1ff-7c1f-4544-a554-063b098e08bb", + "id": "8ec4b7b3-1ac7-4edc-b1d5-0d4ba0456e84", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -1213,13 +1300,13 @@ "raw": "" }, "url": { - "raw": "{{baseUrl}}/csv/UserRole", + "raw": "{{baseUrl}}/csv/Entity", "host": [ "{{baseUrl}}" ], "path": [ "csv", - "UserRole" + "Entity" ] } }, @@ -1230,7 +1317,7 @@ { "listen": "prerequest", "script": { - "id": "63407654-a4a9-4402-9e72-011815893577", + "id": "29e2cbbb-c919-45c8-b74e-cc4a77de1a38", "type": "text/javascript", "exec": [ "" @@ -1240,7 +1327,7 @@ { "listen": "test", "script": { - "id": "1c17cf82-30be-4bd0-9efb-89e228067f49", + "id": "cc457e20-3ed2-4898-9bbb-fb698eccf2d4", "type": "text/javascript", "exec": [ "" @@ -1256,7 +1343,7 @@ { "listen": "test", "script": { - "id": "ad699e86-0ff1-48e2-96aa-a339d48e14a1", + "id": "7d5b1979-b61c-4d4d-b506-bb880b279875", "exec": [ "//confirm that request returns a success code of 200", "pm.test(\"Status code is 200\", function () {", @@ -1300,7 +1387,7 @@ { "listen": "prerequest", "script": { - "id": "3a7ed56b-06c9-4ed2-80df-1e3499f06ecd", + "id": "79586dee-3afa-4b22-b259-e3e63f4e4e15", "type": "text/javascript", "exec": [ "" @@ -1310,7 +1397,7 @@ { "listen": "test", "script": { - "id": "eef185d7-4e91-4ea5-b251-1383ee21ef28", + "id": "8a795a8f-64a9-4311-bb01-2b3e0a109f80", "type": "text/javascript", "exec": [ "" diff --git a/swagger.json b/swagger.json index 8867b7ca..57381205 100644 --- a/swagger.json +++ b/swagger.json @@ -1211,6 +1211,72 @@ } } }, + "/contact/send" : { + "post" : { + "tags" : [ "contact" ], + "summary" : "sends a check-in email to all contacts", + "description" : "By sending a request to this endpoint, you can send an email to a single contact or all contacts based on entity or contact id. By sending entity ids you will send an email to each contact associated with each entity id passed. By passed contact ids you will send an email to each contact for each entity they are associated with. By passing nothing you will send an email to every contact and every association.", + "parameters" : [ { + "in" : "header", + "name" : "token", + "required" : true, + "schema" : { + "type" : "string", + "example" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE1ODA3NTM0MDUsImV4cCI6MTU4MDgzOTgwNX0.Q6W7Vo6By35yjZBeLKkk96s8LyqIE2G39AG1H3LRD9M" + } + } ], + "requestBody" : { + "description" : "The body of the payload", + "required" : true, + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "entityIds" : { + "type" : "array", + "items" : { + "type" : "string", + "format" : "uuid", + "example" : "05533f95-b440-4f9d-876d-653636dce0c8" + } + }, + "contactIds" : { + "type" : "array", + "items" : { + "type" : "string", + "format" : "uuid", + "example" : "05533f95-b440-4f9d-876d-653636dce0c8" + } + }, + "relationshipTitle" : { + "type" : "array", + "items" : { + "type" : "string", + "example" : "Primary Contact" + } + } + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "contacts emailed" + }, + "401" : { + "description" : "Unauthorized" + }, + "422" : { + "description" : "Invalid input" + }, + "500" : { + "description" : "Server error" + } + } + } + }, "/contact/{contact_id}" : { "get" : { "tags" : [ "contact" ], From fc17e5fa4f84c5068c53377a96b7572556364c48 Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Thu, 4 Jun 2020 19:28:39 -0400 Subject: [PATCH 6/7] Adding mkdocs --- mkdocs.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 mkdocs.yml diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..0131c82e --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: Project Template +theme: + name: readthedocs +plugins: + - swagger +extra: + swagger_url: 'https://raw.githubusercontent.com/CodeForBaltimore/Bmore-Responsive/master/swagger.json' \ No newline at end of file From 1ce9632cecd98cad5c20fb4d0f32810e7ef4e8d2 Mon Sep 17 00:00:00 2001 From: Jason Anton Date: Fri, 5 Jun 2020 11:17:00 -0400 Subject: [PATCH 7/7] Removing Casbin db console output; updating swagger --- src/utils/index.js | 4 ++-- swagger.json | 16 ---------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/utils/index.js b/src/utils/index.js index b7e1f0cd..98cf67cc 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -50,11 +50,11 @@ const loadCasbin = async () => { } } : { logging: false }; const a = await SequelizeAdapter.newAdapter( - dbUrl(), { + ...dbUrl(), logging: false, dialect: 'postgres', - dialectOptions: dialectOptions + dialectOptions } ); diff --git a/swagger.json b/swagger.json index 57381205..16bf3991 100644 --- a/swagger.json +++ b/swagger.json @@ -1233,22 +1233,6 @@ "schema" : { "type" : "object", "properties" : { - "entityIds" : { - "type" : "array", - "items" : { - "type" : "string", - "format" : "uuid", - "example" : "05533f95-b440-4f9d-876d-653636dce0c8" - } - }, - "contactIds" : { - "type" : "array", - "items" : { - "type" : "string", - "format" : "uuid", - "example" : "05533f95-b440-4f9d-876d-653636dce0c8" - } - }, "relationshipTitle" : { "type" : "array", "items" : {