diff --git a/client/src/api/boards.js b/client/src/api/boards.js index c8ae9202..c35b2e98 100755 --- a/client/src/api/boards.js +++ b/client/src/api/boards.js @@ -8,15 +8,8 @@ import { transformAttachment } from './attachments'; const createBoard = (projectId, data, headers) => socket.post(`/projects/${projectId}/boards`, data, headers); -// TODO: remove and use createBoard instead -const importBoard = (projectId, data, headers) => - http.post( - `/projects/${projectId}/imports/boards?name=${data.name}&position=${data.position}`, - { - file: data.import.file, - }, - headers, - ); +const createBoardWithImport = (projectId, data, requestId, headers) => + http.post(`/projects/${projectId}/boards?requestId=${requestId}`, data, headers); const getBoard = (id, headers) => socket.get(`/boards/${id}`, undefined, headers).then((body) => ({ @@ -34,7 +27,7 @@ const deleteBoard = (id, headers) => socket.delete(`/boards/${id}`, undefined, h export default { createBoard, - importBoard, + createBoardWithImport, getBoard, updateBoard, deleteBoard, diff --git a/client/src/components/Boards/AddPopup/AddPopup.jsx b/client/src/components/Boards/AddPopup/AddPopup.jsx index ba6628f6..fae37a72 100755 --- a/client/src/components/Boards/AddPopup/AddPopup.jsx +++ b/client/src/components/Boards/AddPopup/AddPopup.jsx @@ -92,14 +92,16 @@ const AddStep = React.memo(({ onCreate, onClose }) => { className={styles.field} onChange={handleFieldChange} /> - +
+ +
diff --git a/client/src/components/Boards/AddPopup/AddPopup.module.scss b/client/src/components/Boards/AddPopup/AddPopup.module.scss index 481fe714..cda85c3c 100644 --- a/client/src/components/Boards/AddPopup/AddPopup.module.scss +++ b/client/src/components/Boards/AddPopup/AddPopup.module.scss @@ -1,4 +1,17 @@ :global(#app) { + .controls { + display: flex; + max-width: 280px; + + @media only screen and (max-width: 767px) { + max-width: 226px; + } + } + + .createButton { + white-space: nowrap; + } + .field { margin-bottom: 8px; } @@ -7,10 +20,9 @@ background: transparent; box-shadow: none; color: #6b808c; - float: right; font-weight: normal; + margin-left: auto; margin-right: 0; - max-width: 49%; overflow: hidden; text-align: left; text-decoration: underline; @@ -25,7 +37,6 @@ } .importButtonIcon { - font-size: 13px; text-decoration: none; } } diff --git a/client/src/entry-actions/boards.js b/client/src/entry-actions/boards.js index 638f49ba..092fddf7 100755 --- a/client/src/entry-actions/boards.js +++ b/client/src/entry-actions/boards.js @@ -7,10 +7,11 @@ const createBoardInCurrentProject = (data) => ({ }, }); -const handleBoardCreate = (board) => ({ +const handleBoardCreate = (board, requestId) => ({ type: EntryActionTypes.BOARD_CREATE_HANDLE, payload: { board, + requestId, }, }); diff --git a/client/src/sagas/core/services/boards.js b/client/src/sagas/core/services/boards.js index eebb74af..b797ecf7 100644 --- a/client/src/sagas/core/services/boards.js +++ b/client/src/sagas/core/services/boards.js @@ -1,4 +1,3 @@ -import omit from 'lodash/omit'; import { call, put, select } from 'redux-saga/effects'; import { goToBoard, goToProject } from './router'; @@ -8,9 +7,7 @@ import actions from '../../../actions'; import api from '../../../api'; import { createLocalId } from '../../../utils/local-id'; -export function* createBoard(projectId, data) { - const isImport = !!data.import; - +export function* createBoard(projectId, { import: boardImport, ...data }) { const nextData = { ...data, position: yield select(selectors.selectNextBoardPosition, projectId), @@ -20,7 +17,7 @@ export function* createBoard(projectId, data) { yield put( actions.createBoard({ - ...(isImport ? omit(nextData, 'import') : nextData), + ...nextData, projectId, id: localId, }), @@ -33,7 +30,19 @@ export function* createBoard(projectId, data) { ({ item: board, included: { boardMemberships }, - } = yield call(request, isImport ? api.importBoard : api.createBoard, projectId, nextData)); + } = yield boardImport + ? call( + request, + api.createBoardWithImport, + projectId, + { + ...nextData, + importType: boardImport.type, + importFile: boardImport.file, + }, + localId, + ) + : call(request, api.createBoard, projectId, nextData)); } catch (error) { yield put(actions.createBoard.failure(localId, error)); return; @@ -49,8 +58,12 @@ export function* createBoardInCurrentProject(data) { yield call(createBoard, projectId, data); } -export function* handleBoardCreate(board) { - yield put(actions.handleBoardCreate(board)); +export function* handleBoardCreate(board, requestId) { + const isExists = yield select(selectors.selectIsBoardWithIdExists, requestId); + + if (!isExists) { + yield put(actions.handleBoardCreate(board)); + } } export function* fetchBoard(id) { diff --git a/client/src/sagas/core/watchers/boards.js b/client/src/sagas/core/watchers/boards.js index ad9f2615..1d6f5348 100644 --- a/client/src/sagas/core/watchers/boards.js +++ b/client/src/sagas/core/watchers/boards.js @@ -8,8 +8,8 @@ export default function* boardsWatchers() { takeEvery(EntryActionTypes.BOARD_IN_CURRENT_PROJECT_CREATE, ({ payload: { data } }) => services.createBoardInCurrentProject(data), ), - takeEvery(EntryActionTypes.BOARD_CREATE_HANDLE, ({ payload: { board } }) => - services.handleBoardCreate(board), + takeEvery(EntryActionTypes.BOARD_CREATE_HANDLE, ({ payload: { board, requestId } }) => + services.handleBoardCreate(board, requestId), ), takeEvery(EntryActionTypes.BOARD_FETCH, ({ payload: { id } }) => services.fetchBoard(id)), takeEvery(EntryActionTypes.BOARD_UPDATE, ({ payload: { id, data } }) => diff --git a/client/src/sagas/core/watchers/socket.js b/client/src/sagas/core/watchers/socket.js index 632946cb..ef6bed94 100644 --- a/client/src/sagas/core/watchers/socket.js +++ b/client/src/sagas/core/watchers/socket.js @@ -48,8 +48,8 @@ const createSocketEventsChannel = () => emit(entryActions.handleProjectManagerDelete(item)); }; - const handleBoardCreate = ({ item }) => { - emit(entryActions.handleBoardCreate(item)); + const handleBoardCreate = ({ item, requestId }) => { + emit(entryActions.handleBoardCreate(item, requestId)); }; const handleBoardUpdate = ({ item }) => { diff --git a/client/src/selectors/boards.js b/client/src/selectors/boards.js index 3fbbb985..52a653e5 100644 --- a/client/src/selectors/boards.js +++ b/client/src/selectors/boards.js @@ -172,6 +172,12 @@ export const selectCurrentUserMembershipForCurrentBoard = createSelector( }, ); +export const selectIsBoardWithIdExists = createSelector( + orm, + (_, id) => id, + ({ Board }, id) => Board.idExists(id), +); + export default { makeSelectBoardById, selectBoardById, @@ -182,4 +188,5 @@ export default { selectFilterUsersForCurrentBoard, selectFilterLabelsForCurrentBoard, selectCurrentUserMembershipForCurrentBoard, + selectIsBoardWithIdExists, }; diff --git a/server/api/controllers/boards/create.js b/server/api/controllers/boards/create.js index 49d04346..c825e911 100755 --- a/server/api/controllers/boards/create.js +++ b/server/api/controllers/boards/create.js @@ -1,7 +1,16 @@ +const util = require('util'); +const { v4: uuid } = require('uuid'); + const Errors = { PROJECT_NOT_FOUND: { projectNotFound: 'Project not found', }, + NO_IMPORT_FILE_WAS_UPLOADED: { + noImportFileWasUploaded: 'No import file was uploaded', + }, + INVALID_IMPORT_FILE: { + invalidImportFile: 'Invalid import file', + }, }; module.exports = { @@ -24,12 +33,26 @@ module.exports = { type: 'string', required: true, }, + importType: { + type: 'string', + isIn: Object.values(Board.ImportTypes), + }, + requestId: { + type: 'string', + isNotEmptyString: true, + }, }, exits: { projectNotFound: { responseType: 'notFound', }, + noImportFileWasUploaded: { + responseType: 'unprocessableEntity', + }, + uploadError: { + responseType: 'unprocessableEntity', + }, }, async fn(inputs) { @@ -49,10 +72,42 @@ module.exports = { const values = _.pick(inputs, ['type', 'position', 'name']); + let boardImport; + if (inputs.importType && Object.values(Board.ImportTypes).includes(inputs.importType)) { + const upload = util.promisify((options, callback) => + this.req.file('importFile').upload(options, (error, files) => callback(error, files)), + ); + + let files; + try { + files = await upload({ + saveAs: uuid(), + maxBytes: null, + }); + } catch (error) { + return exits.uploadError(error.message); // TODO: add error + } + + if (files.length === 0) { + throw Errors.NO_IMPORT_FILE_WAS_UPLOADED; + } + + const file = _.last(files); + + if (inputs.importType === Board.ImportTypes.TRELLO) { + boardImport = { + type: inputs.importType, + board: await sails.helpers.boards.processUploadedTrelloImportFile(file), + }; + } + } + const { board, boardMembership } = await sails.helpers.boards.createOne( values, + boardImport, currentUser, project, + inputs.requestId, this.req, ); diff --git a/server/api/controllers/boards/import.js b/server/api/controllers/boards/import.js deleted file mode 100755 index 60b63ef9..00000000 --- a/server/api/controllers/boards/import.js +++ /dev/null @@ -1,92 +0,0 @@ -const util = require('util'); -const { v4: uuid } = require('uuid'); - -const Errors = { - PROJECT_NOT_FOUND: { - projectNotFound: 'Project not found', - }, - TRELLO_FILE_INVALID: { - trelloFileInvalid: 'Trello File invalid', - }, -}; - -module.exports = { - inputs: { - projectId: { - type: 'string', - regex: /^[0-9]+$/, - required: true, - }, - position: { - type: 'number', - required: true, - }, - name: { - type: 'string', - required: true, - }, - }, - - exits: { - projectNotFound: { - responseType: 'notFound', - }, - trelloFileInvalid: { - responseType: 'badRequest', - }, - }, - - async fn(inputs) { - const { currentUser } = this.req; - - const project = await Project.findOne(inputs.projectId); - if (!project) { - throw Errors.PROJECT_NOT_FOUND; - } - - const isProjectManager = await sails.helpers.users.isProjectManager(currentUser.id, project.id); - if (!isProjectManager) { - throw Errors.PROJECT_NOT_FOUND; - } - - const upload = util.promisify((options, callback) => - this.req.file('file').upload(options, (error, files) => callback(error, files)), - ); - - let files; - let trelloBoard; - try { - files = await upload({ - saveAs: uuid(), - maxBytes: null, - }); - trelloBoard = await sails.helpers.boards.loadTrelloFile(files[0]); - } catch (error) { - throw Errors.TRELLO_FILE_INVALID; - } - - const values = { - ..._.pick(inputs, ['position', 'name']), - type: 'kanban', - }; - const { board, boardMembership } = await sails.helpers.boards.createOne( - values, - currentUser, - project, - this.req, - ); - - await sails.helpers.boards.importTrello(currentUser, board, trelloBoard, this.req); - - if (this.req.isSocket) { - sails.sockets.join(this.req, `board:${board.id}`); - } - - return { - item: board, - included: { - boardMemberships: [boardMembership], - }, - }; - }, -}; diff --git a/server/api/helpers/boards/create-one.js b/server/api/helpers/boards/create-one.js index 8bddc233..4f82ef1f 100644 --- a/server/api/helpers/boards/create-one.js +++ b/server/api/helpers/boards/create-one.js @@ -5,6 +5,13 @@ module.exports = { custom: (value) => _.isPlainObject(value) && _.isFinite(value.position), required: true, }, + import: { + type: 'json', + custom: (value) => + value.type && + Object.values(Board.ImportTypes).includes(value.type) && + _.isPlainObject(value.board), + }, user: { type: 'ref', required: true, @@ -13,6 +20,10 @@ module.exports = { type: 'ref', required: true, }, + requestId: { + type: 'string', + isNotEmptyString: true, + }, request: { type: 'ref', }, @@ -54,6 +65,10 @@ module.exports = { projectId: inputs.project.id, }).fetch(); + if (inputs.import && inputs.import.type === Board.ImportTypes.TRELLO) { + await sails.helpers.boards.importFromTrello(inputs.user, board, inputs.import.board); + } + const boardMembership = await BoardMembership.create({ boardId: board.id, userId: inputs.user.id, @@ -66,6 +81,7 @@ module.exports = { 'boardCreate', { item: board, + requestId: inputs.requestId, }, inputs.request, ); diff --git a/server/api/helpers/boards/import-from-trello.js b/server/api/helpers/boards/import-from-trello.js new file mode 100644 index 00000000..7dcbd2e5 --- /dev/null +++ b/server/api/helpers/boards/import-from-trello.js @@ -0,0 +1,152 @@ +module.exports = { + inputs: { + user: { + type: 'ref', + required: true, + }, + board: { + type: 'ref', + required: true, + }, + trelloBoard: { + type: 'json', + required: true, + }, + }, + + async fn(inputs) { + const trelloToPlankaLabels = {}; + + const getTrelloLists = () => inputs.trelloBoard.lists.filter((list) => !list.closed); + + const getUsedTrelloLabels = () => { + const result = {}; + inputs.trelloBoard.cards + .map((card) => card.labels) + .flat() + .forEach((label) => { + result[label.id] = label; + }); + + return Object.values(result); + }; + + const getTrelloCardsOfList = (listId) => + inputs.trelloBoard.cards.filter((card) => card.idList === listId && !card.closed); + + const getAllTrelloCheckItemsOfCard = (cardId) => + inputs.trelloBoard.checklists + .filter((checklist) => checklist.idCard === cardId) + .map((checklist) => checklist.checkItems) + .flat(); + + const getTrelloCommentsOfCard = (cardId) => + inputs.trelloBoard.actions.filter( + (action) => + action.type === 'commentCard' && + action.data && + action.data.card && + action.data.card.id === cardId, + ); + + const getPlankaLabelColor = (trelloLabelColor) => + Label.COLORS.find((color) => color.indexOf(trelloLabelColor) !== -1) || 'desert-sand'; + + const importCardLabels = async (plankaCard, trelloCard) => { + return Promise.all( + trelloCard.labels.map(async (trelloLabel) => { + return CardLabel.create({ + cardId: plankaCard.id, + labelId: trelloToPlankaLabels[trelloLabel.id].id, + }); + }), + ); + }; + + const importTasks = async (plankaCard, trelloCard) => { + // TODO find workaround for tasks/checklist mismapping, see issue trello2planka#5 + return Promise.all( + getAllTrelloCheckItemsOfCard(trelloCard.id).map(async (trelloCheckItem) => { + return Task.create({ + cardId: plankaCard.id, + position: trelloCheckItem.pos, + name: trelloCheckItem.name, + isCompleted: trelloCheckItem.state === 'complete', + }).fetch(); + }), + ); + }; + + const importComments = async (plankaCard, trelloCard) => { + const trelloComments = getTrelloCommentsOfCard(trelloCard.id); + trelloComments.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + return Promise.all( + trelloComments.map(async (trelloComment) => { + return Action.create({ + cardId: plankaCard.id, + userId: inputs.user.id, + type: 'commentCard', + data: { + text: + `${trelloComment.data.text}\n\n---\n*Note: imported comment, originally posted by ` + + `\n${trelloComment.memberCreator.fullName} (${trelloComment.memberCreator.username}) on ${trelloComment.date}*`, + }, + }).fetch(); + }), + ); + }; + + const importCards = async (plankaList, trelloList) => { + return Promise.all( + getTrelloCardsOfList(trelloList.id).map(async (trelloCard) => { + const plankaCard = await Card.create({ + boardId: inputs.board.id, + listId: plankaList.id, + creatorUserId: inputs.user.id, + position: trelloCard.pos, + name: trelloCard.name, + description: trelloCard.desc || null, + }).fetch(); + + await importCardLabels(plankaCard, trelloCard); + await importTasks(plankaCard, trelloCard); + await importComments(plankaCard, trelloCard); + + return plankaCard; + }), + ); + }; + + const importLabels = async () => { + return Promise.all( + getUsedTrelloLabels().map(async (trelloLabel) => { + const plankaLabel = await Label.create({ + boardId: inputs.board.id, + name: trelloLabel.name || null, + color: getPlankaLabelColor(trelloLabel.color), + }).fetch(); + + trelloToPlankaLabels[trelloLabel.id] = plankaLabel; + }), + ); + }; + + const importLists = async () => { + return Promise.all( + getTrelloLists().map(async (trelloList) => { + const plankaList = await List.create({ + boardId: inputs.board.id, + name: trelloList.name, + position: trelloList.pos, + }).fetch(); + + return importCards(plankaList, trelloList); + }), + ); + }; + + await importLabels(); + await importLists(); + }, +}; diff --git a/server/api/helpers/boards/import-trello.js b/server/api/helpers/boards/import-trello.js deleted file mode 100644 index e2dce04c..00000000 --- a/server/api/helpers/boards/import-trello.js +++ /dev/null @@ -1,171 +0,0 @@ -async function importFromTrello(inputs) { - const trelloToPlankaLabels = {}; - - const getTrelloLists = () => inputs.trelloBoard.lists.filter((list) => !list.closed); - const getUsedTrelloLabels = () => { - const result = {}; - inputs.trelloBoard.cards - .map((card) => card.labels) - .flat() - .forEach((label) => { - result[label.id] = label; - }); - return Object.values(result); - }; - const getTrelloCardsOfList = (listId) => - inputs.trelloBoard.cards.filter((l) => l.idList === listId && !l.closed); - const getAllTrelloCheckItemsOfCard = (cardId) => - inputs.trelloBoard.checklists - .filter((c) => c.idCard === cardId) - .map((checklist) => checklist.checkItems) - .flat(); - const getTrelloCommentsOfCard = (cardId) => - inputs.trelloBoard.actions.filter( - (action) => - action.type === 'commentCard' && - action.data && - action.data.card && - action.data.card.id === cardId, - ); - const getPlankaLabelColor = (trelloLabelColor) => - Label.COLORS.find((c) => c.indexOf(trelloLabelColor) !== -1) || 'desert-sand'; - - const importComments = async (trelloCard, plankaCard) => { - const trelloComments = getTrelloCommentsOfCard(trelloCard.id); - trelloComments.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - return Promise.all( - trelloComments.map(async (trelloComment) => { - return sails.helpers.actions.createOne( - { - type: 'commentCard', - data: { - text: - `${trelloComment.data.text}\n\n---\n*Note: imported comment, originally posted by ` + - `\n${trelloComment.memberCreator.fullName} (${trelloComment.memberCreator.username}) on ${trelloComment.date}*`, - }, - }, - inputs.user, - plankaCard, - inputs.request, - ); - }), - ); - }; - - const importTasks = async (trelloCard, plankaCard) => { - // TODO find workaround for tasks/checklist mismapping, see issue trello2planka#5 - return Promise.all( - getAllTrelloCheckItemsOfCard(trelloCard.id).map(async (trelloCheckItem) => { - return sails.helpers.tasks.createOne( - { - cardId: plankaCard.id, - position: trelloCheckItem.pos, - name: trelloCheckItem.name, - isCompleted: trelloCheckItem.state === 'complete', - }, - plankaCard, - inputs.request, - ); - }), - ); - }; - - const importCardLabels = async (trelloCard, plankaCard) => { - return Promise.all( - trelloCard.labels.map(async (trelloLabel) => { - return sails.helpers.cardLabels.createOne( - trelloToPlankaLabels[trelloLabel.id], - plankaCard, - inputs.request, - ); - }), - ); - }; - - const importCards = async (trelloList, plankaList) => { - return Promise.all( - getTrelloCardsOfList(trelloList.id).map(async (trelloCard) => { - const plankaCard = await sails.helpers.cards.createOne( - { - listId: plankaList.id, - position: trelloCard.pos, - name: trelloCard.name, - description: trelloCard.desc || null, - }, - inputs.user, - inputs.board, - plankaList, - inputs.request, - ); - - await importCardLabels(trelloCard, plankaCard); - await importTasks(trelloCard, plankaCard); - await importComments(trelloCard, plankaCard); - return plankaCard; - }), - ); - }; - - const importLists = async () => { - return Promise.all( - getTrelloLists().map(async (trelloList) => { - const plankaList = await sails.helpers.lists.createOne( - { - name: trelloList.name, - position: trelloList.pos, - }, - inputs.board, - inputs.request, - ); - return importCards(trelloList, plankaList); - }), - ); - }; - - const importLabels = async () => { - return Promise.all( - getUsedTrelloLabels().map(async (trelloLabel) => { - const plankaLabel = await sails.helpers.labels.createOne( - { - name: trelloLabel.name || null, - color: getPlankaLabelColor(trelloLabel.color), - }, - inputs.board, - inputs.request, - ); - trelloToPlankaLabels[trelloLabel.id] = plankaLabel; - }), - ); - }; - - await importLabels(); - await importLists(); -} - -module.exports = { - inputs: { - user: { - type: 'ref', - required: true, - }, - board: { - type: 'ref', - required: true, - }, - trelloBoard: { - type: 'json', - required: true, - }, - request: { - type: 'ref', - }, - }, - - async fn(inputs) { - await importFromTrello(inputs); - - return { - board: inputs.board, - }; - }, -}; diff --git a/server/api/helpers/boards/load-trello-file.js b/server/api/helpers/boards/load-trello-file.js deleted file mode 100644 index 039944ad..00000000 --- a/server/api/helpers/boards/load-trello-file.js +++ /dev/null @@ -1,36 +0,0 @@ -const fs = require('fs'); - -module.exports = { - inputs: { - file: { - type: 'json', - required: true, - }, - }, - - async fn(inputs) { - const isValidTrelloFile = (content) => - content && - Array.isArray(content.lists) && - Array.isArray(content.cards) && - Array.isArray(content.checklists) && - Array.isArray(content.actions); - - return new Promise((resolve, reject) => { - fs.readFile(inputs.file.fd, (err, data) => { - try { - const exp = data && JSON.parse(data); - if (err) { - reject(err); - } else if (isValidTrelloFile(exp)) { - resolve(exp); - } else { - reject(new Error('Invalid Trello File')); - } - } catch (e) { - reject(new Error(e)); - } - }); - }); - }, -}; diff --git a/server/api/helpers/boards/process-uploaded-trello-import-file.js b/server/api/helpers/boards/process-uploaded-trello-import-file.js new file mode 100644 index 00000000..d1662cda --- /dev/null +++ b/server/api/helpers/boards/process-uploaded-trello-import-file.js @@ -0,0 +1,38 @@ +const fs = require('fs').promises; +const rimraf = require('rimraf'); + +module.exports = { + inputs: { + file: { + type: 'json', + required: true, + }, + }, + + exits: { + invalidFile: {}, + }, + + async fn(inputs) { + const content = await fs.readFile(inputs.file.fd); + const trelloBoard = JSON.parse(content); + + if ( + !trelloBoard || + !_.isArray(trelloBoard.lists) || + !_.isArray(trelloBoard.cards) || + !_.isArray(trelloBoard.checklists) || + !_.isArray(trelloBoard.actions) + ) { + throw 'invalidFile'; + } + + try { + rimraf.sync(inputs.file.fd); + } catch (error) { + console.warn(error.stack); // eslint-disable-line no-console + } + + return trelloBoard; + }, +}; diff --git a/server/api/models/Board.js b/server/api/models/Board.js index 9ad16c50..6541769b 100755 --- a/server/api/models/Board.js +++ b/server/api/models/Board.js @@ -10,8 +10,13 @@ const Types = { COLLECTION: 'collection', }; +const ImportTypes = { + TRELLO: 'trello', +}; + module.exports = { Types, + ImportTypes, attributes: { // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ diff --git a/server/config/routes.js b/server/config/routes.js index d830744e..05450eac 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -33,7 +33,6 @@ module.exports.routes = { 'DELETE /api/project-managers/:id': 'project-managers/delete', 'POST /api/projects/:projectId/boards': 'boards/create', - 'POST /api/projects/:projectId/imports/boards': 'boards/import', 'GET /api/boards/:id': 'boards/show', 'PATCH /api/boards/:id': 'boards/update', 'DELETE /api/boards/:id': 'boards/delete',