├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── attachmentsStorage ├── local.js └── s3.js ├── controllers ├── application_privacy.js ├── applications.js ├── files.js ├── hankos.js ├── notifications.js ├── recipient_comment.js └── templates.js ├── db.js ├── index.js ├── kubernetes_manifest.yml ├── logger.js ├── package-lock.json ├── package.json ├── routes ├── application.js ├── application_privacy.js ├── applications.js ├── files.js ├── hankos.js ├── index.js ├── notifications.js └── templates.js ├── test ├── applications.test.js ├── sample_pdf.pdf └── templates.test.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | uploads/* 3 | credentials.js 4 | secrets.js 5 | .env 6 | .nyc_output 7 | build -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - build 3 | - test 4 | - release 5 | - deploy 6 | 7 | image: moreillon/tdd-dind 8 | services: 9 | - name: docker:24.0.7-dind 10 | 11 | variables: 12 | APPLICATION_NAME: shinsei-manager 13 | # Docker 14 | CONTAINER_IMAGE: ${AWS_ECR_PUBLIC_URL}/${APPLICATION_NAME} 15 | CONTAINER_IMAGE_TEST: ${CONTAINER_IMAGE}:test 16 | CONTAINER_IMAGE_LATEST: ${CONTAINER_IMAGE}:latest 17 | CONTAINER_IMAGE_TAGGED: ${CONTAINER_IMAGE}:${CI_COMMIT_TAG} 18 | # TDD 19 | TEST_NETWORK: tdd 20 | TEST_DB: tdd-db 21 | TEST_USER_MANAGER: tdd-user-manager 22 | 23 | build: 24 | stage: build 25 | tags: 26 | - dind 27 | only: 28 | - tags 29 | before_script: 30 | - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${AWS_ECR_PUBLIC_URL} 31 | - > 32 | aws ecr-public create-repository --region us-east-1 --repository-name ${APPLICATION_NAME} 33 | || echo "Repository might have already existed" 34 | script: 35 | - docker build -t ${CONTAINER_IMAGE_TEST} . 36 | - docker push ${CONTAINER_IMAGE_TEST} 37 | 38 | test: 39 | stage: test 40 | only: 41 | - tags 42 | coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' 43 | tags: 44 | - dind 45 | before_script: 46 | # Registry login 47 | - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${AWS_ECR_PUBLIC_URL} 48 | # Creating network for TDD 49 | - docker network create ${TEST_NETWORK} 50 | - > 51 | docker run 52 | -d 53 | --rm 54 | --name ${TEST_DB} 55 | --network ${TEST_NETWORK} 56 | --hostname ${TEST_DB} 57 | --env NEO4J_AUTH=none 58 | neo4j:5.12.0 59 | - sleep 60 # Wait for Neo4J to become available 60 | # Deploy a user-manager / authentication system 61 | - > 62 | docker run 63 | -d 64 | --rm 65 | --name ${TEST_USER_MANAGER} 66 | --network ${TEST_NETWORK} 67 | --hostname ${TEST_USER_MANAGER} 68 | --env NEO4J_URL=bolt://${TEST_DB}:7687 69 | --env NEO4J_USERNAME=neo4j 70 | --env NEO4J_PASSWORD=neo4j 71 | --env JWT_SECRET=keyboardcat 72 | moreillon/user-manager:v4.7.4 73 | script: 74 | - > 75 | docker run 76 | --rm 77 | --name tdd-app 78 | --network ${TEST_NETWORK} 79 | --env NEO4J_URL=bolt://${TEST_DB}:7687 80 | --env IDENTIFICATION_URL=http://${TEST_USER_MANAGER}/users/self 81 | --env LOGIN_URL=http://${TEST_USER_MANAGER}/auth/login 82 | --env TEST_USER_USERNAME=admin 83 | --env TEST_USER_PASSWORD=admin 84 | --env S3_BUCKET=jtekt-moreillon 85 | --env S3_REGION=$S3_REGION 86 | --env S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY 87 | --env S3_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID 88 | ${CONTAINER_IMAGE_TEST} 89 | npm run coverage 90 | 91 | release: 92 | stage: release 93 | only: 94 | - tags 95 | tags: 96 | - dind 97 | before_script: 98 | - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${AWS_ECR_PUBLIC_URL} 99 | script: 100 | - docker pull ${CONTAINER_IMAGE_TEST} 101 | # Tagging 102 | - docker tag ${CONTAINER_IMAGE_TEST} ${CONTAINER_IMAGE_TAGGED} 103 | - docker tag ${CONTAINER_IMAGE_TEST} ${CONTAINER_IMAGE_LATEST} 104 | # Pushing 105 | - docker push ${CONTAINER_IMAGE_TAGGED} 106 | - docker push ${CONTAINER_IMAGE_LATEST} 107 | 108 | deploy: 109 | stage: deploy 110 | only: 111 | - tags 112 | script: 113 | - envsubst < kubernetes_manifest.yml | kubectl apply -f - 114 | environment: 115 | name: production 116 | kubernetes: 117 | namespace: ${KUBERNETES_NAMESPACE} 118 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | WORKDIR /usr/src/app 3 | COPY . . 4 | RUN npm install 5 | EXPOSE 80 6 | CMD [ "node", "index.js" ] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 JTEKT Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 申請マネージャ 2 | 3 | In Japan, the approval of application forms and other documents is generally achieved by printing those out and stamping them with one's personal seal, called ハンコ (Hanko). This practice has been a a significant obstacle to the adoption of remote work, as many employees still need to commute to work in order to get paper documents stamped by their superiors. This repository contains the source-code of 申請マネージャ (Shinsei-manager), a web-based approval system that aims at solving this problem. 4 | 5 | 申請マネージャ is a Node.js application that allows the approval of virtually any kind of application forms or documents. It manages those, alongside their approval or rejections, as nodes and relationships in a Neo4J database. 6 | 7 | The current repository contains the source-code of the back-end application of 申請マネージャ. For its GUI, please see the dedicated repository. 8 | 9 | ## API 10 | 11 | ### Application forms 12 | 13 | | Endpoint | Method | body/query | Description | 14 | | ---------------------------------------------- | ------ | ---------------------------------------------------------- | --------------------------------------------- | 15 | | /applications | POST | type, title, private, form_data, recipients_ids, group_ids | Create an application form | 16 | | /applications | GET | filters, query parameters, etc. | Query application forms | 17 | | /applications/{application_id} | GET | - | Query a single application forms using its ID | 18 | | /applications/{application_id} | DELETE | - | Delete an application forms | 19 | | /applications/{application_id}/approve | POST | - | Approve an application form | 20 | | /applications/{application_id}/reject | POST | - | Reject an application form | 21 | | /applications/{application_id}/files/{file_id} | GET | - | Query an attachment of an application | 22 | 23 | ### Application form templates 24 | 25 | | Endpoint | Method | body/query | Description | 26 | | ------------------------ | ------ | ------------------------------------- | ---------------------------------------------- | 27 | | /templates/ | POST | template_properties | Create a form template | 28 | | /templates/ | GET | - | Get templates visible to the current user | 29 | | /templates/{template_id} | GET | - | gets an application form template using its ID | 30 | | /templates/{template_id} | POST | fields, label, description, group_ids | Creates an application form template | 31 | | /templates/{template_id} | PUT | fields, label, description, group_ids | Updates an application form template | 32 | | /templates/{template_id} | DELETE | - | Deletes an application form template | 33 | 34 | ### Attachments 35 | 36 | | Endpoint | Method | body/query | Description | 37 | | -------- | ------ | ------------------------------------------------- | --------------------- | 38 | | /files | POST | multipart/form-data with file as 'file_to_upload' | Creates an attachment | 39 | 40 | ## Environment variables 41 | 42 | | variable | Description | 43 | | -------------------- | ------------------------------------------------------------------------------------------------ | 44 | | APP_PORT | App on which the application listens for HTTP requests | 45 | | NEO4J_URL | URL of the Neo4J instance | 46 | | NEO4J_USERNAME | Username for the Neo4J instance | 47 | | NEO4J_PASSWORD | Password for the Neo4J instance | 48 | | IDENTIFICATION_URL | URL of the authentication endpoint | 49 | | S3_BUCKET | S3 Bucket to upload images. If set, images are uploaded to S3, otherwise they are stored locally | 50 | | S3_ACCESS_KEY_ID | S3 access key ID | 51 | | S3_SECRET_ACCESS_KEY | S3 secret access key | 52 | | S3_REGION | S3 region | 53 | | S3_ENDPOINT | S3 Endpoint | 54 | -------------------------------------------------------------------------------- /attachmentsStorage/local.js: -------------------------------------------------------------------------------- 1 | const mv = require("mv") 2 | const fs = require("fs") 3 | const path = require("path") 4 | const { v4: uuidv4 } = require("uuid") 5 | 6 | const { UPLOADS_PATH = "/usr/share/pv" } = process.env 7 | 8 | exports.UPLOADS_PATH = UPLOADS_PATH 9 | 10 | exports.store_file_locally = (file_to_upload) => 11 | new Promise((resolve, reject) => { 12 | // Store file in the uploads directory 13 | 14 | const { path: old_path, name: file_name } = file_to_upload 15 | 16 | const file_id = uuidv4() 17 | const new_directory_path = path.join(UPLOADS_PATH, file_id) 18 | const new_file_path = path.join(new_directory_path, file_name) 19 | 20 | mv(old_path, new_file_path, { mkdirp: true }, (err) => { 21 | if (err) reject(err) 22 | resolve(file_id) 23 | }) 24 | }) 25 | 26 | exports.download_file_from_local_folder = async (res, file_id) => { 27 | const directory_path = path.join(UPLOADS_PATH, file_id) 28 | const files = fs.readdirSync(directory_path) 29 | 30 | const file_to_download = files[0] 31 | if (!file_to_download) throw createHttpError(500, `Could not open file`) 32 | 33 | // NOTE: Not using sendfile because specifying file name 34 | res.download(path.join(directory_path, file_to_download), file_to_download) 35 | } 36 | -------------------------------------------------------------------------------- /attachmentsStorage/s3.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { addProxyToClient } = require("aws-sdk-v3-proxy") 3 | const { v4: uuidv4 } = require("uuid") 4 | const fs = require("fs") 5 | const { 6 | S3Client, 7 | PutObjectCommand, 8 | ListObjectsCommand, 9 | GetObjectCommand, 10 | } = require("@aws-sdk/client-s3") 11 | 12 | const { 13 | S3_REGION, 14 | S3_ACCESS_KEY_ID = "", 15 | S3_SECRET_ACCESS_KEY = "", 16 | S3_ENDPOINT, 17 | S3_BUCKET, 18 | HTTPS_PROXY, 19 | } = process.env 20 | 21 | const s3ClientOptions = { 22 | region: S3_REGION, 23 | credentials: { 24 | accessKeyId: S3_ACCESS_KEY_ID, 25 | secretAccessKey: S3_SECRET_ACCESS_KEY, 26 | }, 27 | endpoint: S3_ENDPOINT, 28 | } 29 | 30 | let s3Client 31 | 32 | if (S3_BUCKET) { 33 | console.log( 34 | `[S3] S3_BUCKET is set, uploading attachment to S3 bucket "${S3_BUCKET}" in region ${S3_REGION}` 35 | ) 36 | s3Client = HTTPS_PROXY 37 | ? addProxyToClient(new S3Client(s3ClientOptions)) 38 | : new S3Client(s3ClientOptions) 39 | } else { 40 | console.log(`[S3] S3_BUCKET not set, storing attachments locally`) 41 | } 42 | 43 | const store_file_on_s3 = async (file_to_upload) => { 44 | const file_id = uuidv4() 45 | const Key = `${file_id}/${file_to_upload.name}` 46 | const command = new PutObjectCommand({ 47 | Bucket: S3_BUCKET, 48 | Body: fs.readFileSync(file_to_upload.path), 49 | Key, 50 | }) 51 | await s3Client.send(command) 52 | return file_id 53 | } 54 | 55 | const download_file_from_s3 = async (res, file_id) => { 56 | const listObjectsresult = await s3Client.send( 57 | new ListObjectsCommand({ 58 | Bucket: S3_BUCKET, 59 | Prefix: file_id, 60 | }) 61 | ) 62 | 63 | if (!listObjectsresult.Contents || !listObjectsresult.Contents.length) 64 | throw `File ${file_id} does not exist` 65 | 66 | const { Key } = listObjectsresult.Contents[0] 67 | const getObjectResult = await s3Client.send( 68 | new GetObjectCommand({ 69 | Bucket: S3_BUCKET, 70 | Key, 71 | }) 72 | ) 73 | 74 | const { base: filename } = path.parse(Key) 75 | 76 | getObjectResult.Body.transformToWebStream().pipeTo( 77 | new WritableStream({ 78 | start() { 79 | // TODO: add size 80 | res.setHeader( 81 | "Content-Disposition", 82 | `attachment; filename=${encodeURIComponent(filename)}` 83 | ) 84 | }, 85 | write(chunk) { 86 | res.write(chunk) 87 | }, 88 | close() { 89 | res.end() 90 | }, 91 | }) 92 | ) 93 | } 94 | 95 | exports.S3_BUCKET = S3_BUCKET 96 | exports.S3_REGION = S3_REGION 97 | exports.download_file_from_s3 = download_file_from_s3 98 | exports.store_file_on_s3 = store_file_on_s3 99 | exports.s3Client = s3Client 100 | -------------------------------------------------------------------------------- /controllers/application_privacy.js: -------------------------------------------------------------------------------- 1 | const createHttpError = require("http-errors") 2 | const { driver } = require("../db.js") 3 | const { get_current_user_id } = require("../utils.js") 4 | 5 | exports.update_application_privacy = async (req, res, next) => { 6 | // Make an application private or public 7 | 8 | const session = driver.session() 9 | 10 | try { 11 | if (!("private" in req.body)) 12 | throw createHttpError(400, "Private not defined") 13 | 14 | const user_id = get_current_user_id(res) 15 | const { application_id } = req.params 16 | const { private } = req.body 17 | 18 | const cypher = ` 19 | // Find the application 20 | MATCH (application:ApplicationForm)-[:SUBMITTED_BY]->(applicant:User) 21 | WHERE application._id = $application_id 22 | AND applicant._id = $user_id 23 | 24 | // Set the privacy property 25 | SET application.private = $private 26 | 27 | // Return the application 28 | RETURN PROPERTIES(application) as application 29 | ` 30 | 31 | const params = { user_id, private, application_id } 32 | 33 | const { records } = await session.run(cypher, params) 34 | 35 | if (!records.length) 36 | throw createHttpError(404, `Application ${application_id} not found`) 37 | 38 | console.log(`Application ${application_id} privacy updated`) 39 | 40 | const application = records[0].get("application") 41 | res.send(application) 42 | } catch (error) { 43 | next(error) 44 | } finally { 45 | session.close() 46 | } 47 | } 48 | 49 | exports.make_application_visible_to_group = async (req, res, next) => { 50 | // Make a private application visible to a certain group 51 | 52 | const session = driver.session() 53 | 54 | try { 55 | const user_id = get_current_user_id(res) 56 | const { application_id } = req.params 57 | const { group_id } = req.body 58 | 59 | if (!group_id) throw createHttpError(400, "Group ID not defined") 60 | 61 | const cypher = ` 62 | // Find the application 63 | // Only the applicant can make the update 64 | MATCH (application:ApplicationForm)-[:SUBMITTED_BY]->(applicant:User) 65 | WHERE application._id = $application_id 66 | AND applicant._id = $user_id 67 | 68 | // Find the group 69 | WITH application 70 | MATCH (group:Group {_id: $group_id}) 71 | 72 | // Create the application 73 | MERGE (application)-[:VISIBLE_TO]->(group) 74 | 75 | // Return the application 76 | RETURN PROPERTIES(application) as application, 77 | PROPERTIES(group) as group 78 | ` 79 | 80 | const params = { 81 | user_id, 82 | group_id, 83 | application_id, 84 | } 85 | 86 | const { records } = await session.run(cypher, params) 87 | 88 | if (!records.length) 89 | throw createHttpError(404, `Application ${application_id} not found`) 90 | 91 | console.log( 92 | `Application ${application_id} made visible to group ${group_id}` 93 | ) 94 | 95 | const application = records[0].get("application") 96 | res.send(application) 97 | } catch (error) { 98 | next(error) 99 | } finally { 100 | session.close() 101 | } 102 | } 103 | 104 | exports.remove_application_visibility_to_group = async (req, res, next) => { 105 | // Remove visibility of a private application to a certain group 106 | 107 | const session = driver.session() 108 | 109 | try { 110 | const user_id = get_current_user_id(res) 111 | const { application_id, group_id } = req.params 112 | 113 | const cypher = ` 114 | // Find the application 115 | // Only the applicant can make the update 116 | MATCH (application:ApplicationForm)-[:SUBMITTED_BY]->(applicant:User) 117 | WHERE application._id = $application_id 118 | AND applicant._id = $user_id 119 | 120 | // Find the group 121 | WITH application 122 | MATCH (application)-[rel:VISIBLE_TO]->(group) 123 | WHERE group._id = $group_id 124 | 125 | // delete the relationship 126 | DELETE rel 127 | 128 | // Return the application 129 | RETURN PROPERTIES(application) as application 130 | ` 131 | 132 | const params = { 133 | user_id, 134 | group_id, 135 | application_id, 136 | } 137 | 138 | const { records } = await session.run(cypher, params) 139 | 140 | if (!records.length) 141 | throw createHttpError(404, `Application ${application_id} not found`) 142 | 143 | console.log( 144 | `Removed visibility of application ${application_id} to group ${group_id}` 145 | ) 146 | 147 | const application = records[0].get("application") 148 | res.send(application) 149 | } catch (error) { 150 | next(error) 151 | } finally { 152 | session.close() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /controllers/applications.js: -------------------------------------------------------------------------------- 1 | const createHttpError = require("http-errors") 2 | const { driver } = require("../db.js") 3 | const { 4 | get_current_user_id, 5 | application_batching, 6 | return_application_and_related_nodes_v2, 7 | format_application_from_record_v2, 8 | filter_by_type, 9 | query_with_hanko_id, 10 | query_with_date, 11 | query_with_group, 12 | query_deleted, 13 | query_with_relationship_and_state, 14 | } = require("../utils.js") 15 | 16 | exports.create_application = async (req, res, next) => { 17 | // Create an application form 18 | 19 | const session = driver.session() 20 | 21 | try { 22 | const { 23 | type, 24 | title, 25 | form_data, 26 | recipients_ids = [], 27 | private = false, 28 | group_ids = [], 29 | } = req.body 30 | 31 | const user_id = get_current_user_id(res) 32 | 33 | if (!recipients_ids.length) 34 | throw createHttpError(400, `Application requires one or more recipient`) 35 | 36 | const cypher = ` 37 | // Create the application node 38 | MATCH (user:User {_id: $user_id}) 39 | CREATE (application:ApplicationForm)-[:SUBMITTED_BY {date: date()} ]->(user) 40 | 41 | // Set the application properties using data passed in the request body 42 | SET application = $application_properties 43 | SET application._id = randomUUID() 44 | SET application.creation_date = date() 45 | 46 | // Relationship with recipients 47 | // This also creates flow indices 48 | // Note: flow cannot be empty 49 | // WARNING: submission does not have an ID. probably not an issue because submissions never accessed directly 50 | WITH application, $recipients_ids as recipients_ids 51 | UNWIND range(0, size(recipients_ids)-1) as i 52 | MATCH (recipient:User {_id: recipients_ids[i]}) 53 | CREATE (recipient)<-[submission:SUBMITTED_TO {date: date(), flow_index: i} ]-(application) 54 | 55 | // Groups to which the aplication is visible 56 | // Note: can be an empty set so the logic to deal with it looks terrible 57 | WITH application 58 | UNWIND 59 | CASE 60 | WHEN $group_ids = [] 61 | THEN [null] 62 | ELSE $group_ids 63 | END AS group_id 64 | 65 | OPTIONAL MATCH (group:Group {_id: group_id}) 66 | WITH collect(group) as groups, application 67 | FOREACH(group IN groups | MERGE (application)-[:VISIBLE_TO]->(group)) 68 | 69 | // Finally, Return the created application 70 | RETURN properties(application) as application 71 | ` 72 | 73 | const params = { 74 | user_id, 75 | application_properties: { 76 | form_data: JSON.stringify(form_data), // Neo4J does not support nested props so convert to string 77 | type, 78 | title, 79 | private, 80 | }, 81 | group_ids, 82 | recipients_ids, 83 | } 84 | 85 | const { records } = await session.run(cypher, params) 86 | 87 | if (!records.length) 88 | throw createHttpError(500, `Failed to create the application`) 89 | const application = records[0].get("application") 90 | 91 | res.send(application) 92 | } catch (error) { 93 | next(error) 94 | } finally { 95 | session.close() 96 | } 97 | } 98 | 99 | exports.read_applications = async (req, res, next) => { 100 | // query a list of applications 101 | 102 | const session = driver.session() 103 | 104 | try { 105 | const current_user_id = get_current_user_id(res) 106 | 107 | const { 108 | user_id = current_user_id, // by default, focuses on current user 109 | group_id, 110 | relationship, 111 | state, 112 | type, 113 | start_date, 114 | end_date, 115 | hanko_id, 116 | start_index = 0, 117 | batch_size = 10, 118 | deleted = false, 119 | } = req.query 120 | 121 | const cypher = ` 122 | MATCH (user:User {_id: $user_id}) 123 | WITH user 124 | MATCH (application:ApplicationForm) 125 | ${query_with_relationship_and_state(relationship, state)} 126 | 127 | // from here on, no need for user anymore 128 | // gets requeried later on 129 | ${query_deleted(deleted)} 130 | ${filter_by_type(type)} 131 | ${query_with_date(start_date, end_date)} 132 | ${query_with_group(group_id)} 133 | ${query_with_hanko_id(hanko_id)} 134 | 135 | // batching 136 | ${application_batching} 137 | ${return_application_and_related_nodes_v2} 138 | ` 139 | 140 | const params = { 141 | user_id, 142 | relationship, 143 | type, 144 | start_date, 145 | end_date, 146 | start_index, 147 | batch_size, 148 | hanko_id, 149 | group_id, 150 | } 151 | 152 | const { records } = await session.run(cypher, params) 153 | 154 | const count = records.length ? records[0].get("application_count") : 0 155 | 156 | const applications = records.map((record) => 157 | format_application_from_record_v2(record) 158 | ) 159 | 160 | res.send({ 161 | count, 162 | applications, 163 | start_index, 164 | batch_size, 165 | }) 166 | } catch (error) { 167 | next(error) 168 | } finally { 169 | session.close() 170 | } 171 | } 172 | 173 | exports.read_application = async (req, res, next) => { 174 | // query a single of applications 175 | 176 | const session = driver.session() 177 | 178 | try { 179 | const user_id = get_current_user_id(res) 180 | const { application_id } = req.params 181 | 182 | if (!user_id) throw createHttpError(400, "User ID not defined") 183 | if (!application_id) 184 | throw createHttpError(400, "Application ID not defined") 185 | 186 | const cypher = ` 187 | // Find application 188 | MATCH (application:ApplicationForm {_id: $application_id}) 189 | WHERE application.deleted IS NULL OR NOT application.deleted 190 | 191 | // Dummy application_count because following query uses it 192 | WITH application, 1 as application_count 193 | ${return_application_and_related_nodes_v2} 194 | ` 195 | 196 | const params = { user_id, application_id } 197 | 198 | const { records } = await session.run(cypher, params) 199 | 200 | const record = records[0] 201 | 202 | if (!record) 203 | throw createHttpError(404, `Application ${application_id} not found`) 204 | 205 | const application = format_application_from_record_v2(record) 206 | 207 | res.send(application) 208 | } catch (error) { 209 | next(error) 210 | } finally { 211 | session.close() 212 | } 213 | } 214 | 215 | exports.get_application_types = async (req, res, next) => { 216 | // Used for search 217 | const session = driver.session() 218 | 219 | try { 220 | const cypher = ` 221 | MATCH (application:ApplicationForm) 222 | RETURN DISTINCT(application.type) as application_type 223 | ` 224 | 225 | const { records } = await session.run(cypher, {}) 226 | const types = records.map((record) => record.get("application_type")) 227 | res.send(types) 228 | } catch (error) { 229 | next(error) 230 | } finally { 231 | session.close() 232 | } 233 | } 234 | 235 | exports.delete_application = async (req, res, next) => { 236 | // Delete a single of applications 237 | // Note: only marks applications as deleted and not actually delete nodes 238 | 239 | const session = driver.session() 240 | 241 | try { 242 | const user_id = get_current_user_id(res) 243 | const { application_id } = req.params 244 | 245 | if (!user_id) throw createHttpError(400, "User ID not defined") 246 | if (!application_id) 247 | throw createHttpError(400, "Application ID not defined") 248 | 249 | const cypher = ` 250 | MATCH (applicant:User)<-[:SUBMITTED_BY]-(application:ApplicationForm ) 251 | WHERE applicant._id = $user_id 252 | AND application._id = $application_id 253 | 254 | WITH application, properties(application) as applicationProperties 255 | DETACH DELETE application 256 | 257 | RETURN applicationProperties 258 | ` 259 | 260 | const params = { user_id, application_id } 261 | 262 | const { records } = await session.run(cypher, params) 263 | if (!records.length) 264 | throw createHttpError(404, `Application ${application_id} not found`) 265 | 266 | const application = records[0].get("applicationProperties") 267 | 268 | res.send(application) 269 | } catch (error) { 270 | next(error) 271 | } finally { 272 | session.close() 273 | } 274 | } 275 | 276 | exports.approve_application = async (req, res, next) => { 277 | const session = driver.session() 278 | 279 | try { 280 | const user_id = get_current_user_id(res) 281 | const { application_id } = req.params 282 | 283 | const { attachment_hankos, comment = "" } = req.body 284 | 285 | if (!user_id) throw createHttpError(400, "User ID not defined") 286 | if (!application_id) 287 | throw createHttpError(400, "Application ID not defined") 288 | 289 | const attachment_hankos_query = attachment_hankos 290 | ? `SET approval.attachment_hankos = $attachment_hankos` 291 | : "" 292 | 293 | const cypher = ` 294 | // Find the application and get oneself at the same time 295 | MATCH (application:ApplicationForm)-[submission:SUBMITTED_TO]->(recipient:User) 296 | WHERE application._id = $application_id 297 | AND recipient._id = $user_id 298 | 299 | // TODO: Add check if flow is respected 300 | 301 | // Mark as approved 302 | WITH application, recipient 303 | MERGE (application)<-[approval:APPROVED]-(recipient) 304 | ON CREATE SET approval.date = date() 305 | ON CREATE SET approval._id = randomUUID() 306 | SET approval.comment = $comment 307 | ${attachment_hankos_query} 308 | 309 | RETURN PROPERTIES(approval) as approval, 310 | PROPERTIES(recipient) as recipient, 311 | PROPERTIES(application) as application 312 | ` 313 | 314 | const params = { 315 | user_id, 316 | application_id, 317 | comment, 318 | attachment_hankos: JSON.stringify(attachment_hankos), // Neo4J does not support nested props so convert to string 319 | } 320 | 321 | const { records } = await session.run(cypher, params) 322 | if (!records.length) 323 | throw createHttpError(404, `Application ${application_id} not found`) 324 | 325 | const application = records[0].get("application") 326 | const { _id: recipient_id } = records[0].get("recipient") 327 | 328 | 329 | res.send(application) 330 | } catch (error) { 331 | next(error) 332 | } finally { 333 | session.close() 334 | } 335 | } 336 | 337 | exports.reject_application = async (req, res, next) => { 338 | const session = driver.session() 339 | 340 | try { 341 | const user_id = get_current_user_id(res) 342 | const { application_id } = req.params 343 | 344 | const { comment = "" } = req.body 345 | 346 | if (!user_id) throw createHttpError(400, "User ID not defined") 347 | if (!application_id) 348 | throw createHttpError(400, "Application ID not defined") 349 | 350 | const cypher = ` 351 | MATCH (application:ApplicationForm)-[submission:SUBMITTED_TO]->(recipient:User) 352 | WHERE application._id = $application_id 353 | AND recipient._id = $user_id 354 | 355 | // TODO: Add check if flow is respected 356 | 357 | // Mark as REJECTED 358 | WITH application, recipient 359 | MERGE (application)<-[rejection:REJECTED]-(recipient) 360 | ON CREATE SET rejection._id = randomUUID() 361 | ON CREATE SET rejection.date = date() 362 | SET rejection.comment = $comment 363 | 364 | RETURN PROPERTIES(rejection) as rejection, 365 | PROPERTIES(recipient) as recipient, 366 | PROPERTIES(application) as application 367 | ` 368 | 369 | const params = { 370 | user_id, 371 | application_id, 372 | comment, 373 | } 374 | 375 | const { records } = await session.run(cypher, params) 376 | if (!records.length) 377 | throw createHttpError(404, `Application ${application_id} not found`) 378 | 379 | const application = records[0].get("application") 380 | const { _id: recipient_id } = records[0].get("recipient") 381 | 382 | 383 | res.send(application) 384 | } catch (error) { 385 | next(error) 386 | } finally { 387 | session.close() 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /controllers/files.js: -------------------------------------------------------------------------------- 1 | const createHttpError = require("http-errors") 2 | 3 | const formidable = require("formidable") 4 | const { driver } = require("../db") 5 | const { get_current_user_id } = require("../utils") 6 | const { 7 | s3Client, 8 | store_file_on_s3, 9 | download_file_from_s3, 10 | } = require("../attachmentsStorage/s3") 11 | const { 12 | store_file_locally, 13 | download_file_from_local_folder, 14 | } = require("../attachmentsStorage/local") 15 | 16 | const parse_form = (req) => 17 | new Promise((resolve, reject) => { 18 | const form = new formidable.IncomingForm() 19 | form.parse(req, (err, fields, files) => { 20 | if (err) reject(err) 21 | resolve(files) 22 | }) 23 | }) 24 | 25 | exports.file_upload = async (req, res, next) => { 26 | // Upload an attachment 27 | 28 | try { 29 | const { file_to_upload } = await parse_form(req) 30 | if (!file_to_upload) throw createHttpError(400, "Missing file") 31 | 32 | let file_id 33 | if (s3Client) file_id = await store_file_on_s3(file_to_upload) 34 | else file_id = await store_file_locally(file_to_upload) 35 | 36 | res.send({ file_id }) 37 | } catch (error) { 38 | next(error) 39 | } 40 | } 41 | 42 | exports.get_file = async (req, res, next) => { 43 | const session = driver.session() 44 | 45 | try { 46 | const { file_id } = req.params 47 | const user_id = get_current_user_id(res) 48 | const { application_id } = req.params 49 | 50 | if (!file_id) throw createHttpError(400, "File ID not specified") 51 | if (!application_id) 52 | throw createHttpError(400, "Application ID not specified") 53 | 54 | const query = ` 55 | // Find current user to check for authorization 56 | MATCH (user:User {_id: $user_id}) 57 | 58 | // Find application and applicant 59 | WITH user 60 | MATCH (application:ApplicationForm {_id: $application_id}) 61 | 62 | // Enforce privacy 63 | WITH user, application 64 | WHERE application.private IS NULL 65 | OR NOT application.private 66 | OR (application)-[:SUBMITTED_BY]->(user) 67 | OR (application)-[:SUBMITTED_TO]->(user) 68 | OR (application)-[:VISIBLE_TO]->(:Group)<-[:BELONGS_TO]-(user) 69 | 70 | return application 71 | ` 72 | 73 | const params = { user_id, file_id, application_id } 74 | 75 | const { records } = await session.run(query, params) 76 | 77 | // Check if the application exists (i.e. can be seen by the user) 78 | if (!records.length) 79 | throw createHttpError( 80 | 400, 81 | `Application ${application_id} could not be queried` 82 | ) 83 | 84 | // Check if the application has a file with the given ID 85 | const application_node = records[0].get("application") 86 | const form_data = JSON.parse(application_node.properties.form_data) 87 | const found_file = form_data.find(({ value }) => value === file_id) 88 | if (!found_file) 89 | throw createHttpError( 90 | 400, 91 | `Application ${application_id} does not include the file ${file_id}` 92 | ) 93 | 94 | // Now download the file 95 | if (s3Client) await download_file_from_s3(res, file_id) 96 | else await download_file_from_local_folder(res, file_id) 97 | } catch (error) { 98 | next(error) 99 | } finally { 100 | session.close() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /controllers/hankos.js: -------------------------------------------------------------------------------- 1 | const { driver } = require("../db.js") 2 | const { get_current_user_id } = require("../utils.js") 3 | const createHttpError = require("http-errors") 4 | 5 | exports.update_hankos = async (req, res, next) => { 6 | const session = driver.session() 7 | 8 | try { 9 | const user_id = get_current_user_id(res) 10 | const { application_id } = req.params 11 | const { attachment_hankos } = req.body 12 | 13 | if (!attachment_hankos) 14 | throw createHttpError(400, "attachment_hankos not defined") 15 | 16 | const cypher = ` 17 | MATCH (user:User)-[approval:APPROVED]->(application:ApplicationForm) 18 | WHERE user._id = $user_id AND application._id = $application_id 19 | 20 | SET approval.attachment_hankos = $attachment_hankos 21 | 22 | RETURN PROPERTIES(approval) as approval 23 | ` 24 | 25 | const params = { 26 | user_id, 27 | application_id, 28 | attachment_hankos: JSON.stringify(attachment_hankos), 29 | } 30 | 31 | const { records } = await session.run(cypher, params) 32 | if (!records.length) 33 | throw createHttpError(404, `Application ${application_id} not found`) 34 | 35 | const approval = records[0].get("approval") 36 | console.log(`Hankos of approval ${approval._id} updated by user ${user_id}`) 37 | res.send(approval) 38 | } catch (error) { 39 | next(error) 40 | } finally { 41 | session.close() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /controllers/notifications.js: -------------------------------------------------------------------------------- 1 | const { driver } = require("../db.js") 2 | const { get_current_user_id } = require("../utils.js") 3 | const createHttpError = require("http-errors") 4 | 5 | exports.mark_recipient_as_notified = async (req, res, next) => { 6 | const session = driver.session() 7 | 8 | try { 9 | const { recipient_id, application_id } = req.params 10 | 11 | const current_user_id = get_current_user_id(res) 12 | 13 | // TODO: consider saving notifications for applicant too 14 | // WARNING: applicant can be recipient at the same time 15 | const cypher = ` 16 | // Find current user for access control 17 | MATCH (currentUser:User {_id: $current_user_id}) 18 | WITH currentUser 19 | 20 | MATCH (recipient:User {_id: $recipient_id} )<-[submission:SUBMITTED_TO]-(application:ApplicationForm {_id: $application_id}) 21 | 22 | // Only allow recipient or applicant to perform operation 23 | WHERE (currentUser)<-[:SUBMITTED_TO]-(application:ApplicationForm) 24 | OR (currentUser)<-[:SUBMITTED_BY]-(application:ApplicationForm) 25 | 26 | SET submission.notified = true 27 | 28 | RETURN PROPERTIES(submission) as submission 29 | ` 30 | 31 | const params = { 32 | current_user_id, 33 | recipient_id, 34 | application_id, 35 | } 36 | 37 | const { records } = await session.run(cypher, params) 38 | if (!records.length) 39 | throw createHttpError(404, `Application ${application_id} not found`) 40 | 41 | const submission = records[0].get("submission") 42 | console.log( 43 | `User ${recipient_id} notified of application ${application_id}` 44 | ) 45 | res.send(submission) 46 | } catch (error) { 47 | next(error) 48 | } finally { 49 | session.close() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /controllers/recipient_comment.js: -------------------------------------------------------------------------------- 1 | const createHttpError = require("http-errors") 2 | const { get_current_user_id } = require("../utils.js") 3 | const { driver } = require("../db.js") 4 | 5 | exports.update_comment = async (req, res, next) => { 6 | const session = driver.session() 7 | 8 | try { 9 | const { application_id } = req.params 10 | const { comment } = req.body 11 | const user_id = get_current_user_id(res) 12 | 13 | if (!application_id) throw createHttpError(400, `Missing application_id`) 14 | if (!comment) throw createHttpError(400, `Missing comment`) 15 | 16 | const cypher = ` 17 | // Find current user to check for authorization 18 | // WARNING: decision could be SUBMITTED_BY or SUBMITTED_TO couldn't it? 19 | MATCH (user:User)-[decision]->(application:ApplicationForm) 20 | WHERE user._id = $user_id 21 | AND application._id = $application_id 22 | 23 | // Set the attached hankos 24 | SET decision.comment = $comment 25 | 26 | // Return 27 | RETURN decision.comment as comment 28 | ` 29 | 30 | const params = { 31 | user_id, 32 | application_id, 33 | comment, 34 | } 35 | 36 | const { records } = await session.run(cypher, params) 37 | if (!records.length) 38 | throw createHttpError( 39 | 404, 40 | `Application ${application_id} has no comment candidate for user ${user_id}` 41 | ) 42 | console.log( 43 | `Comment on application ${application_id} by user ${user_id} updated` 44 | ) 45 | res.send({ comment: records[0].get("comment") }) 46 | } catch (error) { 47 | next(error) 48 | } finally { 49 | session.close() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /controllers/templates.js: -------------------------------------------------------------------------------- 1 | const createHttpError = require("http-errors") 2 | const { driver } = require("../db.js") 3 | const { get_current_user_id } = require("../utils.js") 4 | 5 | exports.create_template = async (req, res, next) => { 6 | const session = driver.session() 7 | 8 | try { 9 | const { 10 | label = `Unnnamed template`, 11 | description = "", 12 | fields = [], 13 | group_ids = [], 14 | } = req.body 15 | 16 | const user_id = get_current_user_id(res) 17 | 18 | const cypher = ` 19 | MATCH (creator:User {_id: $user_id}) 20 | CREATE (aft: ApplicationFormTemplate)-[:CREATED_BY]->(creator) 21 | CREATE (aft)-[:MANAGED_BY]->(creator) 22 | 23 | SET aft = $template_properties 24 | SET aft._id = randomUUID() 25 | 26 | // visibility (shared with) 27 | WITH aft 28 | UNWIND 29 | CASE 30 | WHEN $group_ids = [] 31 | THEN [null] 32 | ELSE $group_ids 33 | END AS group_id 34 | 35 | OPTIONAL MATCH (group:Group {_id: group_id}) 36 | WITH collect(group) as groups, aft 37 | FOREACH(group IN groups | CREATE (aft)-[:VISIBLE_TO]->(group)) 38 | 39 | // RETURN 40 | RETURN properties(aft) as template` 41 | 42 | const params = { 43 | user_id, 44 | template_properties: { 45 | fields: JSON.stringify(fields), // Neo4J cannot store nested properties 46 | label, 47 | description, 48 | }, 49 | group_ids, 50 | } 51 | 52 | const { records } = await session.run(cypher, params) 53 | if (!records.length) 54 | throw createHttpError(500, `Failed to create the template`) 55 | const template = records[0].get("template") 56 | res.send(template) 57 | } catch (error) { 58 | next(error) 59 | } finally { 60 | session.close() 61 | } 62 | } 63 | 64 | exports.read_templates = async (req, res, next) => { 65 | // Read application form templates 66 | const session = driver.session() 67 | 68 | try { 69 | const user_id = get_current_user_id(res) 70 | 71 | const cypher = ` 72 | MATCH (current_user:User {_id: $user_id}) 73 | 74 | // Find the template and its creator (creator is not important) 75 | WITH current_user 76 | MATCH (aft:ApplicationFormTemplate)-[:CREATED_BY]->(creator:User) 77 | WHERE (aft)-[:VISIBLE_TO]->(:Group)<-[:BELONGS_TO]-(current_user) 78 | OR (aft)-[:MANAGED_BY]->(current_user) 79 | 80 | WITH aft, creator 81 | OPTIONAL MATCH (aft)-[:MANAGED_BY]->(manager:User) 82 | 83 | WITH aft, creator, manager 84 | OPTIONAL MATCH (aft)-[:VISIBLE_TO]->(group:Group) 85 | 86 | RETURN DISTINCT PROPERTIES(aft) as template, 87 | PROPERTIES(creator) as creator, 88 | COLLECT(DISTINCT PROPERTIES(manager)) as managers, 89 | COLLECT(DISTINCT PROPERTIES(group)) as groups` 90 | 91 | const { records } = await session.run(cypher, { user_id }) 92 | 93 | const templates = records.map((record) => { 94 | const template = record.get("template") 95 | template.fields = JSON.parse(template.fields) 96 | return { 97 | ...template, 98 | author: record.get("creator"), 99 | groups: record.get("groups"), 100 | managers: record.get("managers"), 101 | } 102 | }) 103 | 104 | res.send(templates) 105 | } catch (error) { 106 | next(error) 107 | } finally { 108 | session.close() 109 | } 110 | } 111 | 112 | exports.read_template = async (req, res, next) => { 113 | // Read single application form template 114 | const session = driver.session() 115 | 116 | try { 117 | const { template_id } = req.params 118 | const user_id = get_current_user_id(res) 119 | 120 | const cypher = ` 121 | MATCH (aft:ApplicationFormTemplate {_id: $template_id}) 122 | 123 | WITH aft 124 | OPTIONAL MATCH (aft)-[:CREATED_BY]->(creator:User) 125 | 126 | WITH aft, creator 127 | OPTIONAL MATCH (aft)-[:MANAGED_BY]->(manager:User) 128 | 129 | WITH aft, creator, manager 130 | OPTIONAL MATCH (aft)-[:VISIBLE_TO]->(group:Group) 131 | 132 | RETURN 133 | PROPERTIES(aft) as template, 134 | PROPERTIES(creator) as creator, 135 | COLLECT(DISTINCT PROPERTIES(manager)) as managers, 136 | COLLECT(DISTINCT PROPERTIES(group)) as groups 137 | ` 138 | 139 | const params = { user_id, template_id } 140 | 141 | const { records } = await session.run(cypher, params) 142 | if (!records.length) 143 | throw createHttpError(400, `Template ${template_id} not found`) 144 | 145 | const record = records[0] 146 | 147 | const template = record.get("template") 148 | template.fields = JSON.parse(template.fields) 149 | 150 | const response = { 151 | ...template, 152 | author: record.get("creator"), 153 | groups: record.get("groups"), 154 | managers: record.get("managers"), 155 | } 156 | 157 | res.send(response) 158 | } catch (error) { 159 | next(error) 160 | } finally { 161 | session.close() 162 | } 163 | } 164 | 165 | exports.update_template = async (req, res, next) => { 166 | const session = driver.session() 167 | 168 | try { 169 | const { template_id } = req.params 170 | const { label, description, group_ids, fields } = req.body 171 | 172 | const user_id = get_current_user_id(res) 173 | 174 | const groupUpdateQuery = ` 175 | // first delete everything 176 | OPTIONAL MATCH (aft)-[vis:VISIBLE_TO]->(:Group) 177 | DETACH DELETE vis 178 | 179 | // recreate visibility 180 | // Note: can be an empty set so the logic to deal with it looks terrible 181 | WITH aft 182 | UNWIND 183 | CASE 184 | WHEN $group_ids = [] 185 | THEN [null] 186 | ELSE $group_ids 187 | END AS group_id 188 | 189 | OPTIONAL MATCH (group:Group) 190 | WHERE group._id = group_id 191 | WITH collect(group) as groups, aft 192 | FOREACH(group IN groups | MERGE (aft)-[:VISIBLE_TO]->(group)) 193 | ` 194 | 195 | const cypher = ` 196 | MATCH (aft: ApplicationFormTemplate {_id: $template_id})-[:MANAGED_BY]->(creator:User {_id: $user_id}) 197 | 198 | // set properties 199 | // DIRTY 200 | ${label ? "SET aft.label=$label" : ""} 201 | ${description ? "SET aft.description=$description" : ""} 202 | ${fields ? "SET aft.fields=$fields" : ""} 203 | 204 | // update visibility (shared with) 205 | WITH aft 206 | ${group_ids ? groupUpdateQuery : ""} 207 | 208 | RETURN PROPERTIES(aft) AS template 209 | ` 210 | 211 | const params = { 212 | template_id, 213 | user_id, 214 | fields: JSON.stringify(fields), // cannot have nested props 215 | label, 216 | description, 217 | group_ids, 218 | } 219 | 220 | const { records } = await session.run(cypher, params) 221 | 222 | if (!records.length) 223 | throw createHttpError(500, `Failed to update template ${template_id}`) 224 | 225 | const template = records[0].get("template") 226 | res.send(template) 227 | } catch (error) { 228 | next(error) 229 | } finally { 230 | session.close() 231 | } 232 | } 233 | 234 | exports.delete_template = async (req, res, next) => { 235 | const session = driver.session() 236 | 237 | try { 238 | const { template_id } = req.params 239 | const user_id = get_current_user_id(res) 240 | 241 | const cypher = ` 242 | MATCH (aft: ApplicationFormTemplate {_id: $template_id})-[:MANAGED_BY]->(creator:User {_id: $user_id}) 243 | DETACH DELETE aft 244 | RETURN $template_id AS template_id 245 | ` 246 | 247 | const params = { template_id, user_id } 248 | 249 | const { records } = await session.run(cypher, params) 250 | 251 | if (!records.length) 252 | throw createHttpError(500, `Failed to delete template ${template_id}`) 253 | 254 | const deleted_template_id = records[0].get("template_id") 255 | res.send({ deleted_template_id }) 256 | } catch (error) { 257 | next(error) 258 | } finally { 259 | session.close() 260 | } 261 | } 262 | 263 | exports.add_template_manager = async (req, res, next) => { 264 | const session = driver.session() 265 | 266 | try { 267 | const { template_id } = req.params 268 | const { user_id } = req.body 269 | const current_user_id = get_current_user_id(res) 270 | 271 | const cypher = ` 272 | // Find application 273 | MATCH (aft: ApplicationFormTemplate {_id: $template_id})-[:MANAGED_BY]->(creator:User {_id: $current_user_id}) 274 | WITH aft 275 | MATCH (user:User {_id: $user_id}) 276 | MERGE (user)<-[:MANAGED_BY]-(aft) 277 | 278 | RETURN properties(aft) as template 279 | ` 280 | 281 | const params = { template_id, user_id, current_user_id } 282 | 283 | const { records } = await session.run(cypher, params) 284 | 285 | if (!records.length) 286 | throw createHttpError( 287 | 500, 288 | `Failed to update template ${template_id} managers` 289 | ) 290 | 291 | const template = records[0].get("template") 292 | console.log( 293 | `user ${user_id} made manager of template ${template_id} by user ${current_user_id} ` 294 | ) 295 | res.send({ template }) 296 | } catch (error) { 297 | next(error) 298 | } finally { 299 | session.close() 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | const neo4j = require("neo4j-driver") 2 | 3 | const { 4 | NEO4J_URL = "bolt://neo4j:7687", 5 | NEO4J_USERNAME, 6 | NEO4J_PASSWORD, 7 | } = process.env 8 | 9 | const auth = neo4j.auth.basic(NEO4J_USERNAME, NEO4J_PASSWORD) 10 | const options = { disableLosslessIntegers: true } 11 | const driver = neo4j.driver(NEO4J_URL, auth, options) 12 | 13 | let connected = false 14 | 15 | const get_connection_status = async () => { 16 | const session = driver.session() 17 | try { 18 | console.log(`[Neo4J] Testing connection...`) 19 | await session.run("RETURN 1") 20 | console.log(`[Neo4J] Connection successful`) 21 | return true 22 | } catch (e) { 23 | console.log(`[Neo4J] Connection failed`) 24 | return false 25 | } finally { 26 | session.close() 27 | } 28 | } 29 | 30 | const set_ids = async () => { 31 | // TODO: also deal with relationships? 32 | 33 | const id_setting_query = ` 34 | MATCH (n:ApplicationForm) 35 | WHERE n._id IS NULL 36 | SET n._id = toString(id(n)) 37 | RETURN COUNT(n) as count 38 | ` 39 | 40 | const session = driver.session() 41 | 42 | try { 43 | const { records } = await session.run(id_setting_query) 44 | const count = records[0].get("count") 45 | console.log(`[Neo4J] Formatted new ID for ${count} nodes`) 46 | } catch (e) { 47 | throw e 48 | } finally { 49 | session.close() 50 | } 51 | } 52 | 53 | const create_id_constraint = async () => { 54 | const session = driver.session() 55 | try { 56 | // Nodes 57 | for await (const label of ["ApplicationForm", "ApplicationFormTemplate"]) { 58 | console.log(`[Neo4J] Creating ${label} ID constraint...`) 59 | await session.run( 60 | `CREATE CONSTRAINT IF NOT EXISTS FOR (a:${label}) REQUIRE a._id IS UNIQUE` 61 | ) 62 | console.log(`[Neo4J] Created ${label} ID constraint`) 63 | } 64 | // Relationships 65 | for await (const relLabel of ["APPROVED", "REJECTED"]) { 66 | const session = driver.session() 67 | 68 | console.log(`[Neo4J] Creating ${relLabel} ID constraint...`) 69 | await session.run( 70 | `CREATE CONSTRAINT IF NOT EXISTS FOR ()<-[r:${relLabel}]-() REQUIRE r._id IS UNIQUE` 71 | ) 72 | console.log(`[Neo4J] Created ${relLabel} ID constraint`) 73 | } 74 | } catch (error) { 75 | throw error 76 | } finally { 77 | session.close() 78 | } 79 | } 80 | 81 | const init = async () => { 82 | if (await get_connection_status()) { 83 | connected = true 84 | 85 | console.log("[Neo4J] Initializing DB...") 86 | await set_ids() 87 | await create_id_constraint() 88 | console.log("[Neo4J] DB initialized") 89 | } else { 90 | setTimeout(init, 10000) 91 | } 92 | } 93 | 94 | exports.url = NEO4J_URL 95 | exports.driver = driver 96 | exports.get_connected = () => connected 97 | exports.init = init 98 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv") 2 | dotenv.config() 3 | 4 | const { version, author } = require("./package.json") 5 | console.log(`Shinsei manager v${version}`) 6 | 7 | const express = require("express") 8 | require("express-async-errors") 9 | const cors = require("cors") 10 | const promBundle = require("express-prom-bundle") 11 | const auth = require("@moreillon/express_identification_middleware") 12 | const router = require("./routes") 13 | const { loki_url } = require("./logger") 14 | const { 15 | url: neo4j_url, 16 | get_connected: get_neo4j_connection_status, 17 | init: db_init, 18 | } = require("./db") 19 | const { S3_BUCKET, S3_REGION, S3_ENDPOINT } = require("./attachmentsStorage/s3") 20 | const { UPLOADS_PATH } = require("./attachmentsStorage/local") 21 | 22 | db_init() 23 | 24 | const { APP_PORT = 80, IDENTIFICATION_URL, TZ } = process.env 25 | process.env.TZ = TZ || "Asia/Tokyo" 26 | const corsOptions = { 27 | exposedHeaders: "Content-Disposition", 28 | } 29 | const promOptions = { includeMethod: true, includePath: true } 30 | 31 | const app = express() 32 | 33 | app.use(express.json()) 34 | app.use(cors(corsOptions)) 35 | app.use(promBundle(promOptions)) 36 | 37 | app.get("/", (req, res) => { 38 | res.send({ 39 | application_name: "Shinsei-manager", 40 | author, 41 | version, 42 | neo4j: { 43 | url: neo4j_url, 44 | connected: get_neo4j_connection_status(), 45 | }, 46 | identification: IDENTIFICATION_URL, 47 | attachments: { 48 | uploads_path: !S3_BUCKET ? UPLOADS_PATH : undefined, 49 | s3: S3_BUCKET 50 | ? { 51 | bucket: S3_BUCKET, 52 | region: S3_REGION, 53 | endpoint: S3_ENDPOINT, 54 | } 55 | : undefined, 56 | }, 57 | 58 | loki_url, 59 | }) 60 | }) 61 | 62 | // Require authentication for all following routes 63 | app.use(auth({ url: IDENTIFICATION_URL })) 64 | 65 | app.use("/", router) 66 | app.use("/v1", router) // Temporary alias 67 | app.use("/v2", router) // Temporary alias 68 | 69 | // error handling 70 | app.use((err, req, res, next) => { 71 | console.error(err) 72 | res.status(err.statusCode || 500).send(err.message) 73 | }) 74 | 75 | app.listen(APP_PORT, () => 76 | console.log(`[Express] listening on port ${APP_PORT}`) 77 | ) 78 | 79 | exports.app = app // Exporting app for tests 80 | -------------------------------------------------------------------------------- /kubernetes_manifest.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: ${APPLICATION_NAME} 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: ${APPLICATION_NAME} 10 | template: 11 | metadata: 12 | labels: 13 | app: ${APPLICATION_NAME} 14 | spec: 15 | containers: 16 | - name: ${APPLICATION_NAME} 17 | image: ${CONTAINER_IMAGE_TAGGED} 18 | envFrom: 19 | - secretRef: 20 | name: environment-variables 21 | env: 22 | - name: IDENTIFICATION_URL 23 | value: http://employee-manager/v3/users/self 24 | - name: S3_BUCKET 25 | value: approval-workflow-attachments 26 | --- 27 | apiVersion: v1 28 | kind: Service 29 | metadata: 30 | name: ${APPLICATION_NAME} 31 | spec: 32 | type: NodePort 33 | selector: 34 | app: ${APPLICATION_NAME} 35 | ports: 36 | - port: 80 37 | nodePort: 30111 38 | -------------------------------------------------------------------------------- /logger.js: -------------------------------------------------------------------------------- 1 | const { createLogger, transports, format } = require("winston") 2 | const LokiTransport = require("winston-loki") 3 | 4 | const { LOKI_URL } = process.env 5 | 6 | const consoleTransport = new transports.Console({ 7 | format: format.combine(format.simple(), format.colorize()), 8 | }) 9 | 10 | // By default, only log to console 11 | const loggerOptions = { transports: [consoleTransport] } 12 | 13 | // If the Loki URL is provided, then also log to Loki 14 | if (LOKI_URL) { 15 | console.log(`[Logger] LOKI_URL provided: ${LOKI_URL}`) 16 | 17 | const lokiTransport = new LokiTransport({ 18 | host: LOKI_URL, 19 | labels: { app: "Shinsei manager" }, 20 | json: true, 21 | format: format.json(), 22 | replaceTimestamp: true, 23 | onConnectionError: (err) => console.error(err), 24 | }) 25 | 26 | loggerOptions.transports.push(lokiTransport) 27 | } 28 | 29 | const logger = createLogger(loggerOptions) 30 | 31 | exports.loki_url = LOKI_URL 32 | exports.logger = logger 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application_form_manager", 3 | "version": "2.10.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon", 8 | "test": "mocha --timeout 10000 --exit", 9 | "test-local": "S3_BUCKET= npm run test", 10 | "test-s3": "S3_BUCKET=jtekt-moreillon npm run test", 11 | "test-all": "npm run test-local && npm run test-s3", 12 | "coverage": "nyc npm run test-all" 13 | }, 14 | "keywords": [], 15 | "author": "Maxime Moreillon", 16 | "license": "MIT", 17 | "dependencies": { 18 | "@aws-sdk/client-s3": "^3.504.0", 19 | "@aws-sdk/node-http-handler": "^3.374.0", 20 | "@moreillon/express_identification_middleware": "^1.3.5", 21 | "aws-sdk-v3-proxy": "^2.1.2", 22 | "axios": "^1.5.0", 23 | "cookies": "^0.8.0", 24 | "cors": "^2.8.5", 25 | "dotenv": "^8.6.0", 26 | "express": "^4.17.3", 27 | "express-async-errors": "^3.1.1", 28 | "express-prom-bundle": "^6.6.0", 29 | "formidable": "^1.2.6", 30 | "http-errors": "^2.0.0", 31 | "mv": "^2.1.1", 32 | "neo4j-driver": "^5.12.0", 33 | "prom-client": "^15.0.0", 34 | "proxy-agent": "^6.3.1", 35 | "uuid": "^3.4.0", 36 | "winston": "^3.8.2", 37 | "winston-loki": "^6.0.6" 38 | }, 39 | "devDependencies": { 40 | "chai": "^4.3.6", 41 | "mocha": "^9.2.2", 42 | "nodemon": "^3.0.1", 43 | "nyc": "^15.1.0", 44 | "supertest": "^6.2.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /routes/application.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express") 2 | const { update_comment } = require("../controllers/recipient_comment") 3 | const { 4 | read_application, 5 | delete_application, 6 | approve_application, 7 | reject_application, 8 | } = require("../controllers/applications") 9 | 10 | const router = Router({ mergeParams: true }) 11 | 12 | router.route("/").get(read_application).delete(delete_application) 13 | 14 | router.route("/approve").post(approve_application) 15 | router.route("/reject").post(reject_application) 16 | 17 | // TODO: consider grouping with notifications 18 | router.route("/comment").put(update_comment) 19 | 20 | router.use("/privacy", require("./application_privacy")) 21 | router.use("/files", require("./files")) 22 | router.use("/hankos", require("./hankos")) 23 | router.use( 24 | "/recipients/:recipient_id/notifications", 25 | require("./notifications") 26 | ) 27 | 28 | module.exports = router 29 | -------------------------------------------------------------------------------- /routes/application_privacy.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express") 2 | const { 3 | update_application_privacy, 4 | make_application_visible_to_group, 5 | remove_application_visibility_to_group, 6 | } = require("../controllers/application_privacy.js") 7 | 8 | const router = Router({ mergeParams: true }) 9 | 10 | router.route("/").put(update_application_privacy) 11 | 12 | router.route("/groups").post(make_application_visible_to_group) 13 | 14 | router.route("/groups/:group_id").delete(remove_application_visibility_to_group) 15 | 16 | module.exports = router 17 | -------------------------------------------------------------------------------- /routes/applications.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express") 2 | const singleApplicationRouter = require("./application") 3 | const { 4 | create_application, 5 | read_applications, 6 | get_application_types, 7 | } = require("../controllers/applications.js") 8 | 9 | const router = Router({ mergeParams: true }) 10 | 11 | router.route("/").post(create_application).get(read_applications) 12 | 13 | router.route("/types").get(get_application_types) 14 | 15 | router.use("/:application_id", singleApplicationRouter) 16 | 17 | module.exports = router 18 | -------------------------------------------------------------------------------- /routes/files.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express") 2 | const { file_upload, get_file } = require("../controllers/files.js") 3 | 4 | const router = Router({ mergeParams: true }) 5 | 6 | router.route("/").post(file_upload) 7 | 8 | router.route("/:file_id").get(get_file) 9 | 10 | module.exports = router 11 | -------------------------------------------------------------------------------- /routes/hankos.js: -------------------------------------------------------------------------------- 1 | // /applications/:id/hankos 2 | const { Router } = require("express") 3 | const { update_hankos } = require("../controllers/hankos") 4 | 5 | const router = Router({ mergeParams: true }) 6 | 7 | router.route("/").put(update_hankos) 8 | 9 | module.exports = router 10 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express") 2 | const applicationsController = require("./applications.js") 3 | const templatesController = require("./templates.js") 4 | const filesController = require("./files.js") 5 | 6 | const router = Router() 7 | 8 | router.use("/applications", applicationsController) 9 | 10 | router.use("/application_form_templates", templatesController) 11 | router.use("/templates", templatesController) // alias 12 | 13 | router.use("/files", filesController) 14 | 15 | module.exports = router 16 | -------------------------------------------------------------------------------- /routes/notifications.js: -------------------------------------------------------------------------------- 1 | // /applications/:id/hankos 2 | const { Router } = require("express") 3 | const { mark_recipient_as_notified } = require("../controllers/notifications") 4 | 5 | const router = Router({ mergeParams: true }) 6 | 7 | router.route("/").post(mark_recipient_as_notified) 8 | 9 | module.exports = router 10 | -------------------------------------------------------------------------------- /routes/templates.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express") 2 | const { 3 | create_template, 4 | read_templates, 5 | read_template, 6 | update_template, 7 | delete_template, 8 | add_template_manager, 9 | } = require("../controllers/templates.js") 10 | 11 | const router = Router() 12 | 13 | router.route("/").post(create_template).get(read_templates) 14 | 15 | router 16 | .route("/:template_id") 17 | .get(read_template) 18 | .put(update_template) 19 | .patch(update_template) 20 | .delete(delete_template) 21 | 22 | router.route("/:template_id/managers").post(add_template_manager) 23 | 24 | module.exports = router 25 | -------------------------------------------------------------------------------- /test/applications.test.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv") 2 | dotenv.config() 3 | 4 | const request = require("supertest") 5 | const { expect } = require("chai") 6 | const { app } = require("../index.js") 7 | const axios = require("axios") 8 | 9 | const { 10 | LOGIN_URL, 11 | IDENTIFICATION_URL, 12 | TEST_USER_USERNAME, 13 | TEST_USER_PASSWORD, 14 | } = process.env 15 | 16 | const login = async () => { 17 | const body = { username: TEST_USER_USERNAME, password: TEST_USER_PASSWORD } 18 | const { 19 | data: { jwt }, 20 | } = await axios.post(LOGIN_URL, body) 21 | return jwt 22 | } 23 | 24 | const whoami = async (jwt) => { 25 | const headers = { authorization: `bearer ${jwt}` } 26 | const { data: user } = await axios.get(IDENTIFICATION_URL, { headers }) 27 | return user 28 | } 29 | 30 | describe("/applications", () => { 31 | let user, jwt, application_id, file_id 32 | 33 | before(async () => { 34 | //console.log = () => {} // silence the console 35 | jwt = await login() 36 | user = await whoami(jwt) 37 | }) 38 | 39 | describe("POST /files", () => { 40 | it("Should allow the upload of a file", async () => { 41 | const { body, status } = await request(app) 42 | .post("/files") 43 | .attach("file_to_upload", "test/sample_pdf.pdf") 44 | .set("Authorization", `Bearer ${jwt}`) 45 | 46 | file_id = body.file_id 47 | 48 | expect(status).to.equal(200) 49 | }) 50 | }) 51 | 52 | describe("POST /applications", () => { 53 | it("Should allow the creation of an application", async () => { 54 | const form_data = [{ label: "test", value: file_id }] 55 | const application = { 56 | title: "tdd", 57 | type: "tdd", 58 | form_data, 59 | recipients_ids: [user._id], // self as recipient 60 | } 61 | 62 | const { body, status } = await request(app) 63 | .post("/applications") 64 | .send(application) 65 | .set("Authorization", `Bearer ${jwt}`) 66 | 67 | application_id = body._id 68 | 69 | expect(status).to.equal(200) 70 | }) 71 | 72 | it("Should prevent the creation of an application to unauthenticated users", async () => { 73 | const form_data = [{ label: "test", value: file_id }] 74 | const application = { 75 | title: "tdd", 76 | type: "tdd", 77 | form_data, 78 | recipients_ids: [user._id], // self as recipient 79 | } 80 | 81 | const { body, status, text } = await request(app) 82 | .post("/applications") 83 | .send(application) 84 | 85 | expect(status).to.equal(403) 86 | }) 87 | }) 88 | 89 | describe("GET /applications", () => { 90 | it("Should allow query applications", async () => { 91 | const { body, status } = await request(app) 92 | .get(`/applications`) 93 | .set("Authorization", `Bearer ${jwt}`) 94 | 95 | expect(status).to.equal(200) 96 | }) 97 | }) 98 | 99 | describe("GET /applications/:id", () => { 100 | it("Should allow the query of an application", async () => { 101 | const { status } = await request(app) 102 | .get(`/applications/${application_id}`) 103 | .set("Authorization", `Bearer ${jwt}`) 104 | 105 | expect(status).to.equal(200) 106 | }) 107 | }) 108 | 109 | describe("GET /applications/:id/files/:file_id", () => { 110 | it("Should allow the query of an application attachment", async () => { 111 | const { status } = await request(app) 112 | .get(`/applications/${application_id}/files/${file_id}`) 113 | .set("Authorization", `Bearer ${jwt}`) 114 | 115 | expect(status).to.equal(200) 116 | }) 117 | 118 | it("Should not allow the query of an application attachment with invalid ID", async () => { 119 | const { status } = await request(app) 120 | .get(`/applications/${application_id}/files/banana`) 121 | .set("Authorization", `Bearer ${jwt}`) 122 | 123 | expect(status).to.not.equal(200) 124 | }) 125 | }) 126 | 127 | describe("POST /applications/:id/approve", () => { 128 | it("Should allow to approve an application", async () => { 129 | const { body, status } = await request(app) 130 | .post(`/applications/${application_id}/approve`) 131 | .set("Authorization", `Bearer ${jwt}`) 132 | 133 | expect(status).to.equal(200) 134 | }) 135 | }) 136 | 137 | describe("DELETE /applications/:id", () => { 138 | it("Should allow the deletion of an application", async () => { 139 | const { status } = await request(app) 140 | .delete(`/applications/${application_id}`) 141 | .set("Authorization", `Bearer ${jwt}`) 142 | 143 | expect(status).to.equal(200) 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /test/sample_pdf.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtekt/web-based-approval-system/7fb1335575bdea3f8e3774dfb12fe5271be3381f/test/sample_pdf.pdf -------------------------------------------------------------------------------- /test/templates.test.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv") 2 | dotenv.config() 3 | 4 | const request = require("supertest") 5 | const { expect } = require("chai") 6 | const { app } = require("../index.js") 7 | const axios = require("axios") 8 | 9 | const { 10 | LOGIN_URL, 11 | IDENTIFICATION_URL, 12 | TEST_USER_USERNAME, 13 | TEST_USER_PASSWORD, 14 | } = process.env 15 | 16 | const login = async () => { 17 | const body = { username: TEST_USER_USERNAME, password: TEST_USER_PASSWORD } 18 | const { 19 | data: { jwt }, 20 | } = await axios.post(LOGIN_URL, body) 21 | return jwt 22 | } 23 | 24 | const whoami = async (jwt) => { 25 | const headers = { authorization: `bearer ${jwt}` } 26 | const { data: user } = await axios.get(IDENTIFICATION_URL, { headers }) 27 | return user 28 | } 29 | 30 | describe("/templates", () => { 31 | let user, jwt, template_id 32 | const label = "tdd" 33 | 34 | before(async () => { 35 | //console.log = () => {} // silence the console 36 | jwt = await login() 37 | user = await whoami(jwt) 38 | }) 39 | 40 | describe("POST /templates", () => { 41 | it("Should allow the creation of a template", async () => { 42 | const template = { label } 43 | 44 | const { body, status } = await request(app) 45 | .post("/templates") 46 | .send(template) 47 | .set("Authorization", `Bearer ${jwt}`) 48 | 49 | template_id = body._id 50 | 51 | expect(status).to.equal(200) 52 | }) 53 | }) 54 | 55 | describe("GET /templates", () => { 56 | it("Should allow the query of templates", async () => { 57 | const { status, body } = await request(app) 58 | .get("/templates") 59 | .set("Authorization", `Bearer ${jwt}`) 60 | 61 | expect(status).to.equal(200) 62 | expect(body.length).to.above(0) 63 | }) 64 | }) 65 | 66 | describe("GET /templates/:template_id", () => { 67 | it("Should allow the query of a single template", async () => { 68 | const { status, body } = await request(app) 69 | .get(`/templates/${template_id}`) 70 | .set("Authorization", `Bearer ${jwt}`) 71 | 72 | expect(status).to.equal(200) 73 | expect(body.label).to.equal(label) 74 | }) 75 | }) 76 | 77 | describe("PATCH /templates/:template_id", () => { 78 | it("Should allow the update of template", async () => { 79 | const description = "a test template" 80 | const { status, body } = await request(app) 81 | .patch(`/templates/${template_id}`) 82 | .send({ description }) 83 | .set("Authorization", `Bearer ${jwt}`) 84 | 85 | console.log(body) 86 | 87 | expect(status).to.equal(200) 88 | expect(body.label).to.equal(label) 89 | expect(body.description).to.equal(description) 90 | }) 91 | }) 92 | 93 | describe("DELETE /templates/:template_id", () => { 94 | it("Should allow the deletion of a template", async () => { 95 | const { status } = await request(app) 96 | .delete(`/templates/${template_id}`) 97 | .set("Authorization", `Bearer ${jwt}`) 98 | 99 | expect(status).to.equal(200) 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | exports.get_current_user_id = (res) => { 2 | const user = res.locals?.user 3 | if (!user) throw `User is not authenticated` 4 | 5 | const user_id = res.locals.user._id ?? res.locals.user.properties._id 6 | // ?? res.locals.user.identity.low 7 | // ?? res.locals.user.identity 8 | 9 | if (!user_id) throw `User does not have an ID` 10 | 11 | // converting to string just to be sure 12 | return user_id.toString() 13 | } 14 | 15 | exports.get_application_id = (req) => { 16 | // Prrobably unused now 17 | 18 | const application_id = 19 | req.params.application_id ?? 20 | req.body.application_id ?? 21 | req.body.id ?? 22 | req.query.application_id ?? 23 | req.query.id 24 | 25 | // Just in case 26 | return application_id.toString() 27 | } 28 | 29 | exports.format_application_from_record = (record) => { 30 | // An utility function to format the output of a neo4j query of applications 31 | // In order to be sent to a front end via JSON 32 | 33 | if (record.get("forbidden")) { 34 | const application = record.get("application") 35 | delete application.properties.form_data 36 | application.properties.title = "機密 / Confidential" 37 | } 38 | 39 | return { 40 | ...record.get("application"), 41 | applicant: { 42 | ...record.get("applicant"), 43 | authorship: record.get("authorship"), 44 | }, 45 | visibility: record.get("visibility"), 46 | recipients: record 47 | .get("recipients") 48 | .map((recipient) => ({ 49 | ...recipient, 50 | submission: record 51 | .get("submissions") 52 | .find((submission) => submission.end === recipient.identity), 53 | approval: record 54 | .get("approvals") 55 | .find((approval) => approval.start === recipient.identity), 56 | refusal: record 57 | .get("refusals") 58 | .find((refusal) => refusal.start === recipient.identity), 59 | })) 60 | .sort( 61 | (a, b) => 62 | a.submission.properties.flow_index - 63 | b.submission.properties.flow_index 64 | ), 65 | forbidden: record.get("forbidden"), 66 | } 67 | } 68 | 69 | exports.format_application_from_record_v2 = (record) => { 70 | // An utility function to format the output of a neo4j query of applications 71 | // In order to be sent to a front end via JSON 72 | 73 | if (record.get("forbidden")) { 74 | const application = record.get("application") 75 | delete application.form_data 76 | application.title = "機密 / Confidential" 77 | } 78 | 79 | return { 80 | ...record.get("application"), 81 | applicant: { 82 | ...record.get("applicant"), 83 | authorship: record.get("authorship"), 84 | }, 85 | visibility: record.get("visibility"), 86 | recipients: record 87 | .get("recipients") 88 | .map((recipient) => ({ 89 | ...recipient.properties, 90 | submission: record 91 | .get("submissions") 92 | .find((submission) => submission.end === recipient.identity) 93 | ?.properties, 94 | approval: record 95 | .get("approvals") 96 | .find((approval) => approval.start === recipient.identity) 97 | ?.properties, 98 | refusal: record 99 | .get("refusals") 100 | .find((refusal) => refusal.start === recipient.identity)?.properties, 101 | })) 102 | .sort((a, b) => a.submission.flow_index - b.submission.flow_index), 103 | forbidden: record.get("forbidden"), 104 | } 105 | } 106 | 107 | const filter_by_applcation_id = `WHERE application._id = $application_id` 108 | exports.filter_by_applcation_id = filter_by_applcation_id 109 | 110 | const filter_by_user_id = `WHERE user._id = $user_id` 111 | exports.filter_by_user_id = filter_by_user_id 112 | 113 | exports.return_application_and_related_nodes = ` 114 | // application and count provided by batching 115 | WITH application, application_count 116 | MATCH (user:User {_id: $user_id}) 117 | 118 | // Adding a forbidden flag to applications that the user cannot see 119 | WITH application, application_count, 120 | application.private 121 | AND NOT (application)-[:SUBMITTED_BY]->(user) 122 | AND NOT (application)-[:SUBMITTED_TO]->(user) 123 | AND NOT (application)-[:VISIBLE_TO]->(user) 124 | AND NOT (application)-[:VISIBLE_TO]->(:Group)<-[:BELONGS_TO]-(user) 125 | AS forbidden 126 | 127 | // Find applicant 128 | WITH application, forbidden, application_count 129 | OPTIONAL MATCH (application)-[authorship:SUBMITTED_BY]->(applicant:User) 130 | 131 | // Find recipients 132 | WITH application, applicant, authorship, forbidden, application_count 133 | OPTIONAL MATCH (application)-[submission:SUBMITTED_TO]->(recipient:User) 134 | 135 | // Find approvals 136 | WITH application, applicant, authorship, recipient, submission, forbidden, application_count 137 | OPTIONAL MATCH (application)<-[approval:APPROVED]-(recipient) 138 | 139 | // Find rejections 140 | WITH application, applicant, authorship, recipient, submission, approval, forbidden, application_count 141 | OPTIONAL MATCH (application)<-[refusal:REJECTED]-(recipient) 142 | 143 | // visibility 144 | WITH application, applicant, authorship, recipient, submission, approval, refusal, forbidden, application_count 145 | OPTIONAL MATCH (application)-[:VISIBLE_TO]->(group:Group) 146 | WHERE application.private = true 147 | 148 | // Return everything 149 | RETURN application, 150 | applicant, 151 | authorship, 152 | COLLECT(DISTINCT recipient) as recipients, 153 | COLLECT(DISTINCT submission) as submissions, 154 | COLLECT(DISTINCT approval) as approvals, 155 | COLLECT(DISTINCT refusal) as refusals, 156 | COLLECT(DISTINCT group) as visibility, 157 | forbidden, 158 | application_count 159 | ` 160 | 161 | // TODO: Try to format output better 162 | exports.return_application_and_related_nodes_v2 = ` 163 | // application and count provided by batching 164 | WITH application, application_count 165 | MATCH (user:User {_id: $user_id}) 166 | 167 | // Adding a forbidden flag to applications that the user cannot see 168 | WITH application, application_count, 169 | application.private 170 | AND NOT (application)-[:SUBMITTED_BY]->(user) 171 | AND NOT (application)-[:SUBMITTED_TO]->(user) 172 | AND NOT (application)-[:VISIBLE_TO]->(:Group)<-[:BELONGS_TO]-(user) 173 | AS forbidden 174 | 175 | // Find applicant 176 | WITH application, forbidden, application_count 177 | OPTIONAL MATCH (application)-[authorship:SUBMITTED_BY]->(applicant:User) 178 | 179 | // Find recipients 180 | WITH application, applicant, authorship, forbidden, application_count 181 | OPTIONAL MATCH (application)-[submission:SUBMITTED_TO]->(recipient:User) 182 | 183 | // Find approvals 184 | WITH application, applicant, authorship, recipient, submission, forbidden, application_count 185 | OPTIONAL MATCH (application)<-[approval:APPROVED]-(recipient) 186 | 187 | // Find rejections 188 | WITH application, applicant, authorship, recipient, submission, approval, forbidden, application_count 189 | OPTIONAL MATCH (application)<-[refusal:REJECTED]-(recipient) 190 | 191 | // visibility 192 | WITH application, applicant, authorship, recipient, submission, approval, refusal, forbidden, application_count 193 | OPTIONAL MATCH (application)-[:VISIBLE_TO]->(group:Group) 194 | WHERE application.private = true 195 | 196 | // Return everything 197 | RETURN PROPERTIES(application) as application, 198 | PROPERTIES (applicant) as applicant, 199 | PROPERTIES (authorship) as authorship, 200 | COLLECT(DISTINCT PROPERTIES(group)) as visibility, 201 | // NOTE: Properties not used on the four hereunder 202 | COLLECT(DISTINCT recipient) as recipients, 203 | COLLECT(DISTINCT submission) as submissions, 204 | COLLECT(DISTINCT approval) as approvals, 205 | COLLECT(DISTINCT refusal) as refusals, 206 | forbidden, 207 | application_count 208 | 209 | ` 210 | 211 | // This might be unused 212 | exports.visibility_enforcement = ` 213 | WITH user, application 214 | WHERE application.private IS NULL 215 | OR NOT application.private 216 | OR (application)-[:SUBMITTED_BY]->(user) 217 | OR (application)-[:SUBMITTED_TO]->(user) 218 | OR (application)-[:VISIBLE_TO]->(:Group)<-[:BELONGS_TO]-(user) 219 | ` 220 | 221 | const query_submitted_rejected_applications = ` 222 | WITH application 223 | WHERE (:User)-[:REJECTED]->(application) 224 | ` 225 | exports.query_submitted_rejected_applications = 226 | query_submitted_rejected_applications 227 | 228 | const query_submitted_pending_applications = ` 229 | // A pending application is an application that is does not yet have an equal amount approvals and submissions 230 | // Also, a rejected application is automatiocally not pending 231 | WITH application 232 | MATCH (application)-[:SUBMITTED_TO]->(recipient:User) 233 | WHERE NOT (:User)-[:REJECTED]->(application) 234 | WITH application, COUNT(recipient) AS recipient_count 235 | OPTIONAL MATCH (:User)-[approval:APPROVED]->(application) 236 | WITH application, recipient_count, count(approval) as approval_count 237 | WHERE NOT recipient_count = approval_count 238 | ` 239 | exports.query_submitted_pending_applications = 240 | query_submitted_pending_applications 241 | 242 | const query_submitted_approved_applications = ` 243 | // A submitted approved application has equal number of approvals than submissions 244 | WITH application 245 | MATCH (application)-[:SUBMITTED_TO]->(recipient:User) 246 | WHERE NOT (recipient:User)-[:REJECTED]->(application) 247 | WITH application, COUNT(recipient) AS recipient_count 248 | OPTIONAL MATCH (:User)-[approval:APPROVED]->(application) 249 | WITH application, recipient_count, count(approval) as approval_count 250 | WHERE recipient_count = approval_count 251 | ` 252 | exports.query_submitted_approved_applications = 253 | query_submitted_approved_applications 254 | 255 | const query_received_pending_applications = ` 256 | // Check if recipient is next in the flow 257 | WITH application 258 | 259 | // Get the current user 260 | // Also filter out rejected applications 261 | MATCH (application)-[submission:SUBMITTED_TO]->(user:User {_id: $user_id}) 262 | WHERE NOT (application)<-[:REJECTED]-(:User) 263 | 264 | // Get the approval count 265 | WITH application, submission 266 | OPTIONAL MATCH (application)<-[approval:APPROVED]-(:User) 267 | WITH submission, application, count(approval) as approval_count 268 | WHERE submission.flow_index = approval_count 269 | ` 270 | exports.query_received_pending_applications = 271 | query_received_pending_applications 272 | 273 | const query_received_rejected_applications = ` 274 | // Check if recipient is next in the flow 275 | WITH application 276 | 277 | // Get the current user 278 | // Also filter out rejected applications 279 | MATCH (application)<-[:REJECTED]->(user:User {_id: $user_id}) 280 | ` 281 | exports.query_received_rejected_applications = 282 | query_received_rejected_applications 283 | 284 | const query_received_approved_applications = ` 285 | // Check if recipient is next in the flow 286 | WITH application 287 | 288 | // Get the current user 289 | // Also filter out rejected applications 290 | MATCH (application)<-[:APPROVED]->(user:User {_id: $user_id}) 291 | ` 292 | exports.query_received_approved_applications = 293 | query_received_approved_applications 294 | 295 | exports.application_batching = ` 296 | // Counting must be done before batching 297 | WITH application ORDER BY application.creation_date DESC 298 | WITH collect(application) AS application_collection, count(application) as application_count 299 | WITH application_count, application_collection[toInteger($start_index)..toInteger($start_index)+toInteger($batch_size)] AS application_batch 300 | UNWIND application_batch AS application 301 | ` 302 | 303 | exports.filter_by_type = (type) => { 304 | if (!type) return `` 305 | return ` 306 | WITH application 307 | WHERE application.type = $type 308 | ` 309 | } 310 | 311 | exports.query_with_hanko_id = (hanko_id) => { 312 | if (!hanko_id) return `` 313 | return ` 314 | WITH application 315 | MATCH (application)-[approval:APPROVED]-(:User) 316 | WHERE approval._id = $hanko_id 317 | OR id(approval) = toInteger($hanko_id) // temporary 318 | ` 319 | } 320 | 321 | exports.query_with_application_id = (application_id) => { 322 | if (!application_id) return `` 323 | return ` 324 | WITH application 325 | ${filter_by_applcation_id} 326 | ` 327 | } 328 | 329 | exports.query_with_date = (start_date, end_date) => { 330 | let query = `` 331 | 332 | if (start_date) 333 | query += ` 334 | WITH application 335 | WHERE application.creation_date >= date($start_date) 336 | ` 337 | 338 | if (end_date) 339 | query += ` 340 | WITH application 341 | WHERE application.creation_date <= date($end_date) 342 | ` 343 | 344 | return query 345 | } 346 | 347 | exports.query_with_group = (group_id) => { 348 | if (!group_id) return `` 349 | return ` 350 | WITH application 351 | MATCH (application)-[:SUBMITTED_BY]->(:User)-[:BELONGS_TO]->(group:Group {_id: $group_id}) 352 | ` 353 | } 354 | 355 | exports.query_deleted = (deleted) => { 356 | // Returns deleted applications if specified so 357 | if (deleted) return `` 358 | return ` 359 | WITH application 360 | WHERE application.deleted IS NULL 361 | ` 362 | } 363 | 364 | exports.query_with_relationship_and_state = (relationship, state) => { 365 | // no need to go further if no relationship provided 366 | // maybe... 367 | if (!relationship) return `` 368 | 369 | // base query with relationship 370 | let query = ` 371 | WITH application, user 372 | MATCH (application)-[r]-(user {_id: $user_id}) 373 | WHERE type(r) = $relationship 374 | ` 375 | 376 | if (relationship === "SUBMITTED_BY") { 377 | if (state === "pending") query += query_submitted_pending_applications 378 | else if (state === "rejected") 379 | query += query_submitted_rejected_applications 380 | else if (state === "approved") 381 | query += query_submitted_approved_applications 382 | } else if (relationship === "SUBMITTED_TO") { 383 | // a.k.a received 384 | if (state === "pending") query += query_received_pending_applications 385 | else if (state === "rejected") query += query_received_rejected_applications 386 | else if (state === "approved") query += query_received_approved_applications 387 | } 388 | 389 | return query 390 | } 391 | --------------------------------------------------------------------------------