diff --git a/.env.sample b/.env.sample index 5244b74..b8d8c16 100644 --- a/.env.sample +++ b/.env.sample @@ -26,6 +26,19 @@ BUSAPI_URL="http://localhost:4000/eventBus" MAX_PHASE_PRODUCT_COUNT=100 UNIQUE_GMAIL_VALIDATION=false +ENABLE_FILE_UPLOAD=true +FILE_SERVICE_ENDPOINT="http://localhost:4000/file" +ATTACHMENTS_S3_BUCKET="topcoder-prod-media" +PROJECT_ATTACHMENT_PATH_PREFIX="projects" +PROJECT_ATTACHMENT_PATH_SUFFIX="attachments" + +AWS_S3_ENDPOINT="http://localhost:4566" +AWS_S3_REGION="us-east-1" +AWS_S3_API_VERSION="2006-03-01" +AWS_ACCESS_KEY_ID="test" +AWS_SECRET_ACCESS_KEY="test" +AWS_S3_FORCE_PATH_STYLE=true + IDENTITY_SERVICE_ENDPOINT="http://localhost:4000/identity" MEMBER_SERVICE_ENDPOINT="https://api.topcoder-dev.com/v5/members" COPILOT_PORTAL_URL="https://copilots.topcoder-dev.com" @@ -35,3 +48,10 @@ INVITE_EMAIL_SUBJECT="You are invited to Topcoder" INVITE_EMAIL_SECTION_TITLE="Project Invitation" SSO_REFCODES="[]" +SALESFORCE_CLIENT_AUDIENCE="http://localhost:4000/salesforce" +SALESFORCE_CLIENT_KEY="your-private-key" +SALESFORCE_CLIENT_ID="abcd" +SALESFORCE_SUBJECT="subject" +SFDC_BILLING_ACCOUNT_NAME_FIELD="Billing_Account_name__c" +SFDC_BILLING_ACCOUNT_MARKUP_FIELD="Mark_Up__c" +SFDC_BILLING_ACCOUNT_ACTIVE_FIELD="Active__c" \ No newline at end of file diff --git a/config/config.ts b/config/config.ts index 92791a1..86c1f39 100644 --- a/config/config.ts +++ b/config/config.ts @@ -6,18 +6,23 @@ export const AppConfig = { maxPhaseProductCount: process.env.MAX_PHASE_PRODUCT_COUNT || 100, uniqueGmailValidation: process.env.UNIQUE_GMAIL_VALIDATION === 'true' || false, prismaTransactionTimeout: process.env.PRISMA_TRANSACTION_TIMEOUT ? parseInt(process.env.PRISMA_TRANSACTION_TIMEOUT ) : 60000, // Sets the timeout to 60 seconds + enableFileUpload: process.env.ENABLE_FILE_UPLOAD === 'true' || false, authSecret: process.env.AUTH_SECRET || 'secret', validIssuers: process.env.VALID_ISSUERS ? process.env.VALID_ISSUERS.replace(/\\"/g, '') : '["https://api.topcoder-dev.com", "https://api.topcoder.com","https://topcoder-dev.auth0.com/"]', identityServiceEndpoint: process.env.IDENTITY_SERVICE_ENDPOINT || 'http://localhost:4000/identity', + fileServiceEndpoint: process.env.FILE_SERVICE_ENDPOINT || 'http://localhost:4000/file', memberServiceEndpoint: process.env.MEMBER_SERVICE_ENDPOINT || 'https://api.topcoder-dev.com/v5/members', copilotPortalUrl: process.env.COPILOT_PORTAL_URL || 'https://copilots.topcoder-dev.com', workManagerUrl: process.env.WORK_MANAGER_URL || 'https://challenges.topcoder-dev.com', accountsAppUrl: process.env.ACCOUNTS_APP_URL || 'https://accounts.topcoder-dev.com', inviteEmailSubject: process.env.INVITE_EMAIL_SUBJECT || 'You are invited to Topcoder', inviteEmailSectionTitle: process.env.INVITE_EMAIL_SECTION_TITLE || 'Project Invitation', + attachmentsS3Bucket: process.env.ATTACHMENTS_S3_BUCKET || 'topcoder-prod-media', + projectAttachmentPathPrefix: process.env.PROJECT_ATTACHMENT_PATH_PREFIX || 'projects', + projectAttachmentPathSuffix: process.env.PROJECT_ATTACHMENT_PATH_SUFFIX || 'attachments', SSO_REFCODES: process.env.SSO_REFCODES || '[]', } @@ -47,3 +52,26 @@ export const EventBusConfig = { kafkaErrorTopic: process.env.KAFKA_ERROR_TOPIC ?? 'common.error.reporting', } +export function AwsS3Config() { + return { + endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:4566', + region: process.env.AWS_S3_REGION || 'us-east-1', + apiVersion: process.env.AWS_S3_API_VERSION || '2006-03-01', + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'test', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'test', + s3ForcePathStyle: process.env.AWS_S3_FORCE_PATH_STYLE === 'true' || true, // Crucial for LocalStack S3 + } +} + +export function SalesforceConfig() { + return { + clientAudience: process.env.SALESFORCE_CLIENT_AUDIENCE || 'https://login.salesforce.com', + clientKey: process.env.SALESFORCE_CLIENT_KEY || 'privateKey', + clientId: process.env.SALESFORCE_CLIENT_ID || '', + subject: process.env.SALESFORCE_SUBJECT || '', + sfdcBillingAccountNameField: process.env.SFDC_BILLING_ACCOUNT_NAME_FIELD || 'Billing_Account_name__c', + sfdcBillingAccountMarkupField: process.env.SFDC_BILLING_ACCOUNT_MARKUP_FIELD || 'Mark_Up__c', + sfdcBillingAccountActiveField: process.env.SFDC_BILLING_ACCOUNT_ACTIVE_FIELD || 'Active__c', + } +} + diff --git a/doc/tc-projects-api.postman_collection.json b/doc/tc-projects-api.postman_collection.json index 117b1a3..90c0445 100644 --- a/doc/tc-projects-api.postman_collection.json +++ b/doc/tc-projects-api.postman_collection.json @@ -2223,6 +2223,619 @@ } ] }, + { + "name": "attachment", + "item": [ + { + "name": "get project attachments with admin", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments" + ] + } + }, + "response": [] + }, + { + "name": "get project attachments with user", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessTokenUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments" + ] + } + }, + "response": [] + }, + { + "name": "get project attachment by id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/5", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "5" + ] + } + }, + "response": [] + }, + { + "name": "get project attachment by id with user", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessTokenUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/8", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "8" + ] + } + }, + "response": [] + }, + { + "name": "get project attachment by id with not exist attachment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () { pm.response.to.have.status(404); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessTokenUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/999", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "999" + ] + } + }, + "response": [] + }, + { + "name": "create project attachment link", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"new link\",\n \"type\": \"link\",\n \"tags\": [\n \"tag1\",\n \"tag2\"\n ],\n \"size\": 150,\n \"category\": \"new category\",\n \"description\": \"new description\",\n \"path\": \"new_link\",\n \"allowedUsers\": [\n 40152856\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/projects/101/attachments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments" + ] + } + }, + "response": [] + }, + { + "name": "create project attachment file", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"new file\",\n \"type\": \"file\",\n \"tags\": [\n \"tag1\",\n \"tag2\"\n ],\n \"size\": 150,\n \"category\": \"new category\",\n \"description\": \"new description\",\n \"path\": \"test1.txt\",\n \"s3Bucket\": \"demo-bucket\",\n \"contentType\": \"doc\",\n \"allowedUsers\": [\n 40152856\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/projects/101/attachments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments" + ] + } + }, + "response": [] + }, + { + "name": "update project attachment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"update file\",\n \"tags\": [\n \"tag1-update\",\n \"tag2\"\n ],\n \"description\": \"update description\",\n \"path\": \"update_file1\",\n \"allowedUsers\": [\n 40152856,\n 9999\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/5", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "5" + ] + } + }, + "response": [] + }, + { + "name": "update project attachment with not exist attachment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () { pm.response.to.have.status(404); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"update file\",\n \"tags\": [\n \"tag1-update\",\n \"tag2\"\n ],\n \"description\": \"update description\",\n \"path\": \"update_file1\",\n \"allowedUsers\": [\n 40152856,\n 9999\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/999", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "999" + ] + } + }, + "response": [] + }, + { + "name": "delete project attachment file", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/11", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "11" + ] + } + }, + "response": [] + }, + { + "name": "delete project attachment file with not exist attachment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/attachments/999", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "attachments", + "999" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "billing account", + "item": [ + { + "name": "get project billing accounts", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/billingAccounts", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "billingAccounts" + ] + } + }, + "response": [] + }, + { + "name": "get project billing account", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/103/billingAccount", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "103", + "billingAccount" + ] + } + }, + "response": [] + }, + { + "name": "get project billing account with billing account null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () { pm.response.to.have.status(200); });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/projects/101/billingAccount", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "projects", + "101", + "billingAccount" + ] + } + }, + "response": [] + } + ] + }, { "name": "check project health", "event": [ diff --git a/mock/jwt.ts b/mock/jwt.ts index 8f092af..7bb8484 100644 --- a/mock/jwt.ts +++ b/mock/jwt.ts @@ -13,7 +13,7 @@ const m2mPayload = { iat: 1550906388, exp, azp: 'enjw1810eDz3XTwSO2Rn2Y9cQTrspn3B', - scopes: 'all:connect_project all:projects all:project-members all:project-invites all:customer-payments', + scopes: 'all:connect_project all:projects all:project-members all:project-invites all:customer-payments all:project-attachments', gty: 'client-credentials' } diff --git a/mock/mock-api.js b/mock/mock-api.js index 2b17c03..7426d1c 100644 --- a/mock/mock-api.js +++ b/mock/mock-api.js @@ -173,8 +173,6 @@ app.get('/identity/roles', (req, res) => { res.json(resp) }) - - // Identity users app.get('/identity/users', (req, res) => { logger.info(`Identity received message: ${JSON.stringify(req.query)}`); @@ -192,7 +190,71 @@ app.get('/identity/users', (req, res) => { res.json(resp) }) +// File +app.post('/file/downloadurl', (req, res) => { + logger.info(`File service received message: ${JSON.stringify(req.body)}`); + const bucket = req.body.bucket + const key = req.body.key + res.statusCode = 200; + + const resp = { + url: `http://localhost/files/${bucket}/${key}` + }; + res.json(resp) +}) + +app.post('/file/deletefile', (req, res) => { + logger.info(`File service received message: ${JSON.stringify(req.body)}`); + res.statusCode = 200; + + res.json({}) +}) + +// Salesforce +app.post('/salesforce/services/oauth2/token', (req, res) => { + logger.info(`Salesforce oauth token received message: ${JSON.stringify(req.body)}`); + res.statusCode = 200; + + res.json({ + access_token: 'ABCDEF', + instance_url: 'http://localhost:4000/salesforce' + }) +}) + +app.get('/salesforce/services/data/v37.0/query', (req, res) => { + logger.info(`Salesforce service received message: ${JSON.stringify(req.query)}`); + res.statusCode = 200; + res.json({ + records: [{ + Topcoder_Billing_Account__r: { + Id: 123, + TopCoder_Billing_Account_Id__c: '456', + Billing_Account_name__c: 'name1', + Start_Date__c: '2020-05-06T10:10:47.008Z', + End_Date__c: '2024-05-16T10:10:47.008Z', + }, + TopCoder_Billing_Account_Id__c: '123', + Mark_Up__c: 'markup01', + Active__c: true, + Start_Date__c: '2020-05-06T10:10:47.008Z', + End_Date__c: '2024-05-16T10:10:47.008Z', + }, { + Topcoder_Billing_Account__r: { + Id: 124, + TopCoder_Billing_Account_Id__c: '457', + Billing_Account_name__c: 'name2', + Start_Date__c: '2020-05-15T10:10:47.008Z', + End_Date__c: '2024-05-15T10:10:47.008Z', + }, + TopCoder_Billing_Account_Id__c: '124', + Mark_Up__c: 'markup02', + Active__c: false, + Start_Date__c: '2020-05-05T10:10:47.008Z', + End_Date__c: '2024-05-15T10:10:47.008Z', + }] + }) +}) app.listen(app.get('port'), '0.0.0.0', () => { logger.info(`Express server listening on port ${app.get('port')}`) diff --git a/mock/test1.txt b/mock/test1.txt new file mode 100644 index 0000000..4632e06 --- /dev/null +++ b/mock/test1.txt @@ -0,0 +1 @@ +123456 \ No newline at end of file diff --git a/package.json b/package.json index c41693a..f7aadad 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@prisma/client": "6.11.1", + "aws-sdk": "^2.1692.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cors": "^2.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95fb570..52280a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@prisma/client': specifier: 6.11.1 version: 6.11.1(prisma@6.11.1(typescript@5.8.3))(typescript@5.8.3) + aws-sdk: + specifier: ^2.1692.0 + version: 2.1692.0 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -105,6 +108,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + aws-sdk-mock: + specifier: ^6.2.1 + version: 6.2.1 eslint: specifier: ^9.18.0 version: 9.30.1(jiti@2.4.2) @@ -962,6 +968,15 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sinonjs/fake-timers@13.0.5': + resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + '@swc/cli@0.6.0': resolution: {integrity: sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw==} engines: {node: '>= 16.14.0'} @@ -1464,6 +1479,18 @@ packages: auth0-js@9.28.0: resolution: {integrity: sha512-2xIfQIGM0vX3IdPR91ztLO2+Ar2I5+3iFKcjuZO+LV9vRh4Wje+Ka1hnHjMU9dH892Lm3ZxBAHxRo68YToUhfg==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-sdk-mock@6.2.1: + resolution: {integrity: sha512-d4Gl5SiPRpqVYf1LBYVT3h2hspzEzQ90OR18rIRVcXqL9rAevLDPtrIaL90aAv0UtE97u3gQl7n+lbx4hHVpxw==} + engines: {node: '>=18.0.0'} + + aws-sdk@2.1692.0: + resolution: {integrity: sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==} + engines: {node: '>= 10.0.0'} + aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} @@ -1573,6 +1600,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@4.9.2: + resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1601,6 +1631,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -1874,6 +1908,10 @@ packages: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1897,6 +1935,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + dtrace-provider@0.8.8: resolution: {integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==} engines: {node: '>=0.10'} @@ -2075,6 +2117,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + events@1.1.1: + resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} + engines: {node: '>=0.4.x'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2219,6 +2265,10 @@ packages: debug: optional: true + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2376,6 +2426,9 @@ packages: resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2438,6 +2491,9 @@ packages: idtoken-verifier@2.2.4: resolution: {integrity: sha512-5t7O8cNHpJBB8FnwLD0qFZqy/+qGICObQKUl0njD6vXKHhpZPLEe8LU7qv/GBWB3Qv5e/wAIFHYVi4SoQwdOxQ==} + ieee754@1.1.13: + resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2476,12 +2532,20 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -2498,6 +2562,10 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-generator-function@1.1.0: + resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2517,6 +2585,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2525,6 +2597,10 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} @@ -2719,6 +2795,10 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} + joi@13.7.0: resolution: {integrity: sha512-xuY5VkHfeOYK3Hdi91ulocfuFopwgbSORmIwzcwHKESQhC7w1kD5jaVSPnqDxS2I8t3RZ9omCKAxNwXN5zG1/Q==} engines: {node: '>=8.9.0'} @@ -2796,6 +2876,9 @@ packages: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + jwa@1.4.2: resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} @@ -3075,6 +3158,13 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nise@6.1.1: + resolution: {integrity: sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==} + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} @@ -3242,6 +3332,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + precond@0.2.3: resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==} engines: {node: '>= 0.6'} @@ -3294,6 +3388,9 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@1.3.2: + resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3309,6 +3406,11 @@ packages: resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} engines: {node: '>=0.6'} + querystring@0.2.0: + resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + querystring@0.2.1: resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} engines: {node: '>=0.4.x'} @@ -3436,6 +3538,10 @@ packages: safe-json-stringify@1.2.0: resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -3443,6 +3549,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.2.1: + resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -3495,6 +3604,10 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3532,6 +3645,9 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sinon@19.0.5: + resolution: {integrity: sha512-r15s9/s+ub/d4bxNXqIUmwp6imVSdTorIRaxoecYjqTVLZ8RuoXr/4EDGwIBo6Waxn7f2gnURX9zuhAfCwaF6Q==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3872,6 +3988,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -3943,14 +4063,24 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + url@0.10.3: + resolution: {integrity: sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. hasBin: true + uuid@8.0.0: + resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -3998,6 +4128,10 @@ packages: webpack-cli: optional: true + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4037,6 +4171,14 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5010,6 +5152,17 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@13.0.5': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + + '@sinonjs/text-encoding@0.7.3': {} + '@swc/cli@0.6.0(@swc/core@1.12.9)(chokidar@4.0.3)': dependencies: '@swc/core': 1.12.9 @@ -5599,6 +5752,29 @@ snapshots: transitivePeerDependencies: - supports-color + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + aws-sdk-mock@6.2.1: + dependencies: + aws-sdk: 2.1692.0 + neotraverse: 0.6.18 + sinon: 19.0.5 + + aws-sdk@2.1692.0: + dependencies: + buffer: 4.9.2 + events: 1.1.1 + ieee754: 1.1.13 + jmespath: 0.16.0 + querystring: 0.2.0 + sax: 1.2.1 + url: 0.10.3 + util: 0.12.5 + uuid: 8.0.0 + xml2js: 0.6.2 + aws-sign2@0.7.0: {} aws4@1.13.2: {} @@ -5762,6 +5938,12 @@ snapshots: buffer-from@1.1.2: {} + buffer@4.9.2: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + isarray: 1.0.0 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -5797,6 +5979,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6030,6 +6219,12 @@ snapshots: defer-to-connect@2.0.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -6045,6 +6240,8 @@ snapshots: diff@4.0.2: {} + diff@7.0.0: {} + dtrace-provider@0.8.8: dependencies: nan: 2.22.2 @@ -6226,6 +6423,8 @@ snapshots: etag@1.8.1: {} + events@1.1.1: {} + events@3.3.0: {} execa@5.1.1: @@ -6416,6 +6615,10 @@ snapshots: optionalDependencies: debug: 4.4.1 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -6597,6 +6800,10 @@ snapshots: has-own-prop@2.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -6668,6 +6875,8 @@ snapshots: unfetch: 4.2.0 url-join: 4.0.1 + ieee754@1.1.13: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6699,10 +6908,17 @@ snapshots: ipaddr.js@1.9.1: {} + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-arrayish@0.2.1: {} is-arrayish@0.3.2: {} + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -6713,6 +6929,13 @@ snapshots: is-generator-fn@2.1.0: {} + is-generator-function@1.1.0: + dependencies: + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -6725,10 +6948,21 @@ snapshots: is-promise@4.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + is-stream@2.0.1: {} is-stream@4.0.1: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + is-typedarray@1.0.0: {} is-unicode-supported@0.1.0: {} @@ -7114,6 +7348,8 @@ snapshots: jiti@2.4.2: {} + jmespath@0.16.0: {} + joi@13.7.0: dependencies: hoek: 5.0.4 @@ -7202,6 +7438,8 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + just-extend@6.2.0: {} + jwa@1.4.2: dependencies: buffer-equal-constant-time: 1.0.1 @@ -7454,6 +7692,16 @@ snapshots: neo-async@2.6.2: {} + neotraverse@0.6.18: {} + + nise@6.1.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 13.0.5 + '@sinonjs/text-encoding': 0.7.3 + just-extend: 6.2.0 + path-to-regexp: 8.2.0 + node-abort-controller@3.1.1: {} node-emoji@1.11.0: @@ -7602,6 +7850,8 @@ snapshots: pluralize@8.0.0: {} + possible-typed-array-names@1.1.0: {} + precond@0.2.3: {} prelude-ls@1.1.2: {} @@ -7645,6 +7895,8 @@ snapshots: dependencies: punycode: 2.3.1 + punycode@1.3.2: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -7655,6 +7907,8 @@ snapshots: qs@6.5.3: {} + querystring@0.2.0: {} + querystring@0.2.1: {} queue-microtask@1.2.3: {} @@ -7799,10 +8053,18 @@ snapshots: safe-json-stringify@1.2.0: optional: true + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} + sax@1.2.1: {} + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -7865,6 +8127,15 @@ snapshots: transitivePeerDependencies: - supports-color + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -7909,6 +8180,15 @@ snapshots: dependencies: is-arrayish: 0.3.2 + sinon@19.0.5: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 13.0.5 + '@sinonjs/samsam': 8.0.3 + diff: 7.0.0 + nise: 6.1.1 + supports-color: 7.2.0 + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -8301,6 +8581,8 @@ snapshots: type-detect@4.0.8: {} + type-detect@4.1.0: {} + type-fest@0.21.3: {} type-fest@4.41.0: {} @@ -8363,10 +8645,25 @@ snapshots: url-join@4.0.1: {} + url@0.10.3: + dependencies: + punycode: 1.3.2 + querystring: 0.2.0 + util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.0 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + uuid@3.4.0: {} + uuid@8.0.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -8432,6 +8729,16 @@ snapshots: - esbuild - uglify-js + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -8485,6 +8792,13 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + xml2js@0.6.2: + dependencies: + sax: 1.2.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/prisma/seed.ts b/prisma/seed.ts index 12614aa..7878283 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -330,6 +330,39 @@ const projectData1: Prisma.ProjectCreateInput = { contentType: 'doc', allowedUsers: [], createdBy: 40152856, + }, { + title: 'file2', + type: 'link', + tags: ['tag2', 'tag3'], + size: 110, + category: 'category2', + description: 'description2', + path: 'path2', + contentType: 'doc', + allowedUsers: [22742764, 8547899], + createdBy: 40152856, + }, { + title: 'file3', + type: 'link', + tags: ['tag1', 'tag3'], + size: 120, + category: 'category3', + description: 'description3', + path: 'path3', + contentType: 'doc', + allowedUsers: [8547899], + createdBy: 40152856, + }, { + title: 'file4', + type: 'file', + tags: ['tag1', 'tag2', 'tag3'], + size: 120, + category: 'category3', + description: 'description4', + path: 'path4', + contentType: 'doc', + allowedUsers: [40152856], + createdBy: 22742764, }] }, bookmarks: { diff --git a/src/api/projectAttachment/project-attachment.controller.ts b/src/api/projectAttachment/project-attachment.controller.ts index dd76f57..a9fa4c3 100644 --- a/src/api/projectAttachment/project-attachment.controller.ts +++ b/src/api/projectAttachment/project-attachment.controller.ts @@ -3,19 +3,31 @@ import { Controller, Delete, Get, + HttpCode, HttpStatus, Param, Patch, Post, Req, + UseGuards, } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + ApiBearerAuth, +} from '@nestjs/swagger'; import { AttachmentResponseDto, CreateAttachmentDto, UpdateAttachmentDto, } from './project-attachment.dto'; -import { JwtUser } from 'src/auth/auth.dto'; +import { Roles } from 'src/auth/decorators/roles.decorator'; +import { Scopes } from 'src/auth/decorators/scopes.decorator'; +import { RolesScopesGuard } from 'src/auth/guards/roles-scopes.guard'; +import { MANAGER_ROLES, USER_ROLE, M2M_SCOPES } from 'src/shared/constants'; import { ProjectAttachmentService } from './project-attachment.service'; /** @@ -35,6 +47,10 @@ export class ProjectAttachmentController { * @returns The created attachment response */ @Post('/:projectId/attachments') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT) + @Scopes(M2M_SCOPES.PROJECTS.WRITE) + @ApiBearerAuth() @ApiOperation({ summary: 'Create project attachment' }) @ApiParam({ name: 'projectId', description: 'project id', type: Number }) @ApiResponse({ status: HttpStatus.CREATED, type: AttachmentResponseDto }) @@ -48,11 +64,10 @@ export class ProjectAttachmentController { }) async createAttachment( @Req() req: Request, - @Param('projectId') projectId: string, + @Param('projectId') projectId: number, @Body() dto: CreateAttachmentDto, ): Promise { - const authUser = req['user'] as JwtUser; - return this.service.createAttachment(authUser, projectId, dto); + return this.service.createAttachment(req, projectId, dto); } /** @@ -61,6 +76,10 @@ export class ProjectAttachmentController { * @returns Array of attachment responses */ @Get('/:projectId/attachments') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT, USER_ROLE.TOPCODER_USER) + @Scopes(M2M_SCOPES.PROJECTS.READ) + @ApiBearerAuth() @ApiOperation({ summary: 'Search project attachment' }) @ApiParam({ name: 'projectId', description: 'project id', type: Number }) @ApiResponse({ @@ -77,9 +96,10 @@ export class ProjectAttachmentController { description: 'Internal Server Error', }) async searchAttachment( - @Param('projectId') projectId: string, + @Req() req: Request, + @Param('projectId') projectId: number, ): Promise { - return this.service.searchAttachment(projectId); + return this.service.searchAttachment(req, projectId); } /** @@ -89,6 +109,10 @@ export class ProjectAttachmentController { * @returns The requested attachment response */ @Get('/:projectId/attachments/:id') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT, USER_ROLE.TOPCODER_USER) + @Scopes(M2M_SCOPES.PROJECTS.READ) + @ApiBearerAuth() @ApiOperation({ summary: 'Get project attachment by project id and attachment id', }) @@ -104,10 +128,11 @@ export class ProjectAttachmentController { description: 'Internal Server Error', }) async getAttachment( - @Param('projectId') projectId: string, - @Param('id') id: string, + @Req() req: Request, + @Param('projectId') projectId: number, + @Param('id') id: number, ): Promise { - return this.service.getAttachment(projectId, id); + return this.service.getAttachment(req, projectId, id); } /** @@ -119,6 +144,10 @@ export class ProjectAttachmentController { * @returns The updated attachment response */ @Patch('/:projectId/attachments/:id') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT) + @Scopes(M2M_SCOPES.PROJECTS.WRITE) + @ApiBearerAuth() @ApiOperation({ summary: 'Update project attachment' }) @ApiParam({ name: 'projectId', description: 'project id', type: Number }) @ApiParam({ name: 'id', description: 'attachment id', type: Number }) @@ -133,12 +162,11 @@ export class ProjectAttachmentController { }) async updateAttachment( @Req() req: Request, - @Param('projectId') projectId: string, - @Param('id') id: string, + @Param('projectId') projectId: number, + @Param('id') id: number, @Body() dto: UpdateAttachmentDto, ): Promise { - const authUser = req['user'] as JwtUser; - return this.service.updateAttachment(authUser, projectId, id, dto); + return this.service.updateAttachment(req, projectId, id, dto); } /** @@ -148,6 +176,11 @@ export class ProjectAttachmentController { * @returns Empty promise indicating successful deletion */ @Delete('/:projectId/attachments/:id') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT) + @Scopes(M2M_SCOPES.PROJECTS.WRITE) + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete project attachment by project id and attachment id', }) @@ -166,9 +199,10 @@ export class ProjectAttachmentController { description: 'Internal Server Error', }) async deleteAttachment( - @Param('projectId') projectId: string, - @Param('id') id: string, + @Req() req: Request, + @Param('projectId') projectId: number, + @Param('id') id: number, ): Promise { - await this.service.deleteAttachment(projectId, id); + await this.service.deleteAttachment(req, projectId, id); } } diff --git a/src/api/projectAttachment/project-attachment.dto.ts b/src/api/projectAttachment/project-attachment.dto.ts index 720262c..d44d6fd 100644 --- a/src/api/projectAttachment/project-attachment.dto.ts +++ b/src/api/projectAttachment/project-attachment.dto.ts @@ -74,6 +74,7 @@ export class CreateAttachmentDto { }) @IsArray() @IsString({ each: true }) + @ArrayMinSize(1) @IsOptional() tags?: string[]; @@ -254,7 +255,7 @@ export class UpdateAttachmentDto { }) @IsArray() @IsString({ each: true }) - @ArrayMinSize(1, { each: true }) + @ArrayMinSize(1) @IsOptional() tags?: string[]; diff --git a/src/api/projectAttachment/project-attachment.service.ts b/src/api/projectAttachment/project-attachment.service.ts index 61b4108..b099b5f 100644 --- a/src/api/projectAttachment/project-attachment.service.ts +++ b/src/api/projectAttachment/project-attachment.service.ts @@ -1,12 +1,34 @@ -/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-unused-vars */ -import { Injectable } from '@nestjs/common'; +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */ +import { + Injectable, + ForbiddenException, + NotFoundException, + Logger, +} from '@nestjs/common'; +import * as Path from 'path'; +import { Request } from 'express'; import { JwtUser } from 'src/auth/auth.dto'; import { PrismaService } from 'src/shared/services/prisma.service'; +import { EventBusService } from 'src/shared/services/event-bus.service'; +import { UtilService } from 'src/shared/services/util.service'; +import { PERMISSION } from 'src/auth/constants'; +import { EVENT, RESOURCES, ATTACHMENT_TYPES } from 'src/shared/constants'; +import Utils from 'src/shared/utils'; import { AttachmentResponseDto, CreateAttachmentDto, UpdateAttachmentDto, } from './project-attachment.dto'; +import { + includes, + assign, + cloneDeep, + filter, + isEmpty, + join, + omit, +} from 'lodash'; +import { AppConfig } from 'config/config'; /** * Service class for handling project attachment operations. @@ -14,67 +36,381 @@ import { */ @Injectable() export class ProjectAttachmentService { - constructor(private readonly prisma: PrismaService) {} + private readonly logger = new Logger(ProjectAttachmentService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly eventBus: EventBusService, + private readonly utilService: UtilService, + ) {} /** * Creates a new attachment for a project. - * @param authUser - Authenticated user creating the attachment + * @param req - The request * @param projectId - ID of the project to attach to * @param dto - Data transfer object containing attachment details * @returns Promise resolving to the created attachment response */ async createAttachment( - authUser: JwtUser, - projectId: string, + req: Request, + projectId: number, dto: CreateAttachmentDto, ): Promise { - return new AttachmentResponseDto(); + const allowedUsers = dto.allowedUsers || undefined; + + const authUser = req['authUser'] as JwtUser; + const authUserId = Number(authUser.userId); + + // extract file name + const fileName = Path.parse(dto.path).base; + // create file path + const path = join( + [ + AppConfig.projectAttachmentPathPrefix, + projectId, + AppConfig.projectAttachmentPathSuffix, + fileName, + ], + '/', + ); + + const attachmentBody = { + projectId, + allowedUsers, + createdBy: authUserId, + updatedBy: authUserId, + title: dto.title, + size: dto.size, + category: dto.category || null, + description: dto.description, + contentType: dto.contentType, + path: dto.path, + type: dto.type as any, + tags: dto.tags, + }; + + if (dto.type === ATTACHMENT_TYPES.LINK) { + const linkAttachment = await this.prisma.projectAttachment.create({ + data: attachmentBody, + }); + + this.logger.debug('New Link Attachment record: ', linkAttachment); + + // emit the Kafka event + const payload = assign( + { resource: RESOURCES.ATTACHMENT }, + linkAttachment, + ); + await this.eventBus.postBusEvent( + EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED, + payload, + ); + + return linkAttachment as any; + } + + const sourceBucket = dto.s3Bucket; + const sourceKey = dto.path; + const destBucket = AppConfig.attachmentsS3Bucket; + const destKey = path; + + // don't actually transfer file in development mode if file uploading is disabled, so we can test this endpoint + if (process.env.NODE_ENV !== 'development' || AppConfig.enableFileUpload) { + await this.utilService.s3FileTransfer( + sourceBucket, + sourceKey, + destBucket, + destKey, + ); + } + + // file copied to final destination, create DB record + this.logger.debug('creating db file record'); + attachmentBody.path = path; + const fileAttachment = await this.prisma.projectAttachment.create({ + data: attachmentBody, + }); + let response = cloneDeep(fileAttachment); + response = omit(response, ['path', 'deletedAt']); + + this.logger.debug('New Attachment record: ', fileAttachment); + if (process.env.NODE_ENV !== 'development' || AppConfig.enableFileUpload) { + // retrieve download url for the response + this.logger.debug('retrieving download url'); + const url = await this.utilService.getDownloadUrl(destBucket, path); + this.logger.debug('Retrieving Presigned Url resp: ', url); + response.downloadUrl = url; + } + + // emit the Kafka event + const payload = assign({ resource: RESOURCES.ATTACHMENT }, fileAttachment); + await this.eventBus.postBusEvent( + EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED, + payload, + ); + + return response; } /** * Retrieves all attachments for a specific project. + * @param req - The request * @param projectId - ID of the project to get attachments for * @returns Promise resolving to an array of attachment responses */ - async searchAttachment(projectId: string): Promise { - return [new AttachmentResponseDto()]; + async searchAttachment( + req: Request, + projectId: number, + ): Promise { + let attachments = await this.prisma.projectAttachment.findMany({ + where: { + projectId, + deletedBy: null, + }, + omit: { + deletedAt: true, + deletedBy: true, + }, + }); + + // Permission check need project members + // we need them inside `context.currentProjectMembers` + await this.addProjectMemberToContext(req, projectId); + + // check access to attachment + attachments = filter(attachments, (attachment) => + Utils.hasReadAccessToAttachment(attachment, req), + ); + + return attachments as any; } /** * Retrieves a specific attachment by ID. + * @param req - The request * @param projectId - ID of the associated project * @param id - ID of the attachment to retrieve * @returns Promise resolving to the requested attachment response */ async getAttachment( - projectId: string, - id: string, + req: Request, + projectId: number, + id: number, ): Promise { - return new AttachmentResponseDto(); + let entity = await this.prisma.projectAttachment.findUnique({ + where: { + id, + projectId, + }, + }); + + // Permission check need project members + // we need them inside `context.currentProjectMembers` + await this.addProjectMemberToContext(req, projectId); + + // check access to attachment + if (!Utils.hasReadAccessToAttachment(entity, req)) { + entity = null; + } + + if (!entity) { + throw new NotFoundException( + `attachment not found for project id ${projectId}, id ${id}`, + ); + } + + const { url } = await this.getPreSignedUrl(entity); + + if (!isEmpty(url)) { + entity = assign({ url }, entity); + } + + return entity as any; + } + + /** + * This private function gets the pre-signed url if the attachment is a file + * + * @param {Object} attachment The project attachment object + * @returns {Object} The object of two promises, first one if the attachment object promise, + * The second promise is for the file pre-signed url (if attachment type is file) + */ + private async getPreSignedUrl(attachment) { + // If the attachment is a link return it as-is without getting the pre-signed url + if (attachment.type === ATTACHMENT_TYPES.LINK) { + return { attachment, url: '' }; + } + + // The attachment is a file + // In development mode, if file upload is disabled, we return the dummy attachment object + if ( + includes(['development'], process.env.NODE_ENV) && + !AppConfig.enableFileUpload + ) { + return { attachment, url: 'dummy://url' }; + } + // Not in development mode or file upload is not disabled + const url = await this.utilService.getDownloadUrl( + AppConfig.attachmentsS3Bucket, + attachment.path, + ); + return { attachment, url }; } /** * Updates an existing project attachment. - * @param authUser - Authenticated user updating the attachment + * @param req - The request * @param projectId - ID of the associated project * @param id - ID of the attachment to update * @param dto - Data transfer object containing updated attachment details * @returns Promise resolving to the updated attachment response */ async updateAttachment( - authUser: JwtUser, - projectId: string, - id: string, + req: Request, + projectId: number, + id: number, dto: UpdateAttachmentDto, ): Promise { - return new AttachmentResponseDto(); + const authUser = req['authUser'] as JwtUser; + + const entity = await this.prisma.projectAttachment.findUnique({ + where: { + id, + projectId, + deletedBy: null, + }, + }); + + if (!entity) { + throw new NotFoundException( + `attachment not found for project id ${projectId}, id ${id}`, + ); + } + + if ( + Number(entity.createdBy) !== authUser.userId && + !Utils.hasPermissionByReq( + PERMISSION.UPDATE_PROJECT_ATTACHMENT_NOT_OWN, + req, + ) + ) { + throw new ForbiddenException( + "You don't have permission to update attachment created by another user.", + ); + } + + return this.prisma.$transaction(async (tx) => { + const allowedUsers = dto.allowedUsers || undefined; + const updatedEntity = await tx.projectAttachment.update({ + where: { + id, + }, + data: { + ...dto, + allowedUsers, + updatedBy: authUser.userId, + }, + }); + + this.logger.debug( + 'updated project attachment', + JSON.stringify(updatedEntity), + ); + + // emit the Kafka event + const payload = assign({ resource: RESOURCES.ATTACHMENT }, updatedEntity); + await this.eventBus.postBusEvent( + EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED, + payload, + ); + + return updatedEntity; + }) as any; } /** * Deletes a project attachment. + * @param req - The request * @param projectId - ID of the associated project * @param id - ID of the attachment to delete * @returns Promise that resolves when deletion is complete */ - async deleteAttachment(projectId: string, id: string): Promise {} + async deleteAttachment( + req: Request, + projectId: number, + id: number, + ): Promise { + const authUser = req['authUser'] as JwtUser; + + const entity = await this.prisma.projectAttachment.findUnique({ + where: { + id, + projectId, + deletedBy: null, + }, + }); + + if (!entity) { + throw new NotFoundException( + `attachment not found for project id ${projectId}, id ${id}`, + ); + } + + if ( + Number(entity.createdBy) !== authUser.userId && + !Utils.hasPermissionByReq( + PERMISSION.DELETE_PROJECT_ATTACHMENT_NOT_OWN, + req, + ) + ) { + throw new ForbiddenException( + "You don't have permission to delete attachment created by another user.", + ); + } + + return this.prisma.$transaction(async (tx) => { + await tx.projectAttachment.update({ + where: { + id, + }, + data: { + deletedBy: authUser.userId, + deletedAt: new Date(), + }, + }); + + if ( + entity.type === ATTACHMENT_TYPES.FILE && + (process.env.NODE_ENV !== 'development' || AppConfig.enableFileUpload) + ) { + await this.utilService.deleteFile( + AppConfig.attachmentsS3Bucket, + entity.path, + ); + } + + // emit the Kafka event + const payload = assign({ resource: RESOURCES.ATTACHMENT }, entity); + await this.eventBus.postBusEvent( + EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, + payload, + ); + }); + } + + /** + * Add project members to request context + * + * @param {express.Request} req - request + * @param {Number} projectId - project id + */ + async addProjectMemberToContext(req: any, projectId: number) { + const projectMembers = await this.prisma.projectMember.findMany({ + where: { + projectId, + }, + }); + req.context = req.context || {}; + req.context.currentProjectMembers = projectMembers; + } } diff --git a/src/api/projectBillingAccount/billing-account.controller.ts b/src/api/projectBillingAccount/billing-account.controller.ts index cbfb3b6..89e153a 100644 --- a/src/api/projectBillingAccount/billing-account.controller.ts +++ b/src/api/projectBillingAccount/billing-account.controller.ts @@ -1,9 +1,27 @@ -import { Controller, Get, HttpStatus, Param } from '@nestjs/common'; +import { + Controller, + Get, + HttpStatus, + Param, + Req, + UseGuards, +} from '@nestjs/common'; import { BillingAccountResponseDto, ListBillingAccountItem, } from './billing-account.dto'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { Roles } from 'src/auth/decorators/roles.decorator'; +import { Scopes } from 'src/auth/decorators/scopes.decorator'; +import { RolesScopesGuard } from 'src/auth/guards/roles-scopes.guard'; +import { MANAGER_ROLES, USER_ROLE, M2M_SCOPES } from 'src/shared/constants'; import { BillingAccountService } from './billing-account.service'; /** @@ -21,6 +39,10 @@ export class BillingAccountController { * @returns A single billing account response DTO */ @Get('/:projectId/billingAccount') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT) + @Scopes(M2M_SCOPES.PROJECTS.READ_PROJECT_BILLING_ACCOUNT_DETAILS) + @ApiBearerAuth() @ApiOperation({ summary: 'Get project billing account' }) @ApiParam({ name: 'projectId', description: 'project id', type: Number }) @ApiResponse({ status: HttpStatus.OK, type: BillingAccountResponseDto }) @@ -33,9 +55,10 @@ export class BillingAccountController { description: 'Internal Server Error', }) async getAccount( - @Param('projectId') projectId: string, + @Req() req: Request, + @Param('projectId') projectId: number, ): Promise { - return this.service.getAccount(projectId); + return this.service.getAccount(req, projectId); } /** @@ -44,6 +67,10 @@ export class BillingAccountController { * @returns An array of billing account items */ @Get('/:projectId/billingAccounts') + @UseGuards(RolesScopesGuard) + @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT) + @Scopes(M2M_SCOPES.PROJECTS.READ_USER_BILLING_ACCOUNTS) + @ApiBearerAuth() @ApiOperation({ summary: 'List project billing accounts' }) @ApiParam({ name: 'projectId', description: 'project id', type: Number }) @ApiResponse({ @@ -60,8 +87,9 @@ export class BillingAccountController { description: 'Internal Server Error', }) async listAccounts( - @Param('projectId') projectId: string, + @Req() req: Request, + @Param('projectId') projectId: number, ): Promise { - return this.service.listAccounts(projectId); + return this.service.listAccounts(req, projectId); } } diff --git a/src/api/projectBillingAccount/billing-account.service.ts b/src/api/projectBillingAccount/billing-account.service.ts index c0668e0..7439e65 100644 --- a/src/api/projectBillingAccount/billing-account.service.ts +++ b/src/api/projectBillingAccount/billing-account.service.ts @@ -1,5 +1,9 @@ -/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-unused-vars */ -import { Injectable } from '@nestjs/common'; +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Request } from 'express'; +import { SalesforceService } from 'src/shared/services/salesforce.service'; +import { PrismaService } from 'src/shared/services/prisma.service'; +import { JwtUser } from 'src/auth/auth.dto'; import { BillingAccountResponseDto, ListBillingAccountItem, @@ -11,21 +15,82 @@ import { */ @Injectable() export class BillingAccountService { + private readonly logger = new Logger(BillingAccountService.name); + + constructor( + private readonly salesforceService: SalesforceService, + private readonly prisma: PrismaService, + ) {} + /** * Retrieves a single billing account associated with a project. + * @param req - The request * @param projectId - The unique identifier of the project * @returns A promise that resolves to the billing account details */ - async getAccount(projectId: string): Promise { - return new BillingAccountResponseDto(); + async getAccount( + req: Request, + projectId: number, + ): Promise { + const authUser = req['authUser'] as JwtUser; + + const project = await this.prisma.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + billingAccountId: true, + }, + }); + if (!project) { + throw new NotFoundException(`Project with id ${projectId} not found`); + } + const billingAccountId = project.billingAccountId; + if (!billingAccountId) { + throw new NotFoundException('Billing Account not found'); + } + + const { accessToken, instanceUrl } = + await this.salesforceService.authenticate(); + + const sql = `SELECT TopCoder_Billing_Account_Id__c, Mark_Up__c, Active__c, Start_Date__c, End_Date__c from Topcoder_Billing_Account__c tba where TopCoder_Billing_Account_Id__c='${billingAccountId}'`; + this.logger.debug(sql); + const billingAccount = await this.salesforceService.queryBillingAccount( + sql, + accessToken, + instanceUrl, + ); + if (!authUser.isMachine) { + // delete sensitive information for non machine access + // does not revalidate the scope as it assumes that is already taken care + delete billingAccount.markup; + } + return billingAccount; } /** * Retrieves all billing accounts associated with a project. + * @param req - The request * @param projectId - The unique identifier of the project * @returns A promise that resolves to an array of billing account items */ - async listAccounts(projectId: string): Promise { - return [new ListBillingAccountItem()]; + async listAccounts( + req: Request, + projectId: number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + const authUser = req['authUser'] as JwtUser; + + const { accessToken, instanceUrl } = + await this.salesforceService.authenticate(); + + const sql = `SELECT Topcoder_Billing_Account__r.id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where Topcoder_Billing_Account__r.Active__c=true AND UserID__c='${authUser.userId}'`; + // and Topcoder_Billing_Account__r.TC_Connect_Project_ID__c='${projectId}' + this.logger.debug(sql); + const billingAccounts = + await this.salesforceService.queryUserBillingAccounts( + sql, + accessToken, + instanceUrl, + ); + return billingAccounts; } } diff --git a/src/api/projectMember/project-member.controller.ts b/src/api/projectMember/project-member.controller.ts index 0220919..8607830 100644 --- a/src/api/projectMember/project-member.controller.ts +++ b/src/api/projectMember/project-member.controller.ts @@ -31,7 +31,12 @@ import { ProjectMemberService } from './project-member.service'; import { Roles } from 'src/auth/decorators/roles.decorator'; import { Scopes } from 'src/auth/decorators/scopes.decorator'; import { RolesScopesGuard } from 'src/auth/guards/roles-scopes.guard'; -import { MANAGER_ROLES, USER_ROLE, M2M_SCOPES } from 'src/shared/constants'; +import { + MANAGER_ROLES, + PROJECT_MEMBER_ROLE, + USER_ROLE, + M2M_SCOPES, +} from 'src/shared/constants'; /** * Controller for managing project members. @@ -84,7 +89,13 @@ export class ProjectMemberController { */ @Get('/:projectId/members') @UseGuards(RolesScopesGuard) - @Roles(...MANAGER_ROLES, USER_ROLE.COPILOT, USER_ROLE.TOPCODER_USER) + @Roles( + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + PROJECT_MEMBER_ROLE.COPILOT, + PROJECT_MEMBER_ROLE.CUSTOMER, + PROJECT_MEMBER_ROLE.OBSERVER, + ) @Scopes(M2M_SCOPES.PROJECT_MEMBERS.READ) @ApiBearerAuth() @ApiOperation({ summary: 'Search project member with given parameters' }) diff --git a/src/api/projectMember/project-member.dto.ts b/src/api/projectMember/project-member.dto.ts index 99c42ca..869c117 100644 --- a/src/api/projectMember/project-member.dto.ts +++ b/src/api/projectMember/project-member.dto.ts @@ -28,6 +28,14 @@ const allowedUpdateRoles = [ // Use PROJECT_MEMBER_ROLE values directly instead of duplicating enum const ProjectMemberRoleValues = Object.values(PROJECT_MEMBER_ROLE); +const allowedSearchRoles = [ + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + PROJECT_MEMBER_ROLE.COPILOT, + PROJECT_MEMBER_ROLE.CUSTOMER, + PROJECT_MEMBER_ROLE.OBSERVER, +]; + export class CreateProjectMemberDto { @ApiPropertyOptional({ name: 'userId', @@ -191,10 +199,10 @@ export class ProjectMemberResponseDto { export class QueryProjectMemberDto { @ApiProperty({ description: 'project member role', - enum: ProjectMemberRoleValues, + enum: allowedSearchRoles, }) @IsString() - @IsIn(ProjectMemberRoleValues) + @IsIn(allowedSearchRoles) @IsOptional() role?: string; diff --git a/src/shared/services/salesforce.service.ts b/src/shared/services/salesforce.service.ts new file mode 100644 index 0000000..d2ce29e --- /dev/null +++ b/src/shared/services/salesforce.service.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */ +import { + Injectable, + Logger, + InternalServerErrorException, +} from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; +import Utils from 'src/shared/utils'; +import * as jwt from 'jsonwebtoken'; +import { SalesforceConfig } from '../../../config/config'; +import { get } from 'lodash'; + +@Injectable() +export class SalesforceService { + private readonly logger = new Logger(SalesforceService.name); + private readonly salesforceConfig; + private loginBaseUrl; + private privateKey; + + constructor(private readonly httpService: HttpService) { + this.salesforceConfig = SalesforceConfig(); + this.loginBaseUrl = + this.salesforceConfig.clientAudience || 'https://login.salesforce.com'; + this.privateKey = this.salesforceConfig.clientKey || 'privateKey'; + // we are using dummy private key to fail safe when key is not provided in env + this.privateKey = this.privateKey.replace(/\\n/g, '\n'); + } + + private urlEncodeForm(k) { + return Object.keys(k).reduce( + (a, b) => `${a}&${b}=${encodeURIComponent(k[b])}`, + '', + ); + } + + /** + * Authenticate to Salesforce with pre-configured credentials + * @returns {{accessToken: String, instanceUrl: String}} the result + */ + async authenticate() { + try { + const jwtToken = jwt.sign({}, this.privateKey, { + expiresIn: '1h', // any expiration + issuer: this.salesforceConfig.clientId, + audience: this.salesforceConfig.clientAudience, + subject: this.salesforceConfig.subject, + algorithm: 'RS256', + }); + + const res: any = await firstValueFrom( + this.httpService.post( + `${this.loginBaseUrl}/services/oauth2/token`, + this.urlEncodeForm({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtToken, + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ), + ); + return { + accessToken: res.data.access_token, + instanceUrl: res.data.instance_url, + }; + } catch (err) { + this.logger.error( + 'Error occurs while authenticating from salesforce', + err, + ); + throw new InternalServerErrorException( + 'Error occurs while authenticating from salesforce', + ); + } + } + + /** + * Run the query statement + * @param {String} sql the Salesforce sql statement + * @param {String} accessToken the access token + * @param {String} instanceUrl the salesforce instance url + * @returns {{totalSize: Number, done: Boolean, records: Array}} the result + */ + async queryUserBillingAccounts(sql, accessToken, instanceUrl) { + try { + const res: any = await firstValueFrom( + this.httpService.get( + `${instanceUrl}/services/data/v37.0/query?q=${sql}`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ), + ); + this.logger.debug(get(res, 'data.records', [])); + const billingAccounts = get(res, 'data.records', []).map((o) => ({ + sfBillingAccountId: get(o, 'Topcoder_Billing_Account__r.Id'), + tcBillingAccountId: Utils.parseIntStrictly( + get(o, 'Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c'), + 10, + null, // fallback to null if cannot parse + ), + name: get( + o, + `Topcoder_Billing_Account__r.${this.salesforceConfig.sfdcBillingAccountNameField}`, + ), + startDate: get(o, 'Topcoder_Billing_Account__r.Start_Date__c'), + endDate: get(o, 'Topcoder_Billing_Account__r.End_Date__c'), + })); + return billingAccounts; + } catch (err) { + this.logger.error( + 'Error occurs while querying user billing accounts', + err, + ); + throw new InternalServerErrorException( + 'Error occurs while querying user billing accounts', + ); + } + } + + /** + * Run the query statement + * @param {String} sql the Salesforce sql statement + * @param {String} accessToken the access token + * @param {String} instanceUrl the salesforce instance url + * @returns {{totalSize: Number, done: Boolean, records: Array}} the result + */ + async queryBillingAccount(sql, accessToken, instanceUrl) { + try { + const res: any = await firstValueFrom( + this.httpService.get( + `${instanceUrl}/services/data/v37.0/query?q=${sql}`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ), + ); + this.logger.debug(get(res, 'data.records', [])); + const billingAccounts = get(res, 'data.records', []).map((o) => ({ + tcBillingAccountId: Utils.parseIntStrictly( + get(o, 'TopCoder_Billing_Account_Id__c'), + 10, + null, // fallback to null if cannot parse + ), + markup: get(o, this.salesforceConfig.sfdcBillingAccountMarkupField), + active: get(o, this.salesforceConfig.sfdcBillingAccountActiveField), + startDate: get(o, 'Start_Date__c'), + endDate: get(o, 'End_Date__c'), + })); + return billingAccounts.length > 0 ? billingAccounts[0] : {}; + } catch (err) { + this.logger.error('Error occurs while querying billing account', err); + throw new InternalServerErrorException( + 'Error occurs while querying billing account', + ); + } + } +} diff --git a/src/shared/services/util.service.ts b/src/shared/services/util.service.ts index 6eb9e6f..8037307 100644 --- a/src/shared/services/util.service.ts +++ b/src/shared/services/util.service.ts @@ -1,16 +1,18 @@ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */ -import { Injectable, Logger } from '@nestjs/common'; -import { firstValueFrom } from 'rxjs'; import { + Injectable, + Logger, BadRequestException, InternalServerErrorException, } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import * as AWS from 'aws-sdk'; import { M2MService } from 'src/shared/services/m2m.service'; import { EventBusService } from 'src/shared/services/event-bus.service'; import { HttpService } from '@nestjs/axios'; import Utils from 'src/shared/utils'; import { EVENT, RESOURCES } from 'src/shared/constants'; -import { AppConfig } from '../../../config/config'; +import { AppConfig, AwsS3Config } from '../../../config/config'; import { concat, isEmpty, @@ -487,4 +489,122 @@ export class UtilService { return newMember; } + + /** + * Get the download url + * @param {String} bucket bucket + * @param {String} key the file key + */ + async getDownloadUrl(bucket, key) { + try { + const token = await this.m2mService.getM2mToken(); + const res: any = await firstValueFrom( + this.httpService.post( + `${AppConfig.fileServiceEndpoint}/downloadurl`, + { + bucket, + key, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + this.logger.debug( + `Download url for bucket ${bucket}, key ${key}: ${res.data.url}`, + ); + return get(res, 'data.url'); + } catch (err) { + this.logger.error('Error occurs while getting download url', err); + throw new InternalServerErrorException( + 'Error occurs while getting download url', + ); + } + } + + /** + * Get the download url + * @param {String} bucket bucket + * @param {String} key the file key + */ + async deleteFile(bucket, key) { + try { + const token = await this.m2mService.getM2mToken(); + await firstValueFrom( + this.httpService.post( + `${AppConfig.fileServiceEndpoint}/deletefile`, + { + bucket, + key, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + this.logger.debug(`Delete file for bucket ${bucket}, key ${key}`); + } catch (err) { + this.logger.error('Error occurs while deleting file', err); + throw new InternalServerErrorException( + 'Error occurs while deleting file', + ); + } + } + + /** + * Moves file from source to destination + * @param {string} sourceBucket source bucket + * @param {string} sourceKey source key + * @param {string} destBucket destination bucket + * @param {string} destKey destination key + * @return {promise} promise + */ + async s3FileTransfer(sourceBucket, sourceKey, destBucket, destKey) { + // Make sure set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in Environment Variables + const s3 = new AWS.S3(AwsS3Config()); + + try { + const sourceParam = { + Bucket: sourceBucket, + Key: sourceKey, + }; + + const copyParam = { + Bucket: destBucket, + Key: destKey, + CopySource: `${sourceBucket}/${sourceKey}`, + }; + + await s3.copyObject(copyParam).promise(); + this.logger.debug( + `s3FileTransfer: copyObject successfully: ${sourceBucket}/${sourceKey}`, + ); + // we don't want deleteObject to block the request as it's not critical operation + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + try { + await s3.deleteObject(sourceParam).promise(); + this.logger.debug( + `s3FileTransfer: deleteObject successfully: ${sourceBucket}/${sourceKey}`, + ); + } catch (e) { + this.logger.error( + `s3FileTransfer: deleteObject failed: ${sourceBucket}/${sourceKey} : ${e.message}`, + ); + } + })(); + return { success: true }; + } catch (e) { + this.logger.error(`s3FileTransfer: error: ${e.message}`); + throw e; + } + } } diff --git a/src/shared/shared.module.ts b/src/shared/shared.module.ts index 18c7d75..3f67366 100644 --- a/src/shared/shared.module.ts +++ b/src/shared/shared.module.ts @@ -4,6 +4,7 @@ import { M2MService } from './services/m2m.service'; import { PrismaService } from './services/prisma.service'; import { EventBusService } from './services/event-bus.service'; import { UtilService } from './services/util.service'; +import { SalesforceService } from './services/salesforce.service'; @Global() @Module({ imports: [ @@ -12,7 +13,19 @@ import { UtilService } from './services/util.service'; maxRedirects: 3, }), ], - providers: [M2MService, PrismaService, EventBusService, UtilService], - exports: [M2MService, PrismaService, EventBusService, UtilService], + providers: [ + M2MService, + PrismaService, + EventBusService, + UtilService, + SalesforceService, + ], + exports: [ + M2MService, + PrismaService, + EventBusService, + UtilService, + SalesforceService, + ], }) export class SharedModule {} diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 2092a2d..3d40ca6 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-argument */ import * as _ from 'lodash'; import * as querystring from 'querystring'; import { Request, Response } from 'express'; @@ -232,7 +232,10 @@ class Utils { const userId = !_.isNumber(user.userId) ? parseInt(String(user.userId), 10) : user.userId; - const member = _.find(projectMembers, { userId }); + const member = _.find( + projectMembers, + (item) => userId === Number(item.userId), + ); // check if user has one of allowed Project roles if (permissionRule.projectRoles.length > 0) { @@ -292,7 +295,7 @@ class Utils { */ static getPageLink(req: Request, page: number) { const q = _.assignIn({}, req.query, { page }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return `${req.protocol}://${req.get('Host')}${req.baseUrl}${req.path}?${querystring.stringify(q)}`; } @@ -409,7 +412,7 @@ class Utils { */ static addUserDetailsFieldsIfAllowed(fields, req) { // Only Topcoder Admins can get email - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + if (Utils.hasPermissionByReq(PERMISSION.READ_PROJECT_MEMBER_DETAILS, req)) { return _.concat(fields, ['email', 'firstName', 'lastName']); } @@ -529,7 +532,7 @@ class Utils { const isAdmin = Utils.hasPermissionByReq( new PermissionRule({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }), - req, // eslint-disable-line @typescript-eslint/no-unsafe-argument + req, ); const currentUserId = req.authUser.userId; const currentUserEmail = req.authUser.email; @@ -585,6 +588,80 @@ class Utils { return dataClone; } + + /** + * Check if request from the user has permission to READ attachment + * + * @param {Object} attachment attachment + * @param {express.Request} req request + * + * @returns {Boolean} true if has permission + */ + static hasReadAccessToAttachment(attachment, req) { + if (!attachment) { + return false; + } + + const authUserId = Number(req.authUser.userId); + const isOwnAttachment = Number(attachment.createdBy) === authUserId; + const isAllowedAttachment = + attachment.allowedUsers === null || + !!_.find( + attachment.allowedUsers, + (allowedUser) => Number(allowedUser) === authUserId, + ); + + if ( + this.hasPermissionByReq( + PERMISSION.READ_PROJECT_ATTACHMENT_OWN_OR_ALLOWED, + req, + ) && + (isOwnAttachment || isAllowedAttachment) + ) { + return true; + } + + if ( + this.hasPermissionByReq( + PERMISSION.READ_PROJECT_ATTACHMENT_NOT_OWN_AND_NOT_ALLOWED, + req, + ) && + !isOwnAttachment && + !isAllowedAttachment + ) { + return true; + } + + return false; + } + + /** + * Parse integer value inside string or return fallback value. + * Unlike original parseInt, this method parses the whole string + * and fails if there are non-integer characters inside the string. + * + * @param {String} str number to parse + * @param {Number} radix radix of the number to parse + * @param {Any} fallbackValue value to return if we cannot parse successfully + * + * @returns {Number} parsed number + */ + static parseIntStrictly(str: string, radix: number, fallbackValue: any) { + const int = parseInt(str, radix); + + if (_.isNaN(int)) { + return fallbackValue; + } + + // if we parsed only the part of value and it's not the same as intial value + // example: "12x" => 12 which is not the same as initial value "12x", which means + // we cannot parse the full value successfully and treat it like we cannot parse at all + if (int.toString() !== str) { + return fallbackValue; + } + + return int; + } } export default Utils;