commit 4e268c4478dcc7c1e24ded471ec9464346173740 Author: Flatlogic Bot Date: Sat Oct 18 01:38:52 2025 +0000 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e427ff3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*/node_modules/ +*/build/ diff --git a/502.html b/502.html new file mode 100644 index 0000000..b83e656 --- /dev/null +++ b/502.html @@ -0,0 +1,187 @@ + + + + + + + Service Starting + + + + +
+

Loading the app, just a moment…

+

The application is currently launching. The page will automatically refresh once site is + available.

+
+

Car zone

+

Carzone: Multi-tenant SaaS for car rental agencies.

+
+
+ +
+
+
+ +
+
+ + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..affcee8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20.15.1-alpine AS builder +RUN apk add --no-cache git +WORKDIR /app +COPY frontend/package.json frontend/yarn.lock ./ +RUN yarn install --pure-lockfile +COPY frontend . +RUN yarn build + +FROM node:20.15.1-alpine +WORKDIR /app +COPY backend/package.json backend/yarn.lock ./ +RUN yarn install --pure-lockfile +COPY backend . + +COPY --from=builder /app/build /app/public +CMD ["yarn", "start"] + diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..a8353d5 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,85 @@ +# Base image for Node.js dependencies +FROM node:20.15.1-alpine AS frontend-deps +RUN apk add --no-cache git +WORKDIR /app/frontend +COPY frontend/package.json frontend/yarn.lock ./ +RUN yarn install --pure-lockfile + +FROM node:20.15.1-alpine AS backend-deps +RUN apk add --no-cache git +WORKDIR /app/backend +COPY backend/package.json backend/yarn.lock ./ +RUN yarn install --pure-lockfile + +FROM node:20.15.1-alpine AS app-shell-deps +RUN apk add --no-cache git +WORKDIR /app/app-shell +COPY app-shell/package.json app-shell/yarn.lock ./ +RUN yarn install --pure-lockfile + +# Nginx setup and application build +FROM node:20.15.1-alpine AS build +RUN apk add --no-cache git nginx curl +RUN apk add --no-cache lsof procps +RUN yarn global add concurrently + +RUN apk add --no-cache \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ttf-freefont \ + fontconfig + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser + +RUN mkdir -p /app/pids + +# Make sure to add yarn global bin to PATH +ENV PATH /root/.yarn/bin:/root/.config/yarn/global/node_modules/.bin:$PATH + +# Copy dependencies +WORKDIR /app +COPY --from=frontend-deps /app/frontend /app/frontend +COPY --from=backend-deps /app/backend /app/backend +COPY --from=app-shell-deps /app/app-shell /app/app-shell + +COPY frontend /app/frontend +COPY backend /app/backend +COPY app-shell /app/app-shell +COPY docker /app/docker + +# Copy all files from root to /app +COPY . /app + +# Copy Nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy custom error page +COPY 502.html /usr/share/nginx/html/502.html + +# Change owner and permissions of the error page +RUN chown nginx:nginx /usr/share/nginx/html/502.html && \ + chmod 644 /usr/share/nginx/html/502.html + +# Expose the port the app runs on +EXPOSE 8080 +ENV NODE_ENV=dev_stage +ENV FRONT_PORT=3001 +ENV BACKEND_PORT=3000 +ENV APP_SHELL_PORT=4000 + + +CMD ["sh", "-c", "\ + yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \ + yarn --cwd /app/backend start & echo $! > /app/pids/backend.pid && \ + sleep 10 && nginx -g 'daemon off;' & \ + NGINX_PID=$! && \ + echo 'Waiting for backend (port 3000) to be available...' && \ + while ! nc -z localhost ${BACKEND_PORT}; do \ + sleep 2; \ + done && \ + echo 'Backend is up. Starting app_shell for Git check...' && \ + yarn --cwd /app/app-shell start && \ + wait $NGINX_PID"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d69ed7 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +https://flatlogic.com/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fd7661 --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ + + +# Car zone + +## This project was generated by [Flatlogic Platform](https://flatlogic.com). + + - Frontend: [React.js](https://flatlogic.com/templates?framework%5B%5D=react&sort=default) + + - Backend: [NodeJS](https://flatlogic.com/templates?backend%5B%5D=nodejs&sort=default) + +
Backend Folder Structure + + The generated application has the following backend folder structure: + + `src` folder which contains your working files that will be used later to create the build. The src folder contains folders as: + + - `auth` - config the library for authentication and authorization; + + - `db` - contains such folders as: + + - `api` - documentation that is automatically generated by jsdoc or other tools; + + - `migrations` - is a skeleton of the database or all the actions that users do with the database; + + - `models`- what will represent the database for the backend; + + - `seeders` - the entity that creates the data for the database. + + - `routes` - this folder would contain all the routes that you have created using Express Router and what they do would be exported from a Controller file; + + - `services` - contains such folders as `emails` and `notifications`. +
+ + - Database: PostgreSQL + + - app-shel: Core application framework that provides essential infrastructure services +for the entire application. + ----------------------- +### We offer 2 ways how to start the project locally: by running Frontend and Backend or with Docker. +----------------------- + +## To start the project: + +### Backend: + +> Please change current folder: `cd backend` + +#### Install local dependencies: +`yarn install` + + ------------ + +#### Adjust local db: +##### 1. Install postgres: + +MacOS: + +`brew install postgres` + + > if you don’t have ‘brew‘ please install it (https://brew.sh) and repeat step `brew install postgres`. + +Ubuntu: + +`sudo apt update` + +`sudo apt install postgresql postgresql-contrib` + +##### 2. Create db and admin user: +Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database. + +`psql postgres --u postgres` + +Next, type this command for creating a new user with password then give access for creating the database. + +`postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';` + +`postgres-# ALTER ROLE admin CREATEDB;` + +Quit `psql` then log in again using the new user that previously created. + +`postgres-# \q` + +`psql postgres -U admin` + +Type this command to creating a new database. + +`postgres=> CREATE DATABASE db_{your_project_name};` + +Then give that new user privileges to the new database then quit the `psql`. + +`postgres=> GRANT ALL PRIVILEGES ON DATABASE db_{your_project_name} TO admin;` + +`postgres=> \q` + + ------------ + +#### Create database: +`yarn db:create` + +#### Start production build: +`yarn start` + +### Frontend: + +> Please change current folder: `cd frontend` + +## To start the project with Docker: +### Description: + +The project contains the **docker folder** and the `Dockerfile`. + +The `Dockerfile` is used to Deploy the project to Google Cloud. + +The **docker folder** contains a couple of helper scripts: + +- `docker-compose.yml` (all our services: web, backend, db are described here) +- `start-backend.sh` (starts backend, but only after the database) +- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) + + > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. + +## Run services: + +1. Install docker compose (https://docs.docker.com/compose/install/) + +2. Move to `docker` folder. All next steps should be done from this folder. + + ``` cd docker ``` + +3. Make executables from `wait-for-it.sh` and `start-backend.sh`: + + ``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ``` + +4. Download dependend projects for services. + +5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. + +6. Make sure you have needed ports (see them in `ports`) available on your local machine. + +7. Start services: + + 7.1. With an empty database `rm -rf data && docker-compose up` + + 7.2. With a stored (from previus runs) database data `docker-compose up` + +8. Check http://localhost:3000 + +9. Stop services: + + 9.1. Just press `Ctr+C` + +## Most common errors: + +1. `connection refused` + + There could be many reasons, but the most common are: + + - The port is not open on the destination machine. + + - The port is open on the destination machine, but its backlog of pending connections is full. + + - A firewall between the client and server is blocking access (also check local firewalls). + + After checking for firewalls and that the port is open, use telnet to connect to the IP/port to test connectivity. This removes any potential issues from your application. + + ***MacOS:*** + + If you suspect that your SSH service might be down, you can run this command to find out: + + `sudo service ssh status` + + If the command line returns a status of down, then you’ve likely found the reason behind your connectivity error. + + ***Ubuntu:*** + + Sometimes a connection refused error can also indicate that there is an IP address conflict on your network. You can search for possible IP conflicts by running: + + `arp-scan -I eth0 -l | grep ` + + `arp-scan -I eth0 -l | grep ` + + and + + `arping ` + +2. `yarn db:create` creates database with the assembled tables (on MacOS with Postgres database) + + The workaround - put the next commands to your Postgres database terminal: + + `DROP SCHEMA public CASCADE;` + + `CREATE SCHEMA public;` + + `GRANT ALL ON SCHEMA public TO postgres;` + + `GRANT ALL ON SCHEMA public TO public;` + + Afterwards, continue to start your project in the backend directory by running: + + `yarn start` diff --git a/app-shell/.eslintrc.cjs b/app-shell/.eslintrc.cjs new file mode 100644 index 0000000..563d159 --- /dev/null +++ b/app-shell/.eslintrc.cjs @@ -0,0 +1,26 @@ +const globals = require('globals'); + +module.exports = [ + { + files: ['**/*.js', '**/*.ts', '**/*.tsx'], + languageOptions: { + ecmaVersion: 2021, + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + }, + parser: '@typescript-eslint/parser', + }, + plugins: ['@typescript-eslint'], + rules: { + 'no-unused-vars': 'warn', + 'no-console': 'off', + 'indent': ['error', 2], + 'quotes': ['error', 'single'], + 'semi': ['error', 'always'], + + '@typescript-eslint/no-unused-vars': 'warn', + }, + }, +]; \ No newline at end of file diff --git a/app-shell/.prettierrc b/app-shell/.prettierrc new file mode 100644 index 0000000..bb087f2 --- /dev/null +++ b/app-shell/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "printWidth": 80, + "trailingComma": "all", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always" +} diff --git a/app-shell/.sequelizerc b/app-shell/.sequelizerc new file mode 100644 index 0000000..fe89188 --- /dev/null +++ b/app-shell/.sequelizerc @@ -0,0 +1,7 @@ +const path = require('path'); +module.exports = { + "config": path.resolve("src", "db", "db.config.js"), + "models-path": path.resolve("src", "db", "models"), + "seeders-path": path.resolve("src", "db", "seeders"), + "migrations-path": path.resolve("src", "db", "migrations") +}; \ No newline at end of file diff --git a/app-shell/Dockerfile b/app-shell/Dockerfile new file mode 100644 index 0000000..eb79c5d --- /dev/null +++ b/app-shell/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20.15.1-alpine + +RUN apk update && apk add bash +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY package*.json ./ + +RUN yarn install +# If you are building your code for production +# RUN npm ci --only=production + + +# Bundle app source +COPY . . + + +EXPOSE 4000 + +CMD [ "yarn", "start" ] diff --git a/app-shell/README.md b/app-shell/README.md new file mode 100644 index 0000000..c53191f --- /dev/null +++ b/app-shell/README.md @@ -0,0 +1,13 @@ +#test - template backend, + +#### Run App on local machine: + +##### Install local dependencies: + +- `yarn install` + +--- + +##### Start build: + +- `yarn start` diff --git a/app-shell/package.json b/app-shell/package.json new file mode 100644 index 0000000..12c9700 --- /dev/null +++ b/app-shell/package.json @@ -0,0 +1,43 @@ +{ + "name": "app-shell", + "description": "app-shell", + "scripts": { + "start": "node ./src/index.js" + }, + "dependencies": { + "@babel/parser": "^7.26.7", + "adm-zip": "^0.5.16", + "axios": "^1.6.7", + "bcrypt": "5.1.1", + "cors": "2.8.5", + "eslint": "^9.13.0", + "express": "4.18.2", + "formidable": "1.2.2", + "helmet": "4.1.1", + "json2csv": "^5.0.7", + "jsonwebtoken": "8.5.1", + "lodash": "4.17.21", + "moment": "2.30.1", + "multer": "^1.4.4", + "passport": "^0.7.0", + "passport-google-oauth2": "^0.2.0", + "passport-jwt": "^4.0.1", + "passport-microsoft": "^0.1.0", + "postcss": "^8.5.1", + "puppeteer": "^24.10.0", + "sequelize-json-schema": "^2.1.1", + "pg": "^8.13.3" + }, + "engines": { + "node": ">=18" + }, + "private": true, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.12.2", + "@typescript-eslint/parser": "^8.12.2", + "cross-env": "7.0.3", + "mocha": "8.1.3", + "nodemon": "^3.1.7", + "sequelize-cli": "6.6.2" + } +} diff --git a/app-shell/src/_schema.json b/app-shell/src/_schema.json new file mode 100644 index 0000000..1ba387e --- /dev/null +++ b/app-shell/src/_schema.json @@ -0,0 +1,5 @@ + + +{ + "Initial version": "{\"iv\":\"D2YNbtm8CzrB03IS\",\"encryptedData\":\"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\"}" +} diff --git a/app-shell/src/config.js b/app-shell/src/config.js new file mode 100644 index 0000000..bd18b54 --- /dev/null +++ b/app-shell/src/config.js @@ -0,0 +1,18 @@ + + +const config = { + admin_pass: "9023ec63", + admin_email: "admin@flatlogic.com", + schema_encryption_key: process.env.SCHEMA_ENCRYPTION_KEY || '', + + project_uuid: '9023ec63-5313-4bee-9c57-4d8ac698b0e7', + flHost: process.env.NODE_ENV === 'production' ? 'https://flatlogic.com/projects' : 'http://localhost:3000/projects', + + gitea_domain: process.env.GITEA_DOMAIN || 'gitea.flatlogic.app', + gitea_username: process.env.GITEA_USERNAME || 'admin', + gitea_api_token: process.env.GITEA_API_TOKEN || null, + github_repo_url: process.env.GITHUB_REPO_URL || null, + github_token: process.env.GITHUB_TOKEN || null, +}; + +module.exports = config; diff --git a/app-shell/src/helpers.js b/app-shell/src/helpers.js new file mode 100644 index 0000000..1d918b5 --- /dev/null +++ b/app-shell/src/helpers.js @@ -0,0 +1,23 @@ +const jwt = require('jsonwebtoken'); +const config = require('./config'); + +module.exports = class Helpers { + static wrapAsync(fn) { + return function (req, res, next) { + fn(req, res, next).catch(next); + }; + } + + static commonErrorHandler(error, req, res, next) { + if ([400, 403, 404].includes(error.code)) { + return res.status(error.code).send(error.message); + } + + console.error(error); + return res.status(500).send(error.message); + } + + static jwtSign(data) { + return jwt.sign(data, config.secret_key, { expiresIn: '6h' }); + } +}; diff --git a/app-shell/src/index.js b/app-shell/src/index.js new file mode 100644 index 0000000..5f551fe --- /dev/null +++ b/app-shell/src/index.js @@ -0,0 +1,54 @@ +const express = require('express'); +const cors = require('cors'); +const app = express(); +const bodyParser = require('body-parser'); +const checkPermissions = require('./middlewares/check-permissions'); +const modifyPath = require('./middlewares/modify-path'); +const VCS = require('./services/vcs'); + +const executorRoutes = require('./routes/executor'); +const vcsRoutes = require('./routes/vcs'); + +// Function to initialize the Git repository +function initRepo() { + const projectId = '35035'; + return VCS.initRepo(projectId); +} + +// Start the Express app on APP_SHELL_PORT (4000) +function startServer() { + const PORT = 4000; + app.listen(PORT, () => { + console.log(`Listening on port ${PORT}`); + }); +} + +// Run Git check after the server is up +function runGitCheck() { + initRepo() + .then(result => { + console.log(result?.message ? result.message : result); + // Here you can add additional logic if needed + }) + .catch(err => { + console.error('Error during repo initialization:', err); + // Optionally exit the process if Git check is critical: + // process.exit(1); + }); +} + +app.use(cors({ origin: true })); +app.use(bodyParser.json()); +app.use(checkPermissions); +app.use(modifyPath); + +app.use('/executor', executorRoutes); +app.use('/vcs', vcsRoutes); + +// Start the app_shell server +startServer(); + +// Now perform Git check +runGitCheck(); + +module.exports = app; diff --git a/app-shell/src/middlewares/check-permissions.js b/app-shell/src/middlewares/check-permissions.js new file mode 100644 index 0000000..cc9d90a --- /dev/null +++ b/app-shell/src/middlewares/check-permissions.js @@ -0,0 +1,17 @@ +const config = require('../config'); + +function checkPermissions(req, res, next) { + const project_uuid = config.project_uuid; + const requiredHeader = 'X-Project-UUID'; + const headerValue = req.headers[requiredHeader.toLowerCase()]; + // Logging whatever request we're getting + console.log('Request:', req.url, req.method, req.body, req.headers); + + if (headerValue && headerValue === project_uuid) { + next(); + } else { + res.status(403).send({ error: 'Stop right there, criminal scum! Your project UUID is invalid or missing.' }); + } +} + +module.exports = checkPermissions; \ No newline at end of file diff --git a/app-shell/src/middlewares/modify-path.js b/app-shell/src/middlewares/modify-path.js new file mode 100644 index 0000000..0154280 --- /dev/null +++ b/app-shell/src/middlewares/modify-path.js @@ -0,0 +1,8 @@ +function modifyPath(req, res, next) { + if (req.body && req.body.path) { + req.body.path = '../../../' + req.body.path; + } + next(); + } + +module.exports = modifyPath; \ No newline at end of file diff --git a/app-shell/src/routes/executor.js b/app-shell/src/routes/executor.js new file mode 100644 index 0000000..7cb490b --- /dev/null +++ b/app-shell/src/routes/executor.js @@ -0,0 +1,346 @@ +const express = require('express'); +const multer = require('multer'); +const upload = multer({ dest: 'uploads/' }); +const fs = require('fs'); + +const ExecutorService = require('../services/executor'); +const { takeScreenshot } = require("../services/screenshot_service"); + +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.post( + '/read_project_tree', + wrapAsync(async (req, res) => { + const { path } = req.body; + const tree = await ExecutorService.readProjectTree(path); + res.status(200).send(tree); + }), +); + +router.post( + '/read_file', + wrapAsync(async (req, res) => { + const { path, showLines } = req.body; + const content = await ExecutorService.readFileContents(path, showLines); + res.status(200).send(content); + }), +); + +router.post( + '/count_file_lines', + wrapAsync(async (req, res) => { + const { path } = req.body; + const content = await ExecutorService.countFileLines(path); + res.status(200).send(content); + }), +); + +// router.post( +// '/read_file_header', +// wrapAsync(async (req, res) => { +// const { path, N } = req.body; +// try { +// const header = await ExecutorService.readFileHeader(path, N); +// res.status(200).send(header); +// } catch (error) { +// res.status(500).send({ +// error: true, +// message: error.message, +// details: error.details || error.stack, +// validation: error.validation +// }); +// } +// }), +// ); + +router.post( + '/read_file_line_context', + wrapAsync(async (req, res) => { + const { path, lineNumber, windowSize, showLines } = req.body; + try { + const context = await ExecutorService.readFileLineContext(path, lineNumber, windowSize, showLines); + res.status(200).send(context); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); + +router.post( + '/write_file', + wrapAsync(async (req, res) => { + const { path, fileContents, comment } = req.body; + try { + await ExecutorService.writeFile(path, fileContents, comment); + res.status(200).send({ message: 'File written successfully' }); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); + +router.post( + '/insert_file_content', + wrapAsync(async (req, res) => { + const { path, lineNumber, newContent, message } = req.body; + try { + await ExecutorService.insertFileContent(path, lineNumber, newContent, message); + res.status(200).send({ message: 'File written successfully' }); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); + +router.post( + '/replace_file_line', + wrapAsync(async (req, res) => { + const { path, lineNumber, newText } = req.body; + try { + const result = await ExecutorService.replaceFileLine(path, lineNumber, newText); + res.status(200).send(result); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); +router.post( + '/replace_file_chunk', + wrapAsync(async (req, res) => { + const { path, startLine, endLine, newCode } = req.body; + try { + const result = await ExecutorService.replaceFileChunk(path, startLine, endLine, newCode); + res.status(200).send(result); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); + +router.post( + '/delete_file_lines', + wrapAsync(async (req, res) => { + const { path, startLine, endLine, message } = req.body; + try { + const result = await ExecutorService.deleteFileLines(path, startLine, endLine, message); + res.status(200).send(result); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); + +router.post( + '/validate_file', + wrapAsync(async (req, res) => { + const { path } = req.body; + try { + const validationResult = await ExecutorService.validateFile(path); + res.status(200).send({ validationResult }); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }); + } + }), +); + + +router.post( + '/check_frontend_runtime_error', + wrapAsync(async (req, res) => { + try { + const result = await ExecutorService.checkFrontendRuntimeLogs(); + res.status(200).send(result); + } catch (error) { + res.status(500).send({ error: error }); + } + }), +); + + +router.post( + '/replace_code_block', + wrapAsync(async (req, res) => { + const {path, oldCode, newCode, message} = req.body; + try { + const response = await ExecutorService.replaceCodeBlock(path, oldCode, newCode, message); + res.status(200).send(response); + } catch (error) { + res.status(500).send({ + error: true, + message: error.message, + details: error.details || error.stack, + validation: error.validation + }) + } + }) +) + +router.post('/update_project_files_from_scheme', + upload.single('file'), // 'file' - name of the field in the form + async (req, res) => { + console.log('Request received'); + console.log('Headers:', req.headers); + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + console.log('File info:', { + originalname: req.file.originalname, + path: req.file.path, + size: req.file.size, + mimetype: req.file.mimetype + }); + + try { + console.log('Starting update process...'); + const result = await ExecutorService.updateProjectFilesFromScheme(req.file.path); + console.log('Update completed, result:', result); + + console.log('Removing temp file...'); + fs.unlinkSync(req.file.path); + console.log('Temp file removed'); + + console.log('Sending response...'); + return res.json(result); + } catch (error) { + console.error('Error in route handler:', error); + if (req.file) { + try { + fs.unlinkSync(req.file.path); + console.log('Temp file removed after error'); + } catch (unlinkError) { + console.error('Error removing temp file:', unlinkError); + } + } + console.error('Update project files error:', error); + return res.status(500).json({ + error: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }); + } + } +); + +router.post( + '/get_db_schema', + wrapAsync(async (req, res) => { + try { + + const jsonSchema = await ExecutorService.getDBSchema(); + res.status(200).send({ jsonSchema }); + } catch (error) { + res.status(500).send({ error: error }); + } + }), +); + +router.post( + '/execute_sql', + wrapAsync(async (req, res) => { + try { + const { query } = req.body; + const result = await ExecutorService.executeSQL(query); + res.status(200).send(result); + } catch (error) { + res.status(500).send({ error: error }); + } + }), +); + +router.post( + '/search_files', + wrapAsync(async (req, res) => { + try { + const { searchStrings } = req.body; + + if ( + typeof searchStrings !== 'string' && + !( + Array.isArray(searchStrings) && + searchStrings.every(item => typeof item === 'string') + ) + ) { + return res.status(400).send({ error: 'searchStrings must be a string or an array of strings' }); + } + + const result = await ExecutorService.searchFiles(searchStrings); + res.status(200).send(result); + } catch (error) { + res.status(500).send({ error: error.message }); + } + }), +); + +router.post( + '/screenshot', + wrapAsync(async (req, res) => { + const FRONT_PORT = process.env.FRONT_PORT || 3001; + const targetUrl = `http://localhost:${FRONT_PORT}${req.body.url || '/'}`; + + const filename = req.query.filename || `screenshot-${Date.now()}.png`; + const fullPage = req.query.fullPage !== 'false'; + + try { + console.log(`[App-Shell/Screenshot Route]: request to take screenshot of ${targetUrl} with filename ${filename} and fullPage=${fullPage}`); + const outputPath = await takeScreenshot(targetUrl, filename, fullPage); + + res.sendFile(outputPath, async (err) => { + if (err) { + console.error(`[App-Shell/Screenshot Route]: error sending screenshot ${outputPath}:`, err); + if (err.code === 'ENOENT') { + res.status(404).send('Screenshot not found.'); + } else { + res.status(500).send('Error sending screenshot: ' + err.message); + } + } else { + console.log(`[App-Shell/Screenshot Route]: Screenshot sent successfully: ${outputPath}`); + } + }); + + } catch (error) { + console.error(`[App-Shell/Screenshot Route]: Could not take screenshot of ${targetUrl}:`, error); + res.status(500).send(`Error taking screenshot: ${error.message}`); + } + }) +) + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/app-shell/src/routes/vcs.js b/app-shell/src/routes/vcs.js new file mode 100644 index 0000000..926498d --- /dev/null +++ b/app-shell/src/routes/vcs.js @@ -0,0 +1,40 @@ +const express = require('express'); +const wrapAsync = require('../helpers').wrapAsync; // Ваша обёртка для обработки асинхронных маршрутов +const VSC = require('../services/vcs'); +const router = express.Router(); + +router.post('/init', wrapAsync(async (req, res) => { + const result = await VSC.initRepo(); + res.status(200).send(result); +})); + +router.post('/commit', wrapAsync(async (req, res) => { + const { message, files, dev_schema } = req.body; + const result = await VSC.commitChanges(message, files, dev_schema); + res.status(200).send(result); +})); + +router.post('/log', wrapAsync(async (req, res) => { + const result = await VSC.getLog(); + res.status(200).send(result); +})); + +router.post('/rollback', wrapAsync(async (req, res) => { + const { ref } = req.body; + // const result = await VSC.checkout(ref); + const result = await VSC.revert(ref); + res.status(200).send(result); +})); + +router.post('/sync-to-stable', wrapAsync(async (req, res) => { + const result = await VSC.mergeDevIntoMaster(); + res.status(200).send(result); +})); + +router.post('/reset-dev', wrapAsync(async (req, res) => { + const result = await VSC.resetDevBranch(); + res.status(200).send(result); +})); + +router.use('/', require('../helpers').commonErrorHandler); +module.exports = router; \ No newline at end of file diff --git a/app-shell/src/services/database.js b/app-shell/src/services/database.js new file mode 100644 index 0000000..bf8f3a9 --- /dev/null +++ b/app-shell/src/services/database.js @@ -0,0 +1,88 @@ +// Database.js +const { Client } = require('pg'); +const config = require('../../../backend/src/db/db.config'); + +const env = process.env.NODE_ENV || 'development'; +const dbConfig = config[env]; + +class Database { + constructor() { + this.client = new Client({ + user: dbConfig.username, + password: dbConfig.password, + database: dbConfig.database, + host: dbConfig.host, + port: dbConfig.port + }); + + // Connect once, reuse the client + this.client.connect().catch(err => { + console.error('Error connecting to the database:', err); + throw err; + }); + } + + async executeSQL(query) { + try { + const result = await this.client.query(query); + return { + success: true, + rows: result.rows + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + // Method to fetch simple table/column info from 'information_schema' + // (You can expand this to handle constraints, indexes, etc.) + async getDBSchema(schemaName = 'public') { + try { + const tableQuery = ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = $1 + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + const columnQuery = ` + SELECT table_name, column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = $1 + ORDER BY table_name, ordinal_position + `; + + const [tablesResult, columnsResult] = await Promise.all([ + this.client.query(tableQuery, [schemaName]), + this.client.query(columnQuery, [schemaName]), + ]); + + // Build a simple schema object: + const tables = tablesResult.rows.map(row => row.table_name); + const columnsByTable = {}; + + columnsResult.rows.forEach(row => { + const { table_name, column_name, data_type, is_nullable } = row; + if (!columnsByTable[table_name]) columnsByTable[table_name] = []; + columnsByTable[table_name].push({ column_name, data_type, is_nullable }); + }); + + // Combine tables with their columns + return tables.map(table => ({ + table, + columns: columnsByTable[table] || [], + })); + } catch (error) { + console.error('Error fetching schema:', error); + throw error; + } + } + + async close() { + await this.client.end(); + } +} + +module.exports = new Database(); diff --git a/app-shell/src/services/executor.js b/app-shell/src/services/executor.js new file mode 100644 index 0000000..eecb869 --- /dev/null +++ b/app-shell/src/services/executor.js @@ -0,0 +1,1206 @@ +const fs = require('fs').promises; +const os = require('os'); +const path = require('path'); +const AdmZip = require('adm-zip'); +const { exec } = require('child_process'); +const util = require('util'); +const ProjectEventsService = require('./project-events'); +const config = require('../config.js'); +// Babel Parser for JS/TS/TSX +const babelParser = require('@babel/parser'); +const babelParse = babelParser.parse; + +// Local App DB Connection +const database = require('./database'); + +// PostCSS for CSS +const postcss = require('postcss'); + +const execAsync = util.promisify(exec); + +module.exports = class ExecutorService { + static async readProjectTree(directoryPath) { + const paths = { + frontend: '../../../frontend', + backend: '../../../backend', + default: '../../../' + }; + + try { + const publicDir = path.join(__dirname, paths[directoryPath] || directoryPath || paths.default); + + return await getDirectoryTree(publicDir); + } catch (error) { + console.error('Error reading directory:', error); + + throw error; + } + } + + static async readFileContents(filePath, showLines) { + try { + const fullPath = path.join(__dirname, filePath); + const content = await fs.readFile(fullPath, 'utf8'); + + if (showLines) { + const lines = content.split('\n'); + + const lineObject = {}; + lines.forEach((line, index) => { + lineObject[index + 1] = line; + }); + + return lineObject; + } else { + return content; + } + } catch (error) { + console.error('Error reading file:', error); + throw error; + } + } + + static async countFileLines(filePath) { + try { + const fullPath = path.join(__dirname, filePath); + + // Check file exists + await fs.access(fullPath); + + // Read file content + const content = await fs.readFile(fullPath, 'utf8'); + + // Split by newline and count + const lines = content.split('\n'); + + return { + success: true, + lineCount: lines.length + }; + } catch (error) { + console.error('Error counting file lines:', error); + return { + success: false, + message: error.message + }; + } + } + + // static async readFileHeader(filePath, N = 30) { + // try { + // const fullPath = path.join(__dirname, filePath); + // const content = await fs.readFile(fullPath, 'utf8'); + // const lines = content.split('\n'); + // + // if (lines.length < N) { + // return { error: `File has less than ${N} lines` }; + // } + // + // const headerLines = lines.slice(0, Math.min(50, lines.length)); + // + // const lineObject = {}; + // headerLines.forEach((line, index) => { + // lineObject[index + 1] = line; + // }); + // + // return lineObject; + // } catch (error) { + // console.error('Error reading file header:', error); + // throw error; + // } + // } + + static async readFileLineContext(filePath, lineNumber, windowSize, showLines) { + try { + const fullPath = path.join(__dirname, filePath); + const content = await fs.readFile(fullPath, 'utf8'); + const lines = content.split('\n'); + + const start = Math.max(0, lineNumber - windowSize); + const end = Math.min(lines.length, lineNumber + windowSize + 1); + + const contextLines = lines.slice(start, end); + + if (showLines) { + const lineObject = {}; + contextLines.forEach((line, index) => { + lineObject[start + index + 1] = line; + }); + + return lineObject; + } else { + return contextLines.join('\n'); + } + } catch (error) { + console.error('Error reading file line context:', error); + throw error; + } + } + + static async validateFile(filePath) { + console.log('Validating file:', filePath); + + // Read file content + let content; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch (err) { + throw new Error(`Could not read file: ${filePath}\n${err.message}`); + } + + // Determine file extension + let ext = path.extname(filePath).toLowerCase(); + if (ext === '.temp') { + ext = path.extname(filePath.slice(0, -5)).toLowerCase(); + } + + try { + switch (ext) { + case '.js': + case '.ts': + case '.tsx': { + // Parse JS/TS/TSX with Babel + babelParse(content, { + sourceType: 'module', + // plugins array covers JS, TS, TSX, and optional JS flavors + plugins: ['jsx', 'typescript'] + }); + break; + } + + case '.css': { + // Parse CSS with PostCSS + postcss.parse(content); + break; + } + + default: { + // If the extension isn't recognized, assume it's "valid" + // or you could throw an error to force a known extension + console.warn(`No validation implemented for extension "${ext}". Skipping syntax check.`); + } + } + + // If parsing succeeded, return true + return true; + + } catch (parseError) { + // Rethrow parse errors with a friendlier message + throw parseError; + } + } + + static async checkFrontendRuntimeLogs() { + const frontendLogPath = '../frontend/json/runtimeError.json'; + + try { + // Check if file exists + try { + console.log('Accessing frontend logs:', frontendLogPath); + await fs.access(frontendLogPath); + } catch (error) { + console.log('Frontend logs not found:', error); + // File doesn't exist - return empty object + return {runtime_error: {}}; + } + + // File exists, try to read it + try { + // Read the entire file instead of using tail + const fileContent = await fs.readFile(frontendLogPath, 'utf8'); + console.log('Reading frontend logs:', fileContent); + + // Handle empty file + if (!fileContent || fileContent.trim() === '') { + return {runtime_error: {}}; + } + + // Parse JSON content + const runtime_error = JSON.parse(fileContent); + + console.log('Parsed frontend logs:', runtime_error); + return {runtime_error}; + } catch (error) { + // Error reading or parsing file + console.error('Error reading frontend runtime logs:', error); + return {runtime_error: {}}; + } + } catch (error) { + // Unexpected error + console.log('Error checking frontend logs:', error); + return {runtime_error: {}}; + } + } + + static async writeFile(filePath, fileContents, comment) { + try { + console.log(comment) + const fullPath = path.join(__dirname, filePath); + + // Write to a temp file first + const tempPath = `${fullPath}.temp`; + await fs.writeFile(tempPath, fileContents, 'utf8'); + + // Validate the temp file + await this.validateFile(tempPath); + + // Rename temp file to original path + await fs.rename(tempPath, fullPath); + + return true; + } catch (error) { + console.error('Error writing file:', error); + throw error; + } + } + + static async insertFileContent(filePath, lineNumber, newContent, message) { + try { + const fullPath = path.join(__dirname, filePath); + + // Check file exists + await fs.access(fullPath); + + // Read and split by line + const content = await fs.readFile(fullPath, 'utf8'); + const lines = content.split('\n'); + + // Ensure lineNumber is within [1 ... lines.length + 1] + // 1 means "insert at the very first line" + // lines.length + 1 means "append at the end" + if (lineNumber < 1) { + lineNumber = 1; + } + if (lineNumber > lines.length + 1) { + lineNumber = lines.length + 1; + } + + // Convert to 0-based index + const insertIndex = lineNumber - 1; + + // Prepare preview + const preview = { + insertionLine: lineNumber, + insertedLines: newContent.split('\n') + }; + + // Insert newContent lines at the specified index + lines.splice(insertIndex, 0, ...newContent.split('\n')); + + // Write changes to a temp file first + const updatedContent = lines.join('\n'); + const tempPath = `${fullPath}.temp`; + await fs.writeFile(tempPath, updatedContent, 'utf8'); + + await this.validateFile(tempPath); + + // Rename temp file to original path + await fs.rename(tempPath, fullPath); + + return { + success: true + }; + + } catch (error) { + console.error('Error inserting file content:', error); + throw error; + } + } + + static async replaceFileLine(filePath, lineNumber, newText, message = null) { + const fullPath = path.join(__dirname, filePath); + try { + + try { + await fs.access(fullPath); + } catch (error) { + throw new Error(`File not found: ${filePath}`); + } + + const content = await fs.readFile(fullPath, 'utf8'); + const lines = content.split('\n'); + + if (lineNumber < 1 || lineNumber > lines.length) { + throw new Error(`Invalid line number: ${lineNumber}. File has ${lines.length} lines`); + } + + if (typeof newText !== 'string') { + throw new Error('New text must be a string'); + } + + const preview = { + oldLine: lines[lineNumber - 1], + newLine: newText, + lineNumber: lineNumber + }; + + lines[lineNumber - 1] = newText; + const newContent = lines.join('\n'); + const tempPath = `${fullPath}.temp`; + await fs.writeFile(tempPath, newContent, 'utf8'); + + await this.validateFile(tempPath); + + await fs.rename(tempPath, fullPath); + + return { + success: true + }; + + } catch (error) { + console.error('Error updating file line:', error); + + try { + await fs.unlink(`${fullPath}.temp`); + } catch { + } + + throw { + error: error, + message: error.message, + details: error.stack + }; + } + } + + static async replaceFileChunk(filePath, startLine, endLine, newCode) { + try { + // Check if this is a single-line change + const newCodeLines = newCode.split('\n'); + if (newCodeLines.length === 1 && endLine === startLine) { + // Redirect to replace_file_line + return await this.replaceFileLine(filePath, startLine, newCode); + } + + const fullPath = path.join(__dirname, filePath); + + // Check if file exists + try { + await fs.access(fullPath); + } catch (error) { + throw new Error(`File not found: ${filePath}`); + } + + const content = await fs.readFile(fullPath, 'utf8'); + const lines = content.split('\n'); + + // Adjust line numbers to array indices (subtract 1) + const startIndex = startLine - 1; + const endIndex = endLine - 1; + + // Validate input parameters + if (startIndex < 0 || endIndex >= lines.length || startIndex > endIndex) { + throw new Error(`Invalid line range: ${startLine}-${endLine}. File has ${lines.length} lines`); + } + + // Check type of new code + if (typeof newCode !== 'string') { + throw new Error('New code must be a string'); + } + + // Create changes preview + const preview = { + oldLines: lines.slice(startIndex, endIndex + 1), + newLines: newCode.split('\n'), + startLine, + endLine + }; + + // Apply changes to temp file first + lines.splice(startIndex, endIndex - startIndex + 1, ...newCode.split('\n')); + const newContent = lines.join(os.EOL); + const tempPath = `${fullPath}.temp`; + await fs.writeFile(tempPath, newContent, 'utf8'); + await this.validateFile(tempPath); + // Apply changes if all validations passed + await fs.rename(tempPath, fullPath); + + return { + success: true + }; + + } catch (error) { + console.error('Error updating file slice:', error); + + // Clean up temp file if exists + try { + await fs.unlink(`${fullPath}.temp`); + } catch { + } + + throw { + error: error, + message: error.message, + details: error.details || error.stack + }; + } + } + + static async replaceCodeBlock(filePath, oldCode, newCode, message) { + try { + console.log(message); + const fullPath = path.join(__dirname, filePath); + + // Check file exists + await fs.access(fullPath); + + // Read file content + let content = await fs.readFile(fullPath, 'utf8'); + + // A small helper to unify line breaks to just `\n` + const unifyLineBreaks = (str) => str.replace(/\r\n/g, '\n'); + + // Normalize line breaks in file content, oldCode, and newCode + content = unifyLineBreaks(content); + oldCode = unifyLineBreaks(oldCode); + newCode = unifyLineBreaks(newCode); + + // Optional: Trim trailing spaces or handle other whitespace normalization if needed + // oldCode = oldCode.trim(); + // newCode = newCode.trim(); + + // Check if oldCode actually exists in the content + const index = content.indexOf(oldCode); + if (index === -1) { + return { + success: false, + message: 'Old code not found in file.' + }; + } + + // Create a preview before replacing + const preview = { + oldCodeSnippet: oldCode, + newCodeSnippet: newCode + }; + + // Perform replacement (single occurrence). For multiple, use replaceAll or a loop. + // If you want a global replacement, consider: + // content = content.split(oldCode).join(newCode); + content = content.replace(oldCode, newCode); + + // Write to a temp file first + const tempPath = `${fullPath}.temp`; + await fs.writeFile(tempPath, content, 'utf8'); + + await this.validateFile(tempPath); + // Rename temp file to original + await fs.rename(tempPath, fullPath); + + return { + success: true + }; + + } catch (error) { + console.error('Error replacing code:', error); + return { + error: error, + message: error.message, + details: error.details || error.stack + }; + } + } + + //todo add validation + static async deleteFileLines(filePath, startLine, endLine, veryShortDescription) { + try { + const fullPath = path.join(__dirname, filePath); + + // Check if file exists + await fs.access(fullPath); + + // Read file content + const content = await fs.readFile(fullPath, 'utf8'); + const lines = content.split('\n'); + + // Convert to zero-based indices + const startIndex = startLine - 1; + const endIndex = endLine - 1; + + // Validate range + if (startIndex < 0 || endIndex >= lines.length || startIndex > endIndex) { + throw new Error( + `Invalid line range: ${startLine}-${endLine}. File has ${lines.length} lines` + ); + } + + // Prepare a preview of the lines being deleted + const preview = { + deletedLines: lines.slice(startIndex, endIndex + 1), + startLine, + endLine + }; + + // Remove lines + lines.splice(startIndex, endIndex - startIndex + 1); + + // Join remaining lines and write to a temporary file + const newContent = lines.join('\n'); + const tempPath = `${fullPath}.temp`; + await fs.writeFile(tempPath, newContent, 'utf8'); + + await this.validateFile(tempPath); + // Rename temp file to original + await fs.rename(tempPath, fullPath); + + return { + success: true + }; + + } catch (error) { + console.error('Error deleting file lines:', error); + return { + error: error, + message: error.message, + details: error.details || error.stack + }; + } + } + + static async validateTypeScript(filePath, content = null) { + try { + // Basic validation of JSX syntax + const jsxErrors = []; + + if (content !== null) { + // Check for matching braces + if ((content.match(/{/g) || []).length !== (content.match(/}/g) || []).length) { + jsxErrors.push("Unmatched curly braces"); + } + + // Check for invalid syntax in JSX attributes + if (content.includes('label={')) { + if (!content.match(/label={[^}]+}/)) { + jsxErrors.push("Invalid label attribute syntax"); + } + } + + if (jsxErrors.length > 0) { + return { + valid: false, + errors: jsxErrors.map(error => ({ + code: 'JSX_SYNTAX_ERROR', + severity: 'error', + location: '', + message: error + })) + }; + } + } + + return { + valid: true, + errors: [], + errorCount: 0, + warningCount: 0 + }; + + } catch (error) { + console.error('TypeScript validation error:', error); + return { + valid: false, + errors: [{ + code: 'VALIDATION_FAILED', + severity: 'error', + location: '', + message: `TypeScript validation error: ${error.message}` + }], + errorCount: 1, + warningCount: 0 + }; + } + } + + static async validateBackendFiles(backendPath) { + try { + // Check for syntax errors + await execAsync(`node --check ${backendPath}/src/index.js`); + + // Try to run the code in a test environment + const testProcess = exec( + 'NODE_ENV=test node -e "try { require(\'./src/index.js\') } catch(e) { console.error(e); process.exit(1) }"', + {cwd: backendPath} + ); + + return new Promise((resolve) => { + let output = ''; + let error = ''; + + testProcess.stdout.on('data', (data) => { + output += data; + }); + + testProcess.stderr.on('data', (data) => { + error += data; + }); + + testProcess.on('close', (code) => { + if (code === 0) { + resolve({valid: true}); + } else { + resolve({ + valid: false, + error: error || output + }); + } + }); + + // Timeout on validation + setTimeout(() => { + testProcess.kill(); + resolve({ + valid: true, + warning: 'Validation timeout, but no immediate errors found' + }); + }, 5000); + }); + } catch (error) { + return { + valid: false, + error: error.message + }; + } + } + + static async createBackup(ROOT_PATH) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupDir = path.join(ROOT_PATH, 'backups', timestamp); + + try { + await fs.mkdir(path.join(ROOT_PATH, 'backups'), {recursive: true}); + + const dirsToBackup = ['frontend', 'backend']; + + for (const dir of dirsToBackup) { + const sourceDir = path.join(ROOT_PATH, dir); + const targetDir = path.join(backupDir, dir); + + await fs.mkdir(targetDir, {recursive: true}); + + await execAsync( + `cd "${sourceDir}" && ` + + `find . -type f -not -path "*/node_modules/*" -not -path "*/\\.*" | ` + + `while read file; do ` + + `mkdir -p "${targetDir}/$(dirname "$file")" && ` + + `cp "$file" "${targetDir}/$file"; ` + + `done` + ); + } + + console.log('Backup created at:', backupDir); + return backupDir; + } catch (error) { + console.error('Error creating backup:', error); + throw error; + } + } + + static async restoreFromBackup(backupDir, ROOT_PATH) { + try { + console.log('Restoring from backup:', backupDir); + await execAsync(`rm -rf ${ROOT_PATH}/backend/*`); + await execAsync(`cp -r ${backupDir}/* ${ROOT_PATH}/backend/`); + return true; + } catch (error) { + console.error('Error restoring from backup:', error); + throw error; + } + } + + static async updateProjectFilesFromScheme(zipFilePath) { + const MAX_FILE_SIZE = 10 * 1024 * 1024; + const ROOT_PATH = path.join(__dirname, '../../../'); + + try { + console.log('Checking file access...'); + await fs.access(zipFilePath); + + console.log('Getting file stats...'); + const stats = await fs.stat(zipFilePath); + console.log('File size:', stats.size); + + if (stats.size > MAX_FILE_SIZE) { + console.log('File size exceeds limit'); + return {success: false, error: 'File size exceeds limit'}; + } + + // Copying zip file to /tmp + const tempZipPath = path.join('/tmp', path.basename(zipFilePath)); + await fs.copyFile(zipFilePath, tempZipPath); + + // Launching background update process + const servicesUpdate = (async () => { + try { + console.log('Stopping services...'); + + // await ProjectEventsService.sendEvent('SERVICE_STOP_STARTED', { + // message: 'Stopping services', + // timestamp: new Date().toISOString() + // }); + + await stopServices(); + + // await ProjectEventsService.sendEvent('SERVICE_STOP_COMPLETED', { + // message: 'Services stopped successfully', + // timestamp: new Date().toISOString() + // }); + + console.log('Creating zip instance...'); + const zip = new AdmZip(tempZipPath); + + console.log('Extracting files to:', ROOT_PATH); + zip.extractAllTo(ROOT_PATH, true); + console.log('Files extracted'); + + const removedFilesPath = path.join(ROOT_PATH, 'removed_files.json'); + try { + await fs.access(removedFilesPath); + const removedFilesContent = await fs.readFile(removedFilesPath, 'utf8'); + const filesToRemove = JSON.parse(removedFilesContent); + await removeFiles(filesToRemove, ROOT_PATH); + + await fs.unlink(removedFilesPath); + } catch (error) { + console.log('No removed files to process or error accessing removed_files.json:', error); + } + + // Remove temp zip file + await fs.unlink(tempZipPath); + + // await ProjectEventsService.sendEvent('SERVICE_START_STARTED', { + // message: 'Starting services', + // timestamp: new Date().toISOString() + // }); + + // Start services after a delay + setTimeout(async () => { + try { + await startServices(); + console.log('Services started successfully'); + + await ProjectEventsService.sendEvent('SERVICE_START_COMPLETED', { + message: 'All files have been successfully retrieved and applied.', + timestamp: new Date().toISOString() + }); + } catch (e) { + console.error('Failed to start services:', e); + } + }, 3000); + + } catch (error) { + console.error('Error in service update process:', error); + } + })(); + + servicesUpdate.catch(error => { + console.error('Background update process failed:', error); + }); + + console.log('Returning immediate response'); + + return { + success: true, + message: 'Update process initiated' + }; + + } catch (error) { + console.error('Critical error in updateProjectFilesFromScheme:', error); + return { + success: false, + error: error.message + }; + } + } + + static async getDBSchema() { + try { + return await database.getDBSchema(); + } catch (error) { + console.error('Error reading schema:', error); + throw { + error: error, + message: error.message, + details: error.details || error.stack + }; + } + } + + static async executeSQL(query) { + try { + return await database.executeSQL(query); + } catch (error) { + console.error('Error executing query:', error); + throw { + error: error, + message: error.message, + details: error.details || error.stack + }; + } + } + + static async stopServices() { + return await stopServices(); + } + + static async startServices() { + return await startServices(); + } + + static async checkServicesStatus() { + return await checkStatus(); + } + + static async searchFiles(searchStrings) { + const results = {}; + const ROOT_PATH = path.join(__dirname, '../../../'); + const directories = [`${ROOT_PATH}backend/`, `${ROOT_PATH}frontend/`]; + const excludeDirs = ['node_modules', 'build', 'app_shell']; + + if (!Array.isArray(searchStrings)) { + searchStrings = [searchStrings]; + } + + for (const searchString of searchStrings) { + try { + for (const directoryPath of directories) { + const findCommand = `find '${directoryPath}' -type f ${excludeDirs.map(dir => `-not -path "*/${dir}/*"`).join(' ')} -print | xargs grep -nH -C 1 -e '${searchString}'`; + + try { + const { stdout } = await execAsync(findCommand); + + const lines = stdout.trim().split('\n').filter(line => line !== ''); + const searchResults = {}; + // searchResults['__raw_lines__'] = lines; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(':'); + let filePath = ''; + let lineNumberStr = ''; + let content = ''; + let relativeFilePath = ''; + let lineNum = null; + + if (parts.length >= 3 && !parts[0].includes('-')) { + filePath = parts.shift(); + lineNumberStr = parts.shift(); + content = parts.join(':').trim(); + relativeFilePath = filePath.replace(`${ROOT_PATH}`, ''); + lineNum = parseInt(lineNumberStr, 10) + 1; + } else { + content = line.trim(); + } + + const context = []; + if (i > 0 && lines[i - 1].includes(':')) { + const prevLineParts = lines[i - 1].split(':'); + if (prevLineParts.length >= 3 && !prevLineParts[0].includes('-')) { + prevLineParts.shift(); + prevLineParts.shift(); + context.push(prevLineParts.join(':').trim()); + } else { + context.push(lines[i - 1].trim()); + } + } + context.push(content); + if (i < lines.length - 1 && lines[i + 1].includes(':')) { + const nextLineParts = lines[i + 1].split(':'); + if (nextLineParts.length >= 3 && !nextLineParts[0].includes('-')) { + nextLineParts.shift(); + nextLineParts.shift(); + context.push(nextLineParts.join(':').trim()); + } else { + context.push(lines[i + 1].trim()); + } + } + + if (relativeFilePath && !searchResults[relativeFilePath]) { + searchResults[relativeFilePath] = []; + } + if (relativeFilePath) { + searchResults[relativeFilePath].push({ + lineNumber: lineNum, + context: context.join('\n'), + // __filePathAndLine__: filePath + ':' + lineNumberStr + ':' + content, + }); + } + } + + if (!results[searchString]) { + results[searchString] = {}; + } + Object.assign(results[searchString], searchResults); + } catch (err) { + if (!err.message.includes('No such file or directory') && !err.stderr.includes('No such file or directory')) { + console.error(`Error using find/grep for "${searchString}" in ${directoryPath}:`, err); + } + } + } + } catch (error) { + console.error(`Error searching for "${searchString}":`, error); + results[searchString] = { error: error.message }; + } + } + + return results; + } + +} + +async function getDirectoryTree(dirPath) { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const result = {}; + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isDirectory() && ( + entry.name === 'node_modules' || + entry.name === 'app-shell' || + entry.name === '.git' || + entry.name === '.idea' + )) { + continue; + } + + const relativePath = fullPath.replace('/app', ''); + + if (entry.isDirectory()) { + const subTree = await getDirectoryTree(fullPath); + Object.keys(subTree).forEach(key => { + result[key.replace('/app', '')] = subTree[key]; + }); + } else { + const fileContent = await fs.readFile(fullPath, 'utf8'); + const lineCount = fileContent.split('\n').length; + result[relativePath] = lineCount; + } + } + + return result; +} + +async function stopServices() { + try { + console.log('Finding service processes...'); + // await ProjectEventsService.sendEvent('SERVICE_STOP_INITIATED', { + // message: 'Initiating service stop', + // timestamp: new Date().toISOString() + // }); + // Frontend stopping + const { stdout: frontendProcess } = await execAsync("ps -o pid,cmd | grep '[n]ext-server' | awk '{print $1}'"); + if (frontendProcess.trim()) { + console.log('Stopping frontend, pid:', frontendProcess.trim()); + + // await ProjectEventsService.sendEvent('FRONTEND_STOP_STARTED', { + // message: `Stopping frontend, pid: ${frontendProcess.trim()}`, + // timestamp: new Date().toISOString() + // }); + + // await execAsync(`kill -15 ${frontendProcess.trim()}`); + + // await ProjectEventsService.sendEvent('FRONTEND_STOP_COMPLETED', { + // message: 'Frontend stopped successfully', + // timestamp: new Date().toISOString() + // }); + } + + // Backend stopping + const { stdout: backendProcess } = await execAsync("ps -o pid,cmd | grep '[n]ode ./src/index.js' | grep -v app-shell | awk '{print $1}'"); + if (backendProcess.trim()) { + console.log('Stopping backend, pid:', backendProcess.trim()); + + // await ProjectEventsService.sendEvent('BACKEND_STOP_STARTED', { + // message: `Stopping backend, pid: ${backendProcess.trim()}`, + // timestamp: new Date().toISOString() + // }); + + // await execAsync(`kill -15 ${backendProcess.trim()}`); + + // await ProjectEventsService.sendEvent('BACKEND_STOP_COMPLETED', { + // message: 'Backend stopped successfully', + // timestamp: new Date().toISOString() + // }); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + + + // await ProjectEventsService.sendEvent('SERVICE_STOP_COMPLETED', { + // message: 'All services stopped successfully', + // timestamp: new Date().toISOString() + // }); + + return { success: true }; + } catch (error) { + console.error('Error stopping services:', error); + + await ProjectEventsService.sendEvent('SERVICE_STOP_FAILED', { + message: 'Error stopping services', + error: error.message, + timestamp: new Date().toISOString() + }); + + return { success: false, error: error.message }; + } +} + +async function startServices() { + try { + console.log('Starting services...'); + // await ProjectEventsService.sendEvent('SERVICE_START_INITIATED', { + // message: 'Initiating service start', + // timestamp: new Date().toISOString() + // }); + + // await ProjectEventsService.sendEvent('FRONTEND_START_STARTED', { + // message: 'Starting frontend service', + // timestamp: new Date().toISOString() + // }); + // await execAsync('yarn --cwd /app/frontend dev &'); + // await ProjectEventsService.sendEvent('FRONTEND_START_COMPLETED', { + // message: 'Frontend service started successfully', + // timestamp: new Date().toISOString() + // }); + + // await ProjectEventsService.sendEvent('BACKEND_START_STARTED', { + // message: 'Starting backend service', + // timestamp: new Date().toISOString() + // }); + // await execAsync('yarn --cwd /app/backend start &'); + // await ProjectEventsService.sendEvent('BACKEND_START_COMPLETED', { + // message: 'Backend service started successfully', + // timestamp: new Date().toISOString() + // }); + + // await ProjectEventsService.sendEvent('SERVICE_START_COMPLETED', { + // message: 'All services started successfully', + // timestamp: new Date().toISOString() + // }); + + return { success: true }; + } catch (error) { + console.error('Error starting services:', error); + await ProjectEventsService.sendEvent('SERVICE_START_FAILED', { + message: 'Error starting services', + error: error.message, + timestamp: new Date().toISOString() + }); + return { success: false, error: error.message }; + } +} + +async function checkStatus() { + try { + const { stdout } = await execAsync('ps aux'); + return { + success: true, + frontendRunning: stdout.includes('next-server'), + backendRunning: stdout.includes('nodemon') && stdout.includes('/app/backend'), + nginxRunning: stdout.includes('nginx: master process') + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +} + +async function validateJSXSyntax(code) { + // Define validation rules for JSX + const rules = [ + { + // JSX attribute with expression + pattern: /^[a-zA-Z][a-zA-Z0-9]*={.*}$/, + message: 'Invalid JSX attribute syntax' + }, + { + // Invalid sequences + pattern: /,{2,}/, + message: 'Invalid character sequence detected', + shouldNotMatch: true + }, + { + // Ternary expressions + pattern: /^[a-zA-Z][a-zA-Z0-9]*={[\w\s]+\?[^}]+:[^}]+}$/, + message: 'Invalid ternary expression in JSX' + } + ]; + + // Validate each line + const lines = code.split('\n'); + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines + if (!trimmedLine) continue; + + // Check each rule + for (const rule of rules) { + if (rule.shouldNotMatch) { + // For patterns that should not be present + if (rule.pattern.test(trimmedLine)) { + return { + valid: false, + errors: [{ + code: 'JSX_SYNTAX_ERROR', + severity: 'error', + location: '', + message: rule.message + }] + }; + } + } else { + // For patterns that should match + if (trimmedLine.includes('=') && !rule.pattern.test(trimmedLine)) { + return { + valid: false, + errors: [{ + code: 'JSX_SYNTAX_ERROR', + severity: 'error', + location: '', + message: rule.message + }] + }; + } + } + } + + // Additional JSX-specific checks + if ((trimmedLine.match(/{/g) || []).length !== (trimmedLine.match(/}/g) || []).length) { + return { + valid: false, + errors: [{ + code: 'JSX_SYNTAX_ERROR', + severity: 'error', + location: '', + message: 'Unmatched curly braces in JSX' + }] + }; + } + } + + // If all checks pass + return { + valid: true, + errors: [] + }; +} + +async function removeFiles(files, rootPath) { + try { + for (const file of files) { + const fullPath = path.join(rootPath, file); + try { + await fs.unlink(fullPath); + console.log(`File removed: ${fullPath}`); + } catch (error) { + console.error(`Error when trying to delete a file ${fullPath}:`, error); + } + } + } catch (error) { + console.error('Error removing files:', error); + throw error; + } +} \ No newline at end of file diff --git a/app-shell/src/services/notifications/errors/forbidden.js b/app-shell/src/services/notifications/errors/forbidden.js new file mode 100644 index 0000000..192fa10 --- /dev/null +++ b/app-shell/src/services/notifications/errors/forbidden.js @@ -0,0 +1,16 @@ +const { getNotification, isNotification } = require('../helpers'); + +module.exports = class ForbiddenError extends Error { + constructor(messageCode) { + let message; + + if (messageCode && isNotification(messageCode)) { + message = getNotification(messageCode); + } + + message = message || getNotification('errors.forbidden.message'); + + super(message); + this.code = 403; + } +}; diff --git a/app-shell/src/services/notifications/errors/validation.js b/app-shell/src/services/notifications/errors/validation.js new file mode 100644 index 0000000..464550c --- /dev/null +++ b/app-shell/src/services/notifications/errors/validation.js @@ -0,0 +1,16 @@ +const { getNotification, isNotification } = require('../helpers'); + +module.exports = class ValidationError extends Error { + constructor(messageCode) { + let message; + + if (messageCode && isNotification(messageCode)) { + message = getNotification(messageCode); + } + + message = message || getNotification('errors.validation.message'); + + super(message); + this.code = 400; + } +}; diff --git a/app-shell/src/services/notifications/helpers.js b/app-shell/src/services/notifications/helpers.js new file mode 100644 index 0000000..1c3a60f --- /dev/null +++ b/app-shell/src/services/notifications/helpers.js @@ -0,0 +1,30 @@ +const _get = require('lodash/get'); +const errors = require('./list'); + +function format(message, args) { + if (!message) { + return null; + } + + return message.replace(/{(\d+)}/g, function (match, number) { + return typeof args[number] != 'undefined' ? args[number] : match; + }); +} + +const isNotification = (key) => { + const message = _get(errors, key); + return !!message; +}; + +const getNotification = (key, ...args) => { + const message = _get(errors, key); + + if (!message) { + return key; + } + + return format(message, args); +}; + +exports.getNotification = getNotification; +exports.isNotification = isNotification; diff --git a/app-shell/src/services/notifications/list.js b/app-shell/src/services/notifications/list.js new file mode 100644 index 0000000..a0a1613 --- /dev/null +++ b/app-shell/src/services/notifications/list.js @@ -0,0 +1,100 @@ +const errors = { + app: { + title: 'test', + }, + + auth: { + userDisabled: 'Your account is disabled', + forbidden: 'Forbidden', + unauthorized: 'Unauthorized', + userNotFound: `Sorry, we don't recognize your credentials`, + wrongPassword: `Sorry, we don't recognize your credentials`, + weakPassword: 'This password is too weak', + emailAlreadyInUse: 'Email is already in use', + invalidEmail: 'Please provide a valid email', + passwordReset: { + invalidToken: 'Password reset link is invalid or has expired', + error: `Email not recognized`, + }, + passwordUpdate: { + samePassword: `You can't use the same password. Please create new password`, + }, + userNotVerified: `Sorry, your email has not been verified yet`, + emailAddressVerificationEmail: { + invalidToken: 'Email verification link is invalid or has expired', + error: `Email not recognized`, + }, + }, + + iam: { + errors: { + userAlreadyExists: 'User with this email already exists', + userNotFound: 'User not found', + disablingHimself: `You can't disable yourself`, + revokingOwnPermission: `You can't revoke your own owner permission`, + deletingHimself: `You can't delete yourself`, + emailRequired: 'Email is required', + }, + }, + + importer: { + errors: { + invalidFileEmpty: 'The file is empty', + invalidFileExcel: 'Only excel (.xlsx) files are allowed', + invalidFileUpload: + 'Invalid file. Make sure you are using the last version of the template.', + importHashRequired: 'Import hash is required', + importHashExistent: 'Data has already been imported', + userEmailMissing: 'Some items in the CSV do not have an email', + }, + }, + + errors: { + forbidden: { + message: 'Forbidden', + }, + validation: { + message: 'An error occurred', + }, + searchQueryRequired: { + message: 'Search query is required', + }, + }, + + emails: { + invitation: { + subject: `You've been invited to {0}`, + body: ` +

Hello,

+

You've been invited to {0} set password for your {1} account.

+

{2}

+

Thanks,

+

Your {0} team

+ `, + }, + emailAddressVerification: { + subject: `Verify your email for {0}`, + body: ` +

Hello,

+

Follow this link to verify your email address.

+

{0}

+

If you didn't ask to verify this address, you can ignore this email.

+

Thanks,

+

Your {1} team

+ `, + }, + passwordReset: { + subject: `Reset your password for {0}`, + body: ` +

Hello,

+

Follow this link to reset your {0} password for your {1} account.

+

{2}

+

If you didn't ask to reset your password, you can ignore this email.

+

Thanks,

+

Your {0} team

+ `, + }, + }, +}; + +module.exports = errors; diff --git a/app-shell/src/services/project-events.js b/app-shell/src/services/project-events.js new file mode 100644 index 0000000..dabc32d --- /dev/null +++ b/app-shell/src/services/project-events.js @@ -0,0 +1,67 @@ +const axios = require('axios'); +const config = require('../config.js'); + +class ProjectEventsService { + /** + * Sends a project event to the Rails backend + * + * @param {string} eventType - Type of the event + * @param {object} payload - Event payload data + * @param {object} options - Additional options + * @param {string} [options.conversationId] - Optional conversation ID + * @param {boolean} [options.isError=false] - Whether this is an error event + * @returns {Promise} - Response from the webhook + */ + static async sendEvent(eventType, payload = {}, options = {}) { + try { + console.log(`[DEBUG] Sending project event: ${eventType}`); + + const webhookUrl = `https://flatlogic.com/projects/events_webhook`; + + // Prepare the event data + const eventData = { + project_uuid: config.project_uuid, + event_type: eventType, + payload: { + ...payload, + message: `[APP] ${payload.message}`, + is_error: options.isError || false, + system_message: true, + is_command_info: true + } + }; + + // Add conversation ID if provided + if (options.conversationId) { + eventData.conversation_id = options.conversationId; + } + + const headers = { + 'Content-Type': 'application/json', + 'x-project-uuid': config.project_uuid + }; + + console.log(`[DEBUG] Event data: ${JSON.stringify(eventData)}`); + + const response = await axios.post(webhookUrl, eventData, { headers }); + + console.log(`[DEBUG] Event sent successfully, status: ${response.status}`); + return response.data; + } catch (error) { + console.error(`[ERROR] Failed to send project event: ${error.message}`); + if (error.response) { + console.error(`[ERROR] Response status: ${error.response.status}`); + console.error(`[ERROR] Response data: ${JSON.stringify(error.response.data)}`); + } + + // Don't throw the error, just return a failed status + // This prevents errors in the event service from breaking app functionality + return { + success: false, + error: error.message + }; + } + } +} + +module.exports = ProjectEventsService; \ No newline at end of file diff --git a/app-shell/src/services/screenshot_service.js b/app-shell/src/services/screenshot_service.js new file mode 100644 index 0000000..9042b90 --- /dev/null +++ b/app-shell/src/services/screenshot_service.js @@ -0,0 +1,83 @@ +const puppeteer = require('puppeteer'); +const path = require('path'); +const fs = require('fs/promises'); +const config = require('../config'); + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; +const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR || '/tmp/screenshots'; +fs.mkdir(SCREENSHOT_DIR, { recursive: true }).catch(console.error); + +async function takeScreenshot(url, filename = `screenshot-${Date.now()}.png`, fullPage = true) { + let browser; + + const response = await axios.post( + `${BACKEND_URL}/api/auth/signin/local`, + { email: config.admin_email, password: config.admin_pass }, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + const token = response.data; + + const outputPath = path.join(SCREENSHOT_DIR, filename); + + try { + browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-accelerated-2d-canvas', + '--disable-gpu', + 'window-size=1920,1080' + ] + }); + const page = await browser.newPage(); + + await page.setViewport({ width: 1920, height: 1080 }); + + if (token) { + await page.setRequestInterception(true); + + page.on('request', interceptedRequest => { + if (interceptedRequest.isInterceptResolutionHandled()) { + return; + } + + const headers = interceptedRequest.headers(); + if (!headers['authorization'] && !headers['Authorization']) { + headers['Authorization'] = `Bearer ${token}`; + } + + interceptedRequest.continue({ headers }); + }); + + page.on('requestfailed', request => { + console.error(`[ScreenshotService]: Request failed: ${request.url()} - ${request.failure().errorText}`); + }); + } + + await page.goto(url, { waitUntil: 'load', timeout: 60000 }); + + await page.screenshot({ + path: outputPath, + fullPage: true, + }); + + console.log(`[ScreenshotService]: Screenshot saved to ${outputPath}`); + return outputPath; + } catch (error) { + console.error(`[ScreenshotService]: Error taking screenshot: ${error.message}`); + throw error; + } finally { + if (browser) { + await browser.close(); + } + } +} + +module.exports = { takeScreenshot }; \ No newline at end of file diff --git a/app-shell/src/services/vcs.js b/app-shell/src/services/vcs.js new file mode 100644 index 0000000..6235c57 --- /dev/null +++ b/app-shell/src/services/vcs.js @@ -0,0 +1,1205 @@ +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +const path = require('path'); +const { promises: fs } = require("fs"); +const axios = require('axios'); +const config = require('../config.js'); + +const ROOT_PATH = '/app'; +const MAX_BUFFER = 1024 * 1024 * 50; +const GITEA_DOMAIN = config.gitea_domain; +const USERNAME = config.gitea_username; +const API_TOKEN = config.gitea_api_token; +const GITHUB_REPO_URL = config.github_repo_url; +const GITHUB_TOKEN = config.github_token; + +const devSchemaFilePath = path.join(ROOT_PATH, '/app-shell/src/_schema.json'); + +class VCS { + static isInitRepoRunning = false; + // Main method – controller of the repository initialization process + static async initRepo(projectId = 'test') { + if (VCS.isInitRepoRunning) { + console.warn('[WARNING] initRepo is already running. Skipping.'); + return; + } + VCS.isInitRepoRunning = true; + try { + console.log(`[DEBUG] Starting repository initialization for project "${projectId}"...`); + + await this._waitForGitLockRelease(path.join(ROOT_PATH, '.git')); + // await this._removeGitLockIfExists(path.join(ROOT_PATH, '.git')); + console.log('[DEBUG] Git lock released, proceeding with initialization...'); + + if (GITHUB_REPO_URL) { + console.log(`[DEBUG] GitHub repository URL provided: ${GITHUB_REPO_URL}`); + console.log(`[DEBUG] Setting up local GitHub repository...`); + await this.setupLocalGitHubRepo(); + console.log(`[DEBUG] GitHub repository setup completed.`); + } else { + console.log(`[DEBUG] No GitHub repository URL provided. Skipping GitHub setup.`); + } + + console.log(`[DEBUG] Setting up Gitea remote repository for project "${projectId}"...`); + const giteaRemoteUrl = await this.setupGiteaRemote(projectId); + console.log(`[DEBUG] Gitea remote URL: ${giteaRemoteUrl.replace(/\/\/.*?@/, '//***@')}`); + + if (!GITHUB_REPO_URL) { + console.log(`[DEBUG] Setting up local repository with Gitea remote...`); + await this.setupLocalRepo(giteaRemoteUrl); + console.log(`[DEBUG] Local repository setup with Gitea remote completed.`); + } else { + console.log(`[DEBUG] Adding Gitea as additional remote to existing GitHub repository...`); + await this._addGiteaRemote(giteaRemoteUrl); + console.log(`[DEBUG] Gitea remote added to GitHub repository.`); + } + + console.log(`[DEBUG] Repository initialization for project "${projectId}" completed successfully.`); + console.log(`[DEBUG] Repository configuration: GitHub: ${GITHUB_REPO_URL ? 'Yes' : 'No'}, Gitea: Yes`); + + return { message: `Repository ${projectId} is ready.` }; + } catch (error) { + console.error(`[ERROR] Repository initialization for project "${projectId}" failed: ${error?.message}`); + + throw new Error(`Error during repo initialization: ${error.message}`); + } finally { + VCS.isInitRepoRunning = false; + console.log(`[DEBUG] Repository initialization process for "${projectId}" finished.`); + } + } + + // Checks for the existence of the remote repo and creates it if it doesn't exist + static async setupGiteaRemote(projectId) { + console.log(`[DEBUG] Checking Gitea remote repository "${projectId}"...`); + let repoData = await this.checkRepoExists(projectId); + if (!repoData) { + console.log(`[DEBUG] Gitea remote repository "${projectId}" does not exist. Creating...`); + repoData = await this.createRemoteRepo(projectId); + console.log(`[DEBUG] Gitea remote repository created: ${JSON.stringify(repoData)}`); + } else { + console.log(`[DEBUG] Gitea remote repository "${projectId}" already exists.`); + } + // Return the URL with token authentication + return `https://${USERNAME}:${API_TOKEN}@${GITEA_DOMAIN}/${USERNAME}/${projectId}.git`; + } + + // Sets up the local repository: either fetches/reset if .git exists, + // initializes git in a non-empty directory, or clones the repository if empty. + static async setupLocalRepo(remoteUrl) { + const gitDir = path.join(ROOT_PATH, '.git'); + const localRepoExists = await this.exists(gitDir); + if (localRepoExists) { + await this.fetchAndResetRepo(); + } else { + const files = await fs.readdir(ROOT_PATH); + if (files.length > 0) { + await this.initializeGitRepo(remoteUrl); + } else { + console.log('[DEBUG] Local directory is empty. Cloning remote repository...'); + await exec(`git clone ${remoteUrl} .`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + } + } + + static async setupLocalGitHubRepo() { + try { + if (!GITHUB_REPO_URL) { + console.log('[DEBUG] GITHUB_REPO_URL is not set. Skipping GitHub repo setup.'); + return; + } + + const gitDir = path.join(ROOT_PATH, '.git'); + const repoExists = await this.exists(gitDir); + + if (repoExists) { + console.log('[DEBUG] Git repository already initialized. Fetching and resetting...'); + + await this._addGithubRemote(); + + console.log('[DEBUG] Fetching GitHub remote...'); + await exec(`git fetch github`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + try { + console.log('[DEBUG] Checking for remote branch "github/ai-dev"...'); + await exec(`git rev-parse --verify github/ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Remote branch "github/ai-dev" exists. Resetting local repository to github/ai-dev...'); + await exec(`git reset --hard github/ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Switching to branch "ai-dev"...'); + await exec(`git checkout -B ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } catch (e) { + console.log('[DEBUG] Remote branch "github/ai-dev" does NOT exist. Creating initial commit...'); + await this.commitInitialChanges(); + } + return; + } + + console.log('[DEBUG] Initializing git in existing directory...'); + // const gitignorePath = path.join(ROOT_PATH, '.gitignore'); + // const ignoreContent = `node_modules/\n*/node_modules/\n*/build/\n`; + // await fs.writeFile(gitignorePath, ignoreContent, 'utf8'); + + await exec(`git init`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Configuring git user...'); + await exec(`git config user.email "support@flatlogic.com"`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + await exec(`git config user.name "Flatlogic Bot"`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + await this._addGithubRemote(); + + console.log('[DEBUG] Fetching GitHub remote...'); + await exec(`git fetch github`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + try { + console.log('[DEBUG] Checking for remote branch "github/ai-dev"...'); + await exec(`git rev-parse --verify github/ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Remote branch "github/ai-dev" exists. Resetting local repository to github/ai-dev...'); + await exec(`git reset --hard github/ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Switching to branch "ai-dev"...'); + await exec(`git checkout -B ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } catch (e) { + console.log('[DEBUG] Remote branch "github/ai-dev" does NOT exist. Creating initial commit...'); + await this.commitInitialChanges(); + } + } catch (error) { + console.error(`[ERROR] Failed to setup local GitHub repo: ${error?.message}`); + throw error; + } + } + + // Check if a file/directory exists + static async exists(pathToCheck) { + try { + await fs.access(pathToCheck); + return true; + } catch { + return false; + } + } + + // If the local repository exists, fetches remote data and resets the repository state + static async fetchAndResetRepo() { + console.log('[DEBUG] Local repository exists. Fetching remotes...'); + + if (GITHUB_REPO_URL) { + await exec(`git fetch github`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + const branchReset = await this.tryResetToBranch('ai-dev', 'github'); + + if (branchReset) { + return; + } + } + + await exec(`git fetch gitea`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + const branchReset = await this.tryResetToBranch('ai-dev', 'gitea'); + + if (!branchReset) { + const masterReset = await this.tryResetToBranch('master', 'gitea'); + if (masterReset) { + console.log('[DEBUG] Creating and switching to branch "ai-dev"...'); + await exec(`git branch ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + await exec(`git checkout ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Pushing ai-dev branch to remotes...'); + await exec(`git push -u gitea ai-dev --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (GITHUB_REPO_URL) { + await exec(`git push -u github ai-dev --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + } else { + console.log('[DEBUG] Neither "gitea/ai-dev" nor "gitea/master" exist. Creating initial commit...'); + await this.commitInitialChanges(); + } + } + } + + // Tries to check out and reset to the specified branch + static async tryResetToBranch(branchName, remote) { + try { + console.log(`[DEBUG] Checking for remote branch "${remote}/${branchName}"...`); + await exec(`git rev-parse --verify ${remote}/${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Remote branch "${remote}/${branchName}" found. Resetting local repository to "${remote}/${branchName}"...`); + await exec(`git reset --hard ${remote}/${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + await exec(`git checkout ${branchName === 'ai-dev' ? 'ai-dev' : branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + return true; + } catch (e) { + console.log(`[DEBUG] Remote branch "${remote}/${branchName}" does NOT exist.`); + + return false; + } + } + + // If remote branch doesn't exist, make the initial commit and set up branches + static async commitInitialChanges() { + console.log('[DEBUG] Adding all files for initial commit...'); + await exec(`git add .`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + const { stdout: status } = await exec(`git status --porcelain`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (status.trim()) { + await exec(`git commit -m "Initial version"`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + if (GITHUB_REPO_URL) { + await exec(`git push -u github master --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + await exec(`git push -u gitea master --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + console.log('[DEBUG] Creating and switching to branch "ai-dev"...'); + await exec(`git branch ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + await exec(`git checkout ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Making ai-dev branch identical to master...'); + + if (GITHUB_REPO_URL) { + await exec(`git reset --hard github/master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } else { + await exec(`git reset --hard gitea/master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + + console.log('[DEBUG] Pushing ai-dev branch to remotes...'); + if (GITHUB_REPO_URL) { + await exec(`git push -u github ai-dev --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + await exec(`git push -u gitea ai-dev --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } else { + console.log('[DEBUG] No local changes to commit.'); + } + } + + // If the local directory is not empty but .git doesn't exist, initialize git, + // add .gitignore, configure the user, and add the remote origin. + static async initializeGitRepo(giteaRemoteUrl) { + console.log('[DEBUG] Local directory is not empty. Initializing git...'); + const gitignorePath = path.join(ROOT_PATH, '.gitignore'); + const ignoreContent = `node_modules/\n*/node_modules/\n*/build/\n`; + await fs.writeFile(gitignorePath, ignoreContent, 'utf8'); + + await exec(`git init`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Configuring git user...'); + await exec(`git config user.email "support@flatlogic.com"`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + await exec(`git config user.name "Flatlogic Bot"`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + console.log(`[DEBUG] Adding Gitea remote ${giteaRemoteUrl}...`); + await exec(`git remote add gitea ${giteaRemoteUrl}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + if (GITHUB_REPO_URL) { + await this._addGithubRemote(); + } + + console.log('[DEBUG] Fetching Gitea remote...'); + await exec(`git fetch gitea`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + try { + console.log('[DEBUG] Checking for remote branch "gitea/ai-dev"...'); + await exec(`git rev-parse --verify gitea/ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Remote branch "gitea/ai-dev" exists. Resetting local repository to gitea/ai-dev...'); + await exec(`git reset --hard gitea/ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Switching to branch "ai-dev"...'); + await exec(`git checkout -B ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } catch (e) { + console.log('[DEBUG] Remote branch "gitea/ai-dev" does NOT exist. Creating initial commit...'); + await this.commitInitialChanges(); + } + } + + // Method to check if the repository exists on remote server + static async checkRepoExists(repoName) { + const url = `https://${GITEA_DOMAIN}/api/v1/repos/${USERNAME}/${repoName}`; + try { + const response = await axios.get(url, { + headers: { Authorization: `token ${API_TOKEN}` } + }); + return response.data; + } catch (err) { + if (err.response && err.response.status === 404) { + return null; + } + throw new Error('Error checking repository existence: ' + err?.message); + } + } + + // Method to create a remote repository via API + static async createRemoteRepo(repoName) { + const createUrl = `https://${GITEA_DOMAIN}/api/v1/user/repos`; + console.log("[DEBUG] createUrl", createUrl); + + try { + const response = await axios.post(createUrl, { + name: repoName, + description: `Repository for project ${repoName}`, + private: false + }, { + headers: { Authorization: `token ${API_TOKEN}` } + }); + + return response.data; + } catch (err) { + console.log('Error creating repository via API: ' + err?.message) + // throw new Error('Error creating repository via API: ' + err.message); + } + } + + static async commitChanges(message = "", files = '.', dev_schema) { + try { + console.log(`[DEBUG] Starting commit process...`); + await this._ensureDevBranch(); + + console.log(`[DEBUG] Ensuring .gitignore is properly configured...`); + await this._ensureGitignore(); + + // Save dev_schema + await this._saveDevSchema(message, dev_schema); + + console.log(`[DEBUG] Adding files to git index: ${files}`); + if (files === '.') { + await exec(`git add .`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } else { + await exec(`git add ${files}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + + const { stdout: status } = await exec('git status --porcelain', { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Git status before commit: ${status}`); + + if (!status.trim()) { + console.log(`[DEBUG] No changes to commit`); + return { message: "No changes to commit" }; + } + + const now = new Date(); + const commitMessage = message || `Auto commit: ${now.toISOString()}`; + console.log(`[DEBUG] Committing changes with message: "${commitMessage}"`); + + const { stdout: commitOutput, stderr: commitError } = await exec(`git commit -m "${commitMessage}"`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Commit output: ${commitOutput}`); + if (commitError) { + console.log(`[DEBUG] Commit stderr: ${commitError}`); + } + + const { stdout: currentBranch } = await exec(`git rev-parse --abbrev-ref HEAD`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + const branchName = currentBranch.trim(); + console.log(`[DEBUG] Current branch: ${branchName}`); + + console.log(`[DEBUG] Pushing changes to Gitea...`); + try { + const { stdout: giteaPushOutput, stderr: giteaPushError } = await exec(`git push gitea ${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Gitea push output: ${giteaPushOutput}`); + if (giteaPushError) { + console.log(`[DEBUG] Gitea push stderr: ${giteaPushError}`); + } + } catch (giteaError) { + console.error(`[ERROR] Failed to push to Gitea: ${giteaError?.message}`); + + if (giteaError.stderr && giteaError.stderr.includes('rejected')) { + console.log(`[DEBUG] Push rejected, trying with --force...`); + try { + const { stdout, stderr } = await exec(`git push gitea ${branchName} --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Force push to Gitea output: ${stdout}`); + if (stderr) { + console.log(`[DEBUG] Force push to Gitea stderr: ${stderr}`); + } + } catch (forceError) { + console.error(`[ERROR] Force push to Gitea failed: ${forceError?.message}`); + } + } + } + + if (GITHUB_REPO_URL) { + console.log(`[DEBUG] Pushing changes to GitHub...`); + try { + const { stdout: githubPushOutput, stderr: githubPushError } = await exec(`git push github ${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] GitHub push output: ${githubPushOutput}`); + if (githubPushError) { + console.log(`[DEBUG] GitHub push stderr: ${githubPushError}`); + } + } catch (githubError) { + console.error(`[ERROR] Failed to push to GitHub: ${githubError?.message}`); + + if (githubError.stderr && githubError.stderr.includes('rejected')) { + console.log(`[DEBUG] Push rejected, trying with --force...`); + try { + const { stdout, stderr } = await exec(`git push github ${branchName} --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Force push to GitHub output: ${stdout}`); + if (stderr) { + console.log(`[DEBUG] Force push to GitHub stderr: ${stderr}`); + } + } catch (forceError) { + console.error(`[ERROR] Force push to GitHub failed: ${forceError?.message}`); + } + } + } + } + + console.log(`[DEBUG] Commit process completed`); + return { message: "Changes committed" }; + } catch (error) { + console.error(`[ERROR] Error during commit process: ${error?.message}`); + } + } + + static async getLog() { + try { + const remote = GITHUB_REPO_URL ? 'github' : 'gitea'; + + const { stdout: remotes } = await exec(`git remote -v`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Remotes: ${remotes}`); + + const { stdout: branches } = await exec(`git branch -a`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Branches: ${branches}`); + + const { stdout } = await exec(`git log ${remote}/ai-dev --oneline`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + const lines = stdout.split(/\r?\n/).filter(line => line.trim() !== ''); + const result = {}; + lines.forEach((line) => { + const firstSpaceIndex = line.indexOf(' '); + if (firstSpaceIndex > 0) { + const hash = line.substring(0, firstSpaceIndex); + const message = line.substring(firstSpaceIndex + 1).trim(); + result[hash] = message; + } + }); + return result; + } catch (error) { + console.error(`[ERROR] Error during get log: ${error?.message}`); + throw error; + } + } + + static async checkout(ref) { + try { + await exec(`git checkout ${ref}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + return { message: `Checked out to ${ref}` }; + } catch (error) { + throw new Error(`Error during checkout: ${error?.message}`); + } + } + + static async revert(commitHash) { + try { + const { stdout: currentBranch } = await exec(`git rev-parse --abbrev-ref HEAD`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + const branchName = currentBranch.trim(); + + await exec(`git reset --hard`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + await exec( + `git revert --no-edit ${commitHash}..HEAD --no-commit`, + { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER } + ); + + await exec( + `git commit -m "Revert to version ${commitHash}"`, + { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER } + ); + + await exec(`git push gitea ${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (GITHUB_REPO_URL) { + await exec(`git push github ${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + + const commitMessage = await this._getCommitMessageByHash(commitHash); + const devSchema = await this._getDevSchemaByCommitMessage(commitMessage); + + return { message: `Reverted to commit ${commitHash}`, devSchema }; + } catch (error) { + console.error("Error during revert:", error?.message); + if (error.stdout) { + console.error("Revert stdout:", error.stdout); + } + if (error.stderr) { + console.error("Revert stderr:", error.stderr); + } + throw new Error(`Error during revert: ${error?.message}`); + } + } + + static async mergeDevIntoMaster() { + try { + // First, make sure we have the latest changes from both branches + console.log('[DEBUG] Fetching latest changes from remote repositories...'); + await exec(`git fetch gitea`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (GITHUB_REPO_URL) { + await exec(`git fetch github`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + + // Switch to branch 'master' + console.log('[DEBUG] Switching to branch "master"...'); + await exec(`git checkout master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + // Pull latest changes from master + console.log('[DEBUG] Pulling latest changes from master branch...'); + try { + await exec(`git pull gitea master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Successfully pulled from Gitea master'); + } catch (pullError) { + console.warn(`[WARN] Failed to pull from Gitea master: ${pullError?.message}`); + // Try to continue anyway + } + + // Switch to ai-dev and make sure it's up to date + console.log('[DEBUG] Switching to branch "ai-dev"...'); + await exec(`git checkout ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + // Pull latest changes from ai-dev + console.log('[DEBUG] Pulling latest changes from ai-dev branch...'); + try { + await exec(`git pull gitea ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log('[DEBUG] Successfully pulled from Gitea ai-dev'); + } catch (pullError) { + console.warn(`[WARN] Failed to pull from Gitea ai-dev: ${pullError?.message}`); + // Try to continue anyway + } + + // Switch back to master for the merge + console.log('[DEBUG] Switching back to branch "master"...'); + await exec(`git checkout master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + // Merge branch 'ai-dev' into 'master' with a forced merge. + // Parameter -X theirs is used to resolve conflicts by keeping the changes from the branch being merged in case of conflicts. + console.log('[DEBUG] Merging branch "ai-dev" into "master" (force merge with -X theirs)...'); + try { + const { stdout: mergeOutput, stderr: mergeError } = await exec( + `git merge ai-dev --no-ff -X theirs -m "Forced merge: merge ai-dev into master"`, + { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER } + ); + console.log(`[DEBUG] Merge output: ${mergeOutput}`); + if (mergeError) { + console.log(`[DEBUG] Merge stderr: ${mergeError}`); + } + } catch (mergeError) { + console.error(`[ERROR] Merge failed: ${mergeError?.message}`); + if (mergeError.stdout) { + console.error(`[ERROR] Merge stdout: ${mergeError.stdout}`); + } + if (mergeError.stderr) { + console.error(`[ERROR] Merge stderr: ${mergeError.stderr}`); + } + + // Abort the merge if it failed + console.log('[DEBUG] Aborting failed merge...'); + await exec(`git merge --abort`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + throw new Error(`Failed to merge ai-dev into master: ${mergeError?.message}`); + } + + // Push the merged 'master' branch to both remotes + console.log('[DEBUG] Pushing merged master branch to Gitea remote...'); + try { + const { stdout: giteaPushOutput, stderr: giteaPushError } = await exec(`git push gitea master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Gitea push output: ${giteaPushOutput}`); + if (giteaPushError) { + console.log(`[DEBUG] Gitea push stderr: ${giteaPushError}`); + } + } catch (pushError) { + console.error(`[ERROR] Failed to push to Gitea: ${pushError?.message}`); + + // If push is rejected, try with --force + if (pushError.stderr && pushError.stderr.includes('rejected')) { + console.log('[DEBUG] Push rejected, trying with --force...'); + try { + const { stdout, stderr } = await exec(`git push gitea master --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Force push to Gitea output: ${stdout}`); + if (stderr) { + console.log(`[DEBUG] Force push to Gitea stderr: ${stderr}`); + } + } catch (forceError) { + console.error(`[ERROR] Force push to Gitea also failed: ${forceError?.message}`); + throw forceError; + } + } else { + throw pushError; + } + } + + if (GITHUB_REPO_URL) { + console.log('[DEBUG] Pushing merged master branch to GitHub remote...'); + try { + const { stdout: githubPushOutput, stderr: githubPushError } = await exec(`git push github master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] GitHub push output: ${githubPushOutput}`); + if (githubPushError) { + console.log(`[DEBUG] GitHub push stderr: ${githubPushError}`); + } + } catch (pushError) { + console.error(`[ERROR] Failed to push to GitHub: ${pushError?.message}`); + + // If push is rejected, try with --force + if (pushError.stderr && pushError.stderr.includes('rejected')) { + console.log('[DEBUG] Push rejected, trying with --force...'); + try { + const { stdout, stderr } = await exec(`git push github master --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Force push to GitHub output: ${stdout}`); + if (stderr) { + console.log(`[DEBUG] Force push to GitHub stderr: ${stderr}`); + } + } catch (forceError) { + console.error(`[ERROR] Force push to GitHub also failed: ${forceError?.message}`); + throw forceError; + } + } else { + throw pushError; + } + } + } + + return { message: "Branch ai-dev merged into master and pushed to all remotes" }; + } catch (error) { + console.error(`[ERROR] Error during mergeDevIntoMaster: ${error?.message}`); + throw new Error(`Error during merge of ai-dev into master: ${error?.message}`); + } + } + + static async _mergeDevIntoMasterGitHub() { + try { + // Switch to branch 'master' + console.log('Switching to branch "master" (GitHub)...'); + await exec(`git checkout master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + // Merge branch 'ai-dev' into 'master' with a forced merge. + console.log('Merging branch "ai-dev" into "master" (GitHub, force merge with -X theirs)...'); + await exec( + `git merge ai-dev --no-ff -X theirs -m "Forced merge: merge ai-dev into master"`, + { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER } + ); + + // Push the merged 'master' branch to remote (GitHub) + console.log('Pushing merged master branch to remote (GitHub)...'); + const { stdout, stderr } = await exec(`git push -f github master`, { + cwd: ROOT_PATH, + maxBuffer: MAX_BUFFER + }); + if (stdout) { + console.log("Git push GitHub stdout:", stdout); + } + if (stderr) { + console.error("Git push GitHub stderr:", stderr); + } + return { message: "Branch ai-dev merged into master and pushed to GitHub remote" }; + } catch (error) { + console.error("Error during mergeDevIntoMasterGitHub:", error?.message); + if (error.stdout) { + console.error("Merge GitHub stdout:", error.stdout); + } + if (error.stderr) { + console.error("Merge GitHub stderr:", error.stderr); + } + throw error; + } + } + + static async resetDevBranch() { + try { + console.log(`[DEBUG] Starting reset of ai-dev branch to match master...`); + + // First, fetch all remote branches to ensure we have the latest information + console.log(`[DEBUG] Fetching latest changes from remotes...`); + await exec(`git fetch --all`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + // Check current branch state + const { stdout: initialBranches } = await exec(`git branch -a`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Initial branches: ${initialBranches}`); + + // Check if master branch exists + const { stdout: masterExists } = await exec(`git branch --list master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (!masterExists.trim()) { + console.log(`[DEBUG] Master branch does not exist. Creating it...`); + await exec(`git checkout -b master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } + + // Switch to master branch + console.log(`[DEBUG] Switching to branch "master"...`); + await exec(`git checkout master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + // Pull latest changes from master + console.log(`[DEBUG] Pulling latest changes from master...`); + try { + await exec(`git pull gitea master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } catch (error) { + console.log(`[DEBUG] Error pulling from master: ${error?.message}`); + } + + // Verify we are on master branch + const { stdout: currentBranch } = await exec(`git rev-parse --abbrev-ref HEAD`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Current branch after checkout: ${currentBranch.trim()}`); + + // Get master branch commit hash + const { stdout: masterCommit } = await exec(`git rev-parse master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Master branch commit hash: ${masterCommit.trim()}`); + + // Delete local ai-dev branch if it exists + try { + await exec(`git branch -D ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Local branch ai-dev deleted successfully`); + } catch (error) { + console.log(`[DEBUG] Local branch ai-dev does not exist or could not be deleted: ${error?.message}`); + } + + // Create new ai-dev branch from master using the exact commit hash + await exec(`git branch ai-dev ${masterCommit.trim()}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Created new ai-dev branch from master commit ${masterCommit.trim()}`); + + // Switch to the new ai-dev branch + await exec(`git checkout ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Switched to new ai-dev branch`); + + // Verify we are on ai-dev branch + const { stdout: newCurrentBranch } = await exec(`git rev-parse --abbrev-ref HEAD`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Current branch after creating ai-dev: ${newCurrentBranch.trim()}`); + + // Verify that ai-dev points to the same commit as master + const { stdout: aiDevCommit } = await exec(`git rev-parse ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] ai-dev branch commit hash: ${aiDevCommit.trim()}`); + + if (aiDevCommit.trim() !== masterCommit.trim()) { + console.error(`[ERROR] ai-dev branch does not point to the same commit as master!`); + console.error(`[ERROR] master: ${masterCommit.trim()}, ai-dev: ${aiDevCommit.trim()}`); + throw new Error(`Failed to create ai-dev branch from master`); + } + + console.log(`[DEBUG] Verified: ai-dev branch points to the same commit as master`); + + // Delete remote ai-dev branches if they exist + console.log(`[DEBUG] Deleting remote ai-dev branches if they exist...`); + + // For Gitea + try { + // First check if the remote branch exists + const { stdout: giteaBranches } = await exec(`git ls-remote --heads gitea ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + if (giteaBranches.trim()) { + console.log(`[DEBUG] Remote branch ai-dev exists on Gitea, deleting it...`); + await exec(`git push gitea --delete ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Remote branch ai-dev on Gitea deleted successfully`); + + // Verify deletion + const { stdout: verifyGiteaDeletion } = await exec(`git ls-remote --heads gitea ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (verifyGiteaDeletion.trim()) { + console.log(`[WARN] Remote branch ai-dev on Gitea still exists after deletion attempt`); + } else { + console.log(`[DEBUG] Verified: Remote branch ai-dev on Gitea is deleted`); + } + } else { + console.log(`[DEBUG] Remote branch ai-dev does not exist on Gitea`); + } + } catch (error) { + console.log(`[DEBUG] Error checking/deleting remote branch on Gitea: ${error?.message}`); + } + + // For GitHub + if (GITHUB_REPO_URL) { + try { + // First check if the remote branch exists + const { stdout: githubBranches } = await exec(`git ls-remote --heads github ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + if (githubBranches.trim()) { + console.log(`[DEBUG] Remote branch ai-dev exists on GitHub, deleting it...`); + await exec(`git push github --delete ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Remote branch ai-dev on GitHub deleted successfully`); + + // Verify deletion + const { stdout: verifyGithubDeletion } = await exec(`git ls-remote --heads github ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (verifyGithubDeletion.trim()) { + console.log(`[WARN] Remote branch ai-dev on GitHub still exists after deletion attempt`); + } else { + console.log(`[DEBUG] Verified: Remote branch ai-dev on GitHub is deleted`); + } + } else { + console.log(`[DEBUG] Remote branch ai-dev does not exist on GitHub`); + } + } catch (error) { + console.log(`[DEBUG] Error checking/deleting remote branch on GitHub: ${error?.message}`); + } + } + + // Wait a moment to ensure deletion is processed + console.log(`[DEBUG] Waiting for remote branch deletion to be processed...`); + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Push new ai-dev branch to remote repositories with force + console.log(`[DEBUG] Pushing new ai-dev branch to Gitea (force push)...`); + try { + const { stdout, stderr } = await exec(`git push -u gitea ai-dev --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Gitea force push output: ${stdout}`); + if (stderr) { + console.log(`[DEBUG] Gitea force push stderr: ${stderr}`); + } + + // Verify the push + const { stdout: verifyGiteaPush } = await exec(`git ls-remote --heads gitea ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Gitea ai-dev branch after push: ${verifyGiteaPush}`); + + // Extract the hash from the output + const giteaAiDevHash = verifyGiteaPush.split(/\s+/)[0]; + + if (giteaAiDevHash === masterCommit.trim()) { + console.log(`[DEBUG] Verified: Gitea ai-dev branch matches master branch`); + } else { + console.log(`[WARN] Gitea ai-dev branch does not match master branch!`); + console.log(`[WARN] master: ${masterCommit.trim()}, Gitea ai-dev: ${giteaAiDevHash}`); + } + } catch (error) { + console.error(`[ERROR] Force push to Gitea failed: ${error?.message}`); + throw error; + } + + if (GITHUB_REPO_URL) { + console.log(`[DEBUG] Pushing new ai-dev branch to GitHub (force push)...`); + try { + const { stdout, stderr } = await exec(`git push -u github ai-dev --force`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] GitHub force push output: ${stdout}`); + if (stderr) { + console.log(`[DEBUG] GitHub force push stderr: ${stderr}`); + } + + // Verify the push + const { stdout: verifyGithubPush } = await exec(`git ls-remote --heads github ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] GitHub ai-dev branch after push: ${verifyGithubPush}`); + + // Extract the hash from the output + const githubAiDevHash = verifyGithubPush.split(/\s+/)[0]; + + if (githubAiDevHash === masterCommit.trim()) { + console.log(`[DEBUG] Verified: GitHub ai-dev branch matches master branch`); + } else { + console.log(`[WARN] GitHub ai-dev branch does not match master branch!`); + console.log(`[WARN] master: ${masterCommit.trim()}, GitHub ai-dev: ${githubAiDevHash}`); + } + } catch (error) { + console.error(`[ERROR] Force push to GitHub failed: ${error?.message}`); + throw error; + } + } + + // Final verification + console.log(`[DEBUG] Performing final verification...`); + + // Get master commit hash again to ensure it hasn't changed + const { stdout: finalMasterCommit } = await exec(`git rev-parse master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Final master branch commit hash: ${finalMasterCommit.trim()}`); + + // Get ai-dev commit hash + const { stdout: finalAiDevCommit } = await exec(`git rev-parse ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Final ai-dev branch commit hash: ${finalAiDevCommit.trim()}`); + + // Get remote branches + const { stdout: finalRemoteBranches } = await exec(`git ls-remote --heads`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Final remote branches: ${finalRemoteBranches}`); + + if (finalAiDevCommit.trim() !== finalMasterCommit.trim()) { + console.error(`[ERROR] Final verification failed: ai-dev and master branches point to different commits!`); + console.error(`[ERROR] master: ${finalMasterCommit.trim()}, ai-dev: ${finalAiDevCommit.trim()}`); + } else { + console.log(`[DEBUG] Final verification passed: ai-dev and master branches point to the same commit`); + } + + console.log(`[DEBUG] Reset of ai-dev branch completed successfully`); + return { message: "Branch ai-dev has been reset to be an exact copy of master" }; + } catch (error) { + console.error(`[ERROR] Error during reset of dev branch: ${error?.message}`); + throw new Error(`Error during reset of dev branch: ${error?.message}`); + } + } + + static async _resetDevBranchGitHub() { + try { + console.log('[DEBUG] Switching to branch "master" (GitHub)...'); + await exec(`git checkout master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + console.log('[DEBUG] Resetting branch "ai-dev" to be identical to "master" (GitHub)...'); + await exec(`git checkout -B ai-dev master`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + console.log('[DEBUG] Pushing updated branch "ai-dev" to remote (GitHub, force push)...'); + await exec(`git push -f github ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + + return { message: 'ai-dev branch successfully reset to master (GitHub).' }; + } catch (error) { + console.error("Error during resetting ai-dev branch (GitHub):", error?.message); + if (error.stdout) { + console.error("Reset GitHub stdout:", error.stdout); + } + if (error.stderr) { + console.error("Reset GitHub stderr:", error.stderr); + } + throw new Error(`Error during resetting ai-dev branch (GitHub): ${error?.message}`); + } + } + + static async _pushChangesToGitea() { + try { + const { stdout, stderr } = await exec(`git push gitea ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (stdout) { + console.log("Git push Gitea stdout:", stdout); + } + if (stderr) { + console.error("Git push Gitea stderr:", stderr); + } + return { message: "Changes pushed to Gitea remote repository (ai-dev branch)" }; + } catch (error) { + console.error("Git push Gitea error:", error?.message); + if (error.stdout) { + console.error("Git push Gitea stdout:", error.stdout); + } + if (error.stderr) { + console.error("Git push Gitea stderr:", error.stderr); + } + throw error; + } + } + + static async _pushChangesToGithub() { + try { + const { stdout, stderr } = await exec(`git push github ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (stdout) { + console.log("Git push GitHub stdout:", stdout); + } + if (stderr) { + console.error("Git push GitHub stderr:", stderr); + } + return { message: "Changes pushed to GitHub repository (ai-dev branch)" }; + } catch (error) { + console.error("Git push GitHub error:", error?.message); + if (error.stdout) { + console.error("Git push GitHub stdout:", error.stdout); + } + if (error.stderr) { + console.error("Git push GitHub stderr:", error.stderr); + } + throw error; + } + } + + static async _addGithubRemote() { + if (GITHUB_REPO_URL) { + try { + const { stdout: remotes } = await exec(`git remote -v`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (!remotes.includes('github')) { + console.log(`[DEBUG] Adding GitHub remote: git remote add github ${GITHUB_REPO_URL}`); + await exec(`git remote add github ${GITHUB_REPO_URL}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] GitHub remote added: ${GITHUB_REPO_URL}`); + } else { + console.log(`[DEBUG] GitHub remote already exists.`); + } + } catch (error) { + console.error(`[ERROR] Failed to add GitHub remote: ${error?.message}`); + if (error.stdout) { + console.error(`[ERROR] git remote add stdout: ${error.stdout}`); + } + if (error.stderr) { + console.error(`[ERROR] git remote add stderr: ${error.stderr}`); + } + throw error; + } + } + } + + static async _addGiteaRemote(giteaRemoteUrl) { + try { + const { stdout: remotes } = await exec(`git remote -v`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + if (!remotes.includes('gitea')) { + console.log(`[DEBUG] Adding Gitea remote: git remote add gitea ${giteaRemoteUrl}`); + await exec(`git remote add gitea ${giteaRemoteUrl}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + console.log(`[DEBUG] Gitea remote added: ${giteaRemoteUrl}`); + } else { + console.log(`[DEBUG] Gitea remote already exists.`); + } + } catch (error) { + console.error(`[ERROR] Failed to add Gitea remote: ${error?.message}`); + if (error.stdout) { + console.error(`[ERROR] git remote add stdout: ${error.stdout}`); + } + if (error.stderr) { + console.error(`[ERROR] git remote add stderr: ${error.stderr}`); + } + throw error; + } + } + + static async _revertGitHubChanges(branchName) { + try { + await exec(`git push -f github ${branchName}`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } catch (error) { + console.error("Error during revertGitHubChanges:", error?.message); + if (error.stdout) { + console.error("revertGitHubChanges stdout:", error.stdout); + } + if (error.stderr) { + console.error("revertGitHubChanges stderr:", error.stderr); + } + throw new Error(`Error during revertGitHubChanges: ${error?.message}`); + } + } + + static async _ensureDevBranch() { + try { + console.log(`[DEBUG] Ensuring we are on 'ai-dev' branch...`); + + const { stdout: branchList } = await exec(`git branch --list ai-dev`, { + cwd: ROOT_PATH, + maxBuffer: MAX_BUFFER, + }); + + if (!branchList || branchList.trim() === '') { + console.log(`[DEBUG] Branch 'ai-dev' not found. Creating branch 'ai-dev'.`); + await exec(`git checkout -b ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } else { + const { stdout: currentBranchStdout } = await exec(`git rev-parse --abbrev-ref HEAD`, { + cwd: ROOT_PATH, + maxBuffer: MAX_BUFFER, + }); + const currentBranch = currentBranchStdout.trim(); + + if (currentBranch !== 'ai-dev') { + console.log(`[DEBUG] Switching from branch '${currentBranch}' to 'ai-dev'.`); + await exec(`git checkout ai-dev`, { cwd: ROOT_PATH, maxBuffer: MAX_BUFFER }); + } else { + console.log(`[DEBUG] Already on branch 'ai-dev'.`); + } + } + + console.log(`[DEBUG] Successfully ensured we are on 'ai-dev' branch.`); + } catch (error) { + console.error(`[ERROR] Error ensuring branch 'ai-dev': ${error?.message}`); + if (error.stdout) { + console.error(`[ERROR] stdout: ${error.stdout}`); + } + if (error.stderr) { + console.error(`[ERROR] stderr: ${error.stderr}`); + } + throw new Error(`Error ensuring branch 'ai-dev': ${error?.message}`); + } + } + + static async _ensureGitignore() { + try { + console.log(`[DEBUG] Checking .gitignore file...`); + const gitignorePath = path.join(ROOT_PATH, '.gitignore'); + + let gitignoreContent = ''; + try { + gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); + console.log(`[DEBUG] Existing .gitignore found.`); + } catch (error) { + console.log(`[DEBUG] .gitignore file not found, creating new one.`); + } + + + const requiredPatterns = [ + 'node_modules/', + '*/node_modules/', + '**/node_modules/', + '*/build/', + '**/build/', + '.DS_Store', + '.env' + ]; + + let needsUpdate = false; + for (const pattern of requiredPatterns) { + if (!gitignoreContent.includes(pattern)) { + gitignoreContent += `\n${pattern}`; + needsUpdate = true; + } + } + + if (needsUpdate) { + console.log(`[DEBUG] Updating .gitignore file with missing patterns.`); + await fs.writeFile(gitignorePath, gitignoreContent.trim(), 'utf8'); + console.log(`[DEBUG] .gitignore file updated successfully.`); + } else { + console.log(`[DEBUG] .gitignore file is up to date.`); + } + + return true; + } catch (error) { + console.error(`[ERROR] Error ensuring .gitignore: ${error?.message}`); + return false; + } + } + + static async _waitForGitLockRelease(gitDir, timeout = 10000, interval = 500) { + const lockFilePath = path.join(gitDir, 'index.lock'); + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + await fs.access(lockFilePath); + console.log('[DEBUG] index.lock file exists. Waiting...'); + await new Promise(resolve => setTimeout(resolve, interval)); + } catch (err) { + console.log('[DEBUG] index.lock file no longer exists. Proceeding...'); + return; + } + } + + throw new Error('Timeout waiting for index.lock to be released'); + } + + static async _removeGitLockIfExists(gitDir) { + const lockFilePath = path.join(gitDir, 'index.lock'); + try { + await fs.access(lockFilePath); + console.log('[DEBUG] index.lock file exists. Removing...'); + await fs.unlink(lockFilePath); + console.log('[DEBUG] index.lock file removed.'); + } catch (err) { + console.log('[DEBUG] index.lock file does not exist. No action needed.'); + } + } + + static async _saveDevSchema(commitMessage, dev_schema) { + try { + let devSchemaData = {}; + try { + const fileContent = await fs.readFile(devSchemaFilePath, 'utf8'); + devSchemaData = JSON.parse(fileContent); + } catch (readError) { + console.log(`[DEBUG] _dev_schema.json not found or empty, creating new.`); + devSchemaData = {}; + } + + const schema = JSON.parse(dev_schema); + + devSchemaData[commitMessage] = JSON.stringify(schema); + + await fs.writeFile(devSchemaFilePath, JSON.stringify(devSchemaData, null, 2), 'utf8'); + console.log(`[DEBUG] _dev_schema.json updated with new schema for commit '${commitMessage}'`); + } catch (error) { + console.error(`[ERROR] Error saving dev schema: ${error?.message}`); + throw new Error(`Error saving dev schema: ${error?.message}`); + } + } + + static async _getDevSchemaByHash(hash) { + try { + const fileContent = await fs.readFile(devSchemaFilePath, 'utf8'); + const devSchemaData = JSON.parse(fileContent); + + if (devSchemaData[hash]) { + return devSchemaData[hash]; + } else { + throw new Error(`Schema not found for commit hash: ${hash}`); + } + } catch (error) { + console.error(`[ERROR] Error reading dev schema: ${error?.message}`); + console.error(`Error reading dev schema: ${error?.message}`); + } + } + + static async _getDevSchemaByCommitMessage(commitMessage) { + try { + const fileContent = await fs.readFile(devSchemaFilePath, 'utf8'); + const devSchemaData = JSON.parse(fileContent); + + if (devSchemaData[commitMessage]) { + return devSchemaData[commitMessage]; + } else { + throw new Error(`Schema not found for commit message: ${commitMessage}`); + } + } catch (error) { + console.error(`[ERROR] Error retrieving dev schema: ${error.message}`); + throw new Error(`Error retrieving dev schema: ${error.message}`); + } + } + + static async _getCommitMessageByHash(commitHash) { + return new Promise((resolve, reject) => { + exec(`git log -1 --format=%B ${commitHash}`, (error, stdout, stderr) => { + if (error) { + reject(`Error getting commit message: ${stderr}`); + } else { + resolve(stdout.trim()); + } + }); + }); + } +} + +module.exports = VCS; \ No newline at end of file diff --git a/app-shell/yarn.lock b/app-shell/yarn.lock new file mode 100644 index 0000000..63ccb71 --- /dev/null +++ b/app-shell/yarn.lock @@ -0,0 +1,3044 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@mapbox/node-pre-gyp@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +accepts@^1.3.7, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== + +ansi-regex@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + +ansi-styles@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +anymatch@~3.1.1, anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +array.prototype.map@^1.0.1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/array.prototype.map/-/array.prototype.map-1.0.7.tgz#82fa4d6027272d1fca28a63bbda424d0185d78a7" + integrity sha512-XpcFfLoBEAhezrrNw1V+yLXkE7M6uR7xJEsxbG6c/V9v043qurwVJB9r9UTnoSioFDoz1i1VOydpWGmJpfVZbg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-array-method-boxes-properly "^1.0.0" + es-object-atoms "^1.0.0" + is-string "^1.0.7" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +axios@^1.6.7: + version "1.7.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" + integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64url@3.x.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" + integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== + +bcrypt@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" + integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.11" + node-addon-api "^5.0.0" + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +body-parser@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +busboy@^0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" + integrity sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg== + dependencies: + dicer "0.2.5" + readable-stream "1.1.x" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" + integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.4.0" + optionalDependencies: + fsevents "~2.1.2" + +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +cli-color@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.4.tgz#d658080290968816b322248b7306fad2346fb2c8" + integrity sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA== + dependencies: + d "^1.0.1" + es5-ext "^0.10.64" + es6-iterator "^2.0.3" + memoizee "^0.4.15" + timers-ext "^0.1.7" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-env@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +csv-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" + integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== + dependencies: + minimist "^1.2.0" + +d@1, d@^1.0.1, d@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.2.tgz#2aefd554b81981e7dccf72d6842ae725cb17e5de" + integrity sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw== + dependencies: + es5-ext "^0.10.64" + type "^2.7.2" + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +debug@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.1.2, define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +dicer@0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" + integrity sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg== + dependencies: + readable-stream "1.1.x" + streamsearch "0.1.2" + +diff@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +editorconfig@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" + integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "9.0.1" + semver "^7.5.3" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +es-abstract@^1.17.0-next.1, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-array-method-boxes-properly@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" + integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-get-iterator@^1.0.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" + integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + has-symbols "^1.0.3" + is-arguments "^1.1.1" + is-map "^2.0.2" + is-set "^2.0.2" + is-string "^1.0.7" + isarray "^2.0.5" + stop-iteration-iterator "^1.0.0" + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.64" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" + integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== + dependencies: + es6-iterator "^2.0.3" + es6-symbol "^3.1.3" + esniff "^2.0.1" + next-tick "^1.1.0" + +es6-iterator@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-symbol@^3.1.1, es6-symbol@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.4.tgz#f4e7d28013770b4208ecbf3e0bf14d3bcb557b8c" + integrity sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg== + dependencies: + d "^1.0.2" + ext "^1.7.0" + +es6-weak-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" + integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== + dependencies: + d "1" + es5-ext "^0.10.46" + es6-iterator "^2.0.3" + es6-symbol "^3.1.1" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +esniff@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" + integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== + dependencies: + d "^1.0.1" + es5-ext "^0.10.62" + event-emitter "^0.3.5" + type "^2.7.2" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== + dependencies: + d "1" + es5-ext "~0.10.14" + +express@4.18.2: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.1" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +ext@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" + integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== + dependencies: + type "^2.7.2" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +flat@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.1.tgz#a392059cc382881ff98642f5da4dde0a959f309b" + integrity sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA== + dependencies: + is-buffer "~2.0.3" + +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +foreground-child@^3.1.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77" + integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +form-data@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48" + integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +formidable@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" + integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2, fresh@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.1, function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +glob-parent@~5.1.0, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^10.3.3: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globalthis@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.0, has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +helmet@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-4.1.1.tgz#751f0e273d809ace9c172073e0003bed27d27a4a" + integrity sha512-Avg4XxSBrehD94mkRwEljnO+6RZx7AGfk8Wa6K1nxaU+hbXlFOhlOIMgPfFqOYQB/dBCsTpootTGuiOG+CHiQA== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +internal-slot@^1.0.4, internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-arguments@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-buffer@~2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0: + version "2.15.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37" + integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ== + dependencies: + hasown "^2.0.2" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== + +is-promise@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-set@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +iterate-iterator@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.2.tgz#551b804c9eaa15b847ea6a7cdc2f5bf1ec150f91" + integrity sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw== + +iterate-value@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-value/-/iterate-value-1.0.2.tgz#935115bd37d006a52046535ebc8d07e9c9337f57" + integrity sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ== + dependencies: + es-get-iterator "^1.0.2" + iterate-iterator "^1.0.1" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-beautify@^1.14.5: + version "1.15.1" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64" + integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA== + dependencies: + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.3.3" + js-cookie "^3.0.5" + nopt "^7.2.0" + +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + +js-yaml@3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +json2csv@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae" + integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA== + dependencies: + commander "^6.1.0" + jsonparse "^1.3.1" + lodash.get "^4.4.2" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +jsonwebtoken@8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + +jsonwebtoken@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@4.17.21, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== + dependencies: + es5-ext "~0.10.2" + +make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +memoizee@^0.4.15: + version "0.4.17" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.17.tgz#942a5f8acee281fa6fb9c620bddc57e3b7382949" + integrity sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA== + dependencies: + d "^1.0.2" + es5-ext "^0.10.64" + es6-weak-map "^2.0.3" + event-emitter "^0.3.5" + is-promise "^2.2.2" + lru-queue "^0.1.0" + next-tick "^1.1.0" + timers-ext "^0.1.7" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +merge-descriptors@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +methods@^1.1.2, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0, mime@^1.3.4: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +minimatch@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mocha@8.1.3: + version "8.1.3" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.1.3.tgz#5e93f873e35dfdd69617ea75f9c68c2ca61c2ac5" + integrity sha512-ZbaYib4hT4PpF4bdSO2DohooKXIn4lDeiYqB+vTmCdr6l2woW0b6H3pf5x4sM5nwQMru9RvjjHYWVGltR50ZBw== + dependencies: + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.4.2" + debug "4.1.1" + diff "4.0.2" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.1.6" + growl "1.10.5" + he "1.2.0" + js-yaml "3.14.0" + log-symbols "4.0.0" + minimatch "3.0.4" + ms "2.1.2" + object.assign "4.1.0" + promise.allsettled "1.0.2" + serialize-javascript "4.0.0" + strip-json-comments "3.0.1" + supports-color "7.1.0" + which "2.0.2" + wide-align "1.1.3" + workerpool "6.0.0" + yargs "13.3.2" + yargs-parser "13.1.2" + yargs-unparser "1.6.1" + +moment@2.30.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multer@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4.tgz#e2bc6cac0df57a8832b858d7418ccaa8ebaf7d8c" + integrity sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw== + dependencies: + append-field "^1.0.0" + busboy "^0.2.11" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + on-finished "^2.3.0" + type-is "^1.6.4" + xtend "^4.0.0" + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +next-tick@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-mocks-http@1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/node-mocks-http/-/node-mocks-http-1.9.0.tgz#6000c570fc4b809603782309be81c73a71d85b71" + integrity sha512-ILf7Ws8xyX9Rl2fLZ7xhZBovrRwgaP84M13esndP6V17M/8j25TpwNzb7Im8U9XCo6fRhdwqiQajWXpsas/E6w== + dependencies: + accepts "^1.3.7" + depd "^1.1.0" + fresh "^0.5.2" + merge-descriptors "^1.0.1" + methods "^1.1.2" + mime "^1.3.4" + parseurl "^1.3.3" + range-parser "^1.2.0" + type-is "^1.6.18" + +nodemon@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.7.tgz#07cb1f455f8bece6a499e0d72b5e029485521a54" + integrity sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ== + dependencies: + chokidar "^3.5.2" + debug "^4" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + pstree.remy "^1.1.8" + semver "^7.5.3" + simple-update-notifier "^2.0.0" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.5" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +nopt@^7.2.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +oauth@0.10.x: + version "0.10.0" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.10.0.tgz#3551c4c9b95c53ea437e1e21e46b649482339c58" + integrity sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q== + +oauth@0.9.x: + version "0.9.15" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" + integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA== + +object-assign@^4, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + +object-keys@^1.0.11, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +on-finished@2.4.1, on-finished@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-limit@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parseurl@^1.3.3, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +passport-google-oauth2@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz#fc9ea59e7091f02e24fd16d6be9257ea982ebbc3" + integrity sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ== + dependencies: + passport-oauth2 "^1.1.2" + +passport-jwt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" + integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ== + dependencies: + jsonwebtoken "^9.0.0" + passport-strategy "^1.0.0" + +passport-microsoft@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/passport-microsoft/-/passport-microsoft-0.1.0.tgz#dc72c1a38b294d74f4dc55fe93f52e25cb9aa5b4" + integrity sha512-0giBDgE1fnR5X84zJZkQ11hnKVrzEgViwRO6RGsormK9zTxFQmN/UHMTDbIpvhk989VqALewB6Pk1R5vNr3GHw== + dependencies: + passport-oauth2 "1.2.0" + pkginfo "0.2.x" + +passport-oauth2@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.2.0.tgz#49613a3eca85c7a1e65bf1019e2b6b80a10c8ac2" + integrity sha512-6128N+n/MOrJdXxdC2q/PVKXtqgihGFIeup+9bsPybAvMPOUKqdGhh9ZIzZF8rFKJOlxUP9fgP3H0JQe18n0rg== + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + +passport-oauth2@^1.1.2: + version "1.8.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" + integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== + dependencies: + base64url "3.x.x" + oauth "0.10.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + utils-merge "1.x.x" + +passport-strategy@1.x.x, passport-strategy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== + +passport@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.7.0.tgz#3688415a59a48cf8068417a8a8092d4492ca3a05" + integrity sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + utils-merge "^1.0.1" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pkginfo@0.2.x: + version "0.2.3" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" + integrity sha512-7W7wTrE/NsY8xv/DTGjwNIyNah81EQH0MWcTzrHL6pOpMocOGZc0Mbdz9aXxSrp+U0mSmkU8jrNCDCfUs3sOBg== + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +promise.allsettled@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.2.tgz#d66f78fbb600e83e863d893e98b3d4376a9c47c9" + integrity sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg== + dependencies: + array.prototype.map "^1.0.1" + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + iterate-value "^1.0.0" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@^1.2.0, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" + integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== + dependencies: + picomatch "^2.2.1" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regexp.prototype.flags@^1.5.2: + version "1.5.3" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz#b3ae40b1d2499b8350ab2c3fe6ef3845d3a96f42" + integrity sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve@^1.22.1: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver@^5.6.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.0.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +sequelize-cli@6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/sequelize-cli/-/sequelize-cli-6.6.2.tgz#8d838b25c988cf136914cdc3843e19d88c3dcb67" + integrity sha512-V8Oh+XMz2+uquLZltZES6MVAD+yEnmMfwfn+gpXcDiwE3jyQygLt4xoI0zG8gKt6cRcs84hsKnXAKDQjG/JAgg== + dependencies: + cli-color "^2.0.3" + fs-extra "^9.1.0" + js-beautify "^1.14.5" + lodash "^4.17.21" + resolve "^1.22.1" + umzug "^2.3.0" + yargs "^16.2.0" + +sequelize-json-schema@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/sequelize-json-schema/-/sequelize-json-schema-2.1.1.tgz#a82d3813925e81485d76ce291f4ff5c8cb2ae492" + integrity sha512-yCGaHnmQQeL6MQ/fOxhkR5C2aOGZyTD6OrgjP4yw1rbuujuIUVdzWN3AsC6r6AvlGZ3EUBBbCJHKl8OIFFES4Q== + +serialize-javascript@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +signal-exit@^3.0.0: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +stop-iteration-iterator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" + integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== + dependencies: + internal-slot "^1.0.4" + +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" + integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +supports-color@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tar@^6.1.11: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +timers-ext@^0.1.7: + version "0.1.8" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.8.tgz#b4e442f10b7624a29dd2aa42c295e257150cf16c" + integrity sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww== + dependencies: + es5-ext "^0.10.64" + next-tick "^1.1.0" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type@^2.7.2: + version "2.7.3" + resolved "https://registry.yarnpkg.com/type/-/type-2.7.3.tgz#436981652129285cc3ba94f392886c2637ea0486" + integrity sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ== + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +uid2@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44" + integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== + +umzug@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.3.0.tgz#0ef42b62df54e216b05dcaf627830a6a8b84a184" + integrity sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw== + dependencies: + bluebird "^3.7.2" + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + +which-typed-array@^1.1.14, which-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which@2.0.2, which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +workerpool@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.0.0.tgz#85aad67fa1a2c8ef9386a1b43539900f61d03d58" + integrity sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yargs-parser@13.1.2, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^15.0.1: + version "15.0.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.3.tgz#316e263d5febe8b38eef61ac092b33dfcc9b1115" + integrity sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.1.tgz#bd4b0ee05b4c94d058929c32cb09e3fce71d3c5f" + integrity sha512-qZV14lK9MWsGCmcr7u5oXGH0dbGqZAIxTDrWXZDo5zUr6b6iUmelNKO6x6R1dQT24AH3LgRxJpr8meWy2unolA== + dependencies: + camelcase "^5.3.1" + decamelize "^1.2.0" + flat "^4.1.0" + is-plain-obj "^1.1.0" + yargs "^14.2.3" + +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414" + integrity sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg== + dependencies: + cliui "^5.0.0" + decamelize "^1.2.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^15.0.1" + +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..bb087f2 --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "printWidth": 80, + "trailingComma": "all", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always" +} diff --git a/backend/.sequelizerc b/backend/.sequelizerc new file mode 100644 index 0000000..fe89188 --- /dev/null +++ b/backend/.sequelizerc @@ -0,0 +1,7 @@ +const path = require('path'); +module.exports = { + "config": path.resolve("src", "db", "db.config.js"), + "models-path": path.resolve("src", "db", "models"), + "seeders-path": path.resolve("src", "db", "seeders"), + "migrations-path": path.resolve("src", "db", "migrations") +}; \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..581cb98 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20.15.1-alpine + +RUN apk update && apk add bash +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY package*.json ./ + +RUN yarn install +# If you are building your code for production +# RUN npm ci --only=production + + +# Bundle app source +COPY . . + + +EXPOSE 8080 + +CMD [ "yarn", "start" ] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..cfb3531 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,67 @@ +#Car zone - template backend, + +#### Run App on local machine: + +##### Install local dependencies: + +- `yarn install` + +--- + +##### Adjust local db: + +###### 1. Install postgres: + +- MacOS: + + - `brew install postgres` + +- Ubuntu: + - `sudo apt update` + - `sudo apt install postgresql postgresql-contrib` + +###### 2. Create db and admin user: + +- Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database. + + - `psql postgres --u postgres` + +- Next, type this command for creating a new user with password then give access for creating the database. + + - `postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';` + - `postgres-# ALTER ROLE admin CREATEDB;` + +- Quit `psql` then log in again using the new user that previously created. + + - `postgres-# \q` + - `psql postgres -U admin` + +- Type this command to creating a new database. + + - `postgres=> CREATE DATABASE db_car_zone;` + +- Then give that new user privileges to the new database then quit the `psql`. + - `postgres=> GRANT ALL PRIVILEGES ON DATABASE db_car_zone TO admin;` + - `postgres=> \q` + +--- + +#### Api Documentation (Swagger) + +http://localhost:8080/api-docs (local host) + +http://host_name/api-docs + +--- + +##### Setup database tables or update after schema change + +- `yarn db:migrate` + +##### Seed the initial data (admin accounts, relevant for the first setup): + +- `yarn db:seed` + +##### Start build: + +- `yarn start` diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..855b938 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,53 @@ +{ + "name": "carzone", + "description": "Car zone - template backend", + "scripts": { + "start": "npm run db:migrate && npm run db:seed && npm run watch", + "db:migrate": "sequelize-cli db:migrate", + "db:seed": "sequelize-cli db:seed:all", + "db:drop": "sequelize-cli db:drop", + "db:create": "sequelize-cli db:create", + "watch": "node watcher.js" + }, + "dependencies": { + "@google-cloud/storage": "^5.18.2", + "axios": "^1.6.7", + "bcrypt": "5.1.1", + "chokidar": "^4.0.3", + "cors": "2.8.5", + "csv-parser": "^3.0.0", + "express": "4.18.2", + "formidable": "1.2.2", + "helmet": "4.1.1", + "json2csv": "^5.0.7", + "jsonwebtoken": "8.5.1", + "lodash": "4.17.21", + "moment": "2.30.1", + "multer": "^1.4.4", + "mysql2": "2.2.5", + "nodemailer": "6.9.9", + "passport": "^0.7.0", + "passport-google-oauth2": "^0.2.0", + "passport-jwt": "^4.0.1", + "passport-microsoft": "^0.1.0", + "pg": "8.4.1", + "pg-hstore": "2.3.4", + "sequelize": "6.35.2", + "sequelize-json-schema": "^2.1.1", + "sqlite": "4.0.15", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "tedious": "^18.2.4" + }, + "engines": { + "node": ">=18" + }, + "private": true, + "devDependencies": { + "cross-env": "7.0.3", + "mocha": "8.1.3", + "node-mocks-http": "1.9.0", + "nodemon": "2.0.5", + "sequelize-cli": "6.6.2" + } +} diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js new file mode 100644 index 0000000..0634d88 --- /dev/null +++ b/backend/src/auth/auth.js @@ -0,0 +1,79 @@ +const config = require('../config'); +const providers = config.providers; +const helpers = require('../helpers'); +const db = require('../db/models'); + +const passport = require('passport'); +const JWTstrategy = require('passport-jwt').Strategy; +const ExtractJWT = require('passport-jwt').ExtractJwt; +const GoogleStrategy = require('passport-google-oauth2').Strategy; +const MicrosoftStrategy = require('passport-microsoft').Strategy; +const UsersDBApi = require('../db/api/users'); + +passport.use( + new JWTstrategy( + { + passReqToCallback: true, + secretOrKey: config.secret_key, + jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(), + }, + async (req, token, done) => { + try { + const user = await UsersDBApi.findBy({ email: token.user.email }); + + if (user && user.disabled) { + return done(new Error(`User '${user.email}' is disabled`)); + } + + req.currentUser = user; + + return done(null, user); + } catch (error) { + done(error); + } + }, + ), +); + +passport.use( + new GoogleStrategy( + { + clientID: config.google.clientId, + clientSecret: config.google.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/google/callback', + passReqToCallback: true, + }, + function (request, accessToken, refreshToken, profile, done) { + socialStrategy(profile.email, profile, providers.GOOGLE, done); + }, + ), +); + +passport.use( + new MicrosoftStrategy( + { + clientID: config.microsoft.clientId, + clientSecret: config.microsoft.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', + passReqToCallback: true, + }, + function (request, accessToken, refreshToken, profile, done) { + const email = profile._json.mail || profile._json.userPrincipalName; + socialStrategy(email, profile, providers.MICROSOFT, done); + }, + ), +); + +function socialStrategy(email, profile, provider, done) { + db.users + .findOrCreate({ where: { email, provider } }) + .then(([user, created]) => { + const body = { + id: user.id, + email: user.email, + name: profile.displayName, + }; + const token = helpers.jwtSign({ user: body }); + return done(null, { token }); + }); +} diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..f394426 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,77 @@ +const os = require('os'); + +const config = { + gcloud: { + bucket: 'fldemo-files', + hash: '8ccab677e922e55fea01395b0c4c415e', + }, + bcrypt: { + saltRounds: 12, + }, + admin_pass: '9023ec63', + user_pass: '4d8ac698b0e7', + admin_email: 'admin@flatlogic.com', + providers: { + LOCAL: 'local', + GOOGLE: 'google', + MICROSOFT: 'microsoft', + }, + secret_key: process.env.SECRET_KEY || '', + remote: '', + port: process.env.NODE_ENV === 'production' ? '' : '8080', + hostUI: process.env.NODE_ENV === 'production' ? '' : 'http://localhost', + portUI: process.env.NODE_ENV === 'production' ? '' : '3000', + + portUIProd: process.env.NODE_ENV === 'production' ? '' : ':3000', + + swaggerUI: process.env.NODE_ENV === 'production' ? '' : 'http://localhost', + swaggerPort: process.env.NODE_ENV === 'production' ? '' : ':8080', + google: { + clientId: process.env.GOOGLE_CLIENT_ID || '', + clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + }, + microsoft: { + clientId: process.env.MS_CLIENT_ID || '', + clientSecret: process.env.MS_CLIENT_SECRET || '', + }, + uploadDir: os.tmpdir(), + email: { + from: 'Car zone ', + host: 'email-smtp.us-east-1.amazonaws.com', + port: 587, + auth: { + user: process.env.EMAIL_USER || '', + pass: process.env.EMAIL_PASS, + }, + tls: { + rejectUnauthorized: false, + }, + }, + roles: { + super_admin: 'Super Administrator', + + admin: 'Administrator', + + user: 'Customer Support', + }, + + project_uuid: '9023ec63-5313-4bee-9c57-4d8ac698b0e7', + flHost: + process.env.NODE_ENV === 'production' || + process.env.NODE_ENV === 'dev_stage' + ? 'https://flatlogic.com/projects' + : 'http://localhost:3000/projects', + + gpt_key: process.env.GPT_KEY || '', +}; + +config.pexelsKey = process.env.PEXELS_KEY || ''; +config.pexelsQuery = 'Abstract car rental concept'; +config.host = + process.env.NODE_ENV === 'production' ? config.remote : 'http://localhost'; +config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; +config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; +config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`; +config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`; + +module.exports = config; diff --git a/backend/src/db/api/agences.js b/backend/src/db/api/agences.js new file mode 100644 index 0000000..1a0c24e --- /dev/null +++ b/backend/src/db/api/agences.js @@ -0,0 +1,341 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class AgencesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const agences = await db.agences.create( + { + id: data.id || undefined, + + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return agences; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const agencesData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const agences = await db.agences.bulkCreate(agencesData, { transaction }); + + // For each item created, replace relation files + + return agences; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const agences = await db.agences.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await agences.update(updatePayload, { transaction }); + + return agences; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const agences = await db.agences.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of agences) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of agences) { + await record.destroy({ transaction }); + } + }); + + return agences; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const agences = await db.agences.findByPk(id, options); + + await agences.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await agences.destroy({ + transaction, + }); + + return agences; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const agences = await db.agences.findOne({ where }, { transaction }); + + if (!agences) { + return agences; + } + + const output = agences.get({ plain: true }); + + output.users_agences = await agences.getUsers_agences({ + transaction, + }); + + output.claims_agences = await agences.getClaims_agences({ + transaction, + }); + + output.clients_agences = await agences.getClients_agences({ + transaction, + }); + + output.company_profiles_agences = await agences.getCompany_profiles_agences( + { + transaction, + }, + ); + + output.drivers_agences = await agences.getDrivers_agences({ + transaction, + }); + + output.expenses_agences = await agences.getExpenses_agences({ + transaction, + }); + + output.guarantees_agences = await agences.getGuarantees_agences({ + transaction, + }); + + output.infractions_agences = await agences.getInfractions_agences({ + transaction, + }); + + output.invoices_saas_agences = await agences.getInvoices_saas_agences({ + transaction, + }); + + output.maintenance_agences = await agences.getMaintenance_agences({ + transaction, + }); + + output.payments_agences = await agences.getPayments_agences({ + transaction, + }); + + output.plans_agences = await agences.getPlans_agences({ + transaction, + }); + + output.reservations_agences = await agences.getReservations_agences({ + transaction, + }); + + output.subscriptions_agences = await agences.getSubscriptions_agences({ + transaction, + }); + + output.tenants_agences = await agences.getTenants_agences({ + transaction, + }); + + output.vehicle_conditions_agences = + await agences.getVehicle_conditions_agences({ + transaction, + }); + + output.vehicles_agences = await agences.getVehicles_agences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('agences', 'name', filter.name), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.agences.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('agences', 'name', query), + ], + }; + } + + const records = await db.agences.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/claims.js b/backend/src/db/api/claims.js new file mode 100644 index 0000000..01d5dab --- /dev/null +++ b/backend/src/db/api/claims.js @@ -0,0 +1,463 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ClaimsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const claims = await db.claims.create( + { + id: data.id || undefined, + + category: data.category || null, + description: data.description || null, + status: data.status || null, + attachments: data.attachments || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await claims.setTenant(data.tenant || null, { + transaction, + }); + + await claims.setClient(data.client || null, { + transaction, + }); + + await claims.setReservation(data.reservation || null, { + transaction, + }); + + await claims.setAgences(data.agences || null, { + transaction, + }); + + return claims; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const claimsData = data.map((item, index) => ({ + id: item.id || undefined, + + category: item.category || null, + description: item.description || null, + status: item.status || null, + attachments: item.attachments || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const claims = await db.claims.bulkCreate(claimsData, { transaction }); + + // For each item created, replace relation files + + return claims; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const claims = await db.claims.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.category !== undefined) updatePayload.category = data.category; + + if (data.description !== undefined) + updatePayload.description = data.description; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.attachments !== undefined) + updatePayload.attachments = data.attachments; + + updatePayload.updatedById = currentUser.id; + + await claims.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await claims.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.client !== undefined) { + await claims.setClient( + data.client, + + { transaction }, + ); + } + + if (data.reservation !== undefined) { + await claims.setReservation( + data.reservation, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await claims.setAgences( + data.agences, + + { transaction }, + ); + } + + return claims; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const claims = await db.claims.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of claims) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of claims) { + await record.destroy({ transaction }); + } + }); + + return claims; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const claims = await db.claims.findByPk(id, options); + + await claims.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await claims.destroy({ + transaction, + }); + + return claims; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const claims = await db.claims.findOne({ where }, { transaction }); + + if (!claims) { + return claims; + } + + const output = claims.get({ plain: true }); + + output.tenant = await claims.getTenant({ + transaction, + }); + + output.client = await claims.getClient({ + transaction, + }); + + output.reservation = await claims.getReservation({ + transaction, + }); + + output.agences = await claims.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.clients, + as: 'client', + + where: filter.client + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.client + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + first_name: { + [Op.or]: filter.client + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.reservations, + as: 'reservation', + + where: filter.reservation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.reservation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + ref: { + [Op.or]: filter.reservation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.category) { + where = { + ...where, + [Op.and]: Utils.ilike('claims', 'category', filter.category), + }; + } + + if (filter.description) { + where = { + ...where, + [Op.and]: Utils.ilike('claims', 'description', filter.description), + }; + } + + if (filter.attachments) { + where = { + ...where, + [Op.and]: Utils.ilike('claims', 'attachments', filter.attachments), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.claims.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('claims', 'category', query), + ], + }; + } + + const records = await db.claims.findAll({ + attributes: ['id', 'category'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['category', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.category, + })); + } +}; diff --git a/backend/src/db/api/clients.js b/backend/src/db/api/clients.js new file mode 100644 index 0000000..ecc4be4 --- /dev/null +++ b/backend/src/db/api/clients.js @@ -0,0 +1,539 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ClientsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.create( + { + id: data.id || undefined, + + first_name: data.first_name || null, + last_name: data.last_name || null, + birth_date: data.birth_date || null, + phone: data.phone || null, + address: data.address || null, + id_type: data.id_type || null, + id_number: data.id_number || null, + id_issue_date: data.id_issue_date || null, + license_number: data.license_number || null, + license_issue_date: data.license_issue_date || null, + id_copy_url: data.id_copy_url || null, + license_copy_url: data.license_copy_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await clients.setTenant(data.tenant || null, { + transaction, + }); + + await clients.setAgences(data.agences || null, { + transaction, + }); + + return clients; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const clientsData = data.map((item, index) => ({ + id: item.id || undefined, + + first_name: item.first_name || null, + last_name: item.last_name || null, + birth_date: item.birth_date || null, + phone: item.phone || null, + address: item.address || null, + id_type: item.id_type || null, + id_number: item.id_number || null, + id_issue_date: item.id_issue_date || null, + license_number: item.license_number || null, + license_issue_date: item.license_issue_date || null, + id_copy_url: item.id_copy_url || null, + license_copy_url: item.license_copy_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const clients = await db.clients.bulkCreate(clientsData, { transaction }); + + // For each item created, replace relation files + + return clients; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const clients = await db.clients.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.first_name !== undefined) + updatePayload.first_name = data.first_name; + + if (data.last_name !== undefined) updatePayload.last_name = data.last_name; + + if (data.birth_date !== undefined) + updatePayload.birth_date = data.birth_date; + + if (data.phone !== undefined) updatePayload.phone = data.phone; + + if (data.address !== undefined) updatePayload.address = data.address; + + if (data.id_type !== undefined) updatePayload.id_type = data.id_type; + + if (data.id_number !== undefined) updatePayload.id_number = data.id_number; + + if (data.id_issue_date !== undefined) + updatePayload.id_issue_date = data.id_issue_date; + + if (data.license_number !== undefined) + updatePayload.license_number = data.license_number; + + if (data.license_issue_date !== undefined) + updatePayload.license_issue_date = data.license_issue_date; + + if (data.id_copy_url !== undefined) + updatePayload.id_copy_url = data.id_copy_url; + + if (data.license_copy_url !== undefined) + updatePayload.license_copy_url = data.license_copy_url; + + updatePayload.updatedById = currentUser.id; + + await clients.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await clients.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await clients.setAgences( + data.agences, + + { transaction }, + ); + } + + return clients; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of clients) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of clients) { + await record.destroy({ transaction }); + } + }); + + return clients; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findByPk(id, options); + + await clients.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await clients.destroy({ + transaction, + }); + + return clients; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const clients = await db.clients.findOne({ where }, { transaction }); + + if (!clients) { + return clients; + } + + const output = clients.get({ plain: true }); + + output.claims_client = await clients.getClaims_client({ + transaction, + }); + + output.reservations_client = await clients.getReservations_client({ + transaction, + }); + + output.tenant = await clients.getTenant({ + transaction, + }); + + output.agences = await clients.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.first_name) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'first_name', filter.first_name), + }; + } + + if (filter.last_name) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'last_name', filter.last_name), + }; + } + + if (filter.phone) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'phone', filter.phone), + }; + } + + if (filter.address) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'address', filter.address), + }; + } + + if (filter.id_number) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'id_number', filter.id_number), + }; + } + + if (filter.license_number) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'clients', + 'license_number', + filter.license_number, + ), + }; + } + + if (filter.id_copy_url) { + where = { + ...where, + [Op.and]: Utils.ilike('clients', 'id_copy_url', filter.id_copy_url), + }; + } + + if (filter.license_copy_url) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'clients', + 'license_copy_url', + filter.license_copy_url, + ), + }; + } + + if (filter.birth_dateRange) { + const [start, end] = filter.birth_dateRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + birth_date: { + ...where.birth_date, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + birth_date: { + ...where.birth_date, + [Op.lte]: end, + }, + }; + } + } + + if (filter.id_issue_dateRange) { + const [start, end] = filter.id_issue_dateRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + id_issue_date: { + ...where.id_issue_date, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + id_issue_date: { + ...where.id_issue_date, + [Op.lte]: end, + }, + }; + } + } + + if (filter.license_issue_dateRange) { + const [start, end] = filter.license_issue_dateRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + license_issue_date: { + ...where.license_issue_date, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + license_issue_date: { + ...where.license_issue_date, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.id_type) { + where = { + ...where, + id_type: filter.id_type, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.clients.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('clients', 'first_name', query), + ], + }; + } + + const records = await db.clients.findAll({ + attributes: ['id', 'first_name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['first_name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.first_name, + })); + } +}; diff --git a/backend/src/db/api/company_profiles.js b/backend/src/db/api/company_profiles.js new file mode 100644 index 0000000..9365852 --- /dev/null +++ b/backend/src/db/api/company_profiles.js @@ -0,0 +1,469 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class Company_profilesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const company_profiles = await db.company_profiles.create( + { + id: data.id || undefined, + + legal_name: data.legal_name || null, + address: data.address || null, + phone: data.phone || null, + email: data.email || null, + website: data.website || null, + rc: data.rc || null, + ice: data.ice || null, + patente: data.patente || null, + logo_url: data.logo_url || null, + pdf_footer: data.pdf_footer || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await company_profiles.setTenant(data.tenant || null, { + transaction, + }); + + await company_profiles.setAgences(data.agences || null, { + transaction, + }); + + return company_profiles; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const company_profilesData = data.map((item, index) => ({ + id: item.id || undefined, + + legal_name: item.legal_name || null, + address: item.address || null, + phone: item.phone || null, + email: item.email || null, + website: item.website || null, + rc: item.rc || null, + ice: item.ice || null, + patente: item.patente || null, + logo_url: item.logo_url || null, + pdf_footer: item.pdf_footer || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const company_profiles = await db.company_profiles.bulkCreate( + company_profilesData, + { transaction }, + ); + + // For each item created, replace relation files + + return company_profiles; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const company_profiles = await db.company_profiles.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.legal_name !== undefined) + updatePayload.legal_name = data.legal_name; + + if (data.address !== undefined) updatePayload.address = data.address; + + if (data.phone !== undefined) updatePayload.phone = data.phone; + + if (data.email !== undefined) updatePayload.email = data.email; + + if (data.website !== undefined) updatePayload.website = data.website; + + if (data.rc !== undefined) updatePayload.rc = data.rc; + + if (data.ice !== undefined) updatePayload.ice = data.ice; + + if (data.patente !== undefined) updatePayload.patente = data.patente; + + if (data.logo_url !== undefined) updatePayload.logo_url = data.logo_url; + + if (data.pdf_footer !== undefined) + updatePayload.pdf_footer = data.pdf_footer; + + updatePayload.updatedById = currentUser.id; + + await company_profiles.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await company_profiles.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await company_profiles.setAgences( + data.agences, + + { transaction }, + ); + } + + return company_profiles; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const company_profiles = await db.company_profiles.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of company_profiles) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of company_profiles) { + await record.destroy({ transaction }); + } + }); + + return company_profiles; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const company_profiles = await db.company_profiles.findByPk(id, options); + + await company_profiles.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await company_profiles.destroy({ + transaction, + }); + + return company_profiles; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const company_profiles = await db.company_profiles.findOne( + { where }, + { transaction }, + ); + + if (!company_profiles) { + return company_profiles; + } + + const output = company_profiles.get({ plain: true }); + + output.tenant = await company_profiles.getTenant({ + transaction, + }); + + output.agences = await company_profiles.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.legal_name) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'company_profiles', + 'legal_name', + filter.legal_name, + ), + }; + } + + if (filter.address) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'address', filter.address), + }; + } + + if (filter.phone) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'phone', filter.phone), + }; + } + + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'email', filter.email), + }; + } + + if (filter.website) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'website', filter.website), + }; + } + + if (filter.rc) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'rc', filter.rc), + }; + } + + if (filter.ice) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'ice', filter.ice), + }; + } + + if (filter.patente) { + where = { + ...where, + [Op.and]: Utils.ilike('company_profiles', 'patente', filter.patente), + }; + } + + if (filter.logo_url) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'company_profiles', + 'logo_url', + filter.logo_url, + ), + }; + } + + if (filter.pdf_footer) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'company_profiles', + 'pdf_footer', + filter.pdf_footer, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.company_profiles.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('company_profiles', 'legal_name', query), + ], + }; + } + + const records = await db.company_profiles.findAll({ + attributes: ['id', 'legal_name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['legal_name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.legal_name, + })); + } +}; diff --git a/backend/src/db/api/drivers.js b/backend/src/db/api/drivers.js new file mode 100644 index 0000000..19a7c57 --- /dev/null +++ b/backend/src/db/api/drivers.js @@ -0,0 +1,459 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class DriversDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const drivers = await db.drivers.create( + { + id: data.id || undefined, + + full_name: data.full_name || null, + license_number: data.license_number || null, + license_issue_date: data.license_issue_date || null, + id_copy_url: data.id_copy_url || null, + license_copy_url: data.license_copy_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await drivers.setTenant(data.tenant || null, { + transaction, + }); + + await drivers.setReservation(data.reservation || null, { + transaction, + }); + + await drivers.setAgences(data.agences || null, { + transaction, + }); + + return drivers; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const driversData = data.map((item, index) => ({ + id: item.id || undefined, + + full_name: item.full_name || null, + license_number: item.license_number || null, + license_issue_date: item.license_issue_date || null, + id_copy_url: item.id_copy_url || null, + license_copy_url: item.license_copy_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const drivers = await db.drivers.bulkCreate(driversData, { transaction }); + + // For each item created, replace relation files + + return drivers; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const drivers = await db.drivers.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.full_name !== undefined) updatePayload.full_name = data.full_name; + + if (data.license_number !== undefined) + updatePayload.license_number = data.license_number; + + if (data.license_issue_date !== undefined) + updatePayload.license_issue_date = data.license_issue_date; + + if (data.id_copy_url !== undefined) + updatePayload.id_copy_url = data.id_copy_url; + + if (data.license_copy_url !== undefined) + updatePayload.license_copy_url = data.license_copy_url; + + updatePayload.updatedById = currentUser.id; + + await drivers.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await drivers.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.reservation !== undefined) { + await drivers.setReservation( + data.reservation, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await drivers.setAgences( + data.agences, + + { transaction }, + ); + } + + return drivers; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const drivers = await db.drivers.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of drivers) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of drivers) { + await record.destroy({ transaction }); + } + }); + + return drivers; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const drivers = await db.drivers.findByPk(id, options); + + await drivers.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await drivers.destroy({ + transaction, + }); + + return drivers; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const drivers = await db.drivers.findOne({ where }, { transaction }); + + if (!drivers) { + return drivers; + } + + const output = drivers.get({ plain: true }); + + output.tenant = await drivers.getTenant({ + transaction, + }); + + output.reservation = await drivers.getReservation({ + transaction, + }); + + output.agences = await drivers.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.reservations, + as: 'reservation', + + where: filter.reservation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.reservation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + ref: { + [Op.or]: filter.reservation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.full_name) { + where = { + ...where, + [Op.and]: Utils.ilike('drivers', 'full_name', filter.full_name), + }; + } + + if (filter.license_number) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'drivers', + 'license_number', + filter.license_number, + ), + }; + } + + if (filter.id_copy_url) { + where = { + ...where, + [Op.and]: Utils.ilike('drivers', 'id_copy_url', filter.id_copy_url), + }; + } + + if (filter.license_copy_url) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'drivers', + 'license_copy_url', + filter.license_copy_url, + ), + }; + } + + if (filter.license_issue_dateRange) { + const [start, end] = filter.license_issue_dateRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + license_issue_date: { + ...where.license_issue_date, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + license_issue_date: { + ...where.license_issue_date, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.drivers.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('drivers', 'full_name', query), + ], + }; + } + + const records = await db.drivers.findAll({ + attributes: ['id', 'full_name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['full_name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.full_name, + })); + } +}; diff --git a/backend/src/db/api/expenses.js b/backend/src/db/api/expenses.js new file mode 100644 index 0000000..a7a6785 --- /dev/null +++ b/backend/src/db/api/expenses.js @@ -0,0 +1,482 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ExpensesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const expenses = await db.expenses.create( + { + id: data.id || undefined, + + type: data.type || null, + category: data.category || null, + amount: data.amount || null, + date: data.date || null, + note: data.note || null, + attachment_url: data.attachment_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await expenses.setTenant(data.tenant || null, { + transaction, + }); + + await expenses.setVehicle(data.vehicle || null, { + transaction, + }); + + await expenses.setAgences(data.agences || null, { + transaction, + }); + + return expenses; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const expensesData = data.map((item, index) => ({ + id: item.id || undefined, + + type: item.type || null, + category: item.category || null, + amount: item.amount || null, + date: item.date || null, + note: item.note || null, + attachment_url: item.attachment_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const expenses = await db.expenses.bulkCreate(expensesData, { + transaction, + }); + + // For each item created, replace relation files + + return expenses; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const expenses = await db.expenses.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.type !== undefined) updatePayload.type = data.type; + + if (data.category !== undefined) updatePayload.category = data.category; + + if (data.amount !== undefined) updatePayload.amount = data.amount; + + if (data.date !== undefined) updatePayload.date = data.date; + + if (data.note !== undefined) updatePayload.note = data.note; + + if (data.attachment_url !== undefined) + updatePayload.attachment_url = data.attachment_url; + + updatePayload.updatedById = currentUser.id; + + await expenses.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await expenses.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.vehicle !== undefined) { + await expenses.setVehicle( + data.vehicle, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await expenses.setAgences( + data.agences, + + { transaction }, + ); + } + + return expenses; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const expenses = await db.expenses.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of expenses) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of expenses) { + await record.destroy({ transaction }); + } + }); + + return expenses; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const expenses = await db.expenses.findByPk(id, options); + + await expenses.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await expenses.destroy({ + transaction, + }); + + return expenses; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const expenses = await db.expenses.findOne({ where }, { transaction }); + + if (!expenses) { + return expenses; + } + + const output = expenses.get({ plain: true }); + + output.tenant = await expenses.getTenant({ + transaction, + }); + + output.vehicle = await expenses.getVehicle({ + transaction, + }); + + output.agences = await expenses.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.vehicles, + as: 'vehicle', + + where: filter.vehicle + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.vehicle + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + plate_number: { + [Op.or]: filter.vehicle + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.category) { + where = { + ...where, + [Op.and]: Utils.ilike('expenses', 'category', filter.category), + }; + } + + if (filter.note) { + where = { + ...where, + [Op.and]: Utils.ilike('expenses', 'note', filter.note), + }; + } + + if (filter.attachment_url) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'expenses', + 'attachment_url', + filter.attachment_url, + ), + }; + } + + if (filter.amountRange) { + const [start, end] = filter.amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.dateRange) { + const [start, end] = filter.dateRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + date: { + ...where.date, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + date: { + ...where.date, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.type) { + where = { + ...where, + type: filter.type, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.expenses.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('expenses', 'category', query), + ], + }; + } + + const records = await db.expenses.findAll({ + attributes: ['id', 'category'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['category', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.category, + })); + } +}; diff --git a/backend/src/db/api/file.js b/backend/src/db/api/file.js new file mode 100644 index 0000000..22f9b6f --- /dev/null +++ b/backend/src/db/api/file.js @@ -0,0 +1,73 @@ +const db = require('../models'); +const assert = require('assert'); +const services = require('../../services/file'); + +module.exports = class FileDBApi { + static async replaceRelationFiles(relation, rawFiles, options) { + assert(relation.belongsTo, 'belongsTo is required'); + assert(relation.belongsToColumn, 'belongsToColumn is required'); + assert(relation.belongsToId, 'belongsToId is required'); + + let files = []; + + if (Array.isArray(rawFiles)) { + files = rawFiles; + } else { + files = rawFiles ? [rawFiles] : []; + } + + await this._removeLegacyFiles(relation, files, options); + await this._addFiles(relation, files, options); + } + + static async _addFiles(relation, files, options) { + const transaction = (options && options.transaction) || undefined; + const currentUser = (options && options.currentUser) || { id: null }; + + const inexistentFiles = files.filter((file) => !!file.new); + + for (const file of inexistentFiles) { + await db.file.create( + { + belongsTo: relation.belongsTo, + belongsToColumn: relation.belongsToColumn, + belongsToId: relation.belongsToId, + name: file.name, + sizeInBytes: file.sizeInBytes, + privateUrl: file.privateUrl, + publicUrl: file.publicUrl, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { + transaction, + }, + ); + } + } + + static async _removeLegacyFiles(relation, files, options) { + const transaction = (options && options.transaction) || undefined; + + const filesToDelete = await db.file.findAll({ + where: { + belongsTo: relation.belongsTo, + belongsToId: relation.belongsToId, + belongsToColumn: relation.belongsToColumn, + id: { + [db.Sequelize.Op.notIn]: files + .filter((file) => !file.new) + .map((file) => file.id), + }, + }, + transaction, + }); + + for (let file of filesToDelete) { + await services.deleteGCloud(file.privateUrl); + await file.destroy({ + transaction, + }); + } + } +}; diff --git a/backend/src/db/api/guarantees.js b/backend/src/db/api/guarantees.js new file mode 100644 index 0000000..8798931 --- /dev/null +++ b/backend/src/db/api/guarantees.js @@ -0,0 +1,438 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class GuaranteesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const guarantees = await db.guarantees.create( + { + id: data.id || undefined, + + method: data.method || null, + amount: data.amount || null, + status: data.status || null, + proof_url: data.proof_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await guarantees.setTenant(data.tenant || null, { + transaction, + }); + + await guarantees.setReservation(data.reservation || null, { + transaction, + }); + + await guarantees.setAgences(data.agences || null, { + transaction, + }); + + return guarantees; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const guaranteesData = data.map((item, index) => ({ + id: item.id || undefined, + + method: item.method || null, + amount: item.amount || null, + status: item.status || null, + proof_url: item.proof_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const guarantees = await db.guarantees.bulkCreate(guaranteesData, { + transaction, + }); + + // For each item created, replace relation files + + return guarantees; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const guarantees = await db.guarantees.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.method !== undefined) updatePayload.method = data.method; + + if (data.amount !== undefined) updatePayload.amount = data.amount; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.proof_url !== undefined) updatePayload.proof_url = data.proof_url; + + updatePayload.updatedById = currentUser.id; + + await guarantees.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await guarantees.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.reservation !== undefined) { + await guarantees.setReservation( + data.reservation, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await guarantees.setAgences( + data.agences, + + { transaction }, + ); + } + + return guarantees; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const guarantees = await db.guarantees.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of guarantees) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of guarantees) { + await record.destroy({ transaction }); + } + }); + + return guarantees; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const guarantees = await db.guarantees.findByPk(id, options); + + await guarantees.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await guarantees.destroy({ + transaction, + }); + + return guarantees; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const guarantees = await db.guarantees.findOne({ where }, { transaction }); + + if (!guarantees) { + return guarantees; + } + + const output = guarantees.get({ plain: true }); + + output.tenant = await guarantees.getTenant({ + transaction, + }); + + output.reservation = await guarantees.getReservation({ + transaction, + }); + + output.agences = await guarantees.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.reservations, + as: 'reservation', + + where: filter.reservation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.reservation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + ref: { + [Op.or]: filter.reservation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.proof_url) { + where = { + ...where, + [Op.and]: Utils.ilike('guarantees', 'proof_url', filter.proof_url), + }; + } + + if (filter.amountRange) { + const [start, end] = filter.amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.method) { + where = { + ...where, + method: filter.method, + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.guarantees.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('guarantees', 'method', query), + ], + }; + } + + const records = await db.guarantees.findAll({ + attributes: ['id', 'method'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['method', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.method, + })); + } +}; diff --git a/backend/src/db/api/infractions.js b/backend/src/db/api/infractions.js new file mode 100644 index 0000000..57daaf1 --- /dev/null +++ b/backend/src/db/api/infractions.js @@ -0,0 +1,524 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class InfractionsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const infractions = await db.infractions.create( + { + id: data.id || undefined, + + ref_no: data.ref_no || null, + date: data.date || null, + location: data.location || null, + amount: data.amount || null, + status: data.status || null, + proof_url: data.proof_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await infractions.setTenant(data.tenant || null, { + transaction, + }); + + await infractions.setVehicle(data.vehicle || null, { + transaction, + }); + + await infractions.setReservation(data.reservation || null, { + transaction, + }); + + await infractions.setAgences(data.agences || null, { + transaction, + }); + + return infractions; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const infractionsData = data.map((item, index) => ({ + id: item.id || undefined, + + ref_no: item.ref_no || null, + date: item.date || null, + location: item.location || null, + amount: item.amount || null, + status: item.status || null, + proof_url: item.proof_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const infractions = await db.infractions.bulkCreate(infractionsData, { + transaction, + }); + + // For each item created, replace relation files + + return infractions; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const infractions = await db.infractions.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.ref_no !== undefined) updatePayload.ref_no = data.ref_no; + + if (data.date !== undefined) updatePayload.date = data.date; + + if (data.location !== undefined) updatePayload.location = data.location; + + if (data.amount !== undefined) updatePayload.amount = data.amount; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.proof_url !== undefined) updatePayload.proof_url = data.proof_url; + + updatePayload.updatedById = currentUser.id; + + await infractions.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await infractions.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.vehicle !== undefined) { + await infractions.setVehicle( + data.vehicle, + + { transaction }, + ); + } + + if (data.reservation !== undefined) { + await infractions.setReservation( + data.reservation, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await infractions.setAgences( + data.agences, + + { transaction }, + ); + } + + return infractions; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const infractions = await db.infractions.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of infractions) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of infractions) { + await record.destroy({ transaction }); + } + }); + + return infractions; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const infractions = await db.infractions.findByPk(id, options); + + await infractions.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await infractions.destroy({ + transaction, + }); + + return infractions; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const infractions = await db.infractions.findOne( + { where }, + { transaction }, + ); + + if (!infractions) { + return infractions; + } + + const output = infractions.get({ plain: true }); + + output.tenant = await infractions.getTenant({ + transaction, + }); + + output.vehicle = await infractions.getVehicle({ + transaction, + }); + + output.reservation = await infractions.getReservation({ + transaction, + }); + + output.agences = await infractions.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.vehicles, + as: 'vehicle', + + where: filter.vehicle + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.vehicle + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + plate_number: { + [Op.or]: filter.vehicle + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.reservations, + as: 'reservation', + + where: filter.reservation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.reservation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + ref: { + [Op.or]: filter.reservation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.ref_no) { + where = { + ...where, + [Op.and]: Utils.ilike('infractions', 'ref_no', filter.ref_no), + }; + } + + if (filter.location) { + where = { + ...where, + [Op.and]: Utils.ilike('infractions', 'location', filter.location), + }; + } + + if (filter.proof_url) { + where = { + ...where, + [Op.and]: Utils.ilike('infractions', 'proof_url', filter.proof_url), + }; + } + + if (filter.dateRange) { + const [start, end] = filter.dateRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + date: { + ...where.date, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + date: { + ...where.date, + [Op.lte]: end, + }, + }; + } + } + + if (filter.amountRange) { + const [start, end] = filter.amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.infractions.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('infractions', 'ref_no', query), + ], + }; + } + + const records = await db.infractions.findAll({ + attributes: ['id', 'ref_no'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['ref_no', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.ref_no, + })); + } +}; diff --git a/backend/src/db/api/invoices_saas.js b/backend/src/db/api/invoices_saas.js new file mode 100644 index 0000000..abe6629 --- /dev/null +++ b/backend/src/db/api/invoices_saas.js @@ -0,0 +1,481 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class Invoices_saasDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const invoices_saas = await db.invoices_saas.create( + { + id: data.id || undefined, + + invoice_number: data.invoice_number || null, + amount_mad: data.amount_mad || null, + status: data.status || null, + issued_at: data.issued_at || null, + pdf_url: data.pdf_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await invoices_saas.setTenant(data.tenant || null, { + transaction, + }); + + await invoices_saas.setSubscription(data.subscription || null, { + transaction, + }); + + await invoices_saas.setAgences(data.agences || null, { + transaction, + }); + + return invoices_saas; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const invoices_saasData = data.map((item, index) => ({ + id: item.id || undefined, + + invoice_number: item.invoice_number || null, + amount_mad: item.amount_mad || null, + status: item.status || null, + issued_at: item.issued_at || null, + pdf_url: item.pdf_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const invoices_saas = await db.invoices_saas.bulkCreate(invoices_saasData, { + transaction, + }); + + // For each item created, replace relation files + + return invoices_saas; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const invoices_saas = await db.invoices_saas.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.invoice_number !== undefined) + updatePayload.invoice_number = data.invoice_number; + + if (data.amount_mad !== undefined) + updatePayload.amount_mad = data.amount_mad; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.issued_at !== undefined) updatePayload.issued_at = data.issued_at; + + if (data.pdf_url !== undefined) updatePayload.pdf_url = data.pdf_url; + + updatePayload.updatedById = currentUser.id; + + await invoices_saas.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await invoices_saas.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.subscription !== undefined) { + await invoices_saas.setSubscription( + data.subscription, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await invoices_saas.setAgences( + data.agences, + + { transaction }, + ); + } + + return invoices_saas; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const invoices_saas = await db.invoices_saas.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of invoices_saas) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of invoices_saas) { + await record.destroy({ transaction }); + } + }); + + return invoices_saas; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const invoices_saas = await db.invoices_saas.findByPk(id, options); + + await invoices_saas.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await invoices_saas.destroy({ + transaction, + }); + + return invoices_saas; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const invoices_saas = await db.invoices_saas.findOne( + { where }, + { transaction }, + ); + + if (!invoices_saas) { + return invoices_saas; + } + + const output = invoices_saas.get({ plain: true }); + + output.tenant = await invoices_saas.getTenant({ + transaction, + }); + + output.subscription = await invoices_saas.getSubscription({ + transaction, + }); + + output.agences = await invoices_saas.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.subscriptions, + as: 'subscription', + + where: filter.subscription + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.subscription + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + provider_customer_id: { + [Op.or]: filter.subscription + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.invoice_number) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'invoices_saas', + 'invoice_number', + filter.invoice_number, + ), + }; + } + + if (filter.pdf_url) { + where = { + ...where, + [Op.and]: Utils.ilike('invoices_saas', 'pdf_url', filter.pdf_url), + }; + } + + if (filter.amount_madRange) { + const [start, end] = filter.amount_madRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + amount_mad: { + ...where.amount_mad, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + amount_mad: { + ...where.amount_mad, + [Op.lte]: end, + }, + }; + } + } + + if (filter.issued_atRange) { + const [start, end] = filter.issued_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + issued_at: { + ...where.issued_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + issued_at: { + ...where.issued_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.invoices_saas.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('invoices_saas', 'invoice_number', query), + ], + }; + } + + const records = await db.invoices_saas.findAll({ + attributes: ['id', 'invoice_number'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['invoice_number', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.invoice_number, + })); + } +}; diff --git a/backend/src/db/api/maintenance.js b/backend/src/db/api/maintenance.js new file mode 100644 index 0000000..65cad74 --- /dev/null +++ b/backend/src/db/api/maintenance.js @@ -0,0 +1,505 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class MaintenanceDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const maintenance = await db.maintenance.create( + { + id: data.id || undefined, + + type: data.type || null, + scheduled_at: data.scheduled_at || null, + odometer: data.odometer || null, + cost: data.cost || null, + notes: data.notes || null, + attachments: data.attachments || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await maintenance.setTenant(data.tenant || null, { + transaction, + }); + + await maintenance.setVehicle(data.vehicle || null, { + transaction, + }); + + await maintenance.setAgences(data.agences || null, { + transaction, + }); + + return maintenance; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const maintenanceData = data.map((item, index) => ({ + id: item.id || undefined, + + type: item.type || null, + scheduled_at: item.scheduled_at || null, + odometer: item.odometer || null, + cost: item.cost || null, + notes: item.notes || null, + attachments: item.attachments || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const maintenance = await db.maintenance.bulkCreate(maintenanceData, { + transaction, + }); + + // For each item created, replace relation files + + return maintenance; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const maintenance = await db.maintenance.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.type !== undefined) updatePayload.type = data.type; + + if (data.scheduled_at !== undefined) + updatePayload.scheduled_at = data.scheduled_at; + + if (data.odometer !== undefined) updatePayload.odometer = data.odometer; + + if (data.cost !== undefined) updatePayload.cost = data.cost; + + if (data.notes !== undefined) updatePayload.notes = data.notes; + + if (data.attachments !== undefined) + updatePayload.attachments = data.attachments; + + updatePayload.updatedById = currentUser.id; + + await maintenance.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await maintenance.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.vehicle !== undefined) { + await maintenance.setVehicle( + data.vehicle, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await maintenance.setAgences( + data.agences, + + { transaction }, + ); + } + + return maintenance; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const maintenance = await db.maintenance.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of maintenance) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of maintenance) { + await record.destroy({ transaction }); + } + }); + + return maintenance; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const maintenance = await db.maintenance.findByPk(id, options); + + await maintenance.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await maintenance.destroy({ + transaction, + }); + + return maintenance; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const maintenance = await db.maintenance.findOne( + { where }, + { transaction }, + ); + + if (!maintenance) { + return maintenance; + } + + const output = maintenance.get({ plain: true }); + + output.tenant = await maintenance.getTenant({ + transaction, + }); + + output.vehicle = await maintenance.getVehicle({ + transaction, + }); + + output.agences = await maintenance.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.vehicles, + as: 'vehicle', + + where: filter.vehicle + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.vehicle + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + plate_number: { + [Op.or]: filter.vehicle + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('maintenance', 'notes', filter.notes), + }; + } + + if (filter.attachments) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'maintenance', + 'attachments', + filter.attachments, + ), + }; + } + + if (filter.scheduled_atRange) { + const [start, end] = filter.scheduled_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + scheduled_at: { + ...where.scheduled_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + scheduled_at: { + ...where.scheduled_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.odometerRange) { + const [start, end] = filter.odometerRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + odometer: { + ...where.odometer, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + odometer: { + ...where.odometer, + [Op.lte]: end, + }, + }; + } + } + + if (filter.costRange) { + const [start, end] = filter.costRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + cost: { + ...where.cost, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + cost: { + ...where.cost, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.type) { + where = { + ...where, + type: filter.type, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.maintenance.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('maintenance', 'type', query), + ], + }; + } + + const records = await db.maintenance.findAll({ + attributes: ['id', 'type'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['type', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.type, + })); + } +}; diff --git a/backend/src/db/api/payments.js b/backend/src/db/api/payments.js new file mode 100644 index 0000000..0aefa70 --- /dev/null +++ b/backend/src/db/api/payments.js @@ -0,0 +1,472 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class PaymentsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const payments = await db.payments.create( + { + id: data.id || undefined, + + method: data.method || null, + amount: data.amount || null, + paid_at: data.paid_at || null, + receipt_ref: data.receipt_ref || null, + attachment_url: data.attachment_url || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await payments.setTenant(data.tenant || null, { + transaction, + }); + + await payments.setReservation(data.reservation || null, { + transaction, + }); + + await payments.setAgences(data.agences || null, { + transaction, + }); + + return payments; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const paymentsData = data.map((item, index) => ({ + id: item.id || undefined, + + method: item.method || null, + amount: item.amount || null, + paid_at: item.paid_at || null, + receipt_ref: item.receipt_ref || null, + attachment_url: item.attachment_url || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const payments = await db.payments.bulkCreate(paymentsData, { + transaction, + }); + + // For each item created, replace relation files + + return payments; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const payments = await db.payments.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.method !== undefined) updatePayload.method = data.method; + + if (data.amount !== undefined) updatePayload.amount = data.amount; + + if (data.paid_at !== undefined) updatePayload.paid_at = data.paid_at; + + if (data.receipt_ref !== undefined) + updatePayload.receipt_ref = data.receipt_ref; + + if (data.attachment_url !== undefined) + updatePayload.attachment_url = data.attachment_url; + + updatePayload.updatedById = currentUser.id; + + await payments.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await payments.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.reservation !== undefined) { + await payments.setReservation( + data.reservation, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await payments.setAgences( + data.agences, + + { transaction }, + ); + } + + return payments; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const payments = await db.payments.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of payments) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of payments) { + await record.destroy({ transaction }); + } + }); + + return payments; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const payments = await db.payments.findByPk(id, options); + + await payments.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await payments.destroy({ + transaction, + }); + + return payments; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const payments = await db.payments.findOne({ where }, { transaction }); + + if (!payments) { + return payments; + } + + const output = payments.get({ plain: true }); + + output.tenant = await payments.getTenant({ + transaction, + }); + + output.reservation = await payments.getReservation({ + transaction, + }); + + output.agences = await payments.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.reservations, + as: 'reservation', + + where: filter.reservation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.reservation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + ref: { + [Op.or]: filter.reservation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.receipt_ref) { + where = { + ...where, + [Op.and]: Utils.ilike('payments', 'receipt_ref', filter.receipt_ref), + }; + } + + if (filter.attachment_url) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'payments', + 'attachment_url', + filter.attachment_url, + ), + }; + } + + if (filter.amountRange) { + const [start, end] = filter.amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + amount: { + ...where.amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.paid_atRange) { + const [start, end] = filter.paid_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + paid_at: { + ...where.paid_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + paid_at: { + ...where.paid_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.method) { + where = { + ...where, + method: filter.method, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.payments.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('payments', 'receipt_ref', query), + ], + }; + } + + const records = await db.payments.findAll({ + attributes: ['id', 'receipt_ref'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['receipt_ref', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.receipt_ref, + })); + } +}; diff --git a/backend/src/db/api/permissions.js b/backend/src/db/api/permissions.js new file mode 100644 index 0000000..9d9c53c --- /dev/null +++ b/backend/src/db/api/permissions.js @@ -0,0 +1,257 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class PermissionsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.create( + { + id: data.id || undefined, + + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return permissions; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const permissionsData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const permissions = await db.permissions.bulkCreate(permissionsData, { + transaction, + }); + + // For each item created, replace relation files + + return permissions; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const permissions = await db.permissions.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await permissions.update(updatePayload, { transaction }); + + return permissions; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of permissions) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of permissions) { + await record.destroy({ transaction }); + } + }); + + return permissions; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findByPk(id, options); + + await permissions.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await permissions.destroy({ + transaction, + }); + + return permissions; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const permissions = await db.permissions.findOne( + { where }, + { transaction }, + ); + + if (!permissions) { + return permissions; + } + + const output = permissions.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('permissions', 'name', filter.name), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.permissions.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('permissions', 'name', query), + ], + }; + } + + const records = await db.permissions.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/plans.js b/backend/src/db/api/plans.js new file mode 100644 index 0000000..0974acc --- /dev/null +++ b/backend/src/db/api/plans.js @@ -0,0 +1,371 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class PlansDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const plans = await db.plans.create( + { + id: data.id || undefined, + + code: data.code || null, + name: data.name || null, + period: data.period || null, + price_mad: data.price_mad || null, + limits: data.limits || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await plans.setAgences(data.agences || null, { + transaction, + }); + + return plans; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const plansData = data.map((item, index) => ({ + id: item.id || undefined, + + code: item.code || null, + name: item.name || null, + period: item.period || null, + price_mad: item.price_mad || null, + limits: item.limits || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const plans = await db.plans.bulkCreate(plansData, { transaction }); + + // For each item created, replace relation files + + return plans; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const plans = await db.plans.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.code !== undefined) updatePayload.code = data.code; + + if (data.name !== undefined) updatePayload.name = data.name; + + if (data.period !== undefined) updatePayload.period = data.period; + + if (data.price_mad !== undefined) updatePayload.price_mad = data.price_mad; + + if (data.limits !== undefined) updatePayload.limits = data.limits; + + updatePayload.updatedById = currentUser.id; + + await plans.update(updatePayload, { transaction }); + + if (data.agences !== undefined) { + await plans.setAgences( + data.agences, + + { transaction }, + ); + } + + return plans; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const plans = await db.plans.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of plans) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of plans) { + await record.destroy({ transaction }); + } + }); + + return plans; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const plans = await db.plans.findByPk(id, options); + + await plans.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await plans.destroy({ + transaction, + }); + + return plans; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const plans = await db.plans.findOne({ where }, { transaction }); + + if (!plans) { + return plans; + } + + const output = plans.get({ plain: true }); + + output.subscriptions_plan = await plans.getSubscriptions_plan({ + transaction, + }); + + output.tenants_plan = await plans.getTenants_plan({ + transaction, + }); + + output.agences = await plans.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.code) { + where = { + ...where, + [Op.and]: Utils.ilike('plans', 'code', filter.code), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('plans', 'name', filter.name), + }; + } + + if (filter.limits) { + where = { + ...where, + [Op.and]: Utils.ilike('plans', 'limits', filter.limits), + }; + } + + if (filter.price_madRange) { + const [start, end] = filter.price_madRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + price_mad: { + ...where.price_mad, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + price_mad: { + ...where.price_mad, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.period) { + where = { + ...where, + period: filter.period, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.plans.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('plans', 'name', query), + ], + }; + } + + const records = await db.plans.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/reservations.js b/backend/src/db/api/reservations.js new file mode 100644 index 0000000..9887fb8 --- /dev/null +++ b/backend/src/db/api/reservations.js @@ -0,0 +1,873 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ReservationsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const reservations = await db.reservations.create( + { + id: data.id || undefined, + + ref: data.ref || null, + status_manual: data.status_manual || null, + status_computed: data.status_computed || null, + start_at: data.start_at || null, + end_at: data.end_at || null, + pickup_address: data.pickup_address || null, + return_address: data.return_address || null, + options: data.options || null, + days: data.days || null, + price_per_day: data.price_per_day || null, + total_price: data.total_price || null, + paid_amount: data.paid_amount || null, + balance_amount: data.balance_amount || null, + guarantee_method: data.guarantee_method || null, + guarantee_amount: data.guarantee_amount || null, + guarantee_status: data.guarantee_status || null, + departure_validated_at: data.departure_validated_at || null, + return_validated_at: data.return_validated_at || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await reservations.setTenant(data.tenant || null, { + transaction, + }); + + await reservations.setClient(data.client || null, { + transaction, + }); + + await reservations.setVehicle(data.vehicle || null, { + transaction, + }); + + await reservations.setAgences(data.agences || null, { + transaction, + }); + + return reservations; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const reservationsData = data.map((item, index) => ({ + id: item.id || undefined, + + ref: item.ref || null, + status_manual: item.status_manual || null, + status_computed: item.status_computed || null, + start_at: item.start_at || null, + end_at: item.end_at || null, + pickup_address: item.pickup_address || null, + return_address: item.return_address || null, + options: item.options || null, + days: item.days || null, + price_per_day: item.price_per_day || null, + total_price: item.total_price || null, + paid_amount: item.paid_amount || null, + balance_amount: item.balance_amount || null, + guarantee_method: item.guarantee_method || null, + guarantee_amount: item.guarantee_amount || null, + guarantee_status: item.guarantee_status || null, + departure_validated_at: item.departure_validated_at || null, + return_validated_at: item.return_validated_at || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const reservations = await db.reservations.bulkCreate(reservationsData, { + transaction, + }); + + // For each item created, replace relation files + + return reservations; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const reservations = await db.reservations.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.ref !== undefined) updatePayload.ref = data.ref; + + if (data.status_manual !== undefined) + updatePayload.status_manual = data.status_manual; + + if (data.status_computed !== undefined) + updatePayload.status_computed = data.status_computed; + + if (data.start_at !== undefined) updatePayload.start_at = data.start_at; + + if (data.end_at !== undefined) updatePayload.end_at = data.end_at; + + if (data.pickup_address !== undefined) + updatePayload.pickup_address = data.pickup_address; + + if (data.return_address !== undefined) + updatePayload.return_address = data.return_address; + + if (data.options !== undefined) updatePayload.options = data.options; + + if (data.days !== undefined) updatePayload.days = data.days; + + if (data.price_per_day !== undefined) + updatePayload.price_per_day = data.price_per_day; + + if (data.total_price !== undefined) + updatePayload.total_price = data.total_price; + + if (data.paid_amount !== undefined) + updatePayload.paid_amount = data.paid_amount; + + if (data.balance_amount !== undefined) + updatePayload.balance_amount = data.balance_amount; + + if (data.guarantee_method !== undefined) + updatePayload.guarantee_method = data.guarantee_method; + + if (data.guarantee_amount !== undefined) + updatePayload.guarantee_amount = data.guarantee_amount; + + if (data.guarantee_status !== undefined) + updatePayload.guarantee_status = data.guarantee_status; + + if (data.departure_validated_at !== undefined) + updatePayload.departure_validated_at = data.departure_validated_at; + + if (data.return_validated_at !== undefined) + updatePayload.return_validated_at = data.return_validated_at; + + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await reservations.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await reservations.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.client !== undefined) { + await reservations.setClient( + data.client, + + { transaction }, + ); + } + + if (data.vehicle !== undefined) { + await reservations.setVehicle( + data.vehicle, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await reservations.setAgences( + data.agences, + + { transaction }, + ); + } + + return reservations; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const reservations = await db.reservations.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of reservations) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of reservations) { + await record.destroy({ transaction }); + } + }); + + return reservations; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const reservations = await db.reservations.findByPk(id, options); + + await reservations.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await reservations.destroy({ + transaction, + }); + + return reservations; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const reservations = await db.reservations.findOne( + { where }, + { transaction }, + ); + + if (!reservations) { + return reservations; + } + + const output = reservations.get({ plain: true }); + + output.claims_reservation = await reservations.getClaims_reservation({ + transaction, + }); + + output.drivers_reservation = await reservations.getDrivers_reservation({ + transaction, + }); + + output.guarantees_reservation = + await reservations.getGuarantees_reservation({ + transaction, + }); + + output.infractions_reservation = + await reservations.getInfractions_reservation({ + transaction, + }); + + output.payments_reservation = await reservations.getPayments_reservation({ + transaction, + }); + + output.vehicle_conditions_reservation = + await reservations.getVehicle_conditions_reservation({ + transaction, + }); + + output.tenant = await reservations.getTenant({ + transaction, + }); + + output.client = await reservations.getClient({ + transaction, + }); + + output.vehicle = await reservations.getVehicle({ + transaction, + }); + + output.agences = await reservations.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.clients, + as: 'client', + + where: filter.client + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.client + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + first_name: { + [Op.or]: filter.client + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.vehicles, + as: 'vehicle', + + where: filter.vehicle + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.vehicle + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + plate_number: { + [Op.or]: filter.vehicle + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.ref) { + where = { + ...where, + [Op.and]: Utils.ilike('reservations', 'ref', filter.ref), + }; + } + + if (filter.pickup_address) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'reservations', + 'pickup_address', + filter.pickup_address, + ), + }; + } + + if (filter.return_address) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'reservations', + 'return_address', + filter.return_address, + ), + }; + } + + if (filter.options) { + where = { + ...where, + [Op.and]: Utils.ilike('reservations', 'options', filter.options), + }; + } + + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('reservations', 'notes', filter.notes), + }; + } + + if (filter.calendarStart && filter.calendarEnd) { + where = { + ...where, + [Op.or]: [ + { + start_at: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + { + end_at: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + ], + }; + } + + if (filter.start_atRange) { + const [start, end] = filter.start_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + start_at: { + ...where.start_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + start_at: { + ...where.start_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.end_atRange) { + const [start, end] = filter.end_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + end_at: { + ...where.end_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + end_at: { + ...where.end_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.daysRange) { + const [start, end] = filter.daysRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + days: { + ...where.days, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + days: { + ...where.days, + [Op.lte]: end, + }, + }; + } + } + + if (filter.price_per_dayRange) { + const [start, end] = filter.price_per_dayRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + price_per_day: { + ...where.price_per_day, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + price_per_day: { + ...where.price_per_day, + [Op.lte]: end, + }, + }; + } + } + + if (filter.total_priceRange) { + const [start, end] = filter.total_priceRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + total_price: { + ...where.total_price, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + total_price: { + ...where.total_price, + [Op.lte]: end, + }, + }; + } + } + + if (filter.paid_amountRange) { + const [start, end] = filter.paid_amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + paid_amount: { + ...where.paid_amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + paid_amount: { + ...where.paid_amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.balance_amountRange) { + const [start, end] = filter.balance_amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + balance_amount: { + ...where.balance_amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + balance_amount: { + ...where.balance_amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.guarantee_amountRange) { + const [start, end] = filter.guarantee_amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + guarantee_amount: { + ...where.guarantee_amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + guarantee_amount: { + ...where.guarantee_amount, + [Op.lte]: end, + }, + }; + } + } + + if (filter.departure_validated_atRange) { + const [start, end] = filter.departure_validated_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + departure_validated_at: { + ...where.departure_validated_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + departure_validated_at: { + ...where.departure_validated_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.return_validated_atRange) { + const [start, end] = filter.return_validated_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + return_validated_at: { + ...where.return_validated_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + return_validated_at: { + ...where.return_validated_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status_manual) { + where = { + ...where, + status_manual: filter.status_manual, + }; + } + + if (filter.status_computed) { + where = { + ...where, + status_computed: filter.status_computed, + }; + } + + if (filter.guarantee_method) { + where = { + ...where, + guarantee_method: filter.guarantee_method, + }; + } + + if (filter.guarantee_status) { + where = { + ...where, + guarantee_status: filter.guarantee_status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.reservations.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('reservations', 'ref', query), + ], + }; + } + + const records = await db.reservations.findAll({ + attributes: ['id', 'ref'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['ref', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.ref, + })); + } +}; diff --git a/backend/src/db/api/roles.js b/backend/src/db/api/roles.js new file mode 100644 index 0000000..02ab211 --- /dev/null +++ b/backend/src/db/api/roles.js @@ -0,0 +1,344 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const config = require('../../config'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class RolesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.create( + { + id: data.id || undefined, + + name: data.name || null, + role_customization: data.role_customization || null, + globalAccess: data.globalAccess || false, + + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await roles.setPermissions(data.permissions || [], { + transaction, + }); + + return roles; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const rolesData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + role_customization: item.role_customization || null, + globalAccess: item.globalAccess || false, + + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const roles = await db.roles.bulkCreate(rolesData, { transaction }); + + // For each item created, replace relation files + + return roles; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const roles = await db.roles.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + if (data.role_customization !== undefined) + updatePayload.role_customization = data.role_customization; + + if (data.globalAccess !== undefined) + updatePayload.globalAccess = data.globalAccess; + + updatePayload.updatedById = currentUser.id; + + await roles.update(updatePayload, { transaction }); + + if (data.permissions !== undefined) { + await roles.setPermissions(data.permissions, { transaction }); + } + + return roles; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of roles) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of roles) { + await record.destroy({ transaction }); + } + }); + + return roles; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findByPk(id, options); + + await roles.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await roles.destroy({ + transaction, + }); + + return roles; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const roles = await db.roles.findOne({ where }, { transaction }); + + if (!roles) { + return roles; + } + + const output = roles.get({ plain: true }); + + output.users_app_role = await roles.getUsers_app_role({ + transaction, + }); + + output.permissions = await roles.getPermissions({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.permissions, + as: 'permissions', + required: false, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('roles', 'name', filter.name), + }; + } + + if (filter.role_customization) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'roles', + 'role_customization', + filter.role_customization, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.globalAccess) { + where = { + ...where, + globalAccess: filter.globalAccess, + }; + } + + if (filter.permissions) { + const searchTerms = filter.permissions.split('|'); + + include = [ + { + model: db.permissions, + as: 'permissions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (!globalAccess) { + where = { name: { [Op.ne]: config.roles.super_admin } }; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.roles.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset, globalAccess) { + let where = {}; + + if (!globalAccess) { + where = { name: { [Op.ne]: config.roles.super_admin } }; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('roles', 'name', query), + ], + }; + } + + const records = await db.roles.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/subscriptions.js b/backend/src/db/api/subscriptions.js new file mode 100644 index 0000000..a8658dd --- /dev/null +++ b/backend/src/db/api/subscriptions.js @@ -0,0 +1,504 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class SubscriptionsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const subscriptions = await db.subscriptions.create( + { + id: data.id || undefined, + + status: data.status || null, + current_period_start: data.current_period_start || null, + current_period_end: data.current_period_end || null, + payment_provider: data.payment_provider || null, + provider_customer_id: data.provider_customer_id || null, + provider_subscription_id: data.provider_subscription_id || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await subscriptions.setTenant(data.tenant || null, { + transaction, + }); + + await subscriptions.setPlan(data.plan || null, { + transaction, + }); + + await subscriptions.setAgences(data.agences || null, { + transaction, + }); + + return subscriptions; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const subscriptionsData = data.map((item, index) => ({ + id: item.id || undefined, + + status: item.status || null, + current_period_start: item.current_period_start || null, + current_period_end: item.current_period_end || null, + payment_provider: item.payment_provider || null, + provider_customer_id: item.provider_customer_id || null, + provider_subscription_id: item.provider_subscription_id || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const subscriptions = await db.subscriptions.bulkCreate(subscriptionsData, { + transaction, + }); + + // For each item created, replace relation files + + return subscriptions; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const subscriptions = await db.subscriptions.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.current_period_start !== undefined) + updatePayload.current_period_start = data.current_period_start; + + if (data.current_period_end !== undefined) + updatePayload.current_period_end = data.current_period_end; + + if (data.payment_provider !== undefined) + updatePayload.payment_provider = data.payment_provider; + + if (data.provider_customer_id !== undefined) + updatePayload.provider_customer_id = data.provider_customer_id; + + if (data.provider_subscription_id !== undefined) + updatePayload.provider_subscription_id = data.provider_subscription_id; + + updatePayload.updatedById = currentUser.id; + + await subscriptions.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await subscriptions.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.plan !== undefined) { + await subscriptions.setPlan( + data.plan, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await subscriptions.setAgences( + data.agences, + + { transaction }, + ); + } + + return subscriptions; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const subscriptions = await db.subscriptions.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of subscriptions) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of subscriptions) { + await record.destroy({ transaction }); + } + }); + + return subscriptions; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const subscriptions = await db.subscriptions.findByPk(id, options); + + await subscriptions.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await subscriptions.destroy({ + transaction, + }); + + return subscriptions; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const subscriptions = await db.subscriptions.findOne( + { where }, + { transaction }, + ); + + if (!subscriptions) { + return subscriptions; + } + + const output = subscriptions.get({ plain: true }); + + output.invoices_saas_subscription = + await subscriptions.getInvoices_saas_subscription({ + transaction, + }); + + output.tenant = await subscriptions.getTenant({ + transaction, + }); + + output.plan = await subscriptions.getPlan({ + transaction, + }); + + output.agences = await subscriptions.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.plans, + as: 'plan', + + where: filter.plan + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.plan + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.plan + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.provider_customer_id) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'subscriptions', + 'provider_customer_id', + filter.provider_customer_id, + ), + }; + } + + if (filter.provider_subscription_id) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'subscriptions', + 'provider_subscription_id', + filter.provider_subscription_id, + ), + }; + } + + if (filter.current_period_startRange) { + const [start, end] = filter.current_period_startRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + current_period_start: { + ...where.current_period_start, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + current_period_start: { + ...where.current_period_start, + [Op.lte]: end, + }, + }; + } + } + + if (filter.current_period_endRange) { + const [start, end] = filter.current_period_endRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + current_period_end: { + ...where.current_period_end, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + current_period_end: { + ...where.current_period_end, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.payment_provider) { + where = { + ...where, + payment_provider: filter.payment_provider, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.subscriptions.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('subscriptions', 'provider_customer_id', query), + ], + }; + } + + const records = await db.subscriptions.findAll({ + attributes: ['id', 'provider_customer_id'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['provider_customer_id', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.provider_customer_id, + })); + } +}; diff --git a/backend/src/db/api/tenants.js b/backend/src/db/api/tenants.js new file mode 100644 index 0000000..33cf289 --- /dev/null +++ b/backend/src/db/api/tenants.js @@ -0,0 +1,484 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class TenantsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tenants = await db.tenants.create( + { + id: data.id || undefined, + + name: data.name || null, + subdomain: data.subdomain || null, + status: data.status || null, + trial_ends_at: data.trial_ends_at || null, + billing_email: data.billing_email || null, + billing_phone: data.billing_phone || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await tenants.setPlan(data.plan || null, { + transaction, + }); + + await tenants.setAgences(data.agences || null, { + transaction, + }); + + return tenants; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const tenantsData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + subdomain: item.subdomain || null, + status: item.status || null, + trial_ends_at: item.trial_ends_at || null, + billing_email: item.billing_email || null, + billing_phone: item.billing_phone || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const tenants = await db.tenants.bulkCreate(tenantsData, { transaction }); + + // For each item created, replace relation files + + return tenants; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const tenants = await db.tenants.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + if (data.subdomain !== undefined) updatePayload.subdomain = data.subdomain; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.trial_ends_at !== undefined) + updatePayload.trial_ends_at = data.trial_ends_at; + + if (data.billing_email !== undefined) + updatePayload.billing_email = data.billing_email; + + if (data.billing_phone !== undefined) + updatePayload.billing_phone = data.billing_phone; + + updatePayload.updatedById = currentUser.id; + + await tenants.update(updatePayload, { transaction }); + + if (data.plan !== undefined) { + await tenants.setPlan( + data.plan, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await tenants.setAgences( + data.agences, + + { transaction }, + ); + } + + return tenants; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tenants = await db.tenants.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of tenants) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of tenants) { + await record.destroy({ transaction }); + } + }); + + return tenants; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tenants = await db.tenants.findByPk(id, options); + + await tenants.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await tenants.destroy({ + transaction, + }); + + return tenants; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const tenants = await db.tenants.findOne({ where }, { transaction }); + + if (!tenants) { + return tenants; + } + + const output = tenants.get({ plain: true }); + + output.claims_tenant = await tenants.getClaims_tenant({ + transaction, + }); + + output.clients_tenant = await tenants.getClients_tenant({ + transaction, + }); + + output.company_profiles_tenant = await tenants.getCompany_profiles_tenant({ + transaction, + }); + + output.drivers_tenant = await tenants.getDrivers_tenant({ + transaction, + }); + + output.expenses_tenant = await tenants.getExpenses_tenant({ + transaction, + }); + + output.guarantees_tenant = await tenants.getGuarantees_tenant({ + transaction, + }); + + output.infractions_tenant = await tenants.getInfractions_tenant({ + transaction, + }); + + output.invoices_saas_tenant = await tenants.getInvoices_saas_tenant({ + transaction, + }); + + output.maintenance_tenant = await tenants.getMaintenance_tenant({ + transaction, + }); + + output.payments_tenant = await tenants.getPayments_tenant({ + transaction, + }); + + output.reservations_tenant = await tenants.getReservations_tenant({ + transaction, + }); + + output.subscriptions_tenant = await tenants.getSubscriptions_tenant({ + transaction, + }); + + output.vehicle_conditions_tenant = + await tenants.getVehicle_conditions_tenant({ + transaction, + }); + + output.vehicles_tenant = await tenants.getVehicles_tenant({ + transaction, + }); + + output.plan = await tenants.getPlan({ + transaction, + }); + + output.agences = await tenants.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.plans, + as: 'plan', + + where: filter.plan + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.plan + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.plan + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('tenants', 'name', filter.name), + }; + } + + if (filter.subdomain) { + where = { + ...where, + [Op.and]: Utils.ilike('tenants', 'subdomain', filter.subdomain), + }; + } + + if (filter.billing_email) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'tenants', + 'billing_email', + filter.billing_email, + ), + }; + } + + if (filter.billing_phone) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'tenants', + 'billing_phone', + filter.billing_phone, + ), + }; + } + + if (filter.trial_ends_atRange) { + const [start, end] = filter.trial_ends_atRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + trial_ends_at: { + ...where.trial_ends_at, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + trial_ends_at: { + ...where.trial_ends_at, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.tenants.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('tenants', 'name', query), + ], + }; + } + + const records = await db.tenants.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js new file mode 100644 index 0000000..05f51fc --- /dev/null +++ b/backend/src/db/api/users.js @@ -0,0 +1,800 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const bcrypt = require('bcrypt'); +const config = require('../../config'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class UsersDBApi { + static async create(data, globalAccess, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.create( + { + id: data.data.id || undefined, + + firstName: data.data.firstName || null, + lastName: data.data.lastName || null, + phoneNumber: data.data.phoneNumber || null, + email: data.data.email || null, + disabled: data.data.disabled || false, + + password: data.data.password || null, + emailVerified: data.data.emailVerified || true, + + emailVerificationToken: data.data.emailVerificationToken || null, + emailVerificationTokenExpiresAt: + data.data.emailVerificationTokenExpiresAt || null, + passwordResetToken: data.data.passwordResetToken || null, + passwordResetTokenExpiresAt: + data.data.passwordResetTokenExpiresAt || null, + provider: data.data.provider || null, + importHash: data.data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + if (!data.data.app_role) { + const role = await db.roles.findOne({ + where: { name: 'User' }, + }); + if (role) { + await users.setApp_role(role, { + transaction, + }); + } + } else { + await users.setApp_role(data.data.app_role || null, { + transaction, + }); + } + + await users.setAgences(data.data.agences || null, { + transaction, + }); + + await users.setCustom_permissions(data.data.custom_permissions || [], { + transaction, + }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + belongsToId: users.id, + }, + data.data.avatar, + options, + ); + + return users; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const usersData = data.map((item, index) => ({ + id: item.id || undefined, + + firstName: item.firstName || null, + lastName: item.lastName || null, + phoneNumber: item.phoneNumber || null, + email: item.email || null, + disabled: item.disabled || false, + + password: item.password || null, + emailVerified: item.emailVerified || false, + + emailVerificationToken: item.emailVerificationToken || null, + emailVerificationTokenExpiresAt: + item.emailVerificationTokenExpiresAt || null, + passwordResetToken: item.passwordResetToken || null, + passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null, + provider: item.provider || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const users = await db.users.bulkCreate(usersData, { transaction }); + + // For each item created, replace relation files + + for (let i = 0; i < users.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + belongsToId: users[i].id, + }, + data[i].avatar, + options, + ); + } + + return users; + } + + static async update(id, data, globalAccess, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, {}, { transaction }); + + if (!data?.app_role) { + data.app_role = users?.app_role?.id; + } + if (!data?.custom_permissions) { + data.custom_permissions = users?.custom_permissions?.map( + (item) => item.id, + ); + } + + if (data.password) { + data.password = bcrypt.hashSync(data.password, config.bcrypt.saltRounds); + } else { + data.password = users.password; + } + + const updatePayload = {}; + + if (data.firstName !== undefined) updatePayload.firstName = data.firstName; + + if (data.lastName !== undefined) updatePayload.lastName = data.lastName; + + if (data.phoneNumber !== undefined) + updatePayload.phoneNumber = data.phoneNumber; + + if (data.email !== undefined) updatePayload.email = data.email; + + if (data.disabled !== undefined) updatePayload.disabled = data.disabled; + + if (data.password !== undefined) updatePayload.password = data.password; + + if (data.emailVerified !== undefined) + updatePayload.emailVerified = data.emailVerified; + else updatePayload.emailVerified = true; + + if (data.emailVerificationToken !== undefined) + updatePayload.emailVerificationToken = data.emailVerificationToken; + + if (data.emailVerificationTokenExpiresAt !== undefined) + updatePayload.emailVerificationTokenExpiresAt = + data.emailVerificationTokenExpiresAt; + + if (data.passwordResetToken !== undefined) + updatePayload.passwordResetToken = data.passwordResetToken; + + if (data.passwordResetTokenExpiresAt !== undefined) + updatePayload.passwordResetTokenExpiresAt = + data.passwordResetTokenExpiresAt; + + if (data.provider !== undefined) updatePayload.provider = data.provider; + + updatePayload.updatedById = currentUser.id; + + await users.update(updatePayload, { transaction }); + + if (data.app_role !== undefined) { + await users.setApp_role( + data.app_role, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await users.setAgences( + data.agences, + + { transaction }, + ); + } + + if (data.custom_permissions !== undefined) { + await users.setCustom_permissions(data.custom_permissions, { + transaction, + }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + belongsToId: users.id, + }, + data.avatar, + options, + ); + + return users; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of users) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of users) { + await record.destroy({ transaction }); + } + }); + + return users; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, options); + + await users.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await users.destroy({ + transaction, + }); + + return users; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findOne({ where }, { transaction }); + + if (!users) { + return users; + } + + const output = users.get({ plain: true }); + + output.avatar = await users.getAvatar({ + transaction, + }); + + output.app_role = await users.getApp_role({ + transaction, + }); + + if (output.app_role) { + output.app_role_permissions = await output.app_role.getPermissions({ + transaction, + }); + } + + output.custom_permissions = await users.getCustom_permissions({ + transaction, + }); + + output.agences = await users.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.roles, + as: 'app_role', + + where: filter.app_role + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.app_role + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.app_role + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + + { + model: db.permissions, + as: 'custom_permissions', + required: false, + }, + + { + model: db.file, + as: 'avatar', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.firstName) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'firstName', filter.firstName), + }; + } + + if (filter.lastName) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'lastName', filter.lastName), + }; + } + + if (filter.phoneNumber) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'phoneNumber', filter.phoneNumber), + }; + } + + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'email', filter.email), + }; + } + + if (filter.password) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'password', filter.password), + }; + } + + if (filter.emailVerificationToken) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'users', + 'emailVerificationToken', + filter.emailVerificationToken, + ), + }; + } + + if (filter.passwordResetToken) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'users', + 'passwordResetToken', + filter.passwordResetToken, + ), + }; + } + + if (filter.provider) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'provider', filter.provider), + }; + } + + if (filter.emailVerificationTokenExpiresAtRange) { + const [start, end] = filter.emailVerificationTokenExpiresAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + emailVerificationTokenExpiresAt: { + ...where.emailVerificationTokenExpiresAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + emailVerificationTokenExpiresAt: { + ...where.emailVerificationTokenExpiresAt, + [Op.lte]: end, + }, + }; + } + } + + if (filter.passwordResetTokenExpiresAtRange) { + const [start, end] = filter.passwordResetTokenExpiresAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + passwordResetTokenExpiresAt: { + ...where.passwordResetTokenExpiresAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + passwordResetTokenExpiresAt: { + ...where.passwordResetTokenExpiresAt, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.disabled) { + where = { + ...where, + disabled: filter.disabled, + }; + } + + if (filter.emailVerified) { + where = { + ...where, + emailVerified: filter.emailVerified, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.custom_permissions) { + const searchTerms = filter.custom_permissions.split('|'); + + include = [ + { + model: db.permissions, + as: 'custom_permissions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.users.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('users', 'firstName', query), + ], + }; + } + + const records = await db.users.findAll({ + attributes: ['id', 'firstName'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['firstName', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.firstName, + })); + } + + static async createFromAuth(data, options) { + const transaction = (options && options.transaction) || undefined; + const users = await db.users.create( + { + email: data.email, + firstName: data.firstName, + authenticationUid: data.authenticationUid, + password: data.password, + + organizationId: data.organizationId, + }, + { transaction }, + ); + + const app_role = await db.roles.findOne({ + where: { name: config.roles?.user || 'User' }, + }); + if (app_role?.id) { + await users.setApp_role(app_role?.id || null, { + transaction, + }); + } + + await users.update( + { + authenticationUid: users.id, + }, + { transaction }, + ); + + delete users.password; + return users; + } + + static async updatePassword(id, password, options) { + const currentUser = (options && options.currentUser) || { id: null }; + + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, { + transaction, + }); + + await users.update( + { + password, + authenticationUid: id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return users; + } + + static async generateEmailVerificationToken(email, options) { + return this._generateToken( + ['emailVerificationToken', 'emailVerificationTokenExpiresAt'], + email, + options, + ); + } + + static async generatePasswordResetToken(email, options) { + return this._generateToken( + ['passwordResetToken', 'passwordResetTokenExpiresAt'], + email, + options, + ); + } + + static async findByPasswordResetToken(token, options) { + const transaction = (options && options.transaction) || undefined; + + return db.users.findOne( + { + where: { + passwordResetToken: token, + passwordResetTokenExpiresAt: { + [db.Sequelize.Op.gt]: Date.now(), + }, + }, + }, + { transaction }, + ); + } + + static async findByEmailVerificationToken(token, options) { + const transaction = (options && options.transaction) || undefined; + return db.users.findOne( + { + where: { + emailVerificationToken: token, + emailVerificationTokenExpiresAt: { + [db.Sequelize.Op.gt]: Date.now(), + }, + }, + }, + { transaction }, + ); + } + + static async markEmailVerified(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findByPk(id, { + transaction, + }); + + await users.update( + { + emailVerified: true, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return true; + } + + static async _generateToken(keyNames, email, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const users = await db.users.findOne( + { + where: { email: email.toLowerCase() }, + }, + { + transaction, + }, + ); + + const token = crypto.randomBytes(20).toString('hex'); + const tokenExpiresAt = Date.now() + 360000; + + if (users) { + await users.update( + { + [keyNames[0]]: token, + [keyNames[1]]: tokenExpiresAt, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + return token; + } +}; diff --git a/backend/src/db/api/vehicle_conditions.js b/backend/src/db/api/vehicle_conditions.js new file mode 100644 index 0000000..bbe6320 --- /dev/null +++ b/backend/src/db/api/vehicle_conditions.js @@ -0,0 +1,412 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class Vehicle_conditionsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const vehicle_conditions = await db.vehicle_conditions.create( + { + id: data.id || undefined, + + before: data.before || null, + after: data.after || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await vehicle_conditions.setTenant(data.tenant || null, { + transaction, + }); + + await vehicle_conditions.setReservation(data.reservation || null, { + transaction, + }); + + await vehicle_conditions.setAgences(data.agences || null, { + transaction, + }); + + return vehicle_conditions; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const vehicle_conditionsData = data.map((item, index) => ({ + id: item.id || undefined, + + before: item.before || null, + after: item.after || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const vehicle_conditions = await db.vehicle_conditions.bulkCreate( + vehicle_conditionsData, + { transaction }, + ); + + // For each item created, replace relation files + + return vehicle_conditions; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const vehicle_conditions = await db.vehicle_conditions.findByPk( + id, + {}, + { transaction }, + ); + + const updatePayload = {}; + + if (data.before !== undefined) updatePayload.before = data.before; + + if (data.after !== undefined) updatePayload.after = data.after; + + updatePayload.updatedById = currentUser.id; + + await vehicle_conditions.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await vehicle_conditions.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.reservation !== undefined) { + await vehicle_conditions.setReservation( + data.reservation, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await vehicle_conditions.setAgences( + data.agences, + + { transaction }, + ); + } + + return vehicle_conditions; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const vehicle_conditions = await db.vehicle_conditions.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of vehicle_conditions) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of vehicle_conditions) { + await record.destroy({ transaction }); + } + }); + + return vehicle_conditions; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const vehicle_conditions = await db.vehicle_conditions.findByPk( + id, + options, + ); + + await vehicle_conditions.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await vehicle_conditions.destroy({ + transaction, + }); + + return vehicle_conditions; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const vehicle_conditions = await db.vehicle_conditions.findOne( + { where }, + { transaction }, + ); + + if (!vehicle_conditions) { + return vehicle_conditions; + } + + const output = vehicle_conditions.get({ plain: true }); + + output.tenant = await vehicle_conditions.getTenant({ + transaction, + }); + + output.reservation = await vehicle_conditions.getReservation({ + transaction, + }); + + output.agences = await vehicle_conditions.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.reservations, + as: 'reservation', + + where: filter.reservation + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.reservation + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + ref: { + [Op.or]: filter.reservation + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.before) { + where = { + ...where, + [Op.and]: Utils.ilike('vehicle_conditions', 'before', filter.before), + }; + } + + if (filter.after) { + where = { + ...where, + [Op.and]: Utils.ilike('vehicle_conditions', 'after', filter.after), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.vehicle_conditions.findAndCountAll( + queryOptions, + ); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('vehicle_conditions', 'before', query), + ], + }; + } + + const records = await db.vehicle_conditions.findAll({ + attributes: ['id', 'before'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['before', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.before, + })); + } +}; diff --git a/backend/src/db/api/vehicles.js b/backend/src/db/api/vehicles.js new file mode 100644 index 0000000..8209824 --- /dev/null +++ b/backend/src/db/api/vehicles.js @@ -0,0 +1,599 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class VehiclesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const vehicles = await db.vehicles.create( + { + id: data.id || undefined, + + plate_number: data.plate_number || null, + brand: data.brand || null, + model: data.model || null, + year: data.year || null, + color: data.color || null, + category: data.category || null, + fuel_type: data.fuel_type || null, + transmission: data.transmission || null, + mileage: data.mileage || null, + daily_price: data.daily_price || null, + status: data.status || null, + insurance_expiry: data.insurance_expiry || null, + tech_visit_expiry: data.tech_visit_expiry || null, + photos: data.photos || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await vehicles.setTenant(data.tenant || null, { + transaction, + }); + + await vehicles.setAgences(data.agences || null, { + transaction, + }); + + return vehicles; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const vehiclesData = data.map((item, index) => ({ + id: item.id || undefined, + + plate_number: item.plate_number || null, + brand: item.brand || null, + model: item.model || null, + year: item.year || null, + color: item.color || null, + category: item.category || null, + fuel_type: item.fuel_type || null, + transmission: item.transmission || null, + mileage: item.mileage || null, + daily_price: item.daily_price || null, + status: item.status || null, + insurance_expiry: item.insurance_expiry || null, + tech_visit_expiry: item.tech_visit_expiry || null, + photos: item.photos || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const vehicles = await db.vehicles.bulkCreate(vehiclesData, { + transaction, + }); + + // For each item created, replace relation files + + return vehicles; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const vehicles = await db.vehicles.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.plate_number !== undefined) + updatePayload.plate_number = data.plate_number; + + if (data.brand !== undefined) updatePayload.brand = data.brand; + + if (data.model !== undefined) updatePayload.model = data.model; + + if (data.year !== undefined) updatePayload.year = data.year; + + if (data.color !== undefined) updatePayload.color = data.color; + + if (data.category !== undefined) updatePayload.category = data.category; + + if (data.fuel_type !== undefined) updatePayload.fuel_type = data.fuel_type; + + if (data.transmission !== undefined) + updatePayload.transmission = data.transmission; + + if (data.mileage !== undefined) updatePayload.mileage = data.mileage; + + if (data.daily_price !== undefined) + updatePayload.daily_price = data.daily_price; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.insurance_expiry !== undefined) + updatePayload.insurance_expiry = data.insurance_expiry; + + if (data.tech_visit_expiry !== undefined) + updatePayload.tech_visit_expiry = data.tech_visit_expiry; + + if (data.photos !== undefined) updatePayload.photos = data.photos; + + updatePayload.updatedById = currentUser.id; + + await vehicles.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await vehicles.setTenant( + data.tenant, + + { transaction }, + ); + } + + if (data.agences !== undefined) { + await vehicles.setAgences( + data.agences, + + { transaction }, + ); + } + + return vehicles; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const vehicles = await db.vehicles.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of vehicles) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of vehicles) { + await record.destroy({ transaction }); + } + }); + + return vehicles; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const vehicles = await db.vehicles.findByPk(id, options); + + await vehicles.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await vehicles.destroy({ + transaction, + }); + + return vehicles; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const vehicles = await db.vehicles.findOne({ where }, { transaction }); + + if (!vehicles) { + return vehicles; + } + + const output = vehicles.get({ plain: true }); + + output.expenses_vehicle = await vehicles.getExpenses_vehicle({ + transaction, + }); + + output.infractions_vehicle = await vehicles.getInfractions_vehicle({ + transaction, + }); + + output.maintenance_vehicle = await vehicles.getMaintenance_vehicle({ + transaction, + }); + + output.reservations_vehicle = await vehicles.getReservations_vehicle({ + transaction, + }); + + output.tenant = await vehicles.getTenant({ + transaction, + }); + + output.agences = await vehicles.getAgences({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userAgences = (user && user.agences?.id) || null; + + if (userAgences) { + if (options?.currentUser?.agencesId) { + where.agencesId = options.currentUser.agencesId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + + where: filter.tenant + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.tenant + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.tenant + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + + { + model: db.agences, + as: 'agences', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.plate_number) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'vehicles', + 'plate_number', + filter.plate_number, + ), + }; + } + + if (filter.brand) { + where = { + ...where, + [Op.and]: Utils.ilike('vehicles', 'brand', filter.brand), + }; + } + + if (filter.model) { + where = { + ...where, + [Op.and]: Utils.ilike('vehicles', 'model', filter.model), + }; + } + + if (filter.color) { + where = { + ...where, + [Op.and]: Utils.ilike('vehicles', 'color', filter.color), + }; + } + + if (filter.photos) { + where = { + ...where, + [Op.and]: Utils.ilike('vehicles', 'photos', filter.photos), + }; + } + + if (filter.yearRange) { + const [start, end] = filter.yearRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + year: { + ...where.year, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + year: { + ...where.year, + [Op.lte]: end, + }, + }; + } + } + + if (filter.mileageRange) { + const [start, end] = filter.mileageRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + mileage: { + ...where.mileage, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + mileage: { + ...where.mileage, + [Op.lte]: end, + }, + }; + } + } + + if (filter.daily_priceRange) { + const [start, end] = filter.daily_priceRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + daily_price: { + ...where.daily_price, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + daily_price: { + ...where.daily_price, + [Op.lte]: end, + }, + }; + } + } + + if (filter.insurance_expiryRange) { + const [start, end] = filter.insurance_expiryRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + insurance_expiry: { + ...where.insurance_expiry, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + insurance_expiry: { + ...where.insurance_expiry, + [Op.lte]: end, + }, + }; + } + } + + if (filter.tech_visit_expiryRange) { + const [start, end] = filter.tech_visit_expiryRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + tech_visit_expiry: { + ...where.tech_visit_expiry, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + tech_visit_expiry: { + ...where.tech_visit_expiry, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.category) { + where = { + ...where, + category: filter.category, + }; + } + + if (filter.fuel_type) { + where = { + ...where, + fuel_type: filter.fuel_type, + }; + } + + if (filter.transmission) { + where = { + ...where, + transmission: filter.transmission, + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.agences) { + const listItems = filter.agences.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + agencesId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.agencesId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.vehicles.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('vehicles', 'plate_number', query), + ], + }; + } + + const records = await db.vehicles.findAll({ + attributes: ['id', 'plate_number'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['plate_number', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.plate_number, + })); + } +}; diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js new file mode 100644 index 0000000..5398412 --- /dev/null +++ b/backend/src/db/db.config.js @@ -0,0 +1,31 @@ +module.exports = { + production: { + dialect: 'postgres', + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + logging: console.log, + seederStorage: 'sequelize', + }, + development: { + username: 'postgres', + dialect: 'postgres', + password: '', + database: 'db_car_zone', + host: process.env.DB_HOST || 'localhost', + logging: console.log, + seederStorage: 'sequelize', + }, + dev_stage: { + dialect: 'postgres', + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + logging: console.log, + seederStorage: 'sequelize', + }, +}; diff --git a/backend/src/db/migrations/1760750720800.js b/backend/src/db/migrations/1760750720800.js new file mode 100644 index 0000000..b168e94 --- /dev/null +++ b/backend/src/db/migrations/1760750720800.js @@ -0,0 +1,3293 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'users', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'claims', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'clients', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'company_profiles', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'drivers', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'expenses', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'guarantees', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'infractions', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'invoices_saas', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'maintenance', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'payments', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'plans', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'reservations', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'subscriptions', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'tenants', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'vehicle_conditions', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'vehicles', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'roles', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'permissions', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'agences', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'firstName', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'lastName', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'phoneNumber', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'email', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'disabled', + { + type: Sequelize.DataTypes.BOOLEAN, + + defaultValue: false, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'password', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'emailVerified', + { + type: Sequelize.DataTypes.BOOLEAN, + + defaultValue: false, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'emailVerificationToken', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'emailVerificationTokenExpiresAt', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'passwordResetToken', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'passwordResetTokenExpiresAt', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'provider', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'clientId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'clients', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'reservationId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'reservations', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'category', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'description', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['ouverte', 'en_cours', 'resolue'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'attachments', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'first_name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'last_name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'birth_date', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'phone', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'address', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'id_type', + { + type: Sequelize.DataTypes.ENUM, + + values: ['CIN', 'Passeport'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'id_number', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'id_issue_date', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'license_number', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'license_issue_date', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'id_copy_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'license_copy_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'legal_name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'address', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'phone', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'email', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'website', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'rc', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'ice', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'patente', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'logo_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'pdf_footer', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'reservationId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'reservations', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'full_name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'license_number', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'license_issue_date', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'id_copy_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'license_copy_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'type', + { + type: Sequelize.DataTypes.ENUM, + + values: ['depense', 'revenu'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'category', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'date', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'vehicleId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'vehicles', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'note', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'attachment_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'reservationId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'reservations', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'method', + { + type: Sequelize.DataTypes.ENUM, + + values: ['especes', 'carte_bloquee', 'cheque'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['retenue', 'restituee', 'partielle'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'proof_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'vehicleId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'vehicles', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'reservationId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'reservations', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'ref_no', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'date', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'location', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['ouverte', 'payee', 'conteste'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'proof_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'subscriptionId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'subscriptions', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'invoice_number', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'amount_mad', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['draft', 'sent', 'paid', 'void'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'issued_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'pdf_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'vehicleId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'vehicles', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'type', + { + type: Sequelize.DataTypes.ENUM, + + values: ['preventive', 'corrective'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'scheduled_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'odometer', + { + type: Sequelize.DataTypes.INTEGER, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'cost', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'notes', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'attachments', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'reservationId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'reservations', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'method', + { + type: Sequelize.DataTypes.ENUM, + + values: ['especes', 'carte', 'virement', 'cheque'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'paid_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'receipt_ref', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'attachment_url', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'plans', + 'code', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'plans', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'plans', + 'period', + { + type: Sequelize.DataTypes.ENUM, + + values: ['monthly', 'yearly', 'both'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'plans', + 'price_mad', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'plans', + 'limits', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'ref', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'status_manual', + { + type: Sequelize.DataTypes.ENUM, + + values: ['en_attente', 'confirmee', 'annulee'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'status_computed', + { + type: Sequelize.DataTypes.ENUM, + + values: [ + 'a_venir', + 'en_cours', + 'a_restituer', + 'en_retard', + 'terminee', + 'annulee', + 'no_show', + ], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'clientId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'clients', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'vehicleId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'vehicles', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'start_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'end_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'pickup_address', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'return_address', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'options', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'days', + { + type: Sequelize.DataTypes.INTEGER, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'price_per_day', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'total_price', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'paid_amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'balance_amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'guarantee_method', + { + type: Sequelize.DataTypes.ENUM, + + values: ['especes', 'carte_bloquee', 'cheque', 'aucune'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'guarantee_amount', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'guarantee_status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['retenue', 'restituee', 'partielle', 'non_applicable'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'departure_validated_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'return_validated_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'notes', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'planId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'plans', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['active', 'past_due', 'canceled', 'trialing'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'current_period_start', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'current_period_end', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'payment_provider', + { + type: Sequelize.DataTypes.ENUM, + + values: ['stripe', 'manual', 'none'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'provider_customer_id', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'provider_subscription_id', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'subdomain', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['active', 'suspended', 'trial'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'planId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'plans', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'trial_ends_at', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'billing_email', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'billing_phone', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicle_conditions', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicle_conditions', + 'reservationId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'reservations', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicle_conditions', + 'before', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicle_conditions', + 'after', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'tenantId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'tenants', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'plate_number', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'brand', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'model', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'year', + { + type: Sequelize.DataTypes.INTEGER, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'color', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'category', + { + type: Sequelize.DataTypes.ENUM, + + values: ['citadine', 'berline', 'SUV', 'utilitaire', 'luxe'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'fuel_type', + { + type: Sequelize.DataTypes.ENUM, + + values: ['essence', 'diesel', 'hybride', 'electrique'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'transmission', + { + type: Sequelize.DataTypes.ENUM, + + values: ['manuelle', 'automatique'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'mileage', + { + type: Sequelize.DataTypes.INTEGER, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'daily_price', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['disponible', 'reserve', 'en_location', 'hors_service'], + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'insurance_expiry', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'tech_visit_expiry', + { + type: Sequelize.DataTypes.DATE, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'photos', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'permissions', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'roles', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'roles', + 'role_customization', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'app_roleId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'roles', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'agences', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'roles', + 'globalAccess', + { + type: Sequelize.DataTypes.BOOLEAN, + + defaultValue: false, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'users', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'claims', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'clients', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'company_profiles', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'drivers', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'expenses', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'guarantees', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'infractions', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'invoices_saas', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'maintenance', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'payments', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'plans', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'reservations', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'subscriptions', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tenants', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicle_conditions', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'vehicles', + 'agencesId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'agences', + key: 'id', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('vehicles', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('vehicle_conditions', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('tenants', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('subscriptions', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('plans', 'agencesId', { transaction }); + + await queryInterface.removeColumn('payments', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('expenses', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'agencesId', { + transaction, + }); + + await queryInterface.removeColumn('claims', 'agencesId', { transaction }); + + await queryInterface.removeColumn('users', 'agencesId', { transaction }); + + await queryInterface.removeColumn('roles', 'globalAccess', { + transaction, + }); + + await queryInterface.removeColumn('agences', 'name', { transaction }); + + await queryInterface.removeColumn('users', 'app_roleId', { transaction }); + + await queryInterface.removeColumn('roles', 'role_customization', { + transaction, + }); + + await queryInterface.removeColumn('roles', 'name', { transaction }); + + await queryInterface.removeColumn('permissions', 'name', { transaction }); + + await queryInterface.removeColumn('vehicles', 'photos', { transaction }); + + await queryInterface.removeColumn('vehicles', 'tech_visit_expiry', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'insurance_expiry', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'status', { transaction }); + + await queryInterface.removeColumn('vehicles', 'daily_price', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'mileage', { transaction }); + + await queryInterface.removeColumn('vehicles', 'transmission', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'fuel_type', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'category', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'color', { transaction }); + + await queryInterface.removeColumn('vehicles', 'year', { transaction }); + + await queryInterface.removeColumn('vehicles', 'model', { transaction }); + + await queryInterface.removeColumn('vehicles', 'brand', { transaction }); + + await queryInterface.removeColumn('vehicles', 'plate_number', { + transaction, + }); + + await queryInterface.removeColumn('vehicles', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('vehicle_conditions', 'after', { + transaction, + }); + + await queryInterface.removeColumn('vehicle_conditions', 'before', { + transaction, + }); + + await queryInterface.removeColumn('vehicle_conditions', 'reservationId', { + transaction, + }); + + await queryInterface.removeColumn('vehicle_conditions', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('tenants', 'billing_phone', { + transaction, + }); + + await queryInterface.removeColumn('tenants', 'billing_email', { + transaction, + }); + + await queryInterface.removeColumn('tenants', 'trial_ends_at', { + transaction, + }); + + await queryInterface.removeColumn('tenants', 'planId', { transaction }); + + await queryInterface.removeColumn('tenants', 'status', { transaction }); + + await queryInterface.removeColumn('tenants', 'subdomain', { + transaction, + }); + + await queryInterface.removeColumn('tenants', 'name', { transaction }); + + await queryInterface.removeColumn( + 'subscriptions', + 'provider_subscription_id', + { transaction }, + ); + + await queryInterface.removeColumn( + 'subscriptions', + 'provider_customer_id', + { transaction }, + ); + + await queryInterface.removeColumn('subscriptions', 'payment_provider', { + transaction, + }); + + await queryInterface.removeColumn('subscriptions', 'current_period_end', { + transaction, + }); + + await queryInterface.removeColumn( + 'subscriptions', + 'current_period_start', + { transaction }, + ); + + await queryInterface.removeColumn('subscriptions', 'status', { + transaction, + }); + + await queryInterface.removeColumn('subscriptions', 'planId', { + transaction, + }); + + await queryInterface.removeColumn('subscriptions', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'notes', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'return_validated_at', { + transaction, + }); + + await queryInterface.removeColumn( + 'reservations', + 'departure_validated_at', + { transaction }, + ); + + await queryInterface.removeColumn('reservations', 'guarantee_status', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'guarantee_amount', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'guarantee_method', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'balance_amount', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'paid_amount', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'total_price', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'price_per_day', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'days', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'options', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'return_address', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'pickup_address', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'end_at', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'start_at', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'vehicleId', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'clientId', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'status_computed', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'status_manual', { + transaction, + }); + + await queryInterface.removeColumn('reservations', 'ref', { transaction }); + + await queryInterface.removeColumn('reservations', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('plans', 'limits', { transaction }); + + await queryInterface.removeColumn('plans', 'price_mad', { transaction }); + + await queryInterface.removeColumn('plans', 'period', { transaction }); + + await queryInterface.removeColumn('plans', 'name', { transaction }); + + await queryInterface.removeColumn('plans', 'code', { transaction }); + + await queryInterface.removeColumn('payments', 'attachment_url', { + transaction, + }); + + await queryInterface.removeColumn('payments', 'receipt_ref', { + transaction, + }); + + await queryInterface.removeColumn('payments', 'paid_at', { transaction }); + + await queryInterface.removeColumn('payments', 'amount', { transaction }); + + await queryInterface.removeColumn('payments', 'method', { transaction }); + + await queryInterface.removeColumn('payments', 'reservationId', { + transaction, + }); + + await queryInterface.removeColumn('payments', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'attachments', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'notes', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'cost', { transaction }); + + await queryInterface.removeColumn('maintenance', 'odometer', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'scheduled_at', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'type', { transaction }); + + await queryInterface.removeColumn('maintenance', 'vehicleId', { + transaction, + }); + + await queryInterface.removeColumn('maintenance', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'pdf_url', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'issued_at', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'status', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'amount_mad', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'invoice_number', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'subscriptionId', { + transaction, + }); + + await queryInterface.removeColumn('invoices_saas', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'proof_url', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'status', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'amount', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'location', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'date', { transaction }); + + await queryInterface.removeColumn('infractions', 'ref_no', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'reservationId', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'vehicleId', { + transaction, + }); + + await queryInterface.removeColumn('infractions', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'proof_url', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'status', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'amount', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'method', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'reservationId', { + transaction, + }); + + await queryInterface.removeColumn('guarantees', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('expenses', 'attachment_url', { + transaction, + }); + + await queryInterface.removeColumn('expenses', 'note', { transaction }); + + await queryInterface.removeColumn('expenses', 'vehicleId', { + transaction, + }); + + await queryInterface.removeColumn('expenses', 'date', { transaction }); + + await queryInterface.removeColumn('expenses', 'amount', { transaction }); + + await queryInterface.removeColumn('expenses', 'category', { + transaction, + }); + + await queryInterface.removeColumn('expenses', 'type', { transaction }); + + await queryInterface.removeColumn('expenses', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'license_copy_url', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'id_copy_url', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'license_issue_date', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'license_number', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'full_name', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'reservationId', { + transaction, + }); + + await queryInterface.removeColumn('drivers', 'tenantId', { transaction }); + + await queryInterface.removeColumn('company_profiles', 'pdf_footer', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'logo_url', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'patente', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'ice', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'rc', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'website', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'email', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'phone', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'address', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'legal_name', { + transaction, + }); + + await queryInterface.removeColumn('company_profiles', 'tenantId', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'license_copy_url', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'id_copy_url', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'license_issue_date', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'license_number', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'id_issue_date', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'id_number', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'id_type', { transaction }); + + await queryInterface.removeColumn('clients', 'address', { transaction }); + + await queryInterface.removeColumn('clients', 'phone', { transaction }); + + await queryInterface.removeColumn('clients', 'birth_date', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'last_name', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'first_name', { + transaction, + }); + + await queryInterface.removeColumn('clients', 'tenantId', { transaction }); + + await queryInterface.removeColumn('claims', 'attachments', { + transaction, + }); + + await queryInterface.removeColumn('claims', 'status', { transaction }); + + await queryInterface.removeColumn('claims', 'description', { + transaction, + }); + + await queryInterface.removeColumn('claims', 'category', { transaction }); + + await queryInterface.removeColumn('claims', 'reservationId', { + transaction, + }); + + await queryInterface.removeColumn('claims', 'clientId', { transaction }); + + await queryInterface.removeColumn('claims', 'tenantId', { transaction }); + + await queryInterface.removeColumn('users', 'provider', { transaction }); + + await queryInterface.removeColumn( + 'users', + 'passwordResetTokenExpiresAt', + { transaction }, + ); + + await queryInterface.removeColumn('users', 'passwordResetToken', { + transaction, + }); + + await queryInterface.removeColumn( + 'users', + 'emailVerificationTokenExpiresAt', + { transaction }, + ); + + await queryInterface.removeColumn('users', 'emailVerificationToken', { + transaction, + }); + + await queryInterface.removeColumn('users', 'emailVerified', { + transaction, + }); + + await queryInterface.removeColumn('users', 'password', { transaction }); + + await queryInterface.removeColumn('users', 'disabled', { transaction }); + + await queryInterface.removeColumn('users', 'email', { transaction }); + + await queryInterface.removeColumn('users', 'phoneNumber', { + transaction, + }); + + await queryInterface.removeColumn('users', 'lastName', { transaction }); + + await queryInterface.removeColumn('users', 'firstName', { transaction }); + + await queryInterface.dropTable('agences', { transaction }); + + await queryInterface.dropTable('permissions', { transaction }); + + await queryInterface.dropTable('roles', { transaction }); + + await queryInterface.dropTable('vehicles', { transaction }); + + await queryInterface.dropTable('vehicle_conditions', { transaction }); + + await queryInterface.dropTable('tenants', { transaction }); + + await queryInterface.dropTable('subscriptions', { transaction }); + + await queryInterface.dropTable('reservations', { transaction }); + + await queryInterface.dropTable('plans', { transaction }); + + await queryInterface.dropTable('payments', { transaction }); + + await queryInterface.dropTable('maintenance', { transaction }); + + await queryInterface.dropTable('invoices_saas', { transaction }); + + await queryInterface.dropTable('infractions', { transaction }); + + await queryInterface.dropTable('guarantees', { transaction }); + + await queryInterface.dropTable('expenses', { transaction }); + + await queryInterface.dropTable('drivers', { transaction }); + + await queryInterface.dropTable('company_profiles', { transaction }); + + await queryInterface.dropTable('clients', { transaction }); + + await queryInterface.dropTable('claims', { transaction }); + + await queryInterface.dropTable('users', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/agences.js b/backend/src/db/models/agences.js new file mode 100644 index 0000000..32e624c --- /dev/null +++ b/backend/src/db/models/agences.js @@ -0,0 +1,185 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const agences = sequelize.define( + 'agences', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + agences.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.agences.hasMany(db.users, { + as: 'users_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.claims, { + as: 'claims_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.clients, { + as: 'clients_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.company_profiles, { + as: 'company_profiles_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.drivers, { + as: 'drivers_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.expenses, { + as: 'expenses_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.guarantees, { + as: 'guarantees_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.infractions, { + as: 'infractions_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.invoices_saas, { + as: 'invoices_saas_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.maintenance, { + as: 'maintenance_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.payments, { + as: 'payments_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.plans, { + as: 'plans_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.reservations, { + as: 'reservations_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.subscriptions, { + as: 'subscriptions_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.tenants, { + as: 'tenants_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.vehicle_conditions, { + as: 'vehicle_conditions_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.agences.hasMany(db.vehicles, { + as: 'vehicles_agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + //end loop + + db.agences.belongsTo(db.users, { + as: 'createdBy', + }); + + db.agences.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return agences; +}; diff --git a/backend/src/db/models/claims.js b/backend/src/db/models/claims.js new file mode 100644 index 0000000..767146c --- /dev/null +++ b/backend/src/db/models/claims.js @@ -0,0 +1,95 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const claims = sequelize.define( + 'claims', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + category: { + type: DataTypes.TEXT, + }, + + description: { + type: DataTypes.TEXT, + }, + + status: { + type: DataTypes.ENUM, + + values: ['ouverte', 'en_cours', 'resolue'], + }, + + attachments: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + claims.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.claims.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.claims.belongsTo(db.clients, { + as: 'client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + db.claims.belongsTo(db.reservations, { + as: 'reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.claims.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.claims.belongsTo(db.users, { + as: 'createdBy', + }); + + db.claims.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return claims; +}; diff --git a/backend/src/db/models/clients.js b/backend/src/db/models/clients.js new file mode 100644 index 0000000..42872ec --- /dev/null +++ b/backend/src/db/models/clients.js @@ -0,0 +1,127 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const clients = sequelize.define( + 'clients', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + first_name: { + type: DataTypes.TEXT, + }, + + last_name: { + type: DataTypes.TEXT, + }, + + birth_date: { + type: DataTypes.DATE, + }, + + phone: { + type: DataTypes.TEXT, + }, + + address: { + type: DataTypes.TEXT, + }, + + id_type: { + type: DataTypes.ENUM, + + values: ['CIN', 'Passeport'], + }, + + id_number: { + type: DataTypes.TEXT, + }, + + id_issue_date: { + type: DataTypes.DATE, + }, + + license_number: { + type: DataTypes.TEXT, + }, + + license_issue_date: { + type: DataTypes.DATE, + }, + + id_copy_url: { + type: DataTypes.TEXT, + }, + + license_copy_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + clients.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.clients.hasMany(db.claims, { + as: 'claims_client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + db.clients.hasMany(db.reservations, { + as: 'reservations_client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + //end loop + + db.clients.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.clients.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.clients.belongsTo(db.users, { + as: 'createdBy', + }); + + db.clients.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return clients; +}; diff --git a/backend/src/db/models/company_profiles.js b/backend/src/db/models/company_profiles.js new file mode 100644 index 0000000..13470af --- /dev/null +++ b/backend/src/db/models/company_profiles.js @@ -0,0 +1,101 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const company_profiles = sequelize.define( + 'company_profiles', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + legal_name: { + type: DataTypes.TEXT, + }, + + address: { + type: DataTypes.TEXT, + }, + + phone: { + type: DataTypes.TEXT, + }, + + email: { + type: DataTypes.TEXT, + }, + + website: { + type: DataTypes.TEXT, + }, + + rc: { + type: DataTypes.TEXT, + }, + + ice: { + type: DataTypes.TEXT, + }, + + patente: { + type: DataTypes.TEXT, + }, + + logo_url: { + type: DataTypes.TEXT, + }, + + pdf_footer: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + company_profiles.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.company_profiles.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.company_profiles.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.company_profiles.belongsTo(db.users, { + as: 'createdBy', + }); + + db.company_profiles.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return company_profiles; +}; diff --git a/backend/src/db/models/drivers.js b/backend/src/db/models/drivers.js new file mode 100644 index 0000000..448a4dd --- /dev/null +++ b/backend/src/db/models/drivers.js @@ -0,0 +1,89 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const drivers = sequelize.define( + 'drivers', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + full_name: { + type: DataTypes.TEXT, + }, + + license_number: { + type: DataTypes.TEXT, + }, + + license_issue_date: { + type: DataTypes.DATE, + }, + + id_copy_url: { + type: DataTypes.TEXT, + }, + + license_copy_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + drivers.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.drivers.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.drivers.belongsTo(db.reservations, { + as: 'reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.drivers.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.drivers.belongsTo(db.users, { + as: 'createdBy', + }); + + db.drivers.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return drivers; +}; diff --git a/backend/src/db/models/expenses.js b/backend/src/db/models/expenses.js new file mode 100644 index 0000000..555e235 --- /dev/null +++ b/backend/src/db/models/expenses.js @@ -0,0 +1,95 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const expenses = sequelize.define( + 'expenses', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + type: { + type: DataTypes.ENUM, + + values: ['depense', 'revenu'], + }, + + category: { + type: DataTypes.TEXT, + }, + + amount: { + type: DataTypes.DECIMAL, + }, + + date: { + type: DataTypes.DATE, + }, + + note: { + type: DataTypes.TEXT, + }, + + attachment_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + expenses.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.expenses.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.expenses.belongsTo(db.vehicles, { + as: 'vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.expenses.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.expenses.belongsTo(db.users, { + as: 'createdBy', + }); + + db.expenses.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return expenses; +}; diff --git a/backend/src/db/models/file.js b/backend/src/db/models/file.js new file mode 100644 index 0000000..84ee670 --- /dev/null +++ b/backend/src/db/models/file.js @@ -0,0 +1,53 @@ +module.exports = function (sequelize, DataTypes) { + const file = sequelize.define( + 'file', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + belongsTo: DataTypes.STRING(255), + belongsToId: DataTypes.UUID, + belongsToColumn: DataTypes.STRING(255), + name: { + type: DataTypes.STRING(2083), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + sizeInBytes: { + type: DataTypes.INTEGER, + allowNull: true, + }, + privateUrl: { + type: DataTypes.STRING(2083), + allowNull: true, + }, + publicUrl: { + type: DataTypes.STRING(2083), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + }, + { + timestamps: true, + paranoid: true, + }, + ); + + file.associate = (db) => { + db.file.belongsTo(db.users, { + as: 'createdBy', + }); + + db.file.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return file; +}; diff --git a/backend/src/db/models/guarantees.js b/backend/src/db/models/guarantees.js new file mode 100644 index 0000000..71200bc --- /dev/null +++ b/backend/src/db/models/guarantees.js @@ -0,0 +1,89 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const guarantees = sequelize.define( + 'guarantees', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + method: { + type: DataTypes.ENUM, + + values: ['especes', 'carte_bloquee', 'cheque'], + }, + + amount: { + type: DataTypes.DECIMAL, + }, + + status: { + type: DataTypes.ENUM, + + values: ['retenue', 'restituee', 'partielle'], + }, + + proof_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + guarantees.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.guarantees.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.guarantees.belongsTo(db.reservations, { + as: 'reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.guarantees.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.guarantees.belongsTo(db.users, { + as: 'createdBy', + }); + + db.guarantees.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return guarantees; +}; diff --git a/backend/src/db/models/index.js b/backend/src/db/models/index.js new file mode 100644 index 0000000..e326416 --- /dev/null +++ b/backend/src/db/models/index.js @@ -0,0 +1,47 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const Sequelize = require('sequelize'); +const basename = path.basename(__filename); +const env = process.env.NODE_ENV || 'development'; +const config = require('../db.config')[env]; +const db = {}; + +let sequelize; +console.log(env); +if (config.use_env_variable) { + sequelize = new Sequelize(process.env[config.use_env_variable], config); +} else { + sequelize = new Sequelize( + config.database, + config.username, + config.password, + config, + ); +} + +fs.readdirSync(__dirname) + .filter((file) => { + return ( + file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js' + ); + }) + .forEach((file) => { + const model = require(path.join(__dirname, file))( + sequelize, + Sequelize.DataTypes, + ); + db[model.name] = model; + }); + +Object.keys(db).forEach((modelName) => { + if (db[modelName].associate) { + db[modelName].associate(db); + } +}); + +db.sequelize = sequelize; +db.Sequelize = Sequelize; + +module.exports = db; diff --git a/backend/src/db/models/infractions.js b/backend/src/db/models/infractions.js new file mode 100644 index 0000000..da12caf --- /dev/null +++ b/backend/src/db/models/infractions.js @@ -0,0 +1,103 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const infractions = sequelize.define( + 'infractions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + ref_no: { + type: DataTypes.TEXT, + }, + + date: { + type: DataTypes.DATE, + }, + + location: { + type: DataTypes.TEXT, + }, + + amount: { + type: DataTypes.DECIMAL, + }, + + status: { + type: DataTypes.ENUM, + + values: ['ouverte', 'payee', 'conteste'], + }, + + proof_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + infractions.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.infractions.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.infractions.belongsTo(db.vehicles, { + as: 'vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.infractions.belongsTo(db.reservations, { + as: 'reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.infractions.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.infractions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.infractions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return infractions; +}; diff --git a/backend/src/db/models/invoices_saas.js b/backend/src/db/models/invoices_saas.js new file mode 100644 index 0000000..1010cb8 --- /dev/null +++ b/backend/src/db/models/invoices_saas.js @@ -0,0 +1,91 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const invoices_saas = sequelize.define( + 'invoices_saas', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + invoice_number: { + type: DataTypes.TEXT, + }, + + amount_mad: { + type: DataTypes.DECIMAL, + }, + + status: { + type: DataTypes.ENUM, + + values: ['draft', 'sent', 'paid', 'void'], + }, + + issued_at: { + type: DataTypes.DATE, + }, + + pdf_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + invoices_saas.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.invoices_saas.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.invoices_saas.belongsTo(db.subscriptions, { + as: 'subscription', + foreignKey: { + name: 'subscriptionId', + }, + constraints: false, + }); + + db.invoices_saas.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.invoices_saas.belongsTo(db.users, { + as: 'createdBy', + }); + + db.invoices_saas.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return invoices_saas; +}; diff --git a/backend/src/db/models/maintenance.js b/backend/src/db/models/maintenance.js new file mode 100644 index 0000000..22794bd --- /dev/null +++ b/backend/src/db/models/maintenance.js @@ -0,0 +1,95 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const maintenance = sequelize.define( + 'maintenance', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + type: { + type: DataTypes.ENUM, + + values: ['preventive', 'corrective'], + }, + + scheduled_at: { + type: DataTypes.DATE, + }, + + odometer: { + type: DataTypes.INTEGER, + }, + + cost: { + type: DataTypes.DECIMAL, + }, + + notes: { + type: DataTypes.TEXT, + }, + + attachments: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + maintenance.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.maintenance.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.maintenance.belongsTo(db.vehicles, { + as: 'vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.maintenance.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.maintenance.belongsTo(db.users, { + as: 'createdBy', + }); + + db.maintenance.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return maintenance; +}; diff --git a/backend/src/db/models/payments.js b/backend/src/db/models/payments.js new file mode 100644 index 0000000..ac698c6 --- /dev/null +++ b/backend/src/db/models/payments.js @@ -0,0 +1,91 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const payments = sequelize.define( + 'payments', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + method: { + type: DataTypes.ENUM, + + values: ['especes', 'carte', 'virement', 'cheque'], + }, + + amount: { + type: DataTypes.DECIMAL, + }, + + paid_at: { + type: DataTypes.DATE, + }, + + receipt_ref: { + type: DataTypes.TEXT, + }, + + attachment_url: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + payments.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.payments.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.payments.belongsTo(db.reservations, { + as: 'reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.payments.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.payments.belongsTo(db.users, { + as: 'createdBy', + }); + + db.payments.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return payments; +}; diff --git a/backend/src/db/models/permissions.js b/backend/src/db/models/permissions.js new file mode 100644 index 0000000..d647c73 --- /dev/null +++ b/backend/src/db/models/permissions.js @@ -0,0 +1,49 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const permissions = sequelize.define( + 'permissions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + permissions.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.permissions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.permissions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return permissions; +}; diff --git a/backend/src/db/models/plans.js b/backend/src/db/models/plans.js new file mode 100644 index 0000000..07e8a57 --- /dev/null +++ b/backend/src/db/models/plans.js @@ -0,0 +1,91 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const plans = sequelize.define( + 'plans', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + code: { + type: DataTypes.TEXT, + }, + + name: { + type: DataTypes.TEXT, + }, + + period: { + type: DataTypes.ENUM, + + values: ['monthly', 'yearly', 'both'], + }, + + price_mad: { + type: DataTypes.DECIMAL, + }, + + limits: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + plans.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.plans.hasMany(db.subscriptions, { + as: 'subscriptions_plan', + foreignKey: { + name: 'planId', + }, + constraints: false, + }); + + db.plans.hasMany(db.tenants, { + as: 'tenants_plan', + foreignKey: { + name: 'planId', + }, + constraints: false, + }); + + //end loop + + db.plans.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.plans.belongsTo(db.users, { + as: 'createdBy', + }); + + db.plans.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return plans; +}; diff --git a/backend/src/db/models/reservations.js b/backend/src/db/models/reservations.js new file mode 100644 index 0000000..d25bafc --- /dev/null +++ b/backend/src/db/models/reservations.js @@ -0,0 +1,223 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const reservations = sequelize.define( + 'reservations', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + ref: { + type: DataTypes.TEXT, + }, + + status_manual: { + type: DataTypes.ENUM, + + values: ['en_attente', 'confirmee', 'annulee'], + }, + + status_computed: { + type: DataTypes.ENUM, + + values: [ + 'a_venir', + + 'en_cours', + + 'a_restituer', + + 'en_retard', + + 'terminee', + + 'annulee', + + 'no_show', + ], + }, + + start_at: { + type: DataTypes.DATE, + }, + + end_at: { + type: DataTypes.DATE, + }, + + pickup_address: { + type: DataTypes.TEXT, + }, + + return_address: { + type: DataTypes.TEXT, + }, + + options: { + type: DataTypes.TEXT, + }, + + days: { + type: DataTypes.INTEGER, + }, + + price_per_day: { + type: DataTypes.DECIMAL, + }, + + total_price: { + type: DataTypes.DECIMAL, + }, + + paid_amount: { + type: DataTypes.DECIMAL, + }, + + balance_amount: { + type: DataTypes.DECIMAL, + }, + + guarantee_method: { + type: DataTypes.ENUM, + + values: ['especes', 'carte_bloquee', 'cheque', 'aucune'], + }, + + guarantee_amount: { + type: DataTypes.DECIMAL, + }, + + guarantee_status: { + type: DataTypes.ENUM, + + values: ['retenue', 'restituee', 'partielle', 'non_applicable'], + }, + + departure_validated_at: { + type: DataTypes.DATE, + }, + + return_validated_at: { + type: DataTypes.DATE, + }, + + notes: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + reservations.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.reservations.hasMany(db.claims, { + as: 'claims_reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.reservations.hasMany(db.drivers, { + as: 'drivers_reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.reservations.hasMany(db.guarantees, { + as: 'guarantees_reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.reservations.hasMany(db.infractions, { + as: 'infractions_reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.reservations.hasMany(db.payments, { + as: 'payments_reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.reservations.hasMany(db.vehicle_conditions, { + as: 'vehicle_conditions_reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + //end loop + + db.reservations.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.reservations.belongsTo(db.clients, { + as: 'client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + db.reservations.belongsTo(db.vehicles, { + as: 'vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.reservations.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.reservations.belongsTo(db.users, { + as: 'createdBy', + }); + + db.reservations.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return reservations; +}; diff --git a/backend/src/db/models/roles.js b/backend/src/db/models/roles.js new file mode 100644 index 0000000..0f144d5 --- /dev/null +++ b/backend/src/db/models/roles.js @@ -0,0 +1,86 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const roles = sequelize.define( + 'roles', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + role_customization: { + type: DataTypes.TEXT, + }, + + globalAccess: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + roles.associate = (db) => { + db.roles.belongsToMany(db.permissions, { + as: 'permissions', + foreignKey: { + name: 'roles_permissionsId', + }, + constraints: false, + through: 'rolesPermissionsPermissions', + }); + + db.roles.belongsToMany(db.permissions, { + as: 'permissions_filter', + foreignKey: { + name: 'roles_permissionsId', + }, + constraints: false, + through: 'rolesPermissionsPermissions', + }); + + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.roles.hasMany(db.users, { + as: 'users_app_role', + foreignKey: { + name: 'app_roleId', + }, + constraints: false, + }); + + //end loop + + db.roles.belongsTo(db.users, { + as: 'createdBy', + }); + + db.roles.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return roles; +}; diff --git a/backend/src/db/models/subscriptions.js b/backend/src/db/models/subscriptions.js new file mode 100644 index 0000000..bdd3fae --- /dev/null +++ b/backend/src/db/models/subscriptions.js @@ -0,0 +1,105 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const subscriptions = sequelize.define( + 'subscriptions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + status: { + type: DataTypes.ENUM, + + values: ['active', 'past_due', 'canceled', 'trialing'], + }, + + current_period_start: { + type: DataTypes.DATE, + }, + + current_period_end: { + type: DataTypes.DATE, + }, + + payment_provider: { + type: DataTypes.ENUM, + + values: ['stripe', 'manual', 'none'], + }, + + provider_customer_id: { + type: DataTypes.TEXT, + }, + + provider_subscription_id: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + subscriptions.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.subscriptions.hasMany(db.invoices_saas, { + as: 'invoices_saas_subscription', + foreignKey: { + name: 'subscriptionId', + }, + constraints: false, + }); + + //end loop + + db.subscriptions.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.subscriptions.belongsTo(db.plans, { + as: 'plan', + foreignKey: { + name: 'planId', + }, + constraints: false, + }); + + db.subscriptions.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.subscriptions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.subscriptions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return subscriptions; +}; diff --git a/backend/src/db/models/tenants.js b/backend/src/db/models/tenants.js new file mode 100644 index 0000000..9cc87c1 --- /dev/null +++ b/backend/src/db/models/tenants.js @@ -0,0 +1,199 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const tenants = sequelize.define( + 'tenants', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + subdomain: { + type: DataTypes.TEXT, + }, + + status: { + type: DataTypes.ENUM, + + values: ['active', 'suspended', 'trial'], + }, + + trial_ends_at: { + type: DataTypes.DATE, + }, + + billing_email: { + type: DataTypes.TEXT, + }, + + billing_phone: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + tenants.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.tenants.hasMany(db.claims, { + as: 'claims_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.clients, { + as: 'clients_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.company_profiles, { + as: 'company_profiles_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.drivers, { + as: 'drivers_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.expenses, { + as: 'expenses_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.guarantees, { + as: 'guarantees_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.infractions, { + as: 'infractions_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.invoices_saas, { + as: 'invoices_saas_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.maintenance, { + as: 'maintenance_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.payments, { + as: 'payments_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.reservations, { + as: 'reservations_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.subscriptions, { + as: 'subscriptions_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.vehicle_conditions, { + as: 'vehicle_conditions_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.tenants.hasMany(db.vehicles, { + as: 'vehicles_tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + //end loop + + db.tenants.belongsTo(db.plans, { + as: 'plan', + foreignKey: { + name: 'planId', + }, + constraints: false, + }); + + db.tenants.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.tenants.belongsTo(db.users, { + as: 'createdBy', + }); + + db.tenants.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return tenants; +}; diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js new file mode 100644 index 0000000..d6a937c --- /dev/null +++ b/backend/src/db/models/users.js @@ -0,0 +1,179 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const users = sequelize.define( + 'users', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + firstName: { + type: DataTypes.TEXT, + }, + + lastName: { + type: DataTypes.TEXT, + }, + + phoneNumber: { + type: DataTypes.TEXT, + }, + + email: { + type: DataTypes.TEXT, + }, + + disabled: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + password: { + type: DataTypes.TEXT, + }, + + emailVerified: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + emailVerificationToken: { + type: DataTypes.TEXT, + }, + + emailVerificationTokenExpiresAt: { + type: DataTypes.DATE, + }, + + passwordResetToken: { + type: DataTypes.TEXT, + }, + + passwordResetTokenExpiresAt: { + type: DataTypes.DATE, + }, + + provider: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + users.associate = (db) => { + db.users.belongsToMany(db.permissions, { + as: 'custom_permissions', + foreignKey: { + name: 'users_custom_permissionsId', + }, + constraints: false, + through: 'usersCustom_permissionsPermissions', + }); + + db.users.belongsToMany(db.permissions, { + as: 'custom_permissions_filter', + foreignKey: { + name: 'users_custom_permissionsId', + }, + constraints: false, + through: 'usersCustom_permissionsPermissions', + }); + + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.users.belongsTo(db.roles, { + as: 'app_role', + foreignKey: { + name: 'app_roleId', + }, + constraints: false, + }); + + db.users.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.users.hasMany(db.file, { + as: 'avatar', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + }, + }); + + db.users.belongsTo(db.users, { + as: 'createdBy', + }); + + db.users.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + users.beforeCreate((users, options) => { + users = trimStringFields(users); + + if ( + users.provider !== providers.LOCAL && + Object.values(providers).indexOf(users.provider) > -1 + ) { + users.emailVerified = true; + + if (!users.password) { + const password = crypto.randomBytes(20).toString('hex'); + + const hashedPassword = bcrypt.hashSync( + password, + config.bcrypt.saltRounds, + ); + + users.password = hashedPassword; + } + } + }); + + users.beforeUpdate((users, options) => { + users = trimStringFields(users); + }); + + return users; +}; + +function trimStringFields(users) { + users.email = users.email.trim(); + + users.firstName = users.firstName ? users.firstName.trim() : null; + + users.lastName = users.lastName ? users.lastName.trim() : null; + + return users; +} diff --git a/backend/src/db/models/vehicle_conditions.js b/backend/src/db/models/vehicle_conditions.js new file mode 100644 index 0000000..e7dcb95 --- /dev/null +++ b/backend/src/db/models/vehicle_conditions.js @@ -0,0 +1,77 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const vehicle_conditions = sequelize.define( + 'vehicle_conditions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + before: { + type: DataTypes.TEXT, + }, + + after: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + vehicle_conditions.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.vehicle_conditions.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.vehicle_conditions.belongsTo(db.reservations, { + as: 'reservation', + foreignKey: { + name: 'reservationId', + }, + constraints: false, + }); + + db.vehicle_conditions.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.vehicle_conditions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.vehicle_conditions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return vehicle_conditions; +}; diff --git a/backend/src/db/models/vehicles.js b/backend/src/db/models/vehicles.js new file mode 100644 index 0000000..61372b0 --- /dev/null +++ b/backend/src/db/models/vehicles.js @@ -0,0 +1,157 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const vehicles = sequelize.define( + 'vehicles', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + plate_number: { + type: DataTypes.TEXT, + }, + + brand: { + type: DataTypes.TEXT, + }, + + model: { + type: DataTypes.TEXT, + }, + + year: { + type: DataTypes.INTEGER, + }, + + color: { + type: DataTypes.TEXT, + }, + + category: { + type: DataTypes.ENUM, + + values: ['citadine', 'berline', 'SUV', 'utilitaire', 'luxe'], + }, + + fuel_type: { + type: DataTypes.ENUM, + + values: ['essence', 'diesel', 'hybride', 'electrique'], + }, + + transmission: { + type: DataTypes.ENUM, + + values: ['manuelle', 'automatique'], + }, + + mileage: { + type: DataTypes.INTEGER, + }, + + daily_price: { + type: DataTypes.DECIMAL, + }, + + status: { + type: DataTypes.ENUM, + + values: ['disponible', 'reserve', 'en_location', 'hors_service'], + }, + + insurance_expiry: { + type: DataTypes.DATE, + }, + + tech_visit_expiry: { + type: DataTypes.DATE, + }, + + photos: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + vehicles.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + db.vehicles.hasMany(db.expenses, { + as: 'expenses_vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.vehicles.hasMany(db.infractions, { + as: 'infractions_vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.vehicles.hasMany(db.maintenance, { + as: 'maintenance_vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + db.vehicles.hasMany(db.reservations, { + as: 'reservations_vehicle', + foreignKey: { + name: 'vehicleId', + }, + constraints: false, + }); + + //end loop + + db.vehicles.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.vehicles.belongsTo(db.agences, { + as: 'agences', + foreignKey: { + name: 'agencesId', + }, + constraints: false, + }); + + db.vehicles.belongsTo(db.users, { + as: 'createdBy', + }); + + db.vehicles.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return vehicles; +}; diff --git a/backend/src/db/reset.js b/backend/src/db/reset.js new file mode 100644 index 0000000..bc0b5f9 --- /dev/null +++ b/backend/src/db/reset.js @@ -0,0 +1,16 @@ +const db = require('./models'); +const { execSync } = require('child_process'); + +console.log('Resetting Database'); + +db.sequelize + .sync({ force: true }) + .then(() => { + execSync('sequelize db:seed:all'); + console.log('OK'); + process.exit(); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/backend/src/db/seeders/20200430130759-admin-user.js b/backend/src/db/seeders/20200430130759-admin-user.js new file mode 100644 index 0000000..b60808f --- /dev/null +++ b/backend/src/db/seeders/20200430130759-admin-user.js @@ -0,0 +1,84 @@ +'use strict'; +const bcrypt = require('bcrypt'); +const config = require('../../config'); + +const ids = [ + '193bf4b5-9f07-4bd5-9a43-e7e41f3e96af', + 'af5a87be-8f9c-4630-902a-37a60b7005ba', + '5bc531ab-611f-41f3-9373-b7cc5d09c93d', + 'ab4cf9bf-4eef-4107-b73d-9d0274cf69bc', +]; + +module.exports = { + up: async (queryInterface, Sequelize) => { + let admin_hash = bcrypt.hashSync( + config.admin_pass, + config.bcrypt.saltRounds, + ); + let user_hash = bcrypt.hashSync(config.user_pass, config.bcrypt.saltRounds); + + try { + await queryInterface.bulkInsert('users', [ + { + id: ids[0], + firstName: 'Admin', + email: config.admin_email, + emailVerified: true, + provider: config.providers.LOCAL, + password: admin_hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: ids[1], + firstName: 'John', + email: 'john@doe.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: user_hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: ids[2], + firstName: 'Client', + email: 'client@hello.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: user_hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: ids[3], + firstName: 'Super Admin', + email: 'super_admin@flatlogic.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: admin_hash, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + } catch (error) { + console.error('Error during bulkInsert:', error); + throw error; + } + }, + down: async (queryInterface, Sequelize) => { + try { + await queryInterface.bulkDelete( + 'users', + { + id: { + [Sequelize.Op.in]: ids, + }, + }, + {}, + ); + } catch (error) { + console.error('Error during bulkDelete:', error); + throw error; + } + }, +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js new file mode 100644 index 0000000..f217e35 --- /dev/null +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -0,0 +1,1519 @@ +const { v4: uuid } = require('uuid'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + await queryInterface.bulkInsert('roles', [ + { + id: getId('SuperAdmin'), + name: 'Super Administrator', + createdAt, + updatedAt, + }, + + { + id: getId('Administrator'), + name: 'Administrator', + createdAt, + updatedAt, + }, + + { + id: getId('ChiefExecutive'), + name: 'Chief Executive', + createdAt, + updatedAt, + }, + + { + id: getId('OperationsManager'), + name: 'Operations Manager', + createdAt, + updatedAt, + }, + + { + id: getId('FinanceOfficer'), + name: 'Finance Officer', + createdAt, + updatedAt, + }, + + { + id: getId('TechnicalSpecialist'), + name: 'Technical Specialist', + createdAt, + updatedAt, + }, + + { + id: getId('CustomerSupport'), + name: 'Customer Support', + createdAt, + updatedAt, + }, + + { id: getId('Public'), name: 'Public', createdAt, updatedAt }, + ]); + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = [ + 'users', + 'claims', + 'clients', + 'company_profiles', + 'drivers', + 'expenses', + 'guarantees', + 'infractions', + 'invoices_saas', + 'maintenance', + 'payments', + 'plans', + 'reservations', + 'subscriptions', + 'tenants', + 'vehicle_conditions', + 'vehicles', + 'roles', + 'permissions', + 'agences', + , + ]; + await queryInterface.bulkInsert( + 'permissions', + entities.flatMap(createPermissions), + ); + await queryInterface.bulkInsert('permissions', [ + { + id: getId(`READ_API_DOCS`), + createdAt, + updatedAt, + name: `READ_API_DOCS`, + }, + ]); + await queryInterface.bulkInsert('permissions', [ + { + id: getId(`CREATE_SEARCH`), + createdAt, + updatedAt, + name: `CREATE_SEARCH`, + }, + ]); + + await queryInterface.bulkUpdate( + 'roles', + { globalAccess: true }, + { id: getId('SuperAdmin') }, + ); + + await queryInterface.sequelize + .query(`create table "rolesPermissionsPermissions" +( +"createdAt" timestamp with time zone not null, +"updatedAt" timestamp with time zone not null, +"roles_permissionsId" uuid not null, +"permissionId" uuid not null, +primary key ("roles_permissionsId", "permissionId") +);`); + + await queryInterface.bulkInsert('rolesPermissionsPermissions', [ + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('CREATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('READ_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('READ_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalSpecialist'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('CustomerSupport'), + permissionId: getId('UPDATE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('CREATE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('READ_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('DELETE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('READ_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalSpecialist'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('CustomerSupport'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('CREATE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('READ_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('UPDATE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('DELETE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('READ_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('UPDATE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('UPDATE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalSpecialist'), + permissionId: getId('UPDATE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('CustomerSupport'), + permissionId: getId('UPDATE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('CREATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('READ_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('DELETE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('READ_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('CREATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('READ_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('DELETE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalSpecialist'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('CustomerSupport'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('CREATE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('READ_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('UPDATE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('DELETE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('READ_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('UPDATE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('UPDATE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalSpecialist'), + permissionId: getId('UPDATE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('CustomerSupport'), + permissionId: getId('UPDATE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('ChiefExecutive'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('OperationsManager'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('FinanceOfficer'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('TechnicalSpecialist'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('CustomerSupport'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_CLAIMS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_CLAIMS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_CLAIMS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_CLAIMS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_COMPANY_PROFILES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_COMPANY_PROFILES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_DRIVERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_DRIVERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_DRIVERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_DRIVERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_EXPENSES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_EXPENSES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_EXPENSES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_EXPENSES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_GUARANTEES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_GUARANTEES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_GUARANTEES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_GUARANTEES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_INFRACTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_INFRACTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_INFRACTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_INFRACTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_INVOICES_SAAS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_INVOICES_SAAS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_INVOICES_SAAS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_INVOICES_SAAS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_MAINTENANCE'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_MAINTENANCE'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_MAINTENANCE'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_MAINTENANCE'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_PAYMENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_PAYMENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_PAYMENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_PAYMENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_PLANS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_PLANS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_PLANS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_RESERVATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_RESERVATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_RESERVATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_RESERVATIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_SUBSCRIPTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_SUBSCRIPTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_TENANTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_TENANTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_TENANTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_VEHICLE_CONDITIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_VEHICLE_CONDITIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_VEHICLE_CONDITIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_VEHICLE_CONDITIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_VEHICLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_VEHICLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_VEHICLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_VEHICLES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_USERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_USERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_CLAIMS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_CLAIMS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_CLAIMS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_CLAIMS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_CLIENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_CLIENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_COMPANY_PROFILES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_COMPANY_PROFILES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_COMPANY_PROFILES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_COMPANY_PROFILES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_DRIVERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_DRIVERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_DRIVERS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_DRIVERS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_EXPENSES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_EXPENSES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_EXPENSES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_EXPENSES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_GUARANTEES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_GUARANTEES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_GUARANTEES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_GUARANTEES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_INFRACTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_INFRACTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_INFRACTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_INFRACTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_INVOICES_SAAS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_INVOICES_SAAS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_INVOICES_SAAS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_INVOICES_SAAS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_MAINTENANCE'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_MAINTENANCE'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_MAINTENANCE'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_MAINTENANCE'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_PAYMENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_PAYMENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_PAYMENTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_PAYMENTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_PLANS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_PLANS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_PLANS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_PLANS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_RESERVATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_RESERVATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_RESERVATIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_RESERVATIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_SUBSCRIPTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_SUBSCRIPTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_SUBSCRIPTIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_SUBSCRIPTIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_TENANTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_TENANTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_TENANTS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_TENANTS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_VEHICLE_CONDITIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_VEHICLE_CONDITIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_VEHICLE_CONDITIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_VEHICLE_CONDITIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_VEHICLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_VEHICLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_VEHICLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_VEHICLES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_ROLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_ROLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_ROLES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_ROLES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_PERMISSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_PERMISSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_PERMISSIONS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_PERMISSIONS'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_AGENCES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_AGENCES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_AGENCES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_AGENCES'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_API_DOCS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_SEARCH'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_API_DOCS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_SEARCH'), + }, + ]); + + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'SuperAdmin', + )}' WHERE "email"='super_admin@flatlogic.com'`, + ); + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'Administrator', + )}' WHERE "email"='admin@flatlogic.com'`, + ); + + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'ChiefExecutive', + )}' WHERE "email"='client@hello.com'`, + ); + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"='${getId( + 'OperationsManager', + )}' WHERE "email"='john@doe.com'`, + ); + }, +}; diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js new file mode 100644 index 0000000..c09e055 --- /dev/null +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -0,0 +1,2920 @@ +const db = require('../models'); +const Users = db.users; + +const Claims = db.claims; + +const Clients = db.clients; + +const CompanyProfiles = db.company_profiles; + +const Drivers = db.drivers; + +const Expenses = db.expenses; + +const Guarantees = db.guarantees; + +const Infractions = db.infractions; + +const InvoicesSaas = db.invoices_saas; + +const Maintenance = db.maintenance; + +const Payments = db.payments; + +const Plans = db.plans; + +const Reservations = db.reservations; + +const Subscriptions = db.subscriptions; + +const Tenants = db.tenants; + +const VehicleConditions = db.vehicle_conditions; + +const Vehicles = db.vehicles; + +const Agences = db.agences; + +const ClaimsData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + category: 'Paul Ehrlich', + + description: 'Rudolf Virchow', + + status: 'ouverte', + + attachments: 'Charles Sherrington', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + category: 'Konrad Lorenz', + + description: 'Erwin Schrodinger', + + status: 'resolue', + + attachments: 'Stephen Hawking', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + category: 'Alfred Binet', + + description: 'Max Delbruck', + + status: 'resolue', + + attachments: 'August Kekule', + + // type code here for "relation_one" field + }, +]; + +const ClientsData = [ + { + // type code here for "relation_one" field + + first_name: 'Lucretius', + + last_name: 'Michael Faraday', + + birth_date: new Date(Date.now()), + + phone: 'Dmitri Mendeleev', + + address: 'Ernest Rutherford', + + id_type: 'Passeport', + + id_number: 'B. F. Skinner', + + id_issue_date: new Date(Date.now()), + + license_number: 'Gregor Mendel', + + license_issue_date: new Date(Date.now()), + + id_copy_url: 'Galileo Galilei', + + license_copy_url: 'Johannes Kepler', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + first_name: 'Nicolaus Copernicus', + + last_name: 'Louis Victor de Broglie', + + birth_date: new Date(Date.now()), + + phone: 'Alexander Fleming', + + address: 'William Bayliss', + + id_type: 'Passeport', + + id_number: 'Carl Linnaeus', + + id_issue_date: new Date(Date.now()), + + license_number: 'John Bardeen', + + license_issue_date: new Date(Date.now()), + + id_copy_url: 'Jonas Salk', + + license_copy_url: 'Justus Liebig', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + first_name: 'Murray Gell-Mann', + + last_name: 'Johannes Kepler', + + birth_date: new Date(Date.now()), + + phone: 'William Herschel', + + address: 'John von Neumann', + + id_type: 'Passeport', + + id_number: 'Isaac Newton', + + id_issue_date: new Date(Date.now()), + + license_number: 'Ernest Rutherford', + + license_issue_date: new Date(Date.now()), + + id_copy_url: 'Sigmund Freud', + + license_copy_url: 'Trofim Lysenko', + + // type code here for "relation_one" field + }, +]; + +const CompanyProfilesData = [ + { + // type code here for "relation_one" field + + legal_name: 'Carline Rabat SARL', + + address: '123 Rue de Rabat, Rabat', + + phone: '+212600000001', + + email: 'contact@carline.ma', + + website: 'www.carline.ma', + + rc: 'RC123456', + + ice: 'ICE123456', + + patente: 'PAT123456', + + logo_url: 'https://example.com/logo_carline.png', + + pdf_footer: 'Carline Rabat SARL, Tous droits réservés.', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + legal_name: 'AutoMarrakech SARL', + + address: '456 Avenue de Marrakech, Marrakech', + + phone: '+212600000002', + + email: 'contact@automarrakech.ma', + + website: 'www.automarrakech.ma', + + rc: 'RC654321', + + ice: 'ICE654321', + + patente: 'PAT654321', + + logo_url: 'https://example.com/logo_automarrakech.png', + + pdf_footer: 'AutoMarrakech SARL, Tous droits réservés.', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + legal_name: 'NordRent Tanger SARL', + + address: '789 Boulevard de Tanger, Tanger', + + phone: '+212600000003', + + email: 'contact@nordrent.ma', + + website: 'www.nordrent.ma', + + rc: 'RC789012', + + ice: 'ICE789012', + + patente: 'PAT789012', + + logo_url: 'https://example.com/logo_nordrent.png', + + pdf_footer: 'NordRent Tanger SARL, Tous droits réservés.', + + // type code here for "relation_one" field + }, +]; + +const DriversData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + full_name: 'Emil Fischer', + + license_number: 'Albert Einstein', + + license_issue_date: new Date(Date.now()), + + id_copy_url: 'Edwin Hubble', + + license_copy_url: 'Emil Fischer', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + full_name: 'Gertrude Belle Elion', + + license_number: 'Louis Pasteur', + + license_issue_date: new Date(Date.now()), + + id_copy_url: 'Ernest Rutherford', + + license_copy_url: 'Franz Boas', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + full_name: 'Louis Pasteur', + + license_number: 'Ernest Rutherford', + + license_issue_date: new Date(Date.now()), + + id_copy_url: 'Leonard Euler', + + license_copy_url: 'Albert Einstein', + + // type code here for "relation_one" field + }, +]; + +const ExpensesData = [ + { + // type code here for "relation_one" field + + type: 'depense', + + category: 'Hans Bethe', + + amount: 12.95, + + date: new Date(Date.now()), + + // type code here for "relation_one" field + + note: 'Hans Selye', + + attachment_url: 'Ernst Haeckel', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + type: 'depense', + + category: 'Rudolf Virchow', + + amount: 65.18, + + date: new Date(Date.now()), + + // type code here for "relation_one" field + + note: 'Ernest Rutherford', + + attachment_url: 'Paul Dirac', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + type: 'revenu', + + category: 'Gregor Mendel', + + amount: 76.77, + + date: new Date(Date.now()), + + // type code here for "relation_one" field + + note: 'William Herschel', + + attachment_url: 'Richard Feynman', + + // type code here for "relation_one" field + }, +]; + +const GuaranteesData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + method: 'especes', + + amount: 95.59, + + status: 'retenue', + + proof_url: 'Louis Pasteur', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + method: 'cheque', + + amount: 22.66, + + status: 'restituee', + + proof_url: 'James Clerk Maxwell', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + method: 'especes', + + amount: 79.03, + + status: 'retenue', + + proof_url: 'Emil Kraepelin', + + // type code here for "relation_one" field + }, +]; + +const InfractionsData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + ref_no: 'Neils Bohr', + + date: new Date(Date.now()), + + location: 'Frederick Sanger', + + amount: 99.05, + + status: 'ouverte', + + proof_url: 'Paul Ehrlich', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + ref_no: 'Sigmund Freud', + + date: new Date(Date.now()), + + location: 'Charles Darwin', + + amount: 47.69, + + status: 'payee', + + proof_url: 'Joseph J. Thomson', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + ref_no: 'Charles Lyell', + + date: new Date(Date.now()), + + location: 'Charles Lyell', + + amount: 33.18, + + status: 'conteste', + + proof_url: 'Hans Selye', + + // type code here for "relation_one" field + }, +]; + +const InvoicesSaasData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + invoice_number: 'J. Robert Oppenheimer', + + amount_mad: 72.63, + + status: 'paid', + + issued_at: new Date(Date.now()), + + pdf_url: 'Sheldon Glashow', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + invoice_number: 'Emil Kraepelin', + + amount_mad: 41.61, + + status: 'void', + + issued_at: new Date(Date.now()), + + pdf_url: 'Isaac Newton', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + invoice_number: 'Hans Bethe', + + amount_mad: 98.49, + + status: 'paid', + + issued_at: new Date(Date.now()), + + pdf_url: 'Ernst Mayr', + + // type code here for "relation_one" field + }, +]; + +const MaintenanceData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + type: 'corrective', + + scheduled_at: new Date(Date.now()), + + odometer: 2, + + cost: 61.92, + + notes: 'Albert Einstein', + + attachments: 'Marie Curie', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + type: 'corrective', + + scheduled_at: new Date(Date.now()), + + odometer: 2, + + cost: 85.71, + + notes: 'Joseph J. Thomson', + + attachments: 'Trofim Lysenko', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + type: 'preventive', + + scheduled_at: new Date(Date.now()), + + odometer: 4, + + cost: 32.78, + + notes: 'Charles Darwin', + + attachments: 'Max von Laue', + + // type code here for "relation_one" field + }, +]; + +const PaymentsData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + method: 'carte', + + amount: 73.38, + + paid_at: new Date(Date.now()), + + receipt_ref: 'Sheldon Glashow', + + attachment_url: 'Michael Faraday', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + method: 'cheque', + + amount: 83.43, + + paid_at: new Date(Date.now()), + + receipt_ref: 'Edward O. Wilson', + + attachment_url: 'Trofim Lysenko', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + method: 'cheque', + + amount: 56.61, + + paid_at: new Date(Date.now()), + + receipt_ref: 'Tycho Brahe', + + attachment_url: 'Galileo Galilei', + + // type code here for "relation_one" field + }, +]; + +const PlansData = [ + { + code: 'BASIC199', + + name: 'Basic', + + period: 'yearly', + + price_mad: 199, + + limits: '{users:1,reservations_per_month:50,storage_mb:500}', + + // type code here for "relation_one" field + }, + + { + code: 'PRO399', + + name: 'Pro', + + period: 'monthly', + + price_mad: 399, + + limits: '{users:5,reservations_per_month:200,storage_mb:2000}', + + // type code here for "relation_one" field + }, + + { + code: 'PREMIUM799', + + name: 'Premium', + + period: 'yearly', + + price_mad: 799, + + limits: '{users:-1,reservations_per_month:-1,storage_mb:-1}', + + // type code here for "relation_one" field + }, +]; + +const ReservationsData = [ + { + // type code here for "relation_one" field + + ref: 'Jonas Salk', + + status_manual: 'confirmee', + + status_computed: 'terminee', + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_at: new Date(Date.now()), + + end_at: new Date(Date.now()), + + pickup_address: 'Pierre Simon de Laplace', + + return_address: 'Lucretius', + + options: 'Pierre Simon de Laplace', + + days: 9, + + price_per_day: 86.54, + + total_price: 91.84, + + paid_amount: 77.91, + + balance_amount: 72.26, + + guarantee_method: 'especes', + + guarantee_amount: 79.03, + + guarantee_status: 'retenue', + + departure_validated_at: new Date(Date.now()), + + return_validated_at: new Date(Date.now()), + + notes: 'Paul Ehrlich', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + ref: 'Enrico Fermi', + + status_manual: 'en_attente', + + status_computed: 'en_retard', + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_at: new Date(Date.now()), + + end_at: new Date(Date.now()), + + pickup_address: 'Ernest Rutherford', + + return_address: 'Louis Victor de Broglie', + + options: 'Theodosius Dobzhansky', + + days: 7, + + price_per_day: 80.85, + + total_price: 71.52, + + paid_amount: 17.69, + + balance_amount: 38.02, + + guarantee_method: 'especes', + + guarantee_amount: 15.99, + + guarantee_status: 'restituee', + + departure_validated_at: new Date(Date.now()), + + return_validated_at: new Date(Date.now()), + + notes: 'Francis Crick', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + ref: 'Emil Kraepelin', + + status_manual: 'annulee', + + status_computed: 'a_restituer', + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_at: new Date(Date.now()), + + end_at: new Date(Date.now()), + + pickup_address: 'Sheldon Glashow', + + return_address: 'Gustav Kirchhoff', + + options: 'Claude Bernard', + + days: 6, + + price_per_day: 54.68, + + total_price: 90.09, + + paid_amount: 18.78, + + balance_amount: 98.18, + + guarantee_method: 'especes', + + guarantee_amount: 21.65, + + guarantee_status: 'retenue', + + departure_validated_at: new Date(Date.now()), + + return_validated_at: new Date(Date.now()), + + notes: 'Ludwig Boltzmann', + + // type code here for "relation_one" field + }, +]; + +const SubscriptionsData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + status: 'past_due', + + current_period_start: new Date('2023-01-01T00:00:00Z'), + + current_period_end: new Date('2023-12-31T00:00:00Z'), + + payment_provider: 'none', + + provider_customer_id: 'cust_001', + + provider_subscription_id: 'sub_001', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + status: 'past_due', + + current_period_start: new Date('2023-01-01T00:00:00Z'), + + current_period_end: new Date('2023-12-31T00:00:00Z'), + + payment_provider: 'manual', + + provider_customer_id: 'cust_002', + + provider_subscription_id: 'sub_002', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + status: 'canceled', + + current_period_start: new Date('2023-01-01T00:00:00Z'), + + current_period_end: new Date('2023-12-31T00:00:00Z'), + + payment_provider: 'none', + + provider_customer_id: 'cust_003', + + provider_subscription_id: 'sub_003', + + // type code here for "relation_one" field + }, +]; + +const TenantsData = [ + { + name: 'Carline Rabat', + + subdomain: 'carline', + + status: 'trial', + + // type code here for "relation_one" field + + trial_ends_at: new Date('2023-12-31T00:00:00Z'), + + billing_email: 'billing@carline.ma', + + billing_phone: '+212600000001', + + // type code here for "relation_one" field + }, + + { + name: 'AutoMarrakech', + + subdomain: 'automarrakech', + + status: 'active', + + // type code here for "relation_one" field + + trial_ends_at: new Date('2023-12-31T00:00:00Z'), + + billing_email: 'billing@automarrakech.ma', + + billing_phone: '+212600000002', + + // type code here for "relation_one" field + }, + + { + name: 'NordRent Tanger', + + subdomain: 'nordrent', + + status: 'trial', + + // type code here for "relation_one" field + + trial_ends_at: new Date('2023-12-31T00:00:00Z'), + + billing_email: 'billing@nordrent.ma', + + billing_phone: '+212600000003', + + // type code here for "relation_one" field + }, +]; + +const VehicleConditionsData = [ + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + before: 'Alfred Binet', + + after: 'Linus Pauling', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + before: 'Max Planck', + + after: 'Werner Heisenberg', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + before: 'Francis Crick', + + after: 'Richard Feynman', + + // type code here for "relation_one" field + }, +]; + +const VehiclesData = [ + { + // type code here for "relation_one" field + + plate_number: 'Galileo Galilei', + + brand: 'William Herschel', + + model: 'Albert Einstein', + + year: 4, + + color: 'Archimedes', + + category: 'berline', + + fuel_type: 'electrique', + + transmission: 'automatique', + + mileage: 9, + + daily_price: 56.66, + + status: 'disponible', + + insurance_expiry: new Date(Date.now()), + + tech_visit_expiry: new Date(Date.now()), + + photos: 'August Kekule', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + plate_number: 'Paul Dirac', + + brand: 'Edward Teller', + + model: 'Edward Teller', + + year: 8, + + color: 'George Gaylord Simpson', + + category: 'SUV', + + fuel_type: 'essence', + + transmission: 'manuelle', + + mileage: 9, + + daily_price: 84.24, + + status: 'hors_service', + + insurance_expiry: new Date(Date.now()), + + tech_visit_expiry: new Date(Date.now()), + + photos: 'Ernest Rutherford', + + // type code here for "relation_one" field + }, + + { + // type code here for "relation_one" field + + plate_number: 'Erwin Schrodinger', + + brand: 'Ernest Rutherford', + + model: 'Edward Teller', + + year: 7, + + color: 'Anton van Leeuwenhoek', + + category: 'utilitaire', + + fuel_type: 'essence', + + transmission: 'automatique', + + mileage: 2, + + daily_price: 15.16, + + status: 'en_location', + + insurance_expiry: new Date(Date.now()), + + tech_visit_expiry: new Date(Date.now()), + + photos: 'Trofim Lysenko', + + // type code here for "relation_one" field + }, +]; + +const AgencesData = [ + { + name: 'James Clerk Maxwell', + }, + + { + name: 'Alfred Binet', + }, + + { + name: 'Joseph J. Thomson', + }, +]; + +// Similar logic for "relation_many" + +async function associateUserWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const User0 = await Users.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (User0?.setAgence) { + await User0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const User1 = await Users.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (User1?.setAgence) { + await User1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const User2 = await Users.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (User2?.setAgence) { + await User2.setAgence(relatedAgence2); + } +} + +async function associateClaimWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Claim0 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Claim0?.setTenant) { + await Claim0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Claim1 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Claim1?.setTenant) { + await Claim1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Claim2 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Claim2?.setTenant) { + await Claim2.setTenant(relatedTenant2); + } +} + +async function associateClaimWithClient() { + const relatedClient0 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Claim0 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Claim0?.setClient) { + await Claim0.setClient(relatedClient0); + } + + const relatedClient1 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Claim1 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Claim1?.setClient) { + await Claim1.setClient(relatedClient1); + } + + const relatedClient2 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Claim2 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Claim2?.setClient) { + await Claim2.setClient(relatedClient2); + } +} + +async function associateClaimWithReservation() { + const relatedReservation0 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Claim0 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Claim0?.setReservation) { + await Claim0.setReservation(relatedReservation0); + } + + const relatedReservation1 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Claim1 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Claim1?.setReservation) { + await Claim1.setReservation(relatedReservation1); + } + + const relatedReservation2 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Claim2 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Claim2?.setReservation) { + await Claim2.setReservation(relatedReservation2); + } +} + +async function associateClaimWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Claim0 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Claim0?.setAgence) { + await Claim0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Claim1 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Claim1?.setAgence) { + await Claim1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Claim2 = await Claims.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Claim2?.setAgence) { + await Claim2.setAgence(relatedAgence2); + } +} + +async function associateClientWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Client0 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Client0?.setTenant) { + await Client0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Client1 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Client1?.setTenant) { + await Client1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Client2 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Client2?.setTenant) { + await Client2.setTenant(relatedTenant2); + } +} + +async function associateClientWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Client0 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Client0?.setAgence) { + await Client0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Client1 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Client1?.setAgence) { + await Client1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Client2 = await Clients.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Client2?.setAgence) { + await Client2.setAgence(relatedAgence2); + } +} + +async function associateCompanyProfileWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const CompanyProfile0 = await CompanyProfiles.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (CompanyProfile0?.setTenant) { + await CompanyProfile0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const CompanyProfile1 = await CompanyProfiles.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (CompanyProfile1?.setTenant) { + await CompanyProfile1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const CompanyProfile2 = await CompanyProfiles.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (CompanyProfile2?.setTenant) { + await CompanyProfile2.setTenant(relatedTenant2); + } +} + +async function associateCompanyProfileWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const CompanyProfile0 = await CompanyProfiles.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (CompanyProfile0?.setAgence) { + await CompanyProfile0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const CompanyProfile1 = await CompanyProfiles.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (CompanyProfile1?.setAgence) { + await CompanyProfile1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const CompanyProfile2 = await CompanyProfiles.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (CompanyProfile2?.setAgence) { + await CompanyProfile2.setAgence(relatedAgence2); + } +} + +async function associateDriverWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Driver0 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Driver0?.setTenant) { + await Driver0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Driver1 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Driver1?.setTenant) { + await Driver1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Driver2 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Driver2?.setTenant) { + await Driver2.setTenant(relatedTenant2); + } +} + +async function associateDriverWithReservation() { + const relatedReservation0 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Driver0 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Driver0?.setReservation) { + await Driver0.setReservation(relatedReservation0); + } + + const relatedReservation1 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Driver1 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Driver1?.setReservation) { + await Driver1.setReservation(relatedReservation1); + } + + const relatedReservation2 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Driver2 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Driver2?.setReservation) { + await Driver2.setReservation(relatedReservation2); + } +} + +async function associateDriverWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Driver0 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Driver0?.setAgence) { + await Driver0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Driver1 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Driver1?.setAgence) { + await Driver1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Driver2 = await Drivers.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Driver2?.setAgence) { + await Driver2.setAgence(relatedAgence2); + } +} + +async function associateExpenseWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Expense0 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Expense0?.setTenant) { + await Expense0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Expense1 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Expense1?.setTenant) { + await Expense1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Expense2 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Expense2?.setTenant) { + await Expense2.setTenant(relatedTenant2); + } +} + +async function associateExpenseWithVehicle() { + const relatedVehicle0 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Expense0 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Expense0?.setVehicle) { + await Expense0.setVehicle(relatedVehicle0); + } + + const relatedVehicle1 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Expense1 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Expense1?.setVehicle) { + await Expense1.setVehicle(relatedVehicle1); + } + + const relatedVehicle2 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Expense2 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Expense2?.setVehicle) { + await Expense2.setVehicle(relatedVehicle2); + } +} + +async function associateExpenseWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Expense0 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Expense0?.setAgence) { + await Expense0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Expense1 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Expense1?.setAgence) { + await Expense1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Expense2 = await Expenses.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Expense2?.setAgence) { + await Expense2.setAgence(relatedAgence2); + } +} + +async function associateGuaranteeWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Guarantee0 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Guarantee0?.setTenant) { + await Guarantee0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Guarantee1 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Guarantee1?.setTenant) { + await Guarantee1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Guarantee2 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Guarantee2?.setTenant) { + await Guarantee2.setTenant(relatedTenant2); + } +} + +async function associateGuaranteeWithReservation() { + const relatedReservation0 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Guarantee0 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Guarantee0?.setReservation) { + await Guarantee0.setReservation(relatedReservation0); + } + + const relatedReservation1 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Guarantee1 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Guarantee1?.setReservation) { + await Guarantee1.setReservation(relatedReservation1); + } + + const relatedReservation2 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Guarantee2 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Guarantee2?.setReservation) { + await Guarantee2.setReservation(relatedReservation2); + } +} + +async function associateGuaranteeWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Guarantee0 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Guarantee0?.setAgence) { + await Guarantee0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Guarantee1 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Guarantee1?.setAgence) { + await Guarantee1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Guarantee2 = await Guarantees.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Guarantee2?.setAgence) { + await Guarantee2.setAgence(relatedAgence2); + } +} + +async function associateInfractionWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Infraction0 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Infraction0?.setTenant) { + await Infraction0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Infraction1 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Infraction1?.setTenant) { + await Infraction1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Infraction2 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Infraction2?.setTenant) { + await Infraction2.setTenant(relatedTenant2); + } +} + +async function associateInfractionWithVehicle() { + const relatedVehicle0 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Infraction0 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Infraction0?.setVehicle) { + await Infraction0.setVehicle(relatedVehicle0); + } + + const relatedVehicle1 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Infraction1 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Infraction1?.setVehicle) { + await Infraction1.setVehicle(relatedVehicle1); + } + + const relatedVehicle2 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Infraction2 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Infraction2?.setVehicle) { + await Infraction2.setVehicle(relatedVehicle2); + } +} + +async function associateInfractionWithReservation() { + const relatedReservation0 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Infraction0 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Infraction0?.setReservation) { + await Infraction0.setReservation(relatedReservation0); + } + + const relatedReservation1 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Infraction1 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Infraction1?.setReservation) { + await Infraction1.setReservation(relatedReservation1); + } + + const relatedReservation2 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Infraction2 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Infraction2?.setReservation) { + await Infraction2.setReservation(relatedReservation2); + } +} + +async function associateInfractionWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Infraction0 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Infraction0?.setAgence) { + await Infraction0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Infraction1 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Infraction1?.setAgence) { + await Infraction1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Infraction2 = await Infractions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Infraction2?.setAgence) { + await Infraction2.setAgence(relatedAgence2); + } +} + +async function associateInvoicesSaaWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const InvoicesSaa0 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (InvoicesSaa0?.setTenant) { + await InvoicesSaa0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const InvoicesSaa1 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (InvoicesSaa1?.setTenant) { + await InvoicesSaa1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const InvoicesSaa2 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (InvoicesSaa2?.setTenant) { + await InvoicesSaa2.setTenant(relatedTenant2); + } +} + +async function associateInvoicesSaaWithSubscription() { + const relatedSubscription0 = await Subscriptions.findOne({ + offset: Math.floor(Math.random() * (await Subscriptions.count())), + }); + const InvoicesSaa0 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (InvoicesSaa0?.setSubscription) { + await InvoicesSaa0.setSubscription(relatedSubscription0); + } + + const relatedSubscription1 = await Subscriptions.findOne({ + offset: Math.floor(Math.random() * (await Subscriptions.count())), + }); + const InvoicesSaa1 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (InvoicesSaa1?.setSubscription) { + await InvoicesSaa1.setSubscription(relatedSubscription1); + } + + const relatedSubscription2 = await Subscriptions.findOne({ + offset: Math.floor(Math.random() * (await Subscriptions.count())), + }); + const InvoicesSaa2 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (InvoicesSaa2?.setSubscription) { + await InvoicesSaa2.setSubscription(relatedSubscription2); + } +} + +async function associateInvoicesSaaWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const InvoicesSaa0 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (InvoicesSaa0?.setAgence) { + await InvoicesSaa0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const InvoicesSaa1 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (InvoicesSaa1?.setAgence) { + await InvoicesSaa1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const InvoicesSaa2 = await InvoicesSaas.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (InvoicesSaa2?.setAgence) { + await InvoicesSaa2.setAgence(relatedAgence2); + } +} + +async function associateMaintenanceWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Maintenance0 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Maintenance0?.setTenant) { + await Maintenance0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Maintenance1 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Maintenance1?.setTenant) { + await Maintenance1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Maintenance2 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Maintenance2?.setTenant) { + await Maintenance2.setTenant(relatedTenant2); + } +} + +async function associateMaintenanceWithVehicle() { + const relatedVehicle0 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Maintenance0 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Maintenance0?.setVehicle) { + await Maintenance0.setVehicle(relatedVehicle0); + } + + const relatedVehicle1 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Maintenance1 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Maintenance1?.setVehicle) { + await Maintenance1.setVehicle(relatedVehicle1); + } + + const relatedVehicle2 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Maintenance2 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Maintenance2?.setVehicle) { + await Maintenance2.setVehicle(relatedVehicle2); + } +} + +async function associateMaintenanceWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Maintenance0 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Maintenance0?.setAgence) { + await Maintenance0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Maintenance1 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Maintenance1?.setAgence) { + await Maintenance1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Maintenance2 = await Maintenance.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Maintenance2?.setAgence) { + await Maintenance2.setAgence(relatedAgence2); + } +} + +async function associatePaymentWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Payment0 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Payment0?.setTenant) { + await Payment0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Payment1 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Payment1?.setTenant) { + await Payment1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Payment2 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Payment2?.setTenant) { + await Payment2.setTenant(relatedTenant2); + } +} + +async function associatePaymentWithReservation() { + const relatedReservation0 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Payment0 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Payment0?.setReservation) { + await Payment0.setReservation(relatedReservation0); + } + + const relatedReservation1 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Payment1 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Payment1?.setReservation) { + await Payment1.setReservation(relatedReservation1); + } + + const relatedReservation2 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const Payment2 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Payment2?.setReservation) { + await Payment2.setReservation(relatedReservation2); + } +} + +async function associatePaymentWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Payment0 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Payment0?.setAgence) { + await Payment0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Payment1 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Payment1?.setAgence) { + await Payment1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Payment2 = await Payments.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Payment2?.setAgence) { + await Payment2.setAgence(relatedAgence2); + } +} + +async function associatePlanWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Plan0 = await Plans.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Plan0?.setAgence) { + await Plan0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Plan1 = await Plans.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Plan1?.setAgence) { + await Plan1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Plan2 = await Plans.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Plan2?.setAgence) { + await Plan2.setAgence(relatedAgence2); + } +} + +async function associateReservationWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Reservation0 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Reservation0?.setTenant) { + await Reservation0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Reservation1 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Reservation1?.setTenant) { + await Reservation1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Reservation2 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Reservation2?.setTenant) { + await Reservation2.setTenant(relatedTenant2); + } +} + +async function associateReservationWithClient() { + const relatedClient0 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Reservation0 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Reservation0?.setClient) { + await Reservation0.setClient(relatedClient0); + } + + const relatedClient1 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Reservation1 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Reservation1?.setClient) { + await Reservation1.setClient(relatedClient1); + } + + const relatedClient2 = await Clients.findOne({ + offset: Math.floor(Math.random() * (await Clients.count())), + }); + const Reservation2 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Reservation2?.setClient) { + await Reservation2.setClient(relatedClient2); + } +} + +async function associateReservationWithVehicle() { + const relatedVehicle0 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Reservation0 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Reservation0?.setVehicle) { + await Reservation0.setVehicle(relatedVehicle0); + } + + const relatedVehicle1 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Reservation1 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Reservation1?.setVehicle) { + await Reservation1.setVehicle(relatedVehicle1); + } + + const relatedVehicle2 = await Vehicles.findOne({ + offset: Math.floor(Math.random() * (await Vehicles.count())), + }); + const Reservation2 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Reservation2?.setVehicle) { + await Reservation2.setVehicle(relatedVehicle2); + } +} + +async function associateReservationWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Reservation0 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Reservation0?.setAgence) { + await Reservation0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Reservation1 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Reservation1?.setAgence) { + await Reservation1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Reservation2 = await Reservations.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Reservation2?.setAgence) { + await Reservation2.setAgence(relatedAgence2); + } +} + +async function associateSubscriptionWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Subscription0 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Subscription0?.setTenant) { + await Subscription0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Subscription1 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Subscription1?.setTenant) { + await Subscription1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Subscription2 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Subscription2?.setTenant) { + await Subscription2.setTenant(relatedTenant2); + } +} + +async function associateSubscriptionWithPlan() { + const relatedPlan0 = await Plans.findOne({ + offset: Math.floor(Math.random() * (await Plans.count())), + }); + const Subscription0 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Subscription0?.setPlan) { + await Subscription0.setPlan(relatedPlan0); + } + + const relatedPlan1 = await Plans.findOne({ + offset: Math.floor(Math.random() * (await Plans.count())), + }); + const Subscription1 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Subscription1?.setPlan) { + await Subscription1.setPlan(relatedPlan1); + } + + const relatedPlan2 = await Plans.findOne({ + offset: Math.floor(Math.random() * (await Plans.count())), + }); + const Subscription2 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Subscription2?.setPlan) { + await Subscription2.setPlan(relatedPlan2); + } +} + +async function associateSubscriptionWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Subscription0 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Subscription0?.setAgence) { + await Subscription0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Subscription1 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Subscription1?.setAgence) { + await Subscription1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Subscription2 = await Subscriptions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Subscription2?.setAgence) { + await Subscription2.setAgence(relatedAgence2); + } +} + +async function associateTenantWithPlan() { + const relatedPlan0 = await Plans.findOne({ + offset: Math.floor(Math.random() * (await Plans.count())), + }); + const Tenant0 = await Tenants.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Tenant0?.setPlan) { + await Tenant0.setPlan(relatedPlan0); + } + + const relatedPlan1 = await Plans.findOne({ + offset: Math.floor(Math.random() * (await Plans.count())), + }); + const Tenant1 = await Tenants.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Tenant1?.setPlan) { + await Tenant1.setPlan(relatedPlan1); + } + + const relatedPlan2 = await Plans.findOne({ + offset: Math.floor(Math.random() * (await Plans.count())), + }); + const Tenant2 = await Tenants.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Tenant2?.setPlan) { + await Tenant2.setPlan(relatedPlan2); + } +} + +async function associateTenantWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Tenant0 = await Tenants.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Tenant0?.setAgence) { + await Tenant0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Tenant1 = await Tenants.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Tenant1?.setAgence) { + await Tenant1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Tenant2 = await Tenants.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Tenant2?.setAgence) { + await Tenant2.setAgence(relatedAgence2); + } +} + +async function associateVehicleConditionWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const VehicleCondition0 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (VehicleCondition0?.setTenant) { + await VehicleCondition0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const VehicleCondition1 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (VehicleCondition1?.setTenant) { + await VehicleCondition1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const VehicleCondition2 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (VehicleCondition2?.setTenant) { + await VehicleCondition2.setTenant(relatedTenant2); + } +} + +async function associateVehicleConditionWithReservation() { + const relatedReservation0 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const VehicleCondition0 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (VehicleCondition0?.setReservation) { + await VehicleCondition0.setReservation(relatedReservation0); + } + + const relatedReservation1 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const VehicleCondition1 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (VehicleCondition1?.setReservation) { + await VehicleCondition1.setReservation(relatedReservation1); + } + + const relatedReservation2 = await Reservations.findOne({ + offset: Math.floor(Math.random() * (await Reservations.count())), + }); + const VehicleCondition2 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (VehicleCondition2?.setReservation) { + await VehicleCondition2.setReservation(relatedReservation2); + } +} + +async function associateVehicleConditionWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const VehicleCondition0 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (VehicleCondition0?.setAgence) { + await VehicleCondition0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const VehicleCondition1 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (VehicleCondition1?.setAgence) { + await VehicleCondition1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const VehicleCondition2 = await VehicleConditions.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (VehicleCondition2?.setAgence) { + await VehicleCondition2.setAgence(relatedAgence2); + } +} + +async function associateVehicleWithTenant() { + const relatedTenant0 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Vehicle0 = await Vehicles.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Vehicle0?.setTenant) { + await Vehicle0.setTenant(relatedTenant0); + } + + const relatedTenant1 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Vehicle1 = await Vehicles.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Vehicle1?.setTenant) { + await Vehicle1.setTenant(relatedTenant1); + } + + const relatedTenant2 = await Tenants.findOne({ + offset: Math.floor(Math.random() * (await Tenants.count())), + }); + const Vehicle2 = await Vehicles.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Vehicle2?.setTenant) { + await Vehicle2.setTenant(relatedTenant2); + } +} + +async function associateVehicleWithAgence() { + const relatedAgence0 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Vehicle0 = await Vehicles.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Vehicle0?.setAgence) { + await Vehicle0.setAgence(relatedAgence0); + } + + const relatedAgence1 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Vehicle1 = await Vehicles.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Vehicle1?.setAgence) { + await Vehicle1.setAgence(relatedAgence1); + } + + const relatedAgence2 = await Agences.findOne({ + offset: Math.floor(Math.random() * (await Agences.count())), + }); + const Vehicle2 = await Vehicles.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Vehicle2?.setAgence) { + await Vehicle2.setAgence(relatedAgence2); + } +} + +module.exports = { + up: async (queryInterface, Sequelize) => { + await Claims.bulkCreate(ClaimsData); + + await Clients.bulkCreate(ClientsData); + + await CompanyProfiles.bulkCreate(CompanyProfilesData); + + await Drivers.bulkCreate(DriversData); + + await Expenses.bulkCreate(ExpensesData); + + await Guarantees.bulkCreate(GuaranteesData); + + await Infractions.bulkCreate(InfractionsData); + + await InvoicesSaas.bulkCreate(InvoicesSaasData); + + await Maintenance.bulkCreate(MaintenanceData); + + await Payments.bulkCreate(PaymentsData); + + await Plans.bulkCreate(PlansData); + + await Reservations.bulkCreate(ReservationsData); + + await Subscriptions.bulkCreate(SubscriptionsData); + + await Tenants.bulkCreate(TenantsData); + + await VehicleConditions.bulkCreate(VehicleConditionsData); + + await Vehicles.bulkCreate(VehiclesData); + + await Agences.bulkCreate(AgencesData); + + await Promise.all([ + // Similar logic for "relation_many" + + await associateUserWithAgence(), + + await associateClaimWithTenant(), + + await associateClaimWithClient(), + + await associateClaimWithReservation(), + + await associateClaimWithAgence(), + + await associateClientWithTenant(), + + await associateClientWithAgence(), + + await associateCompanyProfileWithTenant(), + + await associateCompanyProfileWithAgence(), + + await associateDriverWithTenant(), + + await associateDriverWithReservation(), + + await associateDriverWithAgence(), + + await associateExpenseWithTenant(), + + await associateExpenseWithVehicle(), + + await associateExpenseWithAgence(), + + await associateGuaranteeWithTenant(), + + await associateGuaranteeWithReservation(), + + await associateGuaranteeWithAgence(), + + await associateInfractionWithTenant(), + + await associateInfractionWithVehicle(), + + await associateInfractionWithReservation(), + + await associateInfractionWithAgence(), + + await associateInvoicesSaaWithTenant(), + + await associateInvoicesSaaWithSubscription(), + + await associateInvoicesSaaWithAgence(), + + await associateMaintenanceWithTenant(), + + await associateMaintenanceWithVehicle(), + + await associateMaintenanceWithAgence(), + + await associatePaymentWithTenant(), + + await associatePaymentWithReservation(), + + await associatePaymentWithAgence(), + + await associatePlanWithAgence(), + + await associateReservationWithTenant(), + + await associateReservationWithClient(), + + await associateReservationWithVehicle(), + + await associateReservationWithAgence(), + + await associateSubscriptionWithTenant(), + + await associateSubscriptionWithPlan(), + + await associateSubscriptionWithAgence(), + + await associateTenantWithPlan(), + + await associateTenantWithAgence(), + + await associateVehicleConditionWithTenant(), + + await associateVehicleConditionWithReservation(), + + await associateVehicleConditionWithAgence(), + + await associateVehicleWithTenant(), + + await associateVehicleWithAgence(), + ]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete('claims', null, {}); + + await queryInterface.bulkDelete('clients', null, {}); + + await queryInterface.bulkDelete('company_profiles', null, {}); + + await queryInterface.bulkDelete('drivers', null, {}); + + await queryInterface.bulkDelete('expenses', null, {}); + + await queryInterface.bulkDelete('guarantees', null, {}); + + await queryInterface.bulkDelete('infractions', null, {}); + + await queryInterface.bulkDelete('invoices_saas', null, {}); + + await queryInterface.bulkDelete('maintenance', null, {}); + + await queryInterface.bulkDelete('payments', null, {}); + + await queryInterface.bulkDelete('plans', null, {}); + + await queryInterface.bulkDelete('reservations', null, {}); + + await queryInterface.bulkDelete('subscriptions', null, {}); + + await queryInterface.bulkDelete('tenants', null, {}); + + await queryInterface.bulkDelete('vehicle_conditions', null, {}); + + await queryInterface.bulkDelete('vehicles', null, {}); + + await queryInterface.bulkDelete('agences', null, {}); + }, +}; diff --git a/backend/src/db/utils.js b/backend/src/db/utils.js new file mode 100644 index 0000000..c257f8d --- /dev/null +++ b/backend/src/db/utils.js @@ -0,0 +1,24 @@ +const validator = require('validator'); +const { v4: uuid } = require('uuid'); +const Sequelize = require('./models').Sequelize; + +module.exports = class Utils { + static uuid(value) { + let id = value; + + if (!validator.isUUID(id)) { + id = uuid(); + } + + return id; + } + + static ilike(model, column, value) { + return Sequelize.where( + Sequelize.fn('lower', Sequelize.col(`${model}.${column}`)), + { + [Sequelize.Op.like]: `%${value}%`.toLowerCase(), + }, + ); + } +}; diff --git a/backend/src/helpers.js b/backend/src/helpers.js new file mode 100644 index 0000000..1d918b5 --- /dev/null +++ b/backend/src/helpers.js @@ -0,0 +1,23 @@ +const jwt = require('jsonwebtoken'); +const config = require('./config'); + +module.exports = class Helpers { + static wrapAsync(fn) { + return function (req, res, next) { + fn(req, res, next).catch(next); + }; + } + + static commonErrorHandler(error, req, res, next) { + if ([400, 403, 404].includes(error.code)) { + return res.status(error.code).send(error.message); + } + + console.error(error); + return res.status(500).send(error.message); + } + + static jwtSign(data) { + return jwt.sign(data, config.secret_key, { expiresIn: '6h' }); + } +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..315482e --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,279 @@ +const express = require('express'); +const cors = require('cors'); +const app = express(); +const passport = require('passport'); +const path = require('path'); +const fs = require('fs'); +const bodyParser = require('body-parser'); +const db = require('./db/models'); +const config = require('./config'); +const swaggerUI = require('swagger-ui-express'); +const swaggerJsDoc = require('swagger-jsdoc'); + +const authRoutes = require('./routes/auth'); +const fileRoutes = require('./routes/file'); +const searchRoutes = require('./routes/search'); +const pexelsRoutes = require('./routes/pexels'); + +const organizationForAuthRoutes = require('./routes/organizationLogin'); + +const openaiRoutes = require('./routes/openai'); + +const usersRoutes = require('./routes/users'); + +const claimsRoutes = require('./routes/claims'); + +const clientsRoutes = require('./routes/clients'); + +const company_profilesRoutes = require('./routes/company_profiles'); + +const driversRoutes = require('./routes/drivers'); + +const expensesRoutes = require('./routes/expenses'); + +const guaranteesRoutes = require('./routes/guarantees'); + +const infractionsRoutes = require('./routes/infractions'); + +const invoices_saasRoutes = require('./routes/invoices_saas'); + +const maintenanceRoutes = require('./routes/maintenance'); + +const paymentsRoutes = require('./routes/payments'); + +const plansRoutes = require('./routes/plans'); + +const reservationsRoutes = require('./routes/reservations'); + +const subscriptionsRoutes = require('./routes/subscriptions'); + +const tenantsRoutes = require('./routes/tenants'); + +const vehicle_conditionsRoutes = require('./routes/vehicle_conditions'); + +const vehiclesRoutes = require('./routes/vehicles'); + +const rolesRoutes = require('./routes/roles'); + +const permissionsRoutes = require('./routes/permissions'); + +const agencesRoutes = require('./routes/agences'); + +const getBaseUrl = (url) => { + if (!url) return ''; + return url.endsWith('/api') ? url.slice(0, -4) : url; +}; + +const options = { + definition: { + openapi: '3.0.0', + info: { + version: '1.0.0', + title: 'Car zone', + description: + 'Car zone Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.', + }, + servers: [ + { + url: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + responses: { + UnauthorizedError: { + description: 'Access token is missing or invalid', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + apis: ['./src/routes/*.js'], +}; + +const specs = swaggerJsDoc(options); +app.use( + '/api-docs', + function (req, res, next) { + swaggerUI.host = + getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || req.get('host'); + next(); + }, + swaggerUI.serve, + swaggerUI.setup(specs), +); + +app.use(cors({ origin: true })); +require('./auth/auth'); + +app.use(bodyParser.json()); + +app.use('/api/auth', authRoutes); +app.use('/api/file', fileRoutes); +app.use('/api/pexels', pexelsRoutes); +app.enable('trust proxy'); + +app.use( + '/api/users', + passport.authenticate('jwt', { session: false }), + usersRoutes, +); + +app.use( + '/api/claims', + passport.authenticate('jwt', { session: false }), + claimsRoutes, +); + +app.use( + '/api/clients', + passport.authenticate('jwt', { session: false }), + clientsRoutes, +); + +app.use( + '/api/company_profiles', + passport.authenticate('jwt', { session: false }), + company_profilesRoutes, +); + +app.use( + '/api/drivers', + passport.authenticate('jwt', { session: false }), + driversRoutes, +); + +app.use( + '/api/expenses', + passport.authenticate('jwt', { session: false }), + expensesRoutes, +); + +app.use( + '/api/guarantees', + passport.authenticate('jwt', { session: false }), + guaranteesRoutes, +); + +app.use( + '/api/infractions', + passport.authenticate('jwt', { session: false }), + infractionsRoutes, +); + +app.use( + '/api/invoices_saas', + passport.authenticate('jwt', { session: false }), + invoices_saasRoutes, +); + +app.use( + '/api/maintenance', + passport.authenticate('jwt', { session: false }), + maintenanceRoutes, +); + +app.use( + '/api/payments', + passport.authenticate('jwt', { session: false }), + paymentsRoutes, +); + +app.use( + '/api/plans', + passport.authenticate('jwt', { session: false }), + plansRoutes, +); + +app.use( + '/api/reservations', + passport.authenticate('jwt', { session: false }), + reservationsRoutes, +); + +app.use( + '/api/subscriptions', + passport.authenticate('jwt', { session: false }), + subscriptionsRoutes, +); + +app.use( + '/api/tenants', + passport.authenticate('jwt', { session: false }), + tenantsRoutes, +); + +app.use( + '/api/vehicle_conditions', + passport.authenticate('jwt', { session: false }), + vehicle_conditionsRoutes, +); + +app.use( + '/api/vehicles', + passport.authenticate('jwt', { session: false }), + vehiclesRoutes, +); + +app.use( + '/api/roles', + passport.authenticate('jwt', { session: false }), + rolesRoutes, +); + +app.use( + '/api/permissions', + passport.authenticate('jwt', { session: false }), + permissionsRoutes, +); + +app.use( + '/api/agences', + passport.authenticate('jwt', { session: false }), + agencesRoutes, +); + +app.use( + '/api/openai', + passport.authenticate('jwt', { session: false }), + openaiRoutes, +); + +app.use( + '/api/search', + passport.authenticate('jwt', { session: false }), + searchRoutes, +); + +app.use('/api/org-for-auth', organizationForAuthRoutes); + +const publicDir = path.join(__dirname, '../public'); + +if (fs.existsSync(publicDir)) { + app.use('/', express.static(publicDir)); + + app.get('*', function (request, response) { + response.sendFile(path.resolve(publicDir, 'index.html')); + }); +} + +const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080; + +db.sequelize.sync().then(function () { + app.listen(PORT, () => { + console.log(`Listening on port ${PORT}`); + }); +}); + +module.exports = app; diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js new file mode 100644 index 0000000..59772c7 --- /dev/null +++ b/backend/src/middlewares/check-permissions.js @@ -0,0 +1,176 @@ +const ValidationError = require('../services/notifications/errors/validation'); +const RolesDBApi = require('../db/api/roles'); + +// Cache for the 'Public' role object +let publicRoleCache = null; + +// Function to asynchronously fetch and cache the 'Public' role +async function fetchAndCachePublicRole() { + try { + // Use RolesDBApi to find the role by name 'Public' + publicRoleCache = await RolesDBApi.findBy({ name: 'Public' }); + + if (!publicRoleCache) { + console.error( + "WARNING: Role 'Public' not found in database during middleware startup. Check your migrations.", + ); + // The system might not function correctly without this role. May need to throw an error or use a fallback stub. + } else { + console.log("'Public' role successfully loaded and cached."); + } + } catch (error) { + console.error( + "Error fetching 'Public' role during middleware startup:", + error, + ); + // Handle the error during startup fetch + throw error; // Important to know if the app can proceed without the Public role + } +} + +// Trigger the role fetching when the check-permissions.js module is imported/loaded +// This should happen during application startup when routes are being configured. +fetchAndCachePublicRole().catch((error) => { + // Handle the case where the fetchAndCachePublicRole promise is rejected + console.error( + 'Critical error during permissions middleware initialization:', + error, + ); + // Decide here if the process should exit if the Public role is essential. + // process.exit(1); +}); + +/** + * Middleware creator to check if the current user (or Public role) has a specific permission. + * @param {string} permission - The name of the required permission. + * @return {import("express").RequestHandler} Express middleware function. + */ +function checkPermissions(permission) { + return async (req, res, next) => { + const { currentUser } = req; + + // 1. Check self-access bypass (only if the user is authenticated) + if ( + currentUser && + (currentUser.id === req.params.id || currentUser.id === req.body.id) + ) { + return next(); // User has access to their own resource + } + + // 2. Check Custom Permissions (only if the user is authenticated) + if (currentUser) { + // Ensure custom_permissions is an array before using find + const customPermissions = Array.isArray(currentUser.custom_permissions) + ? currentUser.custom_permissions + : []; + const userPermission = customPermissions.find( + (cp) => cp.name === permission, + ); + if (userPermission) { + return next(); // User has a custom permission + } + } + + // 3. Determine the "effective" role for permission check + let effectiveRole = null; + try { + if (currentUser && currentUser.app_role) { + // User is authenticated and has an assigned role + effectiveRole = currentUser.app_role; + } else { + // User is NOT authenticated OR is authenticated but has no role + // Use the cached 'Public' role + if (!publicRoleCache) { + // If the cache is unexpectedly empty (e.g., startup error caught), + // we can try fetching the role again synchronously (less ideal) or just deny access. + console.error( + 'Public role cache is empty. Attempting synchronous fetch...', + ); + // Less efficient fallback option: + effectiveRole = await RolesDBApi.findBy({ name: 'Public' }); // Could be slow + if (!effectiveRole) { + // If even the synchronous attempt failed + return next( + new Error( + 'Internal Server Error: Public role missing and cannot be fetched.', + ), + ); + } + } else { + effectiveRole = publicRoleCache; // Use the cached object + } + } + + // Check if we got a valid role object + if (!effectiveRole) { + return next( + new Error( + 'Internal Server Error: Could not determine effective role.', + ), + ); + } + + // 4. Check Permissions on the "effective" role + // Assume the effectiveRole object (from app_role or RolesDBApi) has a getPermissions() method + // or a 'permissions' property (if permissions are eagerly loaded). + let rolePermissions = []; + if (typeof effectiveRole.getPermissions === 'function') { + rolePermissions = await effectiveRole.getPermissions(); // Get permissions asynchronously if the method exists + } else if (Array.isArray(effectiveRole.permissions)) { + rolePermissions = effectiveRole.permissions; // Or take from property if permissions are pre-loaded + } else { + console.error( + 'Role object lacks getPermissions() method or permissions property:', + effectiveRole, + ); + return next( + new Error('Internal Server Error: Invalid role object format.'), + ); + } + + if (rolePermissions.find((p) => p.name === permission)) { + next(); // The "effective" role has the required permission + } else { + // The "effective" role does not have the required permission + const roleName = effectiveRole.name || 'unknown role'; + next( + new ValidationError( + 'auth.forbidden', + `Role '${roleName}' denied access to '${permission}'.`, + ), + ); + } + } catch (e) { + // Handle errors during role or permission fetching + console.error('Error during permission check:', e); + next(e); // Pass the error to the next middleware + } + }; +} + +const METHOD_MAP = { + POST: 'CREATE', + GET: 'READ', + PUT: 'UPDATE', + PATCH: 'UPDATE', + DELETE: 'DELETE', +}; + +/** + * Middleware creator to check standard CRUD permissions based on HTTP method and entity name. + * @param {string} name - The name of the entity. + * @return {import("express").RequestHandler} Express middleware function. + */ +function checkCrudPermissions(name) { + return (req, res, next) => { + // Dynamically determine the permission name (e.g., 'READ_USERS') + const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`; + // Call the checkPermissions middleware with the determined permission + checkPermissions(permissionName)(req, res, next); + }; +} + +module.exports = { + checkPermissions, + checkCrudPermissions, +}; diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js new file mode 100644 index 0000000..6d88c73 --- /dev/null +++ b/backend/src/middlewares/upload.js @@ -0,0 +1,11 @@ +const util = require('util'); +const Multer = require('multer'); +const maxSize = 10 * 1024 * 1024; + +let processFile = Multer({ + storage: Multer.memoryStorage(), + limits: { fileSize: maxSize }, +}).single('file'); + +let processFileMiddleware = util.promisify(processFile); +module.exports = processFileMiddleware; diff --git a/backend/src/routes/agences.js b/backend/src/routes/agences.js new file mode 100644 index 0000000..37a25f6 --- /dev/null +++ b/backend/src/routes/agences.js @@ -0,0 +1,452 @@ +const express = require('express'); + +const AgencesService = require('../services/agences'); +const AgencesDBApi = require('../db/api/agences'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('agences')); + +/** + * @swagger + * components: + * schemas: + * Agences: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Agences + * description: The Agences managing API + */ + +/** + * @swagger + * /api/agences: + * post: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Agences" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Agences" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await AgencesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Agences" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Agences" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await AgencesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/agences/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Agences" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Agences" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await AgencesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/agences/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Agences" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await AgencesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/agences/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Agences" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await AgencesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/agences: + * get: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Get all agences + * description: Get all agences + * responses: + * 200: + * description: Agences list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Agences" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await AgencesDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/agences/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Count all agences + * description: Count all agences + * responses: + * 200: + * description: Agences count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Agences" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await AgencesDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/agences/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Find all agences that match search criteria + * description: Find all agences that match search criteria + * responses: + * 200: + * description: Agences list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Agences" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await AgencesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/agences/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Agences] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Agences" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await AgencesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..6a82b89 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,270 @@ +const express = require('express'); +const passport = require('passport'); + +const config = require('../config'); +const AuthService = require('../services/auth'); +const ForbiddenError = require('../services/notifications/errors/forbidden'); +const EmailSender = require('../services/email'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Auth: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * default: admin@flatlogic.com + * description: User email + * password: + * type: string + * default: password + * description: User password + */ + +/** + * @swagger + * tags: + * name: Auth + * description: Authorization operations + */ + +/** + * @swagger + * /api/auth/signin/local: + * post: + * tags: [Auth] + * summary: Logs user into the system + * description: Logs user into the system + * requestBody: + * description: Set valid user email and password + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Auth" + * responses: + * 200: + * description: Successful login + * 400: + * description: Invalid username/password supplied + * x-codegen-request-body-name: body + */ + +router.post( + '/signin/local', + wrapAsync(async (req, res) => { + const payload = await AuthService.signin( + req.body.email, + req.body.password, + req, + ); + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/auth/me: + * get: + * security: + * - bearerAuth: [] + * tags: [Auth] + * summary: Get current authorized user info + * description: Get current authorized user info + * responses: + * 200: + * description: Successful retrieval of current authorized user data + * 400: + * description: Invalid username/password supplied + * x-codegen-request-body-name: body + */ + +router.get( + '/me', + passport.authenticate('jwt', { session: false }), + (req, res) => { + if (!req.currentUser || !req.currentUser.id) { + throw new ForbiddenError(); + } + + const payload = req.currentUser; + delete payload.password; + res.status(200).send(payload); + }, +); + +router.put( + '/password-reset', + wrapAsync(async (req, res) => { + const payload = await AuthService.passwordReset( + req.body.token, + req.body.password, + req, + ); + res.status(200).send(payload); + }), +); + +router.put( + '/password-update', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + const payload = await AuthService.passwordUpdate( + req.body.currentPassword, + req.body.newPassword, + req, + ); + res.status(200).send(payload); + }), +); + +router.post( + '/send-email-address-verification-email', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + if (!req.currentUser) { + throw new ForbiddenError(); + } + + await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); + const payload = true; + res.status(200).send(payload); + }), +); + +router.post( + '/send-password-reset-email', + wrapAsync(async (req, res) => { + const link = new URL(req.headers.referer); + await AuthService.sendPasswordResetEmail( + req.body.email, + 'register', + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/auth/signup: + * post: + * tags: [Auth] + * summary: Register new user into the system + * description: Register new user into the system + * requestBody: + * description: Set valid user email and password + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Auth" + * responses: + * 200: + * description: New user successfully signed up + * 400: + * description: Invalid username/password supplied + * 500: + * description: Some server error + * x-codegen-request-body-name: body + */ + +router.post( + '/signup', + wrapAsync(async (req, res) => { + const link = new URL(req.headers.referer); + const payload = await AuthService.signup( + req.body.email, + req.body.password, + + req.body.organizationId, + + req, + link.host, + ); + res.status(200).send(payload); + }), +); + +router.put( + '/profile', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + if (!req.currentUser || !req.currentUser.id) { + throw new ForbiddenError(); + } + + await AuthService.updateProfile(req.body.profile, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +router.put( + '/verify-email', + wrapAsync(async (req, res) => { + const payload = await AuthService.verifyEmail( + req.body.token, + req, + req.headers.referer, + ); + res.status(200).send(payload); + }), +); + +router.get('/email-configured', (req, res) => { + const payload = EmailSender.isConfigured; + res.status(200).send(payload); +}); + +router.get('/signin/google', (req, res, next) => { + passport.authenticate('google', { + scope: ['profile', 'email'], + state: req.query.app, + })(req, res, next); +}); + +router.get( + '/signin/google/callback', + passport.authenticate('google', { + failureRedirect: '/login', + session: false, + }), + + function (req, res) { + socialRedirect(res, req.query.state, req.user.token, config); + }, +); + +router.get('/signin/microsoft', (req, res, next) => { + passport.authenticate('microsoft', { + scope: ['https://graph.microsoft.com/user.read openid'], + state: req.query.app, + })(req, res, next); +}); + +router.get( + '/signin/microsoft/callback', + passport.authenticate('microsoft', { + failureRedirect: '/login', + session: false, + }), + function (req, res) { + socialRedirect(res, req.query.state, req.user.token, config); + }, +); + +router.use('/', require('../helpers').commonErrorHandler); + +function socialRedirect(res, state, token, config) { + res.redirect(config.uiUrl + '/login?token=' + token); +} + +module.exports = router; diff --git a/backend/src/routes/claims.js b/backend/src/routes/claims.js new file mode 100644 index 0000000..8917cde --- /dev/null +++ b/backend/src/routes/claims.js @@ -0,0 +1,454 @@ +const express = require('express'); + +const ClaimsService = require('../services/claims'); +const ClaimsDBApi = require('../db/api/claims'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('claims')); + +/** + * @swagger + * components: + * schemas: + * Claims: + * type: object + * properties: + + * category: + * type: string + * default: category + * description: + * type: string + * default: description + * attachments: + * type: string + * default: attachments + + * + */ + +/** + * @swagger + * tags: + * name: Claims + * description: The Claims managing API + */ + +/** + * @swagger + * /api/claims: + * post: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Claims" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Claims" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ClaimsService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Claims" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Claims" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ClaimsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/claims/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Claims" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Claims" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ClaimsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/claims/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Claims" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ClaimsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/claims/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Claims" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ClaimsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/claims: + * get: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Get all claims + * description: Get all claims + * responses: + * 200: + * description: Claims list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Claims" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ClaimsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'category', 'description', 'attachments']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/claims/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Count all claims + * description: Count all claims + * responses: + * 200: + * description: Claims count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Claims" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ClaimsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/claims/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Find all claims that match search criteria + * description: Find all claims that match search criteria + * responses: + * 200: + * description: Claims list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Claims" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await ClaimsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/claims/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Claims] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Claims" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ClaimsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/clients.js b/backend/src/routes/clients.js new file mode 100644 index 0000000..9fa5711 --- /dev/null +++ b/backend/src/routes/clients.js @@ -0,0 +1,488 @@ +const express = require('express'); + +const ClientsService = require('../services/clients'); +const ClientsDBApi = require('../db/api/clients'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('clients')); + +/** + * @swagger + * components: + * schemas: + * Clients: + * type: object + * properties: + + * first_name: + * type: string + * default: first_name + * last_name: + * type: string + * default: last_name + * phone: + * type: string + * default: phone + * address: + * type: string + * default: address + * id_number: + * type: string + * default: id_number + * license_number: + * type: string + * default: license_number + * id_copy_url: + * type: string + * default: id_copy_url + * license_copy_url: + * type: string + * default: license_copy_url + + * + */ + +/** + * @swagger + * tags: + * name: Clients + * description: The Clients managing API + */ + +/** + * @swagger + * /api/clients: + * post: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Clients" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ClientsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ClientsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Clients" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ClientsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ClientsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ClientsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Get all clients + * description: Get all clients + * responses: + * 200: + * description: Clients list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ClientsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'phone', + 'address', + 'id_number', + 'license_number', + 'id_copy_url', + 'license_copy_url', + + 'birth_date', + 'id_issue_date', + 'license_issue_date', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/clients/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Count all clients + * description: Count all clients + * responses: + * 200: + * description: Clients count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ClientsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/clients/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Find all clients that match search criteria + * description: Find all clients that match search criteria + * responses: + * 200: + * description: Clients list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Clients" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await ClientsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/clients/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Clients] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Clients" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ClientsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/company_profiles.js b/backend/src/routes/company_profiles.js new file mode 100644 index 0000000..2639563 --- /dev/null +++ b/backend/src/routes/company_profiles.js @@ -0,0 +1,498 @@ +const express = require('express'); + +const Company_profilesService = require('../services/company_profiles'); +const Company_profilesDBApi = require('../db/api/company_profiles'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('company_profiles')); + +/** + * @swagger + * components: + * schemas: + * Company_profiles: + * type: object + * properties: + + * legal_name: + * type: string + * default: legal_name + * address: + * type: string + * default: address + * phone: + * type: string + * default: phone + * email: + * type: string + * default: email + * website: + * type: string + * default: website + * rc: + * type: string + * default: rc + * ice: + * type: string + * default: ice + * patente: + * type: string + * default: patente + * logo_url: + * type: string + * default: logo_url + * pdf_footer: + * type: string + * default: pdf_footer + + */ + +/** + * @swagger + * tags: + * name: Company_profiles + * description: The Company_profiles managing API + */ + +/** + * @swagger + * /api/company_profiles: + * post: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Company_profiles" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Company_profiles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Company_profilesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Company_profiles" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Company_profiles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Company_profilesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/company_profiles/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Company_profiles" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Company_profiles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await Company_profilesService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/company_profiles/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Company_profiles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await Company_profilesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/company_profiles/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Company_profiles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await Company_profilesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/company_profiles: + * get: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Get all company_profiles + * description: Get all company_profiles + * responses: + * 200: + * description: Company_profiles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Company_profiles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await Company_profilesDBApi.findAll( + req.query, + globalAccess, + { currentUser }, + ); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'legal_name', + 'address', + 'phone', + 'email', + 'website', + 'rc', + 'ice', + 'patente', + 'logo_url', + 'pdf_footer', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/company_profiles/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Count all company_profiles + * description: Count all company_profiles + * responses: + * 200: + * description: Company_profiles count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Company_profiles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await Company_profilesDBApi.findAll( + req.query, + globalAccess, + { countOnly: true, currentUser }, + ); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/company_profiles/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Find all company_profiles that match search criteria + * description: Find all company_profiles that match search criteria + * responses: + * 200: + * description: Company_profiles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Company_profiles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await Company_profilesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/company_profiles/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Company_profiles] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Company_profiles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await Company_profilesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/contactForm.js b/backend/src/routes/contactForm.js new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/drivers.js b/backend/src/routes/drivers.js new file mode 100644 index 0000000..cd463ee --- /dev/null +++ b/backend/src/routes/drivers.js @@ -0,0 +1,469 @@ +const express = require('express'); + +const DriversService = require('../services/drivers'); +const DriversDBApi = require('../db/api/drivers'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('drivers')); + +/** + * @swagger + * components: + * schemas: + * Drivers: + * type: object + * properties: + + * full_name: + * type: string + * default: full_name + * license_number: + * type: string + * default: license_number + * id_copy_url: + * type: string + * default: id_copy_url + * license_copy_url: + * type: string + * default: license_copy_url + + */ + +/** + * @swagger + * tags: + * name: Drivers + * description: The Drivers managing API + */ + +/** + * @swagger + * /api/drivers: + * post: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Drivers" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Drivers" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await DriversService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Drivers" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Drivers" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await DriversService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/drivers/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Drivers" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Drivers" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await DriversService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/drivers/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Drivers" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await DriversService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/drivers/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Drivers" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await DriversService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/drivers: + * get: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Get all drivers + * description: Get all drivers + * responses: + * 200: + * description: Drivers list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Drivers" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await DriversDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'full_name', + 'license_number', + 'id_copy_url', + 'license_copy_url', + + 'license_issue_date', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/drivers/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Count all drivers + * description: Count all drivers + * responses: + * 200: + * description: Drivers count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Drivers" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await DriversDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/drivers/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Find all drivers that match search criteria + * description: Find all drivers that match search criteria + * responses: + * 200: + * description: Drivers list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Drivers" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await DriversDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/drivers/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Drivers] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Drivers" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await DriversDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/expenses.js b/backend/src/routes/expenses.js new file mode 100644 index 0000000..99526c7 --- /dev/null +++ b/backend/src/routes/expenses.js @@ -0,0 +1,471 @@ +const express = require('express'); + +const ExpensesService = require('../services/expenses'); +const ExpensesDBApi = require('../db/api/expenses'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('expenses')); + +/** + * @swagger + * components: + * schemas: + * Expenses: + * type: object + * properties: + + * category: + * type: string + * default: category + * note: + * type: string + * default: note + * attachment_url: + * type: string + * default: attachment_url + + * amount: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Expenses + * description: The Expenses managing API + */ + +/** + * @swagger + * /api/expenses: + * post: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Expenses" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Expenses" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ExpensesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Expenses" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Expenses" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ExpensesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/expenses/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Expenses" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Expenses" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ExpensesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/expenses/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Expenses" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ExpensesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/expenses/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Expenses" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ExpensesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/expenses: + * get: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Get all expenses + * description: Get all expenses + * responses: + * 200: + * description: Expenses list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Expenses" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ExpensesDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'category', + 'note', + 'attachment_url', + + 'amount', + 'date', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/expenses/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Count all expenses + * description: Count all expenses + * responses: + * 200: + * description: Expenses count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Expenses" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ExpensesDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/expenses/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Find all expenses that match search criteria + * description: Find all expenses that match search criteria + * responses: + * 200: + * description: Expenses list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Expenses" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await ExpensesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/expenses/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Expenses] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Expenses" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ExpensesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js new file mode 100644 index 0000000..e98d04c --- /dev/null +++ b/backend/src/routes/file.js @@ -0,0 +1,40 @@ +const express = require('express'); +const config = require('../config'); +const path = require('path'); +const passport = require('passport'); +const services = require('../services/file'); +const router = express.Router(); + +router.get('/download', (req, res) => { + if ( + process.env.NODE_ENV == 'production' || + process.env.NEXT_PUBLIC_BACK_API + ) { + services.downloadGCloud(req, res); + } else { + services.downloadLocal(req, res); + } +}); + +router.post( + '/upload/:table/:field', + passport.authenticate('jwt', { session: false }), + (req, res) => { + const fileName = `${req.params.table}/${req.params.field}`; + + if ( + process.env.NODE_ENV == 'production' || + process.env.NEXT_PUBLIC_BACK_API + ) { + services.uploadGCloud(fileName, req, res); + } else { + services.uploadLocal(fileName, { + entity: null, + maxFileSize: 10 * 1024 * 1024, + folderIncludesAuthenticationUid: false, + })(req, res); + } + }, +); + +module.exports = router; diff --git a/backend/src/routes/guarantees.js b/backend/src/routes/guarantees.js new file mode 100644 index 0000000..8077623 --- /dev/null +++ b/backend/src/routes/guarantees.js @@ -0,0 +1,458 @@ +const express = require('express'); + +const GuaranteesService = require('../services/guarantees'); +const GuaranteesDBApi = require('../db/api/guarantees'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('guarantees')); + +/** + * @swagger + * components: + * schemas: + * Guarantees: + * type: object + * properties: + + * proof_url: + * type: string + * default: proof_url + + * amount: + * type: integer + * format: int64 + + * + * + */ + +/** + * @swagger + * tags: + * name: Guarantees + * description: The Guarantees managing API + */ + +/** + * @swagger + * /api/guarantees: + * post: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Guarantees" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Guarantees" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await GuaranteesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Guarantees" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Guarantees" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await GuaranteesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/guarantees/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Guarantees" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Guarantees" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await GuaranteesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/guarantees/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Guarantees" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await GuaranteesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/guarantees/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Guarantees" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await GuaranteesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/guarantees: + * get: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Get all guarantees + * description: Get all guarantees + * responses: + * 200: + * description: Guarantees list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Guarantees" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await GuaranteesDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'proof_url', 'amount']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/guarantees/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Count all guarantees + * description: Count all guarantees + * responses: + * 200: + * description: Guarantees count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Guarantees" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await GuaranteesDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/guarantees/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Find all guarantees that match search criteria + * description: Find all guarantees that match search criteria + * responses: + * 200: + * description: Guarantees list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Guarantees" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await GuaranteesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/guarantees/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Guarantees] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Guarantees" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await GuaranteesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/infractions.js b/backend/src/routes/infractions.js new file mode 100644 index 0000000..6b4b5eb --- /dev/null +++ b/backend/src/routes/infractions.js @@ -0,0 +1,475 @@ +const express = require('express'); + +const InfractionsService = require('../services/infractions'); +const InfractionsDBApi = require('../db/api/infractions'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('infractions')); + +/** + * @swagger + * components: + * schemas: + * Infractions: + * type: object + * properties: + + * ref_no: + * type: string + * default: ref_no + * location: + * type: string + * default: location + * proof_url: + * type: string + * default: proof_url + + * amount: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Infractions + * description: The Infractions managing API + */ + +/** + * @swagger + * /api/infractions: + * post: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Infractions" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Infractions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await InfractionsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Infractions" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Infractions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await InfractionsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/infractions/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Infractions" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Infractions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await InfractionsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/infractions/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Infractions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await InfractionsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/infractions/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Infractions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await InfractionsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/infractions: + * get: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Get all infractions + * description: Get all infractions + * responses: + * 200: + * description: Infractions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Infractions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await InfractionsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'ref_no', + 'location', + 'proof_url', + + 'amount', + 'date', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/infractions/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Count all infractions + * description: Count all infractions + * responses: + * 200: + * description: Infractions count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Infractions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await InfractionsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/infractions/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Find all infractions that match search criteria + * description: Find all infractions that match search criteria + * responses: + * 200: + * description: Infractions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Infractions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await InfractionsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/infractions/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Infractions] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Infractions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await InfractionsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/invoices_saas.js b/backend/src/routes/invoices_saas.js new file mode 100644 index 0000000..6e197a0 --- /dev/null +++ b/backend/src/routes/invoices_saas.js @@ -0,0 +1,471 @@ +const express = require('express'); + +const Invoices_saasService = require('../services/invoices_saas'); +const Invoices_saasDBApi = require('../db/api/invoices_saas'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('invoices_saas')); + +/** + * @swagger + * components: + * schemas: + * Invoices_saas: + * type: object + * properties: + + * invoice_number: + * type: string + * default: invoice_number + * pdf_url: + * type: string + * default: pdf_url + + * amount_mad: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Invoices_saas + * description: The Invoices_saas managing API + */ + +/** + * @swagger + * /api/invoices_saas: + * post: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Invoices_saas" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Invoices_saas" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Invoices_saasService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Invoices_saas" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Invoices_saas" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Invoices_saasService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/invoices_saas/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Invoices_saas" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Invoices_saas" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await Invoices_saasService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/invoices_saas/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Invoices_saas" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await Invoices_saasService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/invoices_saas/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Invoices_saas" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await Invoices_saasService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/invoices_saas: + * get: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Get all invoices_saas + * description: Get all invoices_saas + * responses: + * 200: + * description: Invoices_saas list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Invoices_saas" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await Invoices_saasDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'invoice_number', + 'pdf_url', + + 'amount_mad', + 'issued_at', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/invoices_saas/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Count all invoices_saas + * description: Count all invoices_saas + * responses: + * 200: + * description: Invoices_saas count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Invoices_saas" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await Invoices_saasDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/invoices_saas/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Find all invoices_saas that match search criteria + * description: Find all invoices_saas that match search criteria + * responses: + * 200: + * description: Invoices_saas list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Invoices_saas" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await Invoices_saasDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/invoices_saas/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Invoices_saas] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Invoices_saas" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await Invoices_saasDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/maintenance.js b/backend/src/routes/maintenance.js new file mode 100644 index 0000000..27e62b7 --- /dev/null +++ b/backend/src/routes/maintenance.js @@ -0,0 +1,475 @@ +const express = require('express'); + +const MaintenanceService = require('../services/maintenance'); +const MaintenanceDBApi = require('../db/api/maintenance'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('maintenance')); + +/** + * @swagger + * components: + * schemas: + * Maintenance: + * type: object + * properties: + + * notes: + * type: string + * default: notes + * attachments: + * type: string + * default: attachments + + * odometer: + * type: integer + * format: int64 + + * cost: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Maintenance + * description: The Maintenance managing API + */ + +/** + * @swagger + * /api/maintenance: + * post: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Maintenance" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Maintenance" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await MaintenanceService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Maintenance" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Maintenance" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await MaintenanceService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/maintenance/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Maintenance" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Maintenance" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await MaintenanceService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/maintenance/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Maintenance" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await MaintenanceService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/maintenance/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Maintenance" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await MaintenanceService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/maintenance: + * get: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Get all maintenance + * description: Get all maintenance + * responses: + * 200: + * description: Maintenance list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Maintenance" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await MaintenanceDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'notes', + 'attachments', + 'odometer', + 'cost', + 'scheduled_at', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/maintenance/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Count all maintenance + * description: Count all maintenance + * responses: + * 200: + * description: Maintenance count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Maintenance" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await MaintenanceDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/maintenance/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Find all maintenance that match search criteria + * description: Find all maintenance that match search criteria + * responses: + * 200: + * description: Maintenance list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Maintenance" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await MaintenanceDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/maintenance/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Maintenance] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Maintenance" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await MaintenanceDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/openai.js b/backend/src/routes/openai.js new file mode 100644 index 0000000..9260394 --- /dev/null +++ b/backend/src/routes/openai.js @@ -0,0 +1,244 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const router = express.Router(); +const sjs = require('sequelize-json-schema'); +const { getWidget, askGpt } = require('../services/openai'); +const RolesService = require('../services/roles'); +const RolesDBApi = require('../db/api/roles'); + +/** + * @swagger + * /api/roles/roles-info/{infoId}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Remove role information by ID + * description: Remove specific role information by ID + * parameters: + * - in: path + * name: infoId + * description: ID of role information to remove + * required: true + * schema: + * type: string + * - in: query + * name: userId + * description: ID of the user + * required: true + * schema: + * type: string + * - in: query + * name: key + * description: Key of the role information to remove + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Role information successfully removed + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * type: string + * description: The user information + * 400: + * description: Invalid ID or key supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Role not found + * 500: + * description: Some server error + */ + +router.delete( + '/roles-info/:infoId', + wrapAsync(async (req, res) => { + const role = await RolesService.removeRoleInfoById( + req.query.infoId, + req.query.roleId, + req.query.key, + req.currentUser, + ); + + res.status(200).send(role); + }), +); + +/** + * @swagger + * /api/roles/role-info/{roleId}: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Get role information by key + * description: Get specific role information by key + * parameters: + * - in: path + * name: roleId + * description: ID of role to get information for + * required: true + * schema: + * type: string + * - in: query + * name: key + * description: Key of the role information to retrieve + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Role information successfully received + * content: + * application/json: + * schema: + * type: object + * properties: + * info: + * type: string + * description: The role information + * 400: + * description: Invalid ID or key supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Role not found + * 500: + * description: Some server error + */ + +router.get( + '/info-by-key', + wrapAsync(async (req, res) => { + const roleId = req.query.roleId; + const key = req.query.key; + const currentUser = req.currentUser; + let info = await RolesService.getRoleInfoByKey(key, roleId, currentUser); + const role = await RolesDBApi.findBy({ id: roleId }); + if (!role?.role_customization) { + await Promise.all( + ['pie', 'bar'].map(async (e) => { + const schema = await sjs.getSequelizeSchema(db.sequelize, {}); + const payload = { + description: `Create some cool ${e} chart`, + modelDefinition: schema.definitions, + }; + const widgetId = await getWidget(payload, currentUser?.id, roleId); + if (widgetId) { + await RolesService.addRoleInfo( + roleId, + currentUser?.id, + 'widgets', + widgetId, + req.currentUser, + ); + } + }), + ); + info = await RolesService.getRoleInfoByKey(key, roleId, currentUser); + } + res.status(200).send(info); + }), +); + +router.post( + '/create_widget', + wrapAsync(async (req, res) => { + const { description, userId, roleId } = req.body; + + const currentUser = req.currentUser; + const schema = await sjs.getSequelizeSchema(db.sequelize, {}); + const payload = { + description, + modelDefinition: schema.definitions, + }; + + const widgetId = await getWidget(payload, userId, roleId); + + if (widgetId) { + await RolesService.addRoleInfo( + roleId, + userId, + 'widgets', + widgetId, + currentUser, + ); + + return res.status(200).send(widgetId); + } else { + return res.status(400).send(widgetId); + } + }), +); + +/** + * @swagger + * /api/openai/ask: + * post: + * security: + * - bearerAuth: [] + * tags: [OpenAI] + * summary: Ask a question to ChatGPT + * description: Send a question to OpenAI's ChatGPT and get a response + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * question: + * type: string + * description: The question to ask ChatGPT + * apiKey: + * type: string + * description: OpenAI API key + * responses: + * 200: + * description: Question successfully answered + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * description: Whether the request was successful + * data: + * type: string + * description: The answer from ChatGPT + * 400: + * description: Invalid request + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 500: + * description: Some server error + */ +router.post( + '/ask-gpt', + wrapAsync(async (req, res) => { + const { prompt } = req.body; + if (!prompt) { + return res.status(400).send({ + success: false, + error: 'Question and API key are required', + }); + } + + const response = await askGpt(prompt); + + if (response.success) { + return res.status(200).send(response); + } else { + return res.status(500).send(response); + } + }), +); + +module.exports = router; diff --git a/backend/src/routes/organizationLogin.js b/backend/src/routes/organizationLogin.js new file mode 100644 index 0000000..4b7344e --- /dev/null +++ b/backend/src/routes/organizationLogin.js @@ -0,0 +1,46 @@ +const express = require('express'); + +const AgencesDBApi = require('../db/api/agences'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +/** + * @swagger + * /api/organizations: + * get: + * security: + * - bearerAuth: [] + * tags: [Organizations] + * summary: Get all organizations + * description: Get all organizations + * responses: + * 200: + * description: Organizations list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Organizations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ + +router.get( + '/', + wrapAsync(async (req, res) => { + const payload = await AgencesDBApi.findAll(req.query); + const simplifiedPayload = payload.rows.map((org) => ({ + id: org.id, + name: org.name, + })); + res.status(200).send(simplifiedPayload); + }), +); + +module.exports = router; diff --git a/backend/src/routes/payments.js b/backend/src/routes/payments.js new file mode 100644 index 0000000..59fb703 --- /dev/null +++ b/backend/src/routes/payments.js @@ -0,0 +1,467 @@ +const express = require('express'); + +const PaymentsService = require('../services/payments'); +const PaymentsDBApi = require('../db/api/payments'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('payments')); + +/** + * @swagger + * components: + * schemas: + * Payments: + * type: object + * properties: + + * receipt_ref: + * type: string + * default: receipt_ref + * attachment_url: + * type: string + * default: attachment_url + + * amount: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Payments + * description: The Payments managing API + */ + +/** + * @swagger + * /api/payments: + * post: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Payments" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Payments" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PaymentsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Payments" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Payments" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PaymentsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/payments/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Payments" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Payments" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await PaymentsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/payments/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Payments" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await PaymentsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/payments/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Payments" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await PaymentsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/payments: + * get: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Get all payments + * description: Get all payments + * responses: + * 200: + * description: Payments list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Payments" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await PaymentsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'receipt_ref', + 'attachment_url', + + 'amount', + 'paid_at', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/payments/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Count all payments + * description: Count all payments + * responses: + * 200: + * description: Payments count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Payments" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await PaymentsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/payments/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Find all payments that match search criteria + * description: Find all payments that match search criteria + * responses: + * 200: + * description: Payments list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Payments" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await PaymentsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/payments/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Payments] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Payments" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await PaymentsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js new file mode 100644 index 0000000..c3f1685 --- /dev/null +++ b/backend/src/routes/permissions.js @@ -0,0 +1,442 @@ +const express = require('express'); + +const PermissionsService = require('../services/permissions'); +const PermissionsDBApi = require('../db/api/permissions'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('permissions')); + +/** + * @swagger + * components: + * schemas: + * Permissions: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Permissions + * description: The Permissions managing API + */ + +/** + * @swagger + * /api/permissions: + * post: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Permissions" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PermissionsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PermissionsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Permissions" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await PermissionsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await PermissionsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await PermissionsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Get all permissions + * description: Get all permissions + * responses: + * 200: + * description: Permissions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await PermissionsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/permissions/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Count all permissions + * description: Count all permissions + * responses: + * 200: + * description: Permissions count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await PermissionsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/permissions/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Find all permissions that match search criteria + * description: Find all permissions that match search criteria + * responses: + * 200: + * description: Permissions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Permissions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await PermissionsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/permissions/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Permissions] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Permissions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await PermissionsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/pexels.js b/backend/src/routes/pexels.js new file mode 100644 index 0000000..b5319cf --- /dev/null +++ b/backend/src/routes/pexels.js @@ -0,0 +1,106 @@ +const express = require('express'); +const router = express.Router(); +const { pexelsKey, pexelsQuery } = require('../config'); +const fetch = require('node-fetch'); + +const KEY = pexelsKey; + +router.get('/image', async (req, res) => { + const headers = { + Authorization: `${KEY}`, + }; + const query = pexelsQuery || 'nature'; + const orientation = 'portrait'; + const perPage = 1; + const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + + try { + const response = await fetch(url, { headers }); + const data = await response.json(); + res.status(200).json(data.photos[0]); + } catch (error) { + res.status(200).json({ error: 'Failed to fetch image' }); + } +}); + +router.get('/video', async (req, res) => { + const headers = { + Authorization: `${KEY}`, + }; + const query = pexelsQuery || 'nature'; + const orientation = 'portrait'; + const perPage = 1; + const url = `https://api.pexels.com/videos/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + + try { + const response = await fetch(url, { headers }); + const data = await response.json(); + res.status(200).json(data.videos[0]); + } catch (error) { + res.status(200).json({ error: 'Failed to fetch video' }); + } +}); + +router.get('/multiple-images', async (req, res) => { + const headers = { + Authorization: `${KEY}`, + }; + + const queries = req.query.queries + ? req.query.queries.split(',') + : ['home', 'apple', 'pizza', 'mountains', 'cat']; + const orientation = 'square'; + const perPage = 1; + + const fallbackImage = { + src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg', + photographer: 'Yan Krukau', + photographer_url: 'https://www.pexels.com/@yankrukov', + }; + const fetchFallbackImage = async () => { + try { + const response = await fetch('https://picsum.photos/600'); + return { + src: response.url, + photographer: 'Random Picsum', + photographer_url: 'https://picsum.photos/', + }; + } catch (error) { + return fallbackImage; + } + }; + const fetchImage = async (query) => { + const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; + const response = await fetch(url, { headers }); + const data = await response.json(); + return data.photos[0] || null; + }; + + const imagePromises = queries.map((query) => fetchImage(query)); + const imagesResults = await Promise.allSettled(imagePromises); + + const formattedImages = await Promise.all( + imagesResults.map(async (result) => { + if (result.status === 'fulfilled' && result.value) { + const image = result.value; + return { + src: image.src?.original || fallbackImage.src, + photographer: image.photographer || fallbackImage.photographer, + photographer_url: + image.photographer_url || fallbackImage.photographer_url, + }; + } else { + const fallback = await fetchFallbackImage(); + return { + src: fallback.src || '', + photographer: fallback.photographer || 'Unknown', + photographer_url: fallback.photographer_url || '', + }; + } + }), + ); + + res.json(formattedImages); +}); + +module.exports = router; diff --git a/backend/src/routes/plans.js b/backend/src/routes/plans.js new file mode 100644 index 0000000..83293a8 --- /dev/null +++ b/backend/src/routes/plans.js @@ -0,0 +1,458 @@ +const express = require('express'); + +const PlansService = require('../services/plans'); +const PlansDBApi = require('../db/api/plans'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('plans')); + +/** + * @swagger + * components: + * schemas: + * Plans: + * type: object + * properties: + + * code: + * type: string + * default: code + * name: + * type: string + * default: name + * limits: + * type: string + * default: limits + + * price_mad: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Plans + * description: The Plans managing API + */ + +/** + * @swagger + * /api/plans: + * post: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Plans" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Plans" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PlansService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Plans" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Plans" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await PlansService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/plans/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Plans" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Plans" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await PlansService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/plans/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Plans" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await PlansService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/plans/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Plans" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await PlansService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/plans: + * get: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Get all plans + * description: Get all plans + * responses: + * 200: + * description: Plans list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Plans" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await PlansDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'code', 'name', 'limits', 'price_mad']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/plans/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Count all plans + * description: Count all plans + * responses: + * 200: + * description: Plans count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Plans" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await PlansDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/plans/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Find all plans that match search criteria + * description: Find all plans that match search criteria + * responses: + * 200: + * description: Plans list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Plans" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await PlansDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/plans/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Plans] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Plans" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await PlansDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/reservations.js b/backend/src/routes/reservations.js new file mode 100644 index 0000000..75c56cd --- /dev/null +++ b/backend/src/routes/reservations.js @@ -0,0 +1,509 @@ +const express = require('express'); + +const ReservationsService = require('../services/reservations'); +const ReservationsDBApi = require('../db/api/reservations'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('reservations')); + +/** + * @swagger + * components: + * schemas: + * Reservations: + * type: object + * properties: + + * ref: + * type: string + * default: ref + * pickup_address: + * type: string + * default: pickup_address + * return_address: + * type: string + * default: return_address + * options: + * type: string + * default: options + * notes: + * type: string + * default: notes + + * days: + * type: integer + * format: int64 + + * price_per_day: + * type: integer + * format: int64 + * total_price: + * type: integer + * format: int64 + * paid_amount: + * type: integer + * format: int64 + * balance_amount: + * type: integer + * format: int64 + * guarantee_amount: + * type: integer + * format: int64 + + * + * + * + * + */ + +/** + * @swagger + * tags: + * name: Reservations + * description: The Reservations managing API + */ + +/** + * @swagger + * /api/reservations: + * post: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Reservations" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Reservations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ReservationsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Reservations" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Reservations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ReservationsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/reservations/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Reservations" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Reservations" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await ReservationsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/reservations/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Reservations" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await ReservationsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/reservations/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Reservations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await ReservationsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/reservations: + * get: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Get all reservations + * description: Get all reservations + * responses: + * 200: + * description: Reservations list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Reservations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ReservationsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'ref', + 'pickup_address', + 'return_address', + 'options', + 'notes', + 'days', + 'price_per_day', + 'total_price', + 'paid_amount', + 'balance_amount', + 'guarantee_amount', + 'start_at', + 'end_at', + 'departure_validated_at', + 'return_validated_at', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/reservations/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Count all reservations + * description: Count all reservations + * responses: + * 200: + * description: Reservations count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Reservations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await ReservationsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/reservations/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Find all reservations that match search criteria + * description: Find all reservations that match search criteria + * responses: + * 200: + * description: Reservations list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Reservations" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await ReservationsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/reservations/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Reservations] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Reservations" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await ReservationsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.js new file mode 100644 index 0000000..b3999e9 --- /dev/null +++ b/backend/src/routes/roles.js @@ -0,0 +1,444 @@ +const express = require('express'); + +const RolesService = require('../services/roles'); +const RolesDBApi = require('../db/api/roles'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('roles')); + +/** + * @swagger + * components: + * schemas: + * Roles: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Roles + * description: The Roles managing API + */ + +/** + * @swagger + * /api/roles: + * post: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Roles" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await RolesService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await RolesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Roles" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await RolesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await RolesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await RolesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Get all roles + * description: Get all roles + * responses: + * 200: + * description: Roles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await RolesDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/roles/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Count all roles + * description: Count all roles + * responses: + * 200: + * description: Roles count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await RolesDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/roles/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Find all roles that match search criteria + * description: Find all roles that match search criteria + * responses: + * 200: + * description: Roles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Roles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const payload = await RolesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/roles/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Roles] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Roles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await RolesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js new file mode 100644 index 0000000..756c981 --- /dev/null +++ b/backend/src/routes/search.js @@ -0,0 +1,60 @@ +const express = require('express'); +const SearchService = require('../services/search'); + +const config = require('../config'); + +const router = express.Router(); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); +router.use(checkCrudPermissions('search')); + +/** + * @swagger + * path: + * /api/search: + * post: + * summary: Search + * description: Search results across multiple tables + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * searchQuery: + * type: string + * required: + * - searchQuery + * responses: + * 200: + * description: Successful request + * 400: + * description: Invalid request + * 500: + * description: Internal server error + */ + +router.post('/', async (req, res) => { + const { searchQuery, organizationId } = req.body; + + const globalAccess = req.currentUser.app_role.globalAccess; + + if (!searchQuery) { + return res.status(400).json({ error: 'Please enter a search query' }); + } + + try { + const foundMatches = await SearchService.search( + searchQuery, + req.currentUser, + organizationId, + globalAccess, + ); + res.json(foundMatches); + } catch (error) { + console.error('Internal Server Error', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/subscriptions.js b/backend/src/routes/subscriptions.js new file mode 100644 index 0000000..6536887 --- /dev/null +++ b/backend/src/routes/subscriptions.js @@ -0,0 +1,468 @@ +const express = require('express'); + +const SubscriptionsService = require('../services/subscriptions'); +const SubscriptionsDBApi = require('../db/api/subscriptions'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('subscriptions')); + +/** + * @swagger + * components: + * schemas: + * Subscriptions: + * type: object + * properties: + + * provider_customer_id: + * type: string + * default: provider_customer_id + * provider_subscription_id: + * type: string + * default: provider_subscription_id + + * + * + */ + +/** + * @swagger + * tags: + * name: Subscriptions + * description: The Subscriptions managing API + */ + +/** + * @swagger + * /api/subscriptions: + * post: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Subscriptions" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Subscriptions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SubscriptionsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Subscriptions" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Subscriptions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SubscriptionsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/subscriptions/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Subscriptions" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Subscriptions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await SubscriptionsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/subscriptions/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Subscriptions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await SubscriptionsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/subscriptions/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Subscriptions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await SubscriptionsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/subscriptions: + * get: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Get all subscriptions + * description: Get all subscriptions + * responses: + * 200: + * description: Subscriptions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Subscriptions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await SubscriptionsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'provider_customer_id', + 'provider_subscription_id', + + 'current_period_start', + 'current_period_end', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/subscriptions/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Count all subscriptions + * description: Count all subscriptions + * responses: + * 200: + * description: Subscriptions count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Subscriptions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await SubscriptionsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/subscriptions/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Find all subscriptions that match search criteria + * description: Find all subscriptions that match search criteria + * responses: + * 200: + * description: Subscriptions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Subscriptions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await SubscriptionsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/subscriptions/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Subscriptions] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Subscriptions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await SubscriptionsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/tenants.js b/backend/src/routes/tenants.js new file mode 100644 index 0000000..2bcbbdc --- /dev/null +++ b/backend/src/routes/tenants.js @@ -0,0 +1,470 @@ +const express = require('express'); + +const TenantsService = require('../services/tenants'); +const TenantsDBApi = require('../db/api/tenants'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('tenants')); + +/** + * @swagger + * components: + * schemas: + * Tenants: + * type: object + * properties: + + * name: + * type: string + * default: name + * subdomain: + * type: string + * default: subdomain + * billing_email: + * type: string + * default: billing_email + * billing_phone: + * type: string + * default: billing_phone + + * + */ + +/** + * @swagger + * tags: + * name: Tenants + * description: The Tenants managing API + */ + +/** + * @swagger + * /api/tenants: + * post: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Tenants" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tenants" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TenantsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Tenants" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tenants" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TenantsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tenants/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Tenants" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tenants" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await TenantsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tenants/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tenants" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await TenantsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tenants/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tenants" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await TenantsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tenants: + * get: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Get all tenants + * description: Get all tenants + * responses: + * 200: + * description: Tenants list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tenants" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await TenantsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'name', + 'subdomain', + 'billing_email', + 'billing_phone', + + 'trial_ends_at', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/tenants/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Count all tenants + * description: Count all tenants + * responses: + * 200: + * description: Tenants count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tenants" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await TenantsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tenants/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Find all tenants that match search criteria + * description: Find all tenants that match search criteria + * responses: + * 200: + * description: Tenants list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tenants" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await TenantsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/tenants/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Tenants] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tenants" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await TenantsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..fa9083e --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,458 @@ +const express = require('express'); + +const UsersService = require('../services/users'); +const UsersDBApi = require('../db/api/users'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('users')); + +/** + * @swagger + * components: + * schemas: + * Users: + * type: object + * properties: + + * firstName: + * type: string + * default: firstName + * lastName: + * type: string + * default: lastName + * phoneNumber: + * type: string + * default: phoneNumber + * email: + * type: string + * default: email + + */ + +/** + * @swagger + * tags: + * name: Users + * description: The Users managing API + */ + +/** + * @swagger + * /api/users: + * post: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Users" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await UsersService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Users" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await UsersService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Users" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await UsersService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await UsersService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await UsersService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Get all users + * description: Get all users + * responses: + * 200: + * description: Users list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await UsersDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'firstName', 'lastName', 'phoneNumber', 'email']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/users/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Count all users + * description: Count all users + * responses: + * 200: + * description: Users count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await UsersDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/users/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Find all users that match search criteria + * description: Find all users that match search criteria + * responses: + * 200: + * description: Users list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Users" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await UsersDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/users/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Users" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await UsersDBApi.findBy({ id: req.params.id }); + + delete payload.password; + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/vehicle_conditions.js b/backend/src/routes/vehicle_conditions.js new file mode 100644 index 0000000..338c1dc --- /dev/null +++ b/backend/src/routes/vehicle_conditions.js @@ -0,0 +1,462 @@ +const express = require('express'); + +const Vehicle_conditionsService = require('../services/vehicle_conditions'); +const Vehicle_conditionsDBApi = require('../db/api/vehicle_conditions'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('vehicle_conditions')); + +/** + * @swagger + * components: + * schemas: + * Vehicle_conditions: + * type: object + * properties: + + * before: + * type: string + * default: before + * after: + * type: string + * default: after + + */ + +/** + * @swagger + * tags: + * name: Vehicle_conditions + * description: The Vehicle_conditions managing API + */ + +/** + * @swagger + * /api/vehicle_conditions: + * post: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Vehicle_conditions" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicle_conditions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Vehicle_conditionsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Vehicle_conditions" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicle_conditions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Vehicle_conditionsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicle_conditions/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Vehicle_conditions" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicle_conditions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await Vehicle_conditionsService.update( + req.body.data, + req.body.id, + req.currentUser, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicle_conditions/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicle_conditions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await Vehicle_conditionsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicle_conditions/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicle_conditions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await Vehicle_conditionsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicle_conditions: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Get all vehicle_conditions + * description: Get all vehicle_conditions + * responses: + * 200: + * description: Vehicle_conditions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Vehicle_conditions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await Vehicle_conditionsDBApi.findAll( + req.query, + globalAccess, + { currentUser }, + ); + if (filetype && filetype === 'csv') { + const fields = ['id', 'before', 'after']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/vehicle_conditions/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Count all vehicle_conditions + * description: Count all vehicle_conditions + * responses: + * 200: + * description: Vehicle_conditions count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Vehicle_conditions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await Vehicle_conditionsDBApi.findAll( + req.query, + globalAccess, + { countOnly: true, currentUser }, + ); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicle_conditions/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Find all vehicle_conditions that match search criteria + * description: Find all vehicle_conditions that match search criteria + * responses: + * 200: + * description: Vehicle_conditions list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Vehicle_conditions" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await Vehicle_conditionsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/vehicle_conditions/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicle_conditions] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicle_conditions" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await Vehicle_conditionsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/vehicles.js b/backend/src/routes/vehicles.js new file mode 100644 index 0000000..397e24b --- /dev/null +++ b/backend/src/routes/vehicles.js @@ -0,0 +1,491 @@ +const express = require('express'); + +const VehiclesService = require('../services/vehicles'); +const VehiclesDBApi = require('../db/api/vehicles'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('vehicles')); + +/** + * @swagger + * components: + * schemas: + * Vehicles: + * type: object + * properties: + + * plate_number: + * type: string + * default: plate_number + * brand: + * type: string + * default: brand + * model: + * type: string + * default: model + * color: + * type: string + * default: color + * photos: + * type: string + * default: photos + + * year: + * type: integer + * format: int64 + * mileage: + * type: integer + * format: int64 + + * daily_price: + * type: integer + * format: int64 + + * + * + * + * + */ + +/** + * @swagger + * tags: + * name: Vehicles + * description: The Vehicles managing API + */ + +/** + * @swagger + * /api/vehicles: + * post: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Vehicles" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await VehiclesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Vehicles" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await VehiclesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicles/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Vehicles" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await VehiclesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicles/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await VehiclesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicles/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await VehiclesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicles: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Get all vehicles + * description: Get all vehicles + * responses: + * 200: + * description: Vehicles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Vehicles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await VehiclesDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'plate_number', + 'brand', + 'model', + 'color', + 'photos', + 'year', + 'mileage', + 'daily_price', + 'insurance_expiry', + 'tech_visit_expiry', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/vehicles/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Count all vehicles + * description: Count all vehicles + * responses: + * 200: + * description: Vehicles count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Vehicles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await VehiclesDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/vehicles/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Find all vehicles that match search criteria + * description: Find all vehicles that match search criteria + * responses: + * 200: + * description: Vehicles list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Vehicles" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await VehiclesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/vehicles/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Vehicles] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Vehicles" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await VehiclesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/agences.js b/backend/src/services/agences.js new file mode 100644 index 0000000..413c0c3 --- /dev/null +++ b/backend/src/services/agences.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const AgencesDBApi = require('../db/api/agences'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class AgencesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await AgencesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await AgencesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let agences = await AgencesDBApi.findBy({ id }, { transaction }); + + if (!agences) { + throw new ValidationError('agencesNotFound'); + } + + const updatedAgences = await AgencesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedAgences; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await AgencesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await AgencesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js new file mode 100644 index 0000000..881244e --- /dev/null +++ b/backend/src/services/auth.js @@ -0,0 +1,228 @@ +const UsersDBApi = require('../db/api/users'); +const ValidationError = require('./notifications/errors/validation'); +const ForbiddenError = require('./notifications/errors/forbidden'); +const bcrypt = require('bcrypt'); +const EmailAddressVerificationEmail = require('./email/list/addressVerification'); +const InvitationEmail = require('./email/list/invitation'); +const PasswordResetEmail = require('./email/list/passwordReset'); +const EmailSender = require('./email'); +const config = require('../config'); +const helpers = require('../helpers'); + +class Auth { + static async signup(email, password, organizationId, options = {}, host) { + const user = await UsersDBApi.findBy({ email }); + + const hashedPassword = await bcrypt.hash( + password, + config.bcrypt.saltRounds, + ); + + if (user) { + if (user.authenticationUid) { + throw new ValidationError('auth.emailAlreadyInUse'); + } + + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + await UsersDBApi.updatePassword(user.id, hashedPassword, options); + + if (EmailSender.isConfigured) { + await this.sendEmailAddressVerificationEmail(user.email, host); + } + + const data = { + user: { + id: user.id, + email: user.email, + }, + }; + + return helpers.jwtSign(data); + } + + const newUser = await UsersDBApi.createFromAuth( + { + firstName: email.split('@')[0], + password: hashedPassword, + email: email, + + organizationId: organizationId, + }, + options, + ); + + if (EmailSender.isConfigured) { + await this.sendEmailAddressVerificationEmail(newUser.email, host); + } + + const data = { + user: { + id: newUser.id, + email: newUser.email, + }, + }; + + return helpers.jwtSign(data); + } + + static async signin(email, password, options = {}) { + const user = await UsersDBApi.findBy({ email }); + + if (!user) { + throw new ValidationError('auth.userNotFound'); + } + + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + if (!user.password) { + throw new ValidationError('auth.wrongPassword'); + } + + if (!EmailSender.isConfigured) { + user.emailVerified = true; + } + + if (!user.emailVerified) { + throw new ValidationError('auth.userNotVerified'); + } + + const passwordsMatch = await bcrypt.compare(password, user.password); + + if (!passwordsMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + const data = { + user: { + id: user.id, + email: user.email, + }, + }; + + return helpers.jwtSign(data); + } + + static async sendEmailAddressVerificationEmail(email, host) { + let link; + try { + const token = await UsersDBApi.generateEmailVerificationToken(email); + link = `${host}/verify-email?token=${token}`; + } catch (error) { + console.error(error); + throw new ValidationError('auth.emailAddressVerificationEmail.error'); + } + + const emailAddressVerificationEmail = new EmailAddressVerificationEmail( + email, + link, + ); + + return new EmailSender(emailAddressVerificationEmail).send(); + } + + static async sendPasswordResetEmail(email, type = 'register', host) { + let link; + + try { + const token = await UsersDBApi.generatePasswordResetToken(email); + link = `${host}/password-reset?token=${token}`; + } catch (error) { + console.error(error); + throw new ValidationError('auth.passwordReset.error'); + } + + let passwordResetEmail; + if (type === 'register') { + passwordResetEmail = new PasswordResetEmail(email, link); + } + if (type === 'invitation') { + passwordResetEmail = new InvitationEmail(email, link); + } + + return new EmailSender(passwordResetEmail).send(); + } + + static async verifyEmail(token, options = {}) { + const user = await UsersDBApi.findByEmailVerificationToken(token, options); + + if (!user) { + throw new ValidationError( + 'auth.emailAddressVerificationEmail.invalidToken', + ); + } + + return UsersDBApi.markEmailVerified(user.id, options); + } + + static async passwordUpdate(currentPassword, newPassword, options) { + const currentUser = options.currentUser || null; + if (!currentUser) { + throw new ForbiddenError(); + } + + const currentPasswordMatch = await bcrypt.compare( + currentPassword, + currentUser.password, + ); + + if (!currentPasswordMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + const newPasswordMatch = await bcrypt.compare( + newPassword, + currentUser.password, + ); + + if (newPasswordMatch) { + throw new ValidationError('auth.passwordUpdate.samePassword'); + } + + const hashedPassword = await bcrypt.hash( + newPassword, + config.bcrypt.saltRounds, + ); + + return UsersDBApi.updatePassword(currentUser.id, hashedPassword, options); + } + + static async passwordReset(token, password, options = {}) { + const user = await UsersDBApi.findByPasswordResetToken(token, options); + + if (!user) { + throw new ValidationError('auth.passwordReset.invalidToken'); + } + + const hashedPassword = await bcrypt.hash( + password, + config.bcrypt.saltRounds, + ); + + return UsersDBApi.updatePassword(user.id, hashedPassword, options); + } + + static async updateProfile(data, currentUser) { + let transaction = await db.sequelize.transaction(); + + try { + await UsersDBApi.findBy({ id: currentUser.id }, { transaction }); + + await UsersDBApi.update(currentUser.id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +module.exports = Auth; diff --git a/backend/src/services/claims.js b/backend/src/services/claims.js new file mode 100644 index 0000000..c94c4a4 --- /dev/null +++ b/backend/src/services/claims.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const ClaimsDBApi = require('../db/api/claims'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class ClaimsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ClaimsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await ClaimsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let claims = await ClaimsDBApi.findBy({ id }, { transaction }); + + if (!claims) { + throw new ValidationError('claimsNotFound'); + } + + const updatedClaims = await ClaimsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedClaims; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ClaimsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ClaimsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/clients.js b/backend/src/services/clients.js new file mode 100644 index 0000000..3b7e84d --- /dev/null +++ b/backend/src/services/clients.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const ClientsDBApi = require('../db/api/clients'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class ClientsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ClientsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await ClientsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let clients = await ClientsDBApi.findBy({ id }, { transaction }); + + if (!clients) { + throw new ValidationError('clientsNotFound'); + } + + const updatedClients = await ClientsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedClients; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ClientsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ClientsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/company_profiles.js b/backend/src/services/company_profiles.js new file mode 100644 index 0000000..76f7f2e --- /dev/null +++ b/backend/src/services/company_profiles.js @@ -0,0 +1,121 @@ +const db = require('../db/models'); +const Company_profilesDBApi = require('../db/api/company_profiles'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class Company_profilesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await Company_profilesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await Company_profilesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let company_profiles = await Company_profilesDBApi.findBy( + { id }, + { transaction }, + ); + + if (!company_profiles) { + throw new ValidationError('company_profilesNotFound'); + } + + const updatedCompany_profiles = await Company_profilesDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedCompany_profiles; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Company_profilesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Company_profilesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/drivers.js b/backend/src/services/drivers.js new file mode 100644 index 0000000..6e91be0 --- /dev/null +++ b/backend/src/services/drivers.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const DriversDBApi = require('../db/api/drivers'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class DriversService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await DriversDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await DriversDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let drivers = await DriversDBApi.findBy({ id }, { transaction }); + + if (!drivers) { + throw new ValidationError('driversNotFound'); + } + + const updatedDrivers = await DriversDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedDrivers; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await DriversDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await DriversDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html b/backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html new file mode 100644 index 0000000..95d8b3f --- /dev/null +++ b/backend/src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html @@ -0,0 +1,52 @@ + + + + + + + + + diff --git a/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html b/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html new file mode 100644 index 0000000..e685483 --- /dev/null +++ b/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html @@ -0,0 +1,56 @@ + + + + + + + + + diff --git a/backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html b/backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html new file mode 100644 index 0000000..c77f215 --- /dev/null +++ b/backend/src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/backend/src/services/email/index.js b/backend/src/services/email/index.js new file mode 100644 index 0000000..fa7f3c7 --- /dev/null +++ b/backend/src/services/email/index.js @@ -0,0 +1,41 @@ +const config = require('../../config'); +const assert = require('assert'); +const nodemailer = require('nodemailer'); + +module.exports = class EmailSender { + constructor(email) { + this.email = email; + } + + async send() { + assert(this.email, 'email is required'); + assert(this.email.to, 'email.to is required'); + assert(this.email.subject, 'email.subject is required'); + assert(this.email.html, 'email.html is required'); + + const htmlContent = await this.email.html(); + + const transporter = nodemailer.createTransport(this.transportConfig); + + const mailOptions = { + from: this.from, + to: this.email.to, + subject: this.email.subject, + html: htmlContent, + }; + + return transporter.sendMail(mailOptions); + } + + static get isConfigured() { + return !!config.email?.auth?.pass && !!config.email?.auth?.user; + } + + get transportConfig() { + return config.email; + } + + get from() { + return config.email.from; + } +}; diff --git a/backend/src/services/email/list/addressVerification.js b/backend/src/services/email/list/addressVerification.js new file mode 100644 index 0000000..89be6d3 --- /dev/null +++ b/backend/src/services/email/list/addressVerification.js @@ -0,0 +1,41 @@ +const { getNotification } = require('../../notifications/helpers'); +const fs = require('fs').promises; +const path = require('path'); + +module.exports = class EmailAddressVerificationEmail { + constructor(to, link) { + this.to = to; + this.link = link; + } + + get subject() { + return getNotification( + 'emails.emailAddressVerification.subject', + getNotification('app.title'), + ); + } + + async html() { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/addressVerification/emailAddressVerification.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const signupUrl = this.link; + + let html = template + .replace(/{appTitle}/g, appTitle) + .replace(/{signupUrl}/g, signupUrl) + .replace(/{to}/g, this.to); + + return html; + } catch (error) { + console.error('Error generating invitation email HTML:', error); + throw error; + } + } +}; diff --git a/backend/src/services/email/list/invitation.js b/backend/src/services/email/list/invitation.js new file mode 100644 index 0000000..d2afc1e --- /dev/null +++ b/backend/src/services/email/list/invitation.js @@ -0,0 +1,41 @@ +const fs = require('fs').promises; +const path = require('path'); +const { getNotification } = require('../../notifications/helpers'); + +module.exports = class InvitationEmail { + constructor(to, host) { + this.to = to; + this.host = host; + } + + get subject() { + return getNotification( + 'emails.invitation.subject', + getNotification('app.title'), + ); + } + + async html() { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/invitation/invitationTemplate.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const signupUrl = `${this.host}&invitation=true`; + + let html = template + .replace(/{appTitle}/g, appTitle) + .replace(/{signupUrl}/g, signupUrl) + .replace(/{to}/g, this.to); + + return html; + } catch (error) { + console.error('Error generating invitation email HTML:', error); + throw error; + } + } +}; diff --git a/backend/src/services/email/list/passwordReset.js b/backend/src/services/email/list/passwordReset.js new file mode 100644 index 0000000..68ba353 --- /dev/null +++ b/backend/src/services/email/list/passwordReset.js @@ -0,0 +1,42 @@ +const { getNotification } = require('../../notifications/helpers'); +const path = require('path'); +const { promises: fs } = require('fs'); + +module.exports = class PasswordResetEmail { + constructor(to, link) { + this.to = to; + this.link = link; + } + + get subject() { + return getNotification( + 'emails.passwordReset.subject', + getNotification('app.title'), + ); + } + + async html() { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/passwordReset/passwordResetEmail.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const resetUrl = this.link; + const accountName = this.to; + + let html = template + .replace(/{appTitle}/g, appTitle) + .replace(/{resetUrl}/g, resetUrl) + .replace(/{accountName}/g, accountName); + + return html; + } catch (error) { + console.error('Error generating invitation email HTML:', error); + throw error; + } + } +}; diff --git a/backend/src/services/expenses.js b/backend/src/services/expenses.js new file mode 100644 index 0000000..0d5dc33 --- /dev/null +++ b/backend/src/services/expenses.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const ExpensesDBApi = require('../db/api/expenses'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class ExpensesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ExpensesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await ExpensesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let expenses = await ExpensesDBApi.findBy({ id }, { transaction }); + + if (!expenses) { + throw new ValidationError('expensesNotFound'); + } + + const updatedExpenses = await ExpensesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedExpenses; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ExpensesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ExpensesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/file.js b/backend/src/services/file.js new file mode 100644 index 0000000..cb08164 --- /dev/null +++ b/backend/src/services/file.js @@ -0,0 +1,202 @@ +const formidable = require('formidable'); +const fs = require('fs'); +const config = require('../config'); +const path = require('path'); +const { format } = require('util'); + +const ensureDirectoryExistence = (filePath) => { + const dirname = path.dirname(filePath); + + if (fs.existsSync(dirname)) { + return true; + } + + ensureDirectoryExistence(dirname); + fs.mkdirSync(dirname); +}; + +const uploadLocal = ( + folder, + validations = { + entity: null, + maxFileSize: null, + folderIncludesAuthenticationUid: false, + }, +) => { + return (req, res) => { + if (!req.currentUser) { + res.sendStatus(403); + return; + } + + if (validations.entity) { + res.sendStatus(403); + return; + } + + if (validations.folderIncludesAuthenticationUid) { + folder = folder.replace(':userId', req.currentUser.authenticationUid); + if ( + !req.currentUser.authenticationUid || + !folder.includes(req.currentUser.authenticationUid) + ) { + res.sendStatus(403); + return; + } + } + + const form = new formidable.IncomingForm(); + form.uploadDir = config.uploadDir; + + if (validations && validations.maxFileSize) { + form.maxFileSize = validations.maxFileSize; + } + + form.parse(req, function (err, fields, files) { + const filename = String(fields.filename); + const fileTempUrl = files.file.path; + + if (!filename) { + fs.unlinkSync(fileTempUrl); + res.sendStatus(500); + return; + } + + const privateUrl = path.join(form.uploadDir, folder, filename); + ensureDirectoryExistence(privateUrl); + fs.renameSync(fileTempUrl, privateUrl); + res.sendStatus(200); + }); + + form.on('error', function (err) { + res.status(500).send(err); + }); + }; +}; + +const downloadLocal = async (req, res) => { + const privateUrl = req.query.privateUrl; + if (!privateUrl) { + return res.sendStatus(404); + } + res.download(path.join(config.uploadDir, privateUrl)); +}; + +const initGCloud = () => { + const processFile = require('../middlewares/upload'); + const { Storage } = require('@google-cloud/storage'); + + const crypto = require('crypto'); + const hash = config.gcloud.hash; + + const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n'); + + const storage = new Storage({ + projectId: process.env.GC_PROJECT_ID, + credentials: { + client_email: process.env.GC_CLIENT_EMAIL, + private_key: privateKey, + }, + }); + + const bucket = storage.bucket(config.gcloud.bucket); + return { hash, bucket, processFile }; +}; + +const uploadGCloud = async (folder, req, res) => { + try { + const { hash, bucket, processFile } = initGCloud(); + await processFile(req, res); + let buffer = await req.file.buffer; + let filename = await req.body.filename; + + if (!req.file) { + return res.status(400).send({ message: 'Please upload a file!' }); + } + + let path = `${hash}/${folder}/${filename}`; + let blob = bucket.file(path); + + console.log(path); + + const blobStream = blob.createWriteStream({ + resumable: false, + }); + + blobStream.on('error', (err) => { + console.log('Upload error'); + console.log(err.message); + res.status(500).send({ message: err.message }); + }); + + console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); + + blobStream.on('finish', async (data) => { + const publicUrl = format( + `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + ); + + res.status(200).send({ + message: 'Uploaded the file successfully: ' + path, + url: publicUrl, + }); + }); + + blobStream.end(buffer); + } catch (err) { + console.log(err); + + res.status(500).send({ + message: `Could not upload the file. ${err}`, + }); + } +}; + +const downloadGCloud = async (req, res) => { + try { + const { hash, bucket, processFile } = initGCloud(); + + const privateUrl = await req.query.privateUrl; + const filePath = `${hash}/${privateUrl}`; + const file = bucket.file(filePath); + const fileExists = await file.exists(); + + if (fileExists[0]) { + const stream = file.createReadStream(); + stream.pipe(res); + } else { + res.status(404).send({ + message: 'Could not download the file. ' + err, + }); + } + } catch (err) { + res.status(404).send({ + message: 'Could not download the file. ' + err, + }); + } +}; + +const deleteGCloud = async (privateUrl) => { + try { + const { hash, bucket, processFile } = initGCloud(); + const filePath = `${hash}/${privateUrl}`; + + const file = bucket.file(filePath); + const fileExists = await file.exists(); + + if (fileExists[0]) { + file.delete(); + } + } catch (err) { + console.log(`Cannot find the file ${privateUrl}`); + } +}; + +module.exports = { + initGCloud, + uploadLocal, + downloadLocal, + deleteGCloud, + uploadGCloud, + downloadGCloud, +}; diff --git a/backend/src/services/guarantees.js b/backend/src/services/guarantees.js new file mode 100644 index 0000000..79b02a7 --- /dev/null +++ b/backend/src/services/guarantees.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const GuaranteesDBApi = require('../db/api/guarantees'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class GuaranteesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await GuaranteesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await GuaranteesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let guarantees = await GuaranteesDBApi.findBy({ id }, { transaction }); + + if (!guarantees) { + throw new ValidationError('guaranteesNotFound'); + } + + const updatedGuarantees = await GuaranteesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedGuarantees; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await GuaranteesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await GuaranteesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/infractions.js b/backend/src/services/infractions.js new file mode 100644 index 0000000..24a9ad7 --- /dev/null +++ b/backend/src/services/infractions.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const InfractionsDBApi = require('../db/api/infractions'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class InfractionsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await InfractionsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await InfractionsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let infractions = await InfractionsDBApi.findBy({ id }, { transaction }); + + if (!infractions) { + throw new ValidationError('infractionsNotFound'); + } + + const updatedInfractions = await InfractionsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedInfractions; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await InfractionsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await InfractionsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/invoices_saas.js b/backend/src/services/invoices_saas.js new file mode 100644 index 0000000..d2add2d --- /dev/null +++ b/backend/src/services/invoices_saas.js @@ -0,0 +1,117 @@ +const db = require('../db/models'); +const Invoices_saasDBApi = require('../db/api/invoices_saas'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class Invoices_saasService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await Invoices_saasDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await Invoices_saasDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let invoices_saas = await Invoices_saasDBApi.findBy( + { id }, + { transaction }, + ); + + if (!invoices_saas) { + throw new ValidationError('invoices_saasNotFound'); + } + + const updatedInvoices_saas = await Invoices_saasDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedInvoices_saas; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Invoices_saasDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Invoices_saasDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/maintenance.js b/backend/src/services/maintenance.js new file mode 100644 index 0000000..f91ea06 --- /dev/null +++ b/backend/src/services/maintenance.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const MaintenanceDBApi = require('../db/api/maintenance'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class MaintenanceService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await MaintenanceDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await MaintenanceDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let maintenance = await MaintenanceDBApi.findBy({ id }, { transaction }); + + if (!maintenance) { + throw new ValidationError('maintenanceNotFound'); + } + + const updatedMaintenance = await MaintenanceDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedMaintenance; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await MaintenanceDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await MaintenanceDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/notifications/errors/forbidden.js b/backend/src/services/notifications/errors/forbidden.js new file mode 100644 index 0000000..192fa10 --- /dev/null +++ b/backend/src/services/notifications/errors/forbidden.js @@ -0,0 +1,16 @@ +const { getNotification, isNotification } = require('../helpers'); + +module.exports = class ForbiddenError extends Error { + constructor(messageCode) { + let message; + + if (messageCode && isNotification(messageCode)) { + message = getNotification(messageCode); + } + + message = message || getNotification('errors.forbidden.message'); + + super(message); + this.code = 403; + } +}; diff --git a/backend/src/services/notifications/errors/validation.js b/backend/src/services/notifications/errors/validation.js new file mode 100644 index 0000000..464550c --- /dev/null +++ b/backend/src/services/notifications/errors/validation.js @@ -0,0 +1,16 @@ +const { getNotification, isNotification } = require('../helpers'); + +module.exports = class ValidationError extends Error { + constructor(messageCode) { + let message; + + if (messageCode && isNotification(messageCode)) { + message = getNotification(messageCode); + } + + message = message || getNotification('errors.validation.message'); + + super(message); + this.code = 400; + } +}; diff --git a/backend/src/services/notifications/helpers.js b/backend/src/services/notifications/helpers.js new file mode 100644 index 0000000..1c3a60f --- /dev/null +++ b/backend/src/services/notifications/helpers.js @@ -0,0 +1,30 @@ +const _get = require('lodash/get'); +const errors = require('./list'); + +function format(message, args) { + if (!message) { + return null; + } + + return message.replace(/{(\d+)}/g, function (match, number) { + return typeof args[number] != 'undefined' ? args[number] : match; + }); +} + +const isNotification = (key) => { + const message = _get(errors, key); + return !!message; +}; + +const getNotification = (key, ...args) => { + const message = _get(errors, key); + + if (!message) { + return key; + } + + return format(message, args); +}; + +exports.getNotification = getNotification; +exports.isNotification = isNotification; diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js new file mode 100644 index 0000000..d5a8b4e --- /dev/null +++ b/backend/src/services/notifications/list.js @@ -0,0 +1,100 @@ +const errors = { + app: { + title: 'Car zone', + }, + + auth: { + userDisabled: 'Your account is disabled', + forbidden: 'Forbidden', + unauthorized: 'Unauthorized', + userNotFound: `Sorry, we don't recognize your credentials`, + wrongPassword: `Sorry, we don't recognize your credentials`, + weakPassword: 'This password is too weak', + emailAlreadyInUse: 'Email is already in use', + invalidEmail: 'Please provide a valid email', + passwordReset: { + invalidToken: 'Password reset link is invalid or has expired', + error: `Email not recognized`, + }, + passwordUpdate: { + samePassword: `You can't use the same password. Please create new password`, + }, + userNotVerified: `Sorry, your email has not been verified yet`, + emailAddressVerificationEmail: { + invalidToken: 'Email verification link is invalid or has expired', + error: `Email not recognized`, + }, + }, + + iam: { + errors: { + userAlreadyExists: 'User with this email already exists', + userNotFound: 'User not found', + disablingHimself: `You can't disable yourself`, + revokingOwnPermission: `You can't revoke your own owner permission`, + deletingHimself: `You can't delete yourself`, + emailRequired: 'Email is required', + }, + }, + + importer: { + errors: { + invalidFileEmpty: 'The file is empty', + invalidFileExcel: 'Only excel (.xlsx) files are allowed', + invalidFileUpload: + 'Invalid file. Make sure you are using the last version of the template.', + importHashRequired: 'Import hash is required', + importHashExistent: 'Data has already been imported', + userEmailMissing: 'Some items in the CSV do not have an email', + }, + }, + + errors: { + forbidden: { + message: 'Forbidden', + }, + validation: { + message: 'An error occurred', + }, + searchQueryRequired: { + message: 'Search query is required', + }, + }, + + emails: { + invitation: { + subject: `You've been invited to {0}`, + body: ` +

Hello,

+

You've been invited to {0} set password for your {1} account.

+

{2}

+

Thanks,

+

Your {0} team

+ `, + }, + emailAddressVerification: { + subject: `Verify your email for {0}`, + body: ` +

Hello,

+

Follow this link to verify your email address.

+

{0}

+

If you didn't ask to verify this address, you can ignore this email.

+

Thanks,

+

Your {1} team

+ `, + }, + passwordReset: { + subject: `Reset your password for {0}`, + body: ` +

Hello,

+

Follow this link to reset your {0} password for your {1} account.

+

{2}

+

If you didn't ask to reset your password, you can ignore this email.

+

Thanks,

+

Your {0} team

+ `, + }, + }, +}; + +module.exports = errors; diff --git a/backend/src/services/openai.js b/backend/src/services/openai.js new file mode 100644 index 0000000..152299d --- /dev/null +++ b/backend/src/services/openai.js @@ -0,0 +1,68 @@ +const axios = require('axios'); +const { v4: uuid } = require('uuid'); +const RoleService = require('./roles'); +const config = require('../config'); + +module.exports = class OpenAiService { + static async getWidget(payload, userId, roleId) { + const response = await axios.post( + `${config.flHost}/${config.project_uuid}/project_customization_widgets.json`, + payload, + ); + + if (response.status >= 200 && response.status < 300) { + const { widget_id } = await response.data; + await RoleService.addRoleInfo(roleId, userId, 'widgets', widget_id); + return widget_id; + } else { + console.error('=======error=======', response.data); + return { value: null, error: response.data }; + } + } + + static async askGpt(prompt) { + if (!config.gpt_key) { + return { + success: false, + error: 'API key is required', + }; + } + try { + const response = await axios.post( + 'https://api.openai.com/v1/chat/completions', + { + model: 'gpt-4o', + messages: [{ role: 'user', content: prompt }], + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.gpt_key}`, + }, + }, + ); + + if (response.status >= 200 && response.status < 300) { + return { + success: true, + data: response.data.choices[0].message.content, + }; + } else { + console.error('Error asking question to ChatGPT:', response.data); + return { + success: false, + error: response.data, + }; + } + } catch (error) { + console.error( + 'Error asking question to ChatGPT:', + error.response?.data || error.message, + ); + return { + success: false, + error: error.response?.data || error.message, + }; + } + } +}; diff --git a/backend/src/services/payments.js b/backend/src/services/payments.js new file mode 100644 index 0000000..4e72e73 --- /dev/null +++ b/backend/src/services/payments.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const PaymentsDBApi = require('../db/api/payments'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class PaymentsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await PaymentsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await PaymentsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let payments = await PaymentsDBApi.findBy({ id }, { transaction }); + + if (!payments) { + throw new ValidationError('paymentsNotFound'); + } + + const updatedPayments = await PaymentsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedPayments; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PaymentsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PaymentsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/permissions.js b/backend/src/services/permissions.js new file mode 100644 index 0000000..ad78c26 --- /dev/null +++ b/backend/src/services/permissions.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const PermissionsDBApi = require('../db/api/permissions'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class PermissionsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await PermissionsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await PermissionsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let permissions = await PermissionsDBApi.findBy({ id }, { transaction }); + + if (!permissions) { + throw new ValidationError('permissionsNotFound'); + } + + const updatedPermissions = await PermissionsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedPermissions; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PermissionsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PermissionsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/plans.js b/backend/src/services/plans.js new file mode 100644 index 0000000..31b1035 --- /dev/null +++ b/backend/src/services/plans.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const PlansDBApi = require('../db/api/plans'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class PlansService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await PlansDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await PlansDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let plans = await PlansDBApi.findBy({ id }, { transaction }); + + if (!plans) { + throw new ValidationError('plansNotFound'); + } + + const updatedPlans = await PlansDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedPlans; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PlansDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await PlansDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/reservations.js b/backend/src/services/reservations.js new file mode 100644 index 0000000..6eaf2c9 --- /dev/null +++ b/backend/src/services/reservations.js @@ -0,0 +1,117 @@ +const db = require('../db/models'); +const ReservationsDBApi = require('../db/api/reservations'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class ReservationsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ReservationsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await ReservationsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let reservations = await ReservationsDBApi.findBy( + { id }, + { transaction }, + ); + + if (!reservations) { + throw new ValidationError('reservationsNotFound'); + } + + const updatedReservations = await ReservationsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedReservations; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ReservationsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ReservationsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js new file mode 100644 index 0000000..13afb2c --- /dev/null +++ b/backend/src/services/roles.js @@ -0,0 +1,389 @@ +const db = require('../db/models'); +const RolesDBApi = require('../db/api/roles'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +function buildWidgetResult(widget, queryResult, queryString) { + if (queryResult[0] && queryResult[0].length) { + const key = Object.keys(queryResult[0][0])[0]; + const value = + widget.widget_type === 'scalar' ? queryResult[0][0][key] : queryResult[0]; + const widgetData = JSON.parse(widget.data); + return { ...widget, ...widgetData, value, query: queryString }; + } else { + return { ...widget, value: [], query: queryString }; + } +} + +async function executeQuery(queryString, currentUser) { + try { + return await db.sequelize.query(queryString, { + replacements: { organizationId: currentUser.organizationId }, + }); + } catch (e) { + console.log(e); + return []; + } +} + +function insertWhereConditions(queryString, whereConditions) { + if (!whereConditions) return queryString; + + const whereIndex = queryString.toLowerCase().indexOf('where'); + const groupByIndex = queryString.toLowerCase().indexOf('group by'); + const insertIndex = + whereIndex === -1 + ? groupByIndex !== -1 + ? groupByIndex + : queryString.length + : whereIndex + 5; + + const prefix = queryString.substring(0, insertIndex); + const suffix = queryString.substring(insertIndex); + const conditionString = + whereIndex === -1 + ? ` WHERE ${whereConditions} ` + : ` ${whereConditions} AND `; + + return `${prefix}${conditionString}${suffix}`; +} + +function constructWhereConditions(mainTable, currentUser, replacements) { + const { + organizationId, + app_role: { globalAccess }, + } = currentUser; + const tablesWithoutOrgId = ['permissions', 'roles']; + let whereConditions = ''; + + if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) { + whereConditions += `"${mainTable}"."organizationId" = :organizationId`; + replacements.organizationId = organizationId; + } + + whereConditions += whereConditions ? ' AND ' : ''; + whereConditions += `"${mainTable}"."deletedAt" IS NULL`; + + return whereConditions; +} + +function extractTableName(queryString) { + const tableNameRegex = /FROM\s+("?)([^"\s]+)\1\s*/i; + const match = tableNameRegex.exec(queryString); + return match ? match[2] : null; +} + +function buildQueryString(widget, currentUser) { + let queryString = widget?.query || ''; + const tableName = extractTableName(queryString); + const mainTable = JSON.parse(widget?.data)?.main_table || tableName; + const replacements = {}; + const whereConditions = constructWhereConditions( + mainTable, + currentUser, + replacements, + ); + queryString = insertWhereConditions(queryString, whereConditions); + console.log(queryString, 'queryString'); + return queryString; +} + +async function constructWidgetsResults(widgets, currentUser) { + const widgetsResults = []; + for (const widget of widgets) { + if (!widget) continue; + const queryString = buildQueryString(widget, currentUser); + const queryResult = await executeQuery(queryString, currentUser); + widgetsResults.push(buildWidgetResult(widget, queryResult, queryString)); + } + return widgetsResults; +} + +async function fetchWidgetsData(widgets) { + const widgetPromises = (widgets || []).map((widgetId) => + axios.get( + `${config.flHost}/${config.project_uuid}/project_customization_widgets/${widgetId}.json`, + ), + ); + const widgetResults = widgetPromises + ? await Promise.allSettled(widgetPromises) + : []; + return widgetResults + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value.data); +} + +async function processWidgets(widgets, currentUser) { + const widgetData = await fetchWidgetsData(widgets); + return constructWidgetsResults(widgetData, currentUser); +} + +function parseCustomization(role) { + try { + return JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + return {}; + } +} + +async function findRole(roleId, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const role = roleId + ? await RolesDBApi.findBy({ id: roleId }, { transaction }) + : await RolesDBApi.findBy({ name: 'User' }, { transaction }); + await transaction.commit(); + return role; + } catch (error) { + await transaction.rollback(); + throw error; + } +} + +module.exports = class RolesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await RolesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await RolesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let roles = await RolesDBApi.findBy({ id }, { transaction }); + + if (!roles) { + throw new ValidationError('rolesNotFound'); + } + + const updatedRoles = await RolesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedRoles; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await RolesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await RolesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async addRoleInfo(roleId, userId, key, widgetId, currentUser) { + const regexExpForUuid = + /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi; + const widgetIdIsUUID = regexExpForUuid.test(widgetId); + + const transaction = await db.sequelize.transaction(); + let role; + if (roleId) { + role = await RolesDBApi.findBy({ id: roleId }, { transaction }); + } else { + role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); + } + + if (!role) { + throw new ValidationError('rolesNotFound'); + } + + try { + let customization = {}; + try { + customization = JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + } + + if (widgetIdIsUUID && Array.isArray(customization[key])) { + const el = customization[key].find((e) => e === widgetId); + !el ? customization[key].unshift(widgetId) : null; + } + + if (widgetIdIsUUID && !customization[key]) { + customization[key] = [widgetId]; + } + + const newRole = await RolesDBApi.update( + role.id, + { + role_customization: JSON.stringify(customization), + name: role.name, + permissions: role.permissions, + globalAccess: role.globalAccess, + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + + return newRole; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async removeRoleInfoById(infoId, roleId, key, currentUser) { + const transaction = await db.sequelize.transaction(); + + let role; + if (roleId) { + role = await RolesDBApi.findBy({ id: roleId }, { transaction }); + } else { + role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); + } + if (!role) { + await transaction.rollback(); + throw new ValidationError('rolesNotFound'); + } + + let customization = {}; + try { + customization = JSON.parse(role.role_customization || '{}'); + } catch (e) { + console.log(e); + } + + customization[key] = customization[key].filter((item) => item !== infoId); + + const response = await axios.delete( + `${config.flHost}/${config.project_uuid}/project_customization_widgets/${infoId}.json`, + ); + const { status } = await response; + try { + const result = await RolesDBApi.update( + role.id, + { + role_customization: JSON.stringify(customization), + name: role.name, + permissions: role.permissions, + globalAccess: role.globalAccess, + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async getRoleInfoByKey(key, roleId, currentUser) { + const transaction = await db.sequelize.transaction(); + + const organizationId = currentUser.organizationId; + let globalAccess = currentUser.app_role?.globalAccess; + let queryString = ''; + + try { + const role = await findRole(roleId, currentUser); + const customization = parseCustomization(role); + + let result; + if (key === 'widgets') { + result = await processWidgets(customization[key], currentUser); + } else { + result = customization[key]; + } + + await transaction.commit(); + return result; + } catch (error) { + console.error(error); + await transaction.rollback(); + } finally { + if (transaction.finished !== 'commit') { + await transaction.rollback(); + } + } + } +}; diff --git a/backend/src/services/search.js b/backend/src/services/search.js new file mode 100644 index 0000000..7421fff --- /dev/null +++ b/backend/src/services/search.js @@ -0,0 +1,255 @@ +const db = require('../db/models'); +const ValidationError = require('./notifications/errors/validation'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +/** + * @param {string} permission + * @param {object} currentUser + */ +async function checkPermissions(permission, currentUser) { + if (!currentUser) { + throw new ValidationError('auth.unauthorized'); + } + + const userPermission = currentUser.custom_permissions.find( + (cp) => cp.name === permission, + ); + + if (userPermission) { + return true; + } + + try { + if (!currentUser.app_role) { + throw new ValidationError('auth.forbidden'); + } + + const permissions = await currentUser.app_role.getPermissions(); + + return !!permissions.find((p) => p.name === permission); + } catch (e) { + throw e; + } +} + +module.exports = class SearchService { + static async search(searchQuery, currentUser, organizationId, globalAccess) { + try { + if (!searchQuery) { + throw new ValidationError('iam.errors.searchQueryRequired'); + } + const tableColumns = { + users: ['firstName', 'lastName', 'phoneNumber', 'email'], + + claims: ['category', 'description', 'attachments'], + + clients: [ + 'first_name', + + 'last_name', + + 'phone', + + 'address', + + 'id_number', + + 'license_number', + + 'id_copy_url', + + 'license_copy_url', + ], + + company_profiles: [ + 'legal_name', + + 'address', + + 'phone', + + 'email', + + 'website', + + 'rc', + + 'ice', + + 'patente', + + 'logo_url', + + 'pdf_footer', + ], + + drivers: [ + 'full_name', + + 'license_number', + + 'id_copy_url', + + 'license_copy_url', + ], + + expenses: ['category', 'note', 'attachment_url'], + + guarantees: ['proof_url'], + + infractions: ['ref_no', 'location', 'proof_url'], + + invoices_saas: ['invoice_number', 'pdf_url'], + + maintenance: ['notes', 'attachments'], + + payments: ['receipt_ref', 'attachment_url'], + + plans: ['code', 'name', 'limits'], + + reservations: [ + 'ref', + + 'pickup_address', + + 'return_address', + + 'options', + + 'notes', + ], + + subscriptions: ['provider_customer_id', 'provider_subscription_id'], + + tenants: ['name', 'subdomain', 'billing_email', 'billing_phone'], + + vehicle_conditions: ['before', 'after'], + + vehicles: ['plate_number', 'brand', 'model', 'color', 'photos'], + + agences: ['name'], + }; + const columnsInt = { + expenses: ['amount'], + + guarantees: ['amount'], + + infractions: ['amount'], + + invoices_saas: ['amount_mad'], + + maintenance: ['odometer', 'cost'], + + payments: ['amount'], + + plans: ['price_mad'], + + reservations: [ + 'days', + + 'price_per_day', + + 'total_price', + + 'paid_amount', + + 'balance_amount', + + 'guarantee_amount', + ], + + vehicles: ['year', 'mileage', 'daily_price'], + }; + + let allFoundRecords = []; + + for (const tableName in tableColumns) { + if (tableColumns.hasOwnProperty(tableName)) { + const attributesToSearch = tableColumns[tableName]; + const attributesIntToSearch = columnsInt[tableName] || []; + const whereCondition = { + [Op.or]: [ + ...attributesToSearch.map((attribute) => ({ + [attribute]: { + [Op.iLike]: `%${searchQuery}%`, + }, + })), + ...attributesIntToSearch.map((attribute) => + Sequelize.where( + Sequelize.cast( + Sequelize.col(`${tableName}.${attribute}`), + 'varchar', + ), + { [Op.iLike]: `%${searchQuery}%` }, + ), + ), + ], + }; + + if ( + !globalAccess && + tableName !== 'organizations' && + organizationId + ) { + whereCondition.organizationId = organizationId; + } + + const hasPermission = await checkPermissions( + `READ_${tableName.toUpperCase()}`, + currentUser, + ); + if (!hasPermission) { + continue; + } + + const foundRecords = await db[tableName].findAll({ + where: whereCondition, + attributes: [ + ...tableColumns[tableName], + 'id', + ...attributesIntToSearch, + ], + }); + + const modifiedRecords = foundRecords.map((record) => { + const matchAttribute = []; + + for (const attribute of attributesToSearch) { + if ( + record[attribute] + ?.toLowerCase() + ?.includes(searchQuery.toLowerCase()) + ) { + matchAttribute.push(attribute); + } + } + + for (const attribute of attributesIntToSearch) { + const castedValue = String(record[attribute]); + if ( + castedValue && + castedValue.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + matchAttribute.push(attribute); + } + } + + return { + ...record.get(), + matchAttribute, + tableName, + }; + }); + + allFoundRecords = allFoundRecords.concat(modifiedRecords); + } + } + + return allFoundRecords; + } catch (error) { + throw error; + } + } +}; diff --git a/backend/src/services/subscriptions.js b/backend/src/services/subscriptions.js new file mode 100644 index 0000000..a1dd86a --- /dev/null +++ b/backend/src/services/subscriptions.js @@ -0,0 +1,117 @@ +const db = require('../db/models'); +const SubscriptionsDBApi = require('../db/api/subscriptions'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class SubscriptionsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await SubscriptionsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await SubscriptionsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let subscriptions = await SubscriptionsDBApi.findBy( + { id }, + { transaction }, + ); + + if (!subscriptions) { + throw new ValidationError('subscriptionsNotFound'); + } + + const updatedSubscriptions = await SubscriptionsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedSubscriptions; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await SubscriptionsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await SubscriptionsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/tenants.js b/backend/src/services/tenants.js new file mode 100644 index 0000000..60015c1 --- /dev/null +++ b/backend/src/services/tenants.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const TenantsDBApi = require('../db/api/tenants'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class TenantsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await TenantsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await TenantsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let tenants = await TenantsDBApi.findBy({ id }, { transaction }); + + if (!tenants) { + throw new ValidationError('tenantsNotFound'); + } + + const updatedTenants = await TenantsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedTenants; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await TenantsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await TenantsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/users.js b/backend/src/services/users.js new file mode 100644 index 0000000..e75abfb --- /dev/null +++ b/backend/src/services/users.js @@ -0,0 +1,163 @@ +const db = require('../db/models'); +const UsersDBApi = require('../db/api/users'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +const InvitationEmail = require('./email/list/invitation'); +const EmailSender = require('./email'); +const AuthService = require('./auth'); + +module.exports = class UsersService { + static async create(data, currentUser, sendInvitationEmails = true, host) { + let transaction = await db.sequelize.transaction(); + + const globalAccess = currentUser.app_role.globalAccess; + + let email = data.email; + let emailsToInvite = []; + try { + if (email) { + let user = await UsersDBApi.findBy({ email }, { transaction }); + if (user) { + throw new ValidationError('iam.errors.userAlreadyExists'); + } else { + await UsersDBApi.create( + { data }, + + globalAccess, + + { + currentUser, + transaction, + }, + ); + emailsToInvite.push(email); + } + } else { + throw new ValidationError('iam.errors.emailRequired'); + } + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + if (emailsToInvite && emailsToInvite.length) { + if (!sendInvitationEmails) return; + + AuthService.sendPasswordResetEmail(email, 'invitation', host); + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + let emailsToInvite = []; + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', () => { + console.log('results csv', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + const hasAllEmails = results.every((result) => result.email); + + if (!hasAllEmails) { + throw new ValidationError('importer.errors.userEmailMissing'); + } + + await UsersDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + emailsToInvite = results.map((result) => result.email); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + + if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) { + emailsToInvite.forEach((email) => { + AuthService.sendPasswordResetEmail(email, 'invitation', host); + }); + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + + const globalAccess = currentUser.app_role.globalAccess; + + try { + let users = await UsersDBApi.findBy({ id }, { transaction }); + + if (!users) { + throw new ValidationError('iam.errors.userNotFound'); + } + + const updatedUser = await UsersDBApi.update( + id, + data, + + globalAccess, + + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedUser; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + if (currentUser.id === id) { + throw new ValidationError('iam.errors.deletingHimself'); + } + + if ( + currentUser.app_role?.name !== config.roles.admin && + currentUser.app_role?.name !== config.roles.super_admin + ) { + throw new ValidationError('errors.forbidden.message'); + } + + await UsersDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/vehicle_conditions.js b/backend/src/services/vehicle_conditions.js new file mode 100644 index 0000000..27dece3 --- /dev/null +++ b/backend/src/services/vehicle_conditions.js @@ -0,0 +1,121 @@ +const db = require('../db/models'); +const Vehicle_conditionsDBApi = require('../db/api/vehicle_conditions'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class Vehicle_conditionsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await Vehicle_conditionsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await Vehicle_conditionsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let vehicle_conditions = await Vehicle_conditionsDBApi.findBy( + { id }, + { transaction }, + ); + + if (!vehicle_conditions) { + throw new ValidationError('vehicle_conditionsNotFound'); + } + + const updatedVehicle_conditions = await Vehicle_conditionsDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedVehicle_conditions; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Vehicle_conditionsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Vehicle_conditionsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/vehicles.js b/backend/src/services/vehicles.js new file mode 100644 index 0000000..d2e986c --- /dev/null +++ b/backend/src/services/vehicles.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const VehiclesDBApi = require('../db/api/vehicles'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class VehiclesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await VehiclesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await VehiclesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let vehicles = await VehiclesDBApi.findBy({ id }, { transaction }); + + if (!vehicles) { + throw new ValidationError('vehiclesNotFound'); + } + + const updatedVehicles = await VehiclesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedVehicles; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await VehiclesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await VehiclesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/watcher.js b/backend/watcher.js new file mode 100644 index 0000000..6c021cc --- /dev/null +++ b/backend/watcher.js @@ -0,0 +1,45 @@ +const chokidar = require('chokidar'); +const { exec } = require('child_process'); +const nodemon = require('nodemon'); + +const migrationsWatcher = chokidar.watch('./src/db/migrations', { + persistent: true, + ignoreInitial: true, +}); +migrationsWatcher.on('add', (filePath) => { + console.log(`[DEBUG] New migration file: ${filePath}`); + exec('npm run db:migrate', (error, stdout, stderr) => { + console.log(stdout); + if (error) { + console.error(stderr); + } + }); +}); + +const seedersWatcher = chokidar.watch('./src/db/seeders', { + persistent: true, + ignoreInitial: true, +}); +seedersWatcher.on('add', (filePath) => { + console.log(`[DEBUG] New seed file: ${filePath}`); + exec('npm run db:seed', (error, stdout, stderr) => { + console.log(stdout); + if (error) { + console.error(stderr); + } + }); +}); + +nodemon({ + script: './src/index.js', + ignore: ['./src/db/migrations', './src/db/seeders'], + delay: '500', +}); + +nodemon.on('start', () => { + console.log('Nodemon started'); +}); + +nodemon.on('restart', (files) => { + console.log('Nodemon restarted due changes in:', files); +}); diff --git a/backend/yarn.lock b/backend/yarn.lock new file mode 100644 index 0000000..222a4f9 --- /dev/null +++ b/backend/yarn.lock @@ -0,0 +1,4470 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@apidevtools/json-schema-ref-parser@^9.0.6": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz#8ff5386b365d4c9faa7c8b566ff16a46a577d9b8" + integrity sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg== + dependencies: + "@jsdevtools/ono" "^7.1.3" + "@types/json-schema" "^7.0.6" + call-me-maybe "^1.0.1" + js-yaml "^4.1.0" + +"@apidevtools/openapi-schemas@^2.0.4": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" + integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== + +"@apidevtools/swagger-methods@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" + integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== + +"@apidevtools/swagger-parser@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz#32057ae99487872c4dd96b314a1ab4b95d89eaf5" + integrity sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g== + dependencies: + "@apidevtools/json-schema-ref-parser" "^9.0.6" + "@apidevtools/openapi-schemas" "^2.0.4" + "@apidevtools/swagger-methods" "^3.0.2" + "@jsdevtools/ono" "^7.1.3" + call-me-maybe "^1.0.1" + z-schema "^5.0.1" + +"@azure/abort-controller@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249" + integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw== + dependencies: + tslib "^2.2.0" + +"@azure/abort-controller@^2.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-2.1.2.tgz#42fe0ccab23841d9905812c58f1082d27784566d" + integrity sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA== + dependencies: + tslib "^2.6.2" + +"@azure/core-auth@^1.3.0", "@azure/core-auth@^1.4.0", "@azure/core-auth@^1.5.0": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.7.2.tgz#558b7cb7dd12b00beec07ae5df5907d74df1ebd9" + integrity sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-util" "^1.1.0" + tslib "^2.6.2" + +"@azure/core-client@^1.3.0", "@azure/core-client@^1.5.0", "@azure/core-client@^1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@azure/core-client/-/core-client-1.9.2.tgz#6fc69cee2816883ab6c5cdd653ee4f2ff9774f74" + integrity sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-rest-pipeline" "^1.9.1" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.6.1" + "@azure/logger" "^1.0.0" + tslib "^2.6.2" + +"@azure/core-http-compat@^2.0.1": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz#d1585ada24ba750dc161d816169b33b35f762f0d" + integrity sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-client" "^1.3.0" + "@azure/core-rest-pipeline" "^1.3.0" + +"@azure/core-lro@^2.2.0": + version "2.7.2" + resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-2.7.2.tgz#787105027a20e45c77651a98b01a4d3b01b75a08" + integrity sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-util" "^1.2.0" + "@azure/logger" "^1.0.0" + tslib "^2.6.2" + +"@azure/core-paging@^1.1.1": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.6.2.tgz#40d3860dc2df7f291d66350b2cfd9171526433e7" + integrity sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA== + dependencies: + tslib "^2.6.2" + +"@azure/core-rest-pipeline@^1.1.0", "@azure/core-rest-pipeline@^1.3.0", "@azure/core-rest-pipeline@^1.8.1", "@azure/core-rest-pipeline@^1.9.1": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.16.2.tgz#3f71b09e45a65926cc598478b4f1bcd0fe67bf4b" + integrity sha512-Hnhm/PG9/SQ07JJyLDv3l9Qr8V3xgAe1hFoBYzt6LaalMxfL/ZqFaZf/bz5VN3pMcleCPwl8ivlS2Fjxq/iC8Q== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-tracing" "^1.0.1" + "@azure/core-util" "^1.9.0" + "@azure/logger" "^1.0.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.0" + tslib "^2.6.2" + +"@azure/core-tracing@^1.0.0", "@azure/core-tracing@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.1.2.tgz#065dab4e093fb61899988a1cdbc827d9ad90b4ee" + integrity sha512-dawW9ifvWAWmUm9/h+/UQ2jrdvjCJ7VJEuCJ6XVNudzcOwm53BFZH4Q845vjfgoUAM8ZxokvVNxNxAITc502YA== + dependencies: + tslib "^2.6.2" + +"@azure/core-util@^1.0.0", "@azure/core-util@^1.1.0", "@azure/core-util@^1.2.0", "@azure/core-util@^1.3.0", "@azure/core-util@^1.6.1", "@azure/core-util@^1.9.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.9.1.tgz#05ea9505c5cdf29c55ccf99a648c66ddd678590b" + integrity sha512-OLsq0etbHO1MA7j6FouXFghuHrAFGk+5C1imcpQ2e+0oZhYF07WLA+NW2Vqs70R7d+zOAWiWM3tbE1sXcDN66g== + dependencies: + "@azure/abort-controller" "^2.0.0" + tslib "^2.6.2" + +"@azure/identity@^4.2.1": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-4.4.0.tgz#f2743e63d346000a70b0eed5a3b397dedd3984a7" + integrity sha512-oG6oFNMxUuoivYg/ElyZWVSZfw42JQyHbrp+lR7VJ1BYWsGzt34NwyDw3miPp1QI7Qm5+4KAd76wGsbHQmkpkg== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.5.0" + "@azure/core-client" "^1.9.2" + "@azure/core-rest-pipeline" "^1.1.0" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.3.0" + "@azure/logger" "^1.0.0" + "@azure/msal-browser" "^3.14.0" + "@azure/msal-node" "^2.9.2" + events "^3.0.0" + jws "^4.0.0" + open "^8.0.0" + stoppable "^1.1.0" + tslib "^2.2.0" + +"@azure/keyvault-keys@^4.4.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@azure/keyvault-keys/-/keyvault-keys-4.8.0.tgz#1513b3a187bb3a9a372b5980c593962fb793b2ad" + integrity sha512-jkuYxgkw0aaRfk40OQhFqDIupqblIOIlYESWB6DKCVDxQet1pyv86Tfk9M+5uFM0+mCs6+MUHU+Hxh3joiUn4Q== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.3.0" + "@azure/core-client" "^1.5.0" + "@azure/core-http-compat" "^2.0.1" + "@azure/core-lro" "^2.2.0" + "@azure/core-paging" "^1.1.1" + "@azure/core-rest-pipeline" "^1.8.1" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.0.0" + "@azure/logger" "^1.0.0" + tslib "^2.2.0" + +"@azure/logger@^1.0.0": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.1.3.tgz#09a8fd4850b9112865756e92d5e8b728ee457345" + integrity sha512-J8/cIKNQB1Fc9fuYqBVnrppiUtW+5WWJPCj/tAokC5LdSTwkWWttN+jsRgw9BLYD7JDBx7PceiqOBxJJ1tQz3Q== + dependencies: + tslib "^2.6.2" + +"@azure/msal-browser@^3.14.0": + version "3.19.1" + resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.19.1.tgz#c5e5a7996f95cadc11920bffa2bf6321e3a24555" + integrity sha512-pqYP2gK0GCEa4OxtOqlS+EdFQqhXV6ZuESgSTYWq2ABXyxBVVdd5KNuqgR5SU0OwI2V1YWdFVvLDe1487dyQ0g== + dependencies: + "@azure/msal-common" "14.13.1" + +"@azure/msal-common@14.13.1": + version "14.13.1" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.13.1.tgz#e296cf8cc556082af9c35d803496424e8a95d8b7" + integrity sha512-iUp3BYrsRZ4X3EiaZ2fDjNFjmtYMv9rEQd6c1op6ULn0HWk4ACvDmosL6NaBgWOhl1BAblIbd9vmB5/ilF8d4A== + +"@azure/msal-node@^2.9.2": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.11.1.tgz#7fea67a1c6904301eb8853fae7df86c34306a9cc" + integrity sha512-8ECtug4RL+zsgh20VL8KYHjrRO3MJOeAKEPRXT2lwtiu5U3BdyIdBb50+QZthEkIi60K6pc/pdOx/k5Jp4sLng== + dependencies: + "@azure/msal-common" "14.13.1" + jsonwebtoken "^9.0.0" + uuid "^8.3.0" + +"@google-cloud/paginator@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" + integrity sha512-jJNutk0arIQhmpUUQJPJErsojqo834KcyB6X7a1mxuic8i1tKXxde8E69IZxNZawRIlZdIK2QY4WALvlK5MzYQ== + dependencies: + arrify "^2.0.0" + extend "^3.0.2" + +"@google-cloud/projectify@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-2.1.1.tgz#ae6af4fee02d78d044ae434699a630f8df0084ef" + integrity sha512-+rssMZHnlh0twl122gXY4/aCrk0G1acBqkHFfYddtsqpYXGxA29nj9V5V9SfC+GyOG00l650f6lG9KL+EpFEWQ== + +"@google-cloud/promisify@^2.0.0": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-2.0.4.tgz#9d8705ecb2baa41b6b2673f3a8e9b7b7e1abc52a" + integrity sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA== + +"@google-cloud/storage@^5.18.2": + version "5.20.5" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-5.20.5.tgz#1de71fc88d37934a886bc815722c134b162d335d" + integrity sha512-lOs/dCyveVF8TkVFnFSF7IGd0CJrTm91qiK6JLu+Z8qiT+7Ag0RyVhxZIWkhiACqwABo7kSHDm8FdH8p2wxSSw== + dependencies: + "@google-cloud/paginator" "^3.0.7" + "@google-cloud/projectify" "^2.0.0" + "@google-cloud/promisify" "^2.0.0" + abort-controller "^3.0.0" + arrify "^2.0.0" + async-retry "^1.3.3" + compressible "^2.0.12" + configstore "^5.0.0" + duplexify "^4.0.0" + ent "^2.2.0" + extend "^3.0.2" + gaxios "^4.0.0" + google-auth-library "^7.14.1" + hash-stream-validation "^0.2.2" + mime "^3.0.0" + mime-types "^2.0.8" + p-limit "^3.0.1" + pumpify "^2.0.0" + retry-request "^4.2.2" + stream-events "^1.0.4" + teeny-request "^7.1.3" + uuid "^8.0.0" + xdg-basedir "^4.0.0" + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@js-joda/core@^5.6.1": + version "5.6.3" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.6.3.tgz#41ae1c07de1ebe0f6dde1abcbc9700a09b9c6056" + integrity sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA== + +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + +"@mapbox/node-pre-gyp@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@sindresorhus/is@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" + integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== + +"@szmarczak/http-timer@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" + integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== + dependencies: + defer-to-connect "^1.0.1" + +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + +"@types/debug@^4.1.8": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/json-schema@^7.0.6": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + +"@types/node@*", "@types/node@>=18": + version "20.14.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.11.tgz#09b300423343460455043ddd4d0ded6ac579b74b" + integrity sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA== + dependencies: + undici-types "~5.26.4" + +"@types/readable-stream@^4.0.0": + version "4.0.15" + resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-4.0.15.tgz#e6ec26fe5b02f578c60baf1fa9452e90957d2bfb" + integrity sha512-oAZ3kw+kJFkEqyh7xORZOku1YAKvsFTogRY8kVl4vHpEKiDkfnSA/My8haRE7fvmix5Zyy+1pwzOi7yycGLBJw== + dependencies: + "@types/node" "*" + safe-buffer "~5.1.1" + +"@types/validator@^13.7.17": + version "13.12.0" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.0.tgz#1fe4c3ae9de5cf5193ce64717c99ef2fa7d8756f" + integrity sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@^1.3.7, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + +ansi-align@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" + integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== + dependencies: + string-width "^4.1.0" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== + +ansi-regex@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +anymatch@~3.1.1, anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +array.prototype.map@^1.0.1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/array.prototype.map/-/array.prototype.map-1.0.7.tgz#82fa4d6027272d1fca28a63bbda424d0185d78a7" + integrity sha512-XpcFfLoBEAhezrrNw1V+yLXkE7M6uR7xJEsxbG6c/V9v043qurwVJB9r9UTnoSioFDoz1i1VOydpWGmJpfVZbg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-array-method-boxes-properly "^1.0.0" + es-object-atoms "^1.0.0" + is-string "^1.0.7" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +arrify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + +async-retry@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" + integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== + dependencies: + retry "0.13.1" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +axios@^1.6.7: + version "1.7.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" + integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.0, base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +base64url@3.x.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" + integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== + +bcrypt@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" + integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.11" + node-addon-api "^5.0.0" + +bignumber.js@^9.0.0: + version "9.1.2" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" + integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +bl@^6.0.11: + version "6.0.14" + resolved "https://registry.yarnpkg.com/bl/-/bl-6.0.14.tgz#b9ae9862118a3d2ebec999c5318466012314f96c" + integrity sha512-TJfbvGdL7KFGxTsEbsED7avqpFdY56q9IW0/aiytyheJzxST/+Io6cx/4Qx0K2/u0BPRDs65mjaQzYvMZeNocQ== + dependencies: + "@types/readable-stream" "^4.0.0" + buffer "^6.0.3" + inherits "^2.0.4" + readable-stream "^4.2.0" + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +body-parser@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + +boxen@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" + integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^5.3.1" + chalk "^3.0.0" + cli-boxes "^2.2.0" + string-width "^4.1.0" + term-size "^2.1.0" + type-fest "^0.8.1" + widest-line "^3.1.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-writer@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" + integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +busboy@^0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" + integrity sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg== + dependencies: + dicer "0.2.5" + readable-stream "1.1.x" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cacheable-request@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" + integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^3.0.0" + lowercase-keys "^2.0.0" + normalize-url "^4.1.0" + responselike "^1.0.2" + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +call-me-maybe@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" + integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" + integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.4.0" + optionalDependencies: + fsevents "~2.1.2" + +chokidar@^3.2.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cli-boxes@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + +cli-color@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.4.tgz#d658080290968816b322248b7306fad2346fb2c8" + integrity sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA== + dependencies: + d "^1.0.1" + es5-ext "^0.10.64" + es6-iterator "^2.0.3" + memoizee "^0.4.15" + timers-ext "^0.1.7" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-response@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" + integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== + dependencies: + mimic-response "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +compressible@^2.0.12: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +configstore@^5.0.0, configstore@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" + integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== + dependencies: + dot-prop "^5.2.0" + graceful-fs "^4.1.2" + make-dir "^3.0.0" + unique-string "^2.0.0" + write-file-atomic "^3.0.0" + xdg-basedir "^4.0.0" + +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-env@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + +csv-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" + integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== + dependencies: + minimist "^1.2.0" + +d@1, d@^1.0.1, d@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.2.tgz#2aefd554b81981e7dccf72d6842ae725cb17e5de" + integrity sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw== + dependencies: + es5-ext "^0.10.64" + type "^2.7.2" + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.1.1, debug@^4.3.4: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + +debug@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +debug@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +decompress-response@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" + integrity sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA== + dependencies: + mimic-response "^1.0.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +defer-to-connect@^1.0.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" + integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + +define-properties@^1.1.2, define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +denque@^1.4.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" + integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +dicer@0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" + integrity sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg== + dependencies: + readable-stream "1.1.x" + streamsearch "0.1.2" + +diff@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +doctrine@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dot-prop@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== + dependencies: + is-obj "^2.0.0" + +dottie@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/dottie/-/dottie-2.0.6.tgz#34564ebfc6ec5e5772272d466424ad5b696484d4" + integrity sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA== + +duplexer3@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e" + integrity sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA== + +duplexify@^4.0.0, duplexify@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.3.tgz#a07e1c0d0a2c001158563d32592ba58bddb0236f" + integrity sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA== + dependencies: + end-of-stream "^1.4.1" + inherits "^2.0.3" + readable-stream "^3.1.1" + stream-shift "^1.0.2" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +editorconfig@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" + integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "9.0.1" + semver "^7.5.3" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +ent@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.1.tgz#68dc99a002f115792c26239baedaaea9e70c0ca2" + integrity sha512-QHuXVeZx9d+tIQAz/XztU0ZwZf2Agg9CcXcgE1rurqvdBeDBrpSwjl8/6XUqMg7tw2Y7uAdKb2sRv+bSEFqQ5A== + dependencies: + punycode "^1.4.1" + +es-abstract@^1.17.0-next.1, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-array-method-boxes-properly@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" + integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-get-iterator@^1.0.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" + integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + has-symbols "^1.0.3" + is-arguments "^1.1.1" + is-map "^2.0.2" + is-set "^2.0.2" + is-string "^1.0.7" + isarray "^2.0.5" + stop-iteration-iterator "^1.0.0" + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.64" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" + integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== + dependencies: + es6-iterator "^2.0.3" + es6-symbol "^3.1.3" + esniff "^2.0.1" + next-tick "^1.1.0" + +es6-iterator@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-symbol@^3.1.1, es6-symbol@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.4.tgz#f4e7d28013770b4208ecbf3e0bf14d3bcb557b8c" + integrity sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg== + dependencies: + d "^1.0.2" + ext "^1.7.0" + +es6-weak-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" + integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== + dependencies: + d "1" + es5-ext "^0.10.46" + es6-iterator "^2.0.3" + es6-symbol "^3.1.1" + +escalade@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-goat@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" + integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +esniff@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" + integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== + dependencies: + d "^1.0.1" + es5-ext "^0.10.62" + event-emitter "^0.3.5" + type "^2.7.2" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== + dependencies: + d "1" + es5-ext "~0.10.14" + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.0.0, events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +express@4.18.2: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.1" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +ext@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" + integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== + dependencies: + type "^2.7.2" + +extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-text-encoding@^1.0.0: + version "1.0.6" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" + integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +flat@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.1.tgz#a392059cc382881ff98642f5da4dde0a959f309b" + integrity sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA== + dependencies: + is-buffer "~2.0.3" + +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +foreground-child@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" + integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +formidable@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" + integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2, fresh@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.1, function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +gaxios@^4.0.0: + version "4.3.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-4.3.3.tgz#d44bdefe52d34b6435cc41214fdb160b64abfc22" + integrity sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.6.7" + +gcp-metadata@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.3.1.tgz#fb205fe6a90fef2fd9c85e6ba06e5559ee1eefa9" + integrity sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A== + dependencies: + gaxios "^4.0.0" + json-bigint "^1.0.0" + +generate-function@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-stream@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +glob-parent@~5.1.0, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^10.3.3: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-dirs@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" + integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== + dependencies: + ini "1.3.7" + +globalthis@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +google-auth-library@^7.14.1: + version "7.14.1" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" + integrity sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^4.0.0" + gcp-metadata "^4.2.0" + gtoken "^5.0.4" + jws "^4.0.0" + lru-cache "^6.0.0" + +google-p12-pem@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.4.tgz#123f7b40da204de4ed1fbf2fd5be12c047fc8b3b" + integrity sha512-HHuHmkLgwjdmVRngf5+gSmpkyaRI6QmOg77J8tkNBHhNEI62sGHyw4/+UkgyZEI7h84NbWprXDJ+sa3xOYFvTg== + dependencies: + node-forge "^1.3.1" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +got@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" + integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== + dependencies: + "@sindresorhus/is" "^0.14.0" + "@szmarczak/http-timer" "^1.1.2" + cacheable-request "^6.0.0" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^4.1.0" + lowercase-keys "^1.0.1" + mimic-response "^1.0.1" + p-cancelable "^1.0.0" + to-readable-stream "^1.0.0" + url-parse-lax "^3.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +gtoken@^5.0.4: + version "5.3.2" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.3.2.tgz#deb7dc876abe002178e0515e383382ea9446d58f" + integrity sha512-gkvEKREW7dXWF8NV8pVrKfW7WqReAmjjkMBh6lNCCGOM4ucS0r0YyXXl0r/9Yj8wcW/32ISkfc8h5mPTDbtifQ== + dependencies: + gaxios "^4.0.0" + google-p12-pem "^3.1.3" + jws "^4.0.0" + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.0, has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + +has-yarn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" + integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== + +hash-stream-validation@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz#ee68b41bf822f7f44db1142ec28ba9ee7ccb7512" + integrity sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ== + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +helmet@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-4.1.1.tgz#751f0e273d809ace9c172073e0003bed27d27a4a" + integrity sha512-Avg4XxSBrehD94mkRwEljnO+6RZx7AGfk8Wa6K1nxaU+hbXlFOhlOIMgPfFqOYQB/dBCsTpootTGuiOG+CHiQA== + +http-cache-semantics@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +https-proxy-agent@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.2, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + +import-lazy@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" + integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflection@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.13.4.tgz#65aa696c4e2da6225b148d7a154c449366633a32" + integrity sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== + +ini@^1.3.4, ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +internal-slot@^1.0.4, internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-arguments@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-buffer@~2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-core-module@^2.13.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.0.tgz#71c72ec5442ace7e76b306e9d48db361f22699ea" + integrity sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA== + dependencies: + hasown "^2.0.2" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-installed-globally@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + +is-map@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-npm@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" + integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + +is-path-inside@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== + +is-promise@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-set@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +is-yarn-global@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" + integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +iterate-iterator@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.2.tgz#551b804c9eaa15b847ea6a7cdc2f5bf1ec150f91" + integrity sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw== + +iterate-value@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-value/-/iterate-value-1.0.2.tgz#935115bd37d006a52046535ebc8d07e9c9337f57" + integrity sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ== + dependencies: + es-get-iterator "^1.0.2" + iterate-iterator "^1.0.1" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-beautify@^1.14.5: + version "1.15.1" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64" + integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA== + dependencies: + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.3.3" + js-cookie "^3.0.5" + nopt "^7.2.0" + +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + +js-md4@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" + integrity sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== + +js-yaml@3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + +json-buffer@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" + integrity sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ== + +json2csv@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae" + integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA== + dependencies: + commander "^6.1.0" + jsonparse "^1.3.1" + lodash.get "^4.4.2" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +jsonwebtoken@8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + +jsonwebtoken@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + +keyv@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" + integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== + dependencies: + json-buffer "3.0.0" + +latest-version@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" + integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== + dependencies: + package-json "^6.3.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.mergewith@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@4.17.21, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" + integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== + +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + +lru-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== + dependencies: + es5-ext "~0.10.2" + +make-dir@^3.0.0, make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +memoizee@^0.4.15: + version "0.4.17" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.17.tgz#942a5f8acee281fa6fb9c620bddc57e3b7382949" + integrity sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA== + dependencies: + d "^1.0.2" + es5-ext "^0.10.64" + es6-weak-map "^2.0.3" + event-emitter "^0.3.5" + is-promise "^2.2.2" + lru-queue "^0.1.0" + next-tick "^1.1.0" + timers-ext "^0.1.7" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +merge-descriptors@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +methods@^1.1.2, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +"mime-db@>= 1.43.0 < 2": + version "1.53.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== + +mime-types@^2.0.8, mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0, mime@^1.3.4: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + +mimic-response@^1.0.0, mimic-response@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + +minimatch@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mocha@8.1.3: + version "8.1.3" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.1.3.tgz#5e93f873e35dfdd69617ea75f9c68c2ca61c2ac5" + integrity sha512-ZbaYib4hT4PpF4bdSO2DohooKXIn4lDeiYqB+vTmCdr6l2woW0b6H3pf5x4sM5nwQMru9RvjjHYWVGltR50ZBw== + dependencies: + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.4.2" + debug "4.1.1" + diff "4.0.2" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.1.6" + growl "1.10.5" + he "1.2.0" + js-yaml "3.14.0" + log-symbols "4.0.0" + minimatch "3.0.4" + ms "2.1.2" + object.assign "4.1.0" + promise.allsettled "1.0.2" + serialize-javascript "4.0.0" + strip-json-comments "3.0.1" + supports-color "7.1.0" + which "2.0.2" + wide-align "1.1.3" + workerpool "6.0.0" + yargs "13.3.2" + yargs-parser "13.1.2" + yargs-unparser "1.6.1" + +moment-timezone@^0.5.43: + version "0.5.45" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c" + integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ== + dependencies: + moment "^2.29.4" + +moment@2.30.1, moment@^2.29.4: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multer@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4.tgz#e2bc6cac0df57a8832b858d7418ccaa8ebaf7d8c" + integrity sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw== + dependencies: + append-field "^1.0.0" + busboy "^0.2.11" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + on-finished "^2.3.0" + type-is "^1.6.4" + xtend "^4.0.0" + +mysql2@2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-2.2.5.tgz#72624ffb4816f80f96b9c97fedd8c00935f9f340" + integrity sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g== + dependencies: + denque "^1.4.1" + generate-function "^2.3.1" + iconv-lite "^0.6.2" + long "^4.0.0" + lru-cache "^6.0.0" + named-placeholders "^1.1.2" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + +named-placeholders@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.3.tgz#df595799a36654da55dda6152ba7a137ad1d9351" + integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w== + dependencies: + lru-cache "^7.14.1" + +native-duplexpair@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/native-duplexpair/-/native-duplexpair-1.0.0.tgz#7899078e64bf3c8a3d732601b3d40ff05db58fa0" + integrity sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +next-tick@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + +node-fetch@^2.6.1, node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + +node-mocks-http@1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/node-mocks-http/-/node-mocks-http-1.9.0.tgz#6000c570fc4b809603782309be81c73a71d85b71" + integrity sha512-ILf7Ws8xyX9Rl2fLZ7xhZBovrRwgaP84M13esndP6V17M/8j25TpwNzb7Im8U9XCo6fRhdwqiQajWXpsas/E6w== + dependencies: + accepts "^1.3.7" + depd "^1.1.0" + fresh "^0.5.2" + merge-descriptors "^1.0.1" + methods "^1.1.2" + mime "^1.3.4" + parseurl "^1.3.3" + range-parser "^1.2.0" + type-is "^1.6.18" + +nodemailer@6.9.9: + version "6.9.9" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.9.tgz#4549bfbf710cc6addec5064dd0f19874d24248d9" + integrity sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA== + +nodemon@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.5.tgz#df67fe1fd1312ddb0c1e393ae2cf55aacdcec2f3" + integrity sha512-6/jqtZvJdk092pVnD2AIH19KQ9GQZAKOZVy/yT1ueL6aoV+Ix7a1lVZStXzvEh0fP4zE41DDWlkVoHjR6WlozA== + dependencies: + chokidar "^3.2.2" + debug "^3.2.6" + ignore-by-default "^1.0.1" + minimatch "^3.0.4" + pstree.remy "^1.1.7" + semver "^5.7.1" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.3" + update-notifier "^4.1.0" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +nopt@^7.2.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-url@^4.1.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" + integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== + +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +oauth@0.10.x: + version "0.10.0" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.10.0.tgz#3551c4c9b95c53ea437e1e21e46b649482339c58" + integrity sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q== + +oauth@0.9.x: + version "0.9.15" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" + integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA== + +object-assign@^4, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + +object-keys@^1.0.11, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +on-finished@2.4.1, on-finished@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +open@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" + integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + +p-cancelable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" + integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== + +p-limit@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.1, p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + +package-json@^6.3.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" + integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== + dependencies: + got "^9.6.0" + registry-auth-token "^4.0.0" + registry-url "^5.0.0" + semver "^6.2.0" + +packet-reader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" + integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== + +parseurl@^1.3.3, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +passport-google-oauth2@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz#fc9ea59e7091f02e24fd16d6be9257ea982ebbc3" + integrity sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ== + dependencies: + passport-oauth2 "^1.1.2" + +passport-jwt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" + integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ== + dependencies: + jsonwebtoken "^9.0.0" + passport-strategy "^1.0.0" + +passport-microsoft@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/passport-microsoft/-/passport-microsoft-0.1.0.tgz#dc72c1a38b294d74f4dc55fe93f52e25cb9aa5b4" + integrity sha512-0giBDgE1fnR5X84zJZkQ11hnKVrzEgViwRO6RGsormK9zTxFQmN/UHMTDbIpvhk989VqALewB6Pk1R5vNr3GHw== + dependencies: + passport-oauth2 "1.2.0" + pkginfo "0.2.x" + +passport-oauth2@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.2.0.tgz#49613a3eca85c7a1e65bf1019e2b6b80a10c8ac2" + integrity sha512-6128N+n/MOrJdXxdC2q/PVKXtqgihGFIeup+9bsPybAvMPOUKqdGhh9ZIzZF8rFKJOlxUP9fgP3H0JQe18n0rg== + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + +passport-oauth2@^1.1.2: + version "1.8.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" + integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== + dependencies: + base64url "3.x.x" + oauth "0.10.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + utils-merge "1.x.x" + +passport-strategy@1.x.x, passport-strategy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== + +passport@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.7.0.tgz#3688415a59a48cf8068417a8a8092d4492ca3a05" + integrity sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + utils-merge "^1.0.1" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== + +pg-connection-string@^2.4.0, pg-connection-string@^2.6.1: + version "2.6.4" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.4.tgz#f543862adfa49fa4e14bc8a8892d2a84d754246d" + integrity sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA== + +pg-hstore@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/pg-hstore/-/pg-hstore-2.3.4.tgz#4425e3e2a3e15d2a334c35581186c27cf2e9b8dd" + integrity sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA== + dependencies: + underscore "^1.13.1" + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.2.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.2.tgz#3a592370b8ae3f02a7c8130d245bc02fa2c5f3f2" + integrity sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg== + +pg-protocol@^1.3.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3" + integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@8.4.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.4.1.tgz#06cfb6208ae787a869b2f0022da11b90d13d933e" + integrity sha512-NRsH0aGMXmX1z8Dd0iaPCxWUw4ffu+lIAmGm+sTCwuDDWkpEgRCAHZYDwqaNhC5hG5DRMOjSUFasMWhvcmLN1A== + dependencies: + buffer-writer "2.0.0" + packet-reader "1.0.0" + pg-connection-string "^2.4.0" + pg-pool "^3.2.1" + pg-protocol "^1.3.0" + pg-types "^2.1.0" + pgpass "1.x" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pkginfo@0.2.x: + version "0.2.3" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" + integrity sha512-7W7wTrE/NsY8xv/DTGjwNIyNah81EQH0MWcTzrHL6pOpMocOGZc0Mbdz9aXxSrp+U0mSmkU8jrNCDCfUs3sOBg== + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +prepend-http@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + integrity sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +promise.allsettled@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.2.tgz#d66f78fbb600e83e863d893e98b3d4376a9c47c9" + integrity sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg== + dependencies: + array.prototype.map "^1.0.1" + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + iterate-value "^1.0.0" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pstree.remy@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-2.0.1.tgz#abfc7b5a621307c728b551decbbefb51f0e4aa1e" + integrity sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw== + dependencies: + duplexify "^4.1.1" + inherits "^2.0.3" + pump "^3.0.0" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== + +pupa@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" + integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== + dependencies: + escape-goat "^2.0.0" + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@^1.2.0, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +rc@1.2.8, rc@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@^4.2.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +readdirp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" + integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== + dependencies: + picomatch "^2.2.1" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regexp.prototype.flags@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" + integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== + dependencies: + call-bind "^1.0.6" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.1" + +registry-auth-token@^4.0.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.2.tgz#f02d49c3668884612ca031419491a13539e21fac" + integrity sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg== + dependencies: + rc "1.2.8" + +registry-url@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" + integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== + dependencies: + rc "^1.2.8" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve@^1.22.1: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +responselike@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" + integrity sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ== + dependencies: + lowercase-keys "^1.0.0" + +retry-as-promised@^7.0.4: + version "7.0.4" + resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-7.0.4.tgz#9df73adaeea08cb2948b9d34990549dc13d800a2" + integrity sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA== + +retry-request@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.2.2.tgz#b7d82210b6d2651ed249ba3497f07ea602f1a903" + integrity sha512-xA93uxUD/rogV7BV59agW/JHPGXeREMWiZc9jhcwY4YdZ7QOtC7qbomYg0n4wyk2lJhggjvKvhNX8wln/Aldhg== + dependencies: + debug "^4.1.1" + extend "^3.0.2" + +retry@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver-diff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" + integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== + dependencies: + semver "^6.3.0" + +semver@^5.6.0, semver@^5.7.1: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +seq-queue@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" + integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q== + +sequelize-cli@6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/sequelize-cli/-/sequelize-cli-6.6.2.tgz#8d838b25c988cf136914cdc3843e19d88c3dcb67" + integrity sha512-V8Oh+XMz2+uquLZltZES6MVAD+yEnmMfwfn+gpXcDiwE3jyQygLt4xoI0zG8gKt6cRcs84hsKnXAKDQjG/JAgg== + dependencies: + cli-color "^2.0.3" + fs-extra "^9.1.0" + js-beautify "^1.14.5" + lodash "^4.17.21" + resolve "^1.22.1" + umzug "^2.3.0" + yargs "^16.2.0" + +sequelize-json-schema@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/sequelize-json-schema/-/sequelize-json-schema-2.1.1.tgz#a82d3813925e81485d76ce291f4ff5c8cb2ae492" + integrity sha512-yCGaHnmQQeL6MQ/fOxhkR5C2aOGZyTD6OrgjP4yw1rbuujuIUVdzWN3AsC6r6AvlGZ3EUBBbCJHKl8OIFFES4Q== + +sequelize-pool@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/sequelize-pool/-/sequelize-pool-7.1.0.tgz#210b391af4002762f823188fd6ecfc7413020768" + integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== + +sequelize@6.35.2: + version "6.35.2" + resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-6.35.2.tgz#9276d24055a9a07bd6812c89ab402659f5853e70" + integrity sha512-EdzLaw2kK4/aOnWQ7ed/qh3B6/g+1DvmeXr66RwbcqSm/+QRS9X0LDI5INBibsy4eNJHWIRPo3+QK0zL+IPBHg== + dependencies: + "@types/debug" "^4.1.8" + "@types/validator" "^13.7.17" + debug "^4.3.4" + dottie "^2.0.6" + inflection "^1.13.4" + lodash "^4.17.21" + moment "^2.29.4" + moment-timezone "^0.5.43" + pg-connection-string "^2.6.1" + retry-as-promised "^7.0.4" + semver "^7.5.4" + sequelize-pool "^7.1.0" + toposort-class "^1.0.1" + uuid "^8.3.2" + validator "^13.9.0" + wkx "^0.5.0" + +serialize-javascript@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +sqlite@4.0.15: + version "4.0.15" + resolved "https://registry.yarnpkg.com/sqlite/-/sqlite-4.0.15.tgz#071e0577afb327fbd74a75354ea15964378392e3" + integrity sha512-irPPTrbVoDvwzRGpe0v8vxpNwMl+q0tXQzffQTcCUnaJzQFO0hfLLvFwGDKxd6vYBuvEr3uvPkObVoGOvVsmzA== + +sqlstring@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" + integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +stop-iteration-iterator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" + integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== + dependencies: + internal-slot "^1.0.4" + +stoppable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b" + integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw== + +stream-events@^1.0.4, stream-events@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" + integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== + dependencies: + stubs "^3.0.0" + +stream-shift@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" + integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== + +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" + integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + +stubs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" + integrity sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw== + +supports-color@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +swagger-jsdoc@^6.2.8: + version "6.2.8" + resolved "https://registry.yarnpkg.com/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz#6d33d9fb07ff4a7c1564379c52c08989ec7d0256" + integrity sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ== + dependencies: + commander "6.2.0" + doctrine "3.0.0" + glob "7.1.6" + lodash.mergewith "^4.6.2" + swagger-parser "^10.0.3" + yaml "2.0.0-1" + +swagger-parser@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/swagger-parser/-/swagger-parser-10.0.3.tgz#04cb01c18c3ac192b41161c77f81e79309135d03" + integrity sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg== + dependencies: + "@apidevtools/swagger-parser" "10.0.3" + +swagger-ui-dist@>=5.0.0: + version "5.17.14" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz#e2c222e5bf9e15ccf80ec4bc08b4aaac09792fd6" + integrity sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw== + +swagger-ui-express@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz#fb8c1b781d2793a6bd2f8a205a3f4bd6fa020dd8" + integrity sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA== + dependencies: + swagger-ui-dist ">=5.0.0" + +tar@^6.1.11: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +tedious@^18.2.4: + version "18.2.4" + resolved "https://registry.yarnpkg.com/tedious/-/tedious-18.2.4.tgz#c33986f2561b4fde92bb9df70f44ae1a14f71b46" + integrity sha512-+6Nzn/aURTQ+8OxLAJ8fKK5Fbb84HRTI3bHiAC3ZzBKrBg9BHtcHxjmlIni5Zn46hzKiZ5WrDMSwDH8oIYjV8w== + dependencies: + "@azure/identity" "^4.2.1" + "@azure/keyvault-keys" "^4.4.0" + "@js-joda/core" "^5.6.1" + "@types/node" ">=18" + bl "^6.0.11" + iconv-lite "^0.6.3" + js-md4 "^0.3.2" + native-duplexpair "^1.0.0" + sprintf-js "^1.1.3" + +teeny-request@^7.1.3: + version "7.2.0" + resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" + integrity sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw== + dependencies: + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + node-fetch "^2.6.1" + stream-events "^1.0.5" + uuid "^8.0.0" + +term-size@^2.1.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54" + integrity sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg== + +timers-ext@^0.1.7: + version "0.1.8" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.8.tgz#b4e442f10b7624a29dd2aa42c295e257150cf16c" + integrity sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww== + dependencies: + es5-ext "^0.10.64" + next-tick "^1.1.0" + +to-readable-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" + integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +toposort-class@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988" + integrity sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg== + +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +tslib@^2.2.0, tslib@^2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type@^2.7.2: + version "2.7.3" + resolved "https://registry.yarnpkg.com/type/-/type-2.7.3.tgz#436981652129285cc3ba94f392886c2637ea0486" + integrity sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ== + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +uid2@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44" + integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== + +umzug@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.3.0.tgz#0ef42b62df54e216b05dcaf627830a6a8b84a184" + integrity sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw== + dependencies: + bluebird "^3.7.2" + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undefsafe@^2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +underscore@^1.13.1: + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-notifier@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.3.tgz#be86ee13e8ce48fb50043ff72057b5bd598e1ea3" + integrity sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A== + dependencies: + boxen "^4.2.0" + chalk "^3.0.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.3.1" + is-npm "^4.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.0.0" + pupa "^2.0.1" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + +url-parse-lax@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" + integrity sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ== + dependencies: + prepend-http "^2.0.0" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +validator@^13.7.0, validator@^13.9.0: + version "13.12.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f" + integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + +which-typed-array@^1.1.14, which-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which@2.0.2, which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + +wkx@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c" + integrity sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg== + dependencies: + "@types/node" "*" + +workerpool@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.0.0.tgz#85aad67fa1a2c8ef9386a1b43539900f61d03d58" + integrity sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +xdg-basedir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" + integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@2.0.0-1: + version "2.0.0-1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-1.tgz#8c3029b3ee2028306d5bcf396980623115ff8d18" + integrity sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ== + +yargs-parser@13.1.2, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^15.0.1: + version "15.0.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.3.tgz#316e263d5febe8b38eef61ac092b33dfcc9b1115" + integrity sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.1.tgz#bd4b0ee05b4c94d058929c32cb09e3fce71d3c5f" + integrity sha512-qZV14lK9MWsGCmcr7u5oXGH0dbGqZAIxTDrWXZDo5zUr6b6iUmelNKO6x6R1dQT24AH3LgRxJpr8meWy2unolA== + dependencies: + camelcase "^5.3.1" + decamelize "^1.2.0" + flat "^4.1.0" + is-plain-obj "^1.1.0" + yargs "^14.2.3" + +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414" + integrity sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg== + dependencies: + cliui "^5.0.0" + decamelize "^1.2.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^15.0.1" + +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +z-schema@^5.0.1: + version "5.0.6" + resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-5.0.6.tgz#46d6a687b15e4a4369e18d6cb1c7b8618fc256c5" + integrity sha512-+XR1GhnWklYdfr8YaZv/iu+vY+ux7V5DS5zH1DQf6bO5ufrt/5cgNhVO5qyhsjFXvsqQb/f08DWE9b6uPscyAg== + dependencies: + lodash.get "^4.4.2" + lodash.isequal "^4.5.0" + validator "^13.7.0" + optionalDependencies: + commander "^10.0.0" diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..1ea0695 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,14 @@ +steps: + - name: 'gcr.io/cloud-builders/docker' + entrypoint: 'bash' + args: ['-c', 'docker pull gcr.io/fldemo-315215/car-zone-35035-dev:latest || exit 0'] + - name: 'gcr.io/cloud-builders/docker' + args: [ + 'build', + '-t', 'gcr.io/fldemo-315215/car-zone-35035-dev:latest', + '--file', 'Dockerfile.dev', + '--cache-from', 'gcr.io/fldemo-315215/car-zone-35035-dev:latest', + '.' + ] +images: ['gcr.io/fldemo-315215/car-zone-35035-dev:latest'] +logsBucket: 'gs://fldemo-315215-cloudbuild-logs' \ No newline at end of file diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..adbb97d --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1 @@ +data/ \ No newline at end of file diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..69d1021 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,46 @@ +## Description: + + The project contains the **docker folder** and the `Dockerfile`. + + The `Dockerfile` is used to Deploy the project to Google Cloud. + + The **docker folder** contains a couple of helper scripts: + +- `docker-compose.yml` (all our services: web, backend, db are described here) +- `start-backend.sh` (starts backend, but only after the database) +- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) + + > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. + + + ## Run services: + + 1. Install docker compose (https://docs.docker.com/compose/install/) + + 2. Move to `docker` folder. All next steps should be done from this folder. + + ``` cd docker ``` + + 3. Make executables from `wait-for-it.sh` and `start-backend.sh`: + + ``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ``` + + 4. Download dependend projects for services. + + 5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. + + 6. Make sure you have needed ports (see them in `ports`) available on your local machine. + + 7. Start services: + + 7.1. With an empty database `rm -rf data && docker-compose up` + + 7.2. With a stored (from previus runs) database data `docker-compose up` + + 8. Check http://localhost:3000 + + 9. Stop services: + + 9.1. Just press `Ctr+C` + + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..a1a3864 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,56 @@ + + +version: "3.9" +services: + web: + image: frontend + build: ../frontend + stdin_open: true # docker run -i + tty: true # docker run -t + ports: + - "3000:3000" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + db: + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + image: postgres + volumes: + - ./data/db:/var/lib/postgresql/data + environment: + - POSTGRES_HOST_AUTH_METHOD=trust + - POSTGRES_DB=db_car_zone + ports: + - "5432:5432" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + backend: + image: backend + volumes: + - ./wait-for-it.sh:/usr/src/app/wait-for-it.sh + - ./start-backend.sh:/usr/src/app/start-backend.sh + build: ../backend + environment: + - DB_HOST=db + ports: + - "8080:8080" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + depends_on: + - "db" + + command: ["bash", "./wait-for-it.sh", "db:5432", "--timeout=0", "--strict", "--", "bash", "./start-backend.sh"] + diff --git a/docker/start-backend.sh b/docker/start-backend.sh new file mode 100644 index 0000000..fb353bf --- /dev/null +++ b/docker/start-backend.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +yarn start diff --git a/docker/wait-for-it.sh b/docker/wait-for-it.sh new file mode 100644 index 0000000..d990e0d --- /dev/null +++ b/docker/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..444b61f --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + extends: [ + 'next/core-web-vitals', + 'plugin:@typescript-eslint/recommended', + 'eslint-config-prettier', + ], + rules: { + 'react/no-children-prop': 'off', + }, +}; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..fdc0491 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,33 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local.js env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel +/.idea/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..cedf9c7 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..56e10d0 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20.15.1-alpine + +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY package*.json ./ + +RUN yarn install +# If you are building your code for production +# RUN npm ci --only=production + +# Bundle app source +COPY . . + +EXPOSE 3000 +CMD [ "yarn", "dev" ] \ No newline at end of file diff --git a/frontend/LICENSE-justboil b/frontend/LICENSE-justboil new file mode 100644 index 0000000..798238d --- /dev/null +++ b/frontend/LICENSE-justboil @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-current JustBoil.me (https://justboil.me) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..ce120b1 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,91 @@ +# Car zone + +## This project was generated by Flatlogic Platform. + +## Install + +`cd` to project's dir and run `npm install` + +### Builds + +Build are handled by Next.js CLI — [Info](https://nextjs.org/docs/api-reference/cli) + +### Hot-reloads for development + +``` +npm run dev +``` + +### Builds and minifies for production + +``` +npm run build +``` + +### Exports build for static hosts + +``` +npm run export +``` + +### Lint + +``` +npm run lint +``` + +### Format with prettier + +``` +npm run format +``` + +## Support + +For any additional information please refer to [Flatlogic homepage](https://flatlogic.com). + +## To start the project with Docker: + +### Description: + +The project contains the **docker folder** and the `Dockerfile`. + +The `Dockerfile` is used to Deploy the project to Google Cloud. + +The **docker folder** contains a couple of helper scripts: + +- `docker-compose.yml` (all our services: web, backend, db are described here) +- `start-backend.sh` (starts backend, but only after the database) +- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it) + + > To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`. + +### Run services: + +1. Install docker compose (https://docs.docker.com/compose/install/) + +2. Move to `docker` folder. All next steps should be done from this folder. + + `cd docker` + +3. Make executables from `wait-for-it.sh` and `start-backend.sh`: + + `chmod +x start-backend.sh && chmod +x wait-for-it.sh` + +4. Download dependend projects for services. + +5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile. + +6. Make sure you have needed ports (see them in `ports`) available on your local machine. + +7. Start services: + + 7.1. With an empty database `rm -rf data && docker-compose up` + + 7.2. With a stored (from previus runs) database data `docker-compose up` + +8. Check http://localhost:3000 + +9. Stop services: + + 9.1. Just press `Ctr+C` diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..00a8785 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 0000000..7173974 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,31 @@ +/** + * @type {import('next').NextConfig} + */ + +const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone'; +const nextConfig = { + trailingSlash: true, + distDir: 'build', + output, + basePath: '', + devIndicators: { + position: 'bottom-left', + }, + typescript: { + ignoreBuildErrors: true, + }, + eslint: { + ignoreDuringBuilds: true, + }, + images: { + unoptimized: true, + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1adcc23 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,77 @@ +{ + "private": true, + "scripts": { + "dev": "cross-env PORT=${FRONT_PORT:-3000} next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint", + "format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write" + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@mdi/js": "^7.4.47", + "@mui/material": "^6.3.0", + "@mui/x-data-grid": "^6.19.2", + "@reduxjs/toolkit": "^2.1.0", + "@tailwindcss/typography": "^0.5.13", + "@tinymce/tinymce-react": "^4.3.2", + "apexcharts": "^3.45.2", + "axios": "^1.8.4", + "chart.js": "^4.4.1", + "chroma-js": "^2.4.2", + "dayjs": "^1.11.10", + "file-saver": "^2.0.5", + "formik": "^2.4.5", + "html2canvas": "^1.4.1", + "i18next": "^25.1.2", + "i18next-browser-languagedetector": "^8.1.0", + "i18next-http-backend": "^3.0.2", + "intro.js": "^7.2.0", + "intro.js-react": "^1.0.0", + "jsonwebtoken": "^9.0.2", + "jwt-decode": "^3.1.2", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "next": "^15.3.1", + "next-i18next": "^15.4.2", + "numeral": "^2.0.6", + "query-string": "^8.1.0", + "react": "^19.0.0", + "react-apexcharts": "^1.4.1", + "react-big-calendar": "^1.10.3", + "react-chartjs-2": "^4.3.1", + "react-datepicker": "^4.10.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^19.0.0", + "react-i18next": "^15.5.1", + "react-redux": "^8.0.2", + "react-select": "^5.7.0", + "react-select-async-paginate": "^0.7.9", + "react-switch": "^7.0.0", + "react-toastify": "^11.0.2", + "swr": "^1.3.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/line-clamp": "^0.4.4", + "@types/node": "18.7.16", + "@types/numeral": "^2.0.2", + "@types/react-big-calendar": "^1.8.8", + "@types/react-redux": "^7.1.24", + "@typescript-eslint/eslint-plugin": "^5.37.0", + "@typescript-eslint/parser": "^5.37.0", + "autoprefixer": "^10.4.0", + "cross-env": "^7.0.3", + "eslint": "^8.23.1", + "eslint-config-next": "^13.0.4", + "eslint-config-prettier": "^8.5.0", + "postcss": "^8.4.4", + "postcss-import": "^14.1.0", + "prettier": "^3.2.4", + "tailwindcss": "^3.4.1", + "typescript": "^5.4.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..5bee7ce --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,9 @@ +/* eslint-env node */ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 0000000..d0ee549 --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,13 @@ +module.exports = { + semi: false, + singleQuote: true, + printWidth: 100, + trailingComma: 'es5', + arrowParens: 'always', + tabWidth: 2, + useTabs: false, + quoteProps: 'as-needed', + jsxSingleQuote: false, + bracketSpacing: true, + bracketSameLine: false, +}; diff --git a/frontend/public/data-sources/clients.json b/frontend/public/data-sources/clients.json new file mode 100644 index 0000000..c97621a --- /dev/null +++ b/frontend/public/data-sources/clients.json @@ -0,0 +1,224 @@ +{ + "data": [ + { + "id": 19, + "avatar": "https://avatars.dicebear.com/v2/gridy/Howell-Hand.svg", + "login": "percy64", + "name": "Howell Hand", + "company": "Kiehn-Green", + "city": "Emelyside", + "progress": 70, + "created": "Mar 3, 2022", + "created_mm_dd_yyyy": "03-03-2022" + }, + { + "id": 11, + "avatar": "https://avatars.dicebear.com/v2/gridy/Hope-Howe.svg", + "login": "dare.concepcion", + "name": "Hope Howe", + "company": "Nolan Inc", + "city": "Paristown", + "progress": 68, + "created": "Dec 1, 2022", + "created_mm_dd_yyyy": "12-01-2022" + }, + { + "id": 32, + "avatar": "https://avatars.dicebear.com/v2/gridy/Nelson-Jerde.svg", + "login": "geovanni.kessler", + "name": "Nelson Jerde", + "company": "Nitzsche LLC", + "city": "Jailynbury", + "progress": 49, + "created": "May 18, 2022", + "created_mm_dd_yyyy": "05-18-2022" + }, + { + "id": 22, + "avatar": "https://avatars.dicebear.com/v2/gridy/Kim-Weimann.svg", + "login": "macejkovic.dashawn", + "name": "Kim Weimann", + "company": "Brown-Lueilwitz", + "city": "New Emie", + "progress": 38, + "created": "May 4, 2022", + "created_mm_dd_yyyy": "05-04-2022" + }, + { + "id": 34, + "avatar": "https://avatars.dicebear.com/v2/gridy/Justice-OReilly.svg", + "login": "hilpert.leora", + "name": "Justice O'Reilly", + "company": "Lakin-Muller", + "city": "New Kacie", + "progress": 38, + "created": "Mar 27, 2022", + "created_mm_dd_yyyy": "03-27-2022" + }, + { + "id": 48, + "avatar": "https://avatars.dicebear.com/v2/gridy/Adrienne-Mayer-III.svg", + "login": "ferry.sophia", + "name": "Adrienne Mayer III", + "company": "Kozey, McLaughlin and Kuhn", + "city": "Howardbury", + "progress": 39, + "created": "Mar 29, 2022", + "created_mm_dd_yyyy": "03-29-2022" + }, + { + "id": 20, + "avatar": "https://avatars.dicebear.com/v2/gridy/Mr.-Julien-Ebert.svg", + "login": "gokuneva", + "name": "Mr. Julien Ebert", + "company": "Cormier LLC", + "city": "South Serenaburgh", + "progress": 29, + "created": "Jun 25, 2022", + "created_mm_dd_yyyy": "06-25-2022" + }, + { + "id": 47, + "avatar": "https://avatars.dicebear.com/v2/gridy/Lenna-Smitham.svg", + "login": "paolo.walter", + "name": "Lenna Smitham", + "company": "King Inc", + "city": "McCulloughfort", + "progress": 59, + "created": "Oct 8, 2022", + "created_mm_dd_yyyy": "10-08-2022" + }, + { + "id": 24, + "avatar": "https://avatars.dicebear.com/v2/gridy/Travis-Davis.svg", + "login": "lkessler", + "name": "Travis Davis", + "company": "Leannon and Sons", + "city": "West Frankton", + "progress": 52, + "created": "Oct 20, 2022", + "created_mm_dd_yyyy": "10-20-2022" + }, + { + "id": 49, + "avatar": "https://avatars.dicebear.com/v2/gridy/Prof.-Esteban-Steuber.svg", + "login": "shana.lang", + "name": "Prof. Esteban Steuber", + "company": "Langosh-Ernser", + "city": "East Sedrick", + "progress": 34, + "created": "May 16, 2022", + "created_mm_dd_yyyy": "05-16-2022" + }, + { + "id": 36, + "avatar": "https://avatars.dicebear.com/v2/gridy/Russell-Goodwin-V.svg", + "login": "jewel07", + "name": "Russell Goodwin V", + "company": "Nolan-Stracke", + "city": "Williamsonmouth", + "progress": 55, + "created": "Apr 22, 2022", + "created_mm_dd_yyyy": "04-22-2022" + }, + { + "id": 33, + "avatar": "https://avatars.dicebear.com/v2/gridy/Ms.-Cassidy-Wiegand-DVM.svg", + "login": "burnice.okuneva", + "name": "Ms. Cassidy Wiegand DVM", + "company": "Kuhlman-Hahn", + "city": "New Ruthiehaven", + "progress": 76, + "created": "Sep 16, 2022", + "created_mm_dd_yyyy": "09-16-2022" + }, + { + "id": 44, + "avatar": "https://avatars.dicebear.com/v2/gridy/Mr.-Watson-Brakus-PhD.svg", + "login": "oconnell.juanita", + "name": "Mr. Watson Brakus PhD", + "company": "Osinski, Bins and Kuhn", + "city": "Lake Gloria", + "progress": 58, + "created": "Jun 22, 2022", + "created_mm_dd_yyyy": "06-22-2022" + }, + { + "id": 46, + "avatar": "https://avatars.dicebear.com/v2/gridy/Mr.-Garrison-Friesen-V.svg", + "login": "vgutmann", + "name": "Mr. Garrison Friesen V", + "company": "VonRueden, Rippin and Pfeffer", + "city": "Port Cieloport", + "progress": 39, + "created": "Oct 19, 2022", + "created_mm_dd_yyyy": "10-19-2022" + }, + { + "id": 14, + "avatar": "https://avatars.dicebear.com/v2/gridy/Ms.-Sister-Morar.svg", + "login": "veum.lucio", + "name": "Ms. Sister Morar", + "company": "Gusikowski, Altenwerth and Abbott", + "city": "Lake Macville", + "progress": 34, + "created": "Jun 11, 2022", + "created_mm_dd_yyyy": "06-11-2022" + }, + { + "id": 40, + "avatar": "https://avatars.dicebear.com/v2/gridy/Ms.-Laisha-Reinger.svg", + "login": "edietrich", + "name": "Ms. Laisha Reinger", + "company": "Boehm PLC", + "city": "West Alexiemouth", + "progress": 73, + "created": "Nov 2, 2022", + "created_mm_dd_yyyy": "11-02-2022" + }, + { + "id": 5, + "avatar": "https://avatars.dicebear.com/v2/gridy/Cameron-Lind.svg", + "login": "mose44", + "name": "Cameron Lind", + "company": "Tremblay, Padberg and Pouros", + "city": "Naderview", + "progress": 59, + "created": "Sep 14, 2022", + "created_mm_dd_yyyy": "09-14-2022" + }, + { + "id": 43, + "avatar": "https://avatars.dicebear.com/v2/gridy/Sarai-Little.svg", + "login": "rau.abelardo", + "name": "Sarai Little", + "company": "Deckow LLC", + "city": "Jeanieborough", + "progress": 49, + "created": "Jun 13, 2022", + "created_mm_dd_yyyy": "06-13-2022" + }, + { + "id": 2, + "avatar": "https://avatars.dicebear.com/v2/gridy/Shyann-Kautzer.svg", + "login": "imurazik", + "name": "Shyann Kautzer", + "company": "Osinski, Boehm and Kihn", + "city": "New Alvera", + "progress": 41, + "created": "Feb 15, 2022", + "created_mm_dd_yyyy": "02-15-2022" + }, + { + "id": 15, + "avatar": "https://avatars.dicebear.com/v2/gridy/Lorna-Christiansen.svg", + "login": "annalise97", + "name": "Lorna Christiansen", + "company": "Altenwerth-Friesen", + "city": "Port Elbertland", + "progress": 36, + "created": "Mar 9, 2022", + "created_mm_dd_yyyy": "03-09-2022" + } + ] +} diff --git a/frontend/public/data-sources/history.json b/frontend/public/data-sources/history.json new file mode 100644 index 0000000..12e81b6 --- /dev/null +++ b/frontend/public/data-sources/history.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "id": 1, + "amount": 375.53, + "account": "45721474", + "name": "Home Loan Account", + "date": "3 days ago", + "type": "deposit", + "business": "Turcotte" + }, + { + "id": 2, + "amount": 470.26, + "account": "94486537", + "name": "Savings Account", + "date": "3 days ago", + "type": "payment", + "business": "Murazik - Graham" + }, + { + "id": 3, + "amount": 971.34, + "account": "63189893", + "name": "Checking Account", + "date": "5 days ago", + "type": "invoice", + "business": "Fahey - Keebler" + }, + { + "id": 4, + "amount": 374.63, + "account": "74828780", + "name": "Auto Loan Account", + "date": "7 days ago", + "type": "withdraw", + "business": "Collier - Hintz" + } + ] +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..c8c4e3e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/locales/de/common.json b/frontend/public/locales/de/common.json new file mode 100644 index 0000000..0d13fe5 --- /dev/null +++ b/frontend/public/locales/de/common.json @@ -0,0 +1,55 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Dashboard", + "overview": "Übersicht", + "loadingWidgets": "Widgets werden geladen...", + "loading": "Laden..." + }, + "login": { + "pageTitle": "Anmeldung", + + "sampleCredentialsAdmin": "Verwenden Sie {{email}} / {{password}}, um sich als Administrator anzumelden", + "sampleCredentialsUser": "Verwenden Sie {{email}} / {{password}}, um sich als Benutzer anzumelden", + + "form": { + "loginLabel": "Login", + "loginHelp": "Bitte geben Sie Ihren Login ein", + "passwordLabel": "Passwort", + "passwordHelp": "Bitte geben Sie Ihr Passwort ein", + "remember": "Angemeldet bleiben", + "forgotPassword": "Passwort vergessen?", + "loginButton": "Anmelden", + "loading": "Wird geladen...", + "noAccountYet": "Noch kein Konto?", + "newAccount": "Neues Konto" + }, + + "pexels": { + "photoCredit": "Foto von {{photographer}} auf Pexels", + "videoCredit": "Video von {{name}} auf Pexels", + "videoUnsupported": "Ihr Browser unterstützt das Video-Tag nicht." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. Alle Rechte vorbehalten", + "privacy": "Datenschutzrichtlinie" + } + } + }, + "components": { + "widgetCreator": { + "title": "Diagramm oder Widget erstellen", + "helpText": "Beschreiben Sie Ihr neues Widget oder Diagramm in natürlicher Sprache. Zum Beispiel: \"Anzahl der Admin-Benutzer\" ODER \"rotes Diagramm mit der Anzahl geschlossener Verträge gruppiert nach Monat\"", + "settingsTitle": "Einstellungen für Widget Creator", + "settingsDescription": "Für welche Rolle zeigen wir Widgets an und erstellen sie?", + "doneButton": "Fertig", + "loading": "Laden..." + }, + "search": { + "placeholder": "Suche", + "required": "Pflichtfeld", + "minLength": "Mindestlänge: {{count}} Zeichen" + } + } +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json new file mode 100644 index 0000000..2c1a38d --- /dev/null +++ b/frontend/public/locales/en/common.json @@ -0,0 +1,52 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Dashboard", + "overview": "Overview", + "loadingWidgets": "Loading widgets...", + "loading": "Loading..." + }, + "login": { + "pageTitle": "Login", + + "form": { + "loginLabel": "Login", + "loginHelp": "Please enter your login", + "passwordLabel": "Password", + "passwordHelp": "Please enter your password", + "remember": "Remember", + "forgotPassword": "Forgot password?", + "loginButton": "Login", + "loading": "Loading...", + "noAccountYet": "Don’t have an account yet?", + "newAccount": "New Account" + }, + + "pexels": { + "photoCredit": "Photo by {{photographer}} on Pexels", + "videoCredit": "Video by {{name}} on Pexels", + "videoUnsupported": "Your browser does not support the video tag." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. All rights reserved", + "privacy": "Privacy Policy" + } + } + }, + "components": { + "widgetCreator": { + "title": "Create Chart or Widget", + "helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"", + "settingsTitle": "Widget Creator Settings", + "settingsDescription": "What role are we showing and creating widgets for?", + "doneButton": "Done", + "loading": "Loading..." + }, + "search": { + "placeholder": "Search", + "required": "Required", + "minLength": "Minimum length: {{count}} characters" + } + } +} diff --git a/frontend/public/locales/es/common.json b/frontend/public/locales/es/common.json new file mode 100644 index 0000000..a7ba27a --- /dev/null +++ b/frontend/public/locales/es/common.json @@ -0,0 +1,55 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Tablero", + "overview": "Resumen", + "loadingWidgets": "Cargando widgets...", + "loading": "Cargando..." + }, + "login": { + "pageTitle": "Inicio de sesión", + + "sampleCredentialsAdmin": "Use {{email}} / {{password}} para iniciar sesión como Administrador", + "sampleCredentialsUser": "Use {{email}} / {{password}} para iniciar sesión como Usuario", + + "form": { + "loginLabel": "Usuario", + "loginHelp": "Introduzca su usuario", + "passwordLabel": "Contraseña", + "passwordHelp": "Introduzca su contraseña", + "remember": "Recuérdame", + "forgotPassword": "¿Olvidó su contraseña?", + "loginButton": "Acceder", + "loading": "Cargando...", + "noAccountYet": "¿Aún no tiene una cuenta?", + "newAccount": "Crear cuenta" + }, + + "pexels": { + "photoCredit": "Foto de {{photographer}} en Pexels", + "videoCredit": "Vídeo de {{name}} en Pexels", + "videoUnsupported": "Su navegador no admite la etiqueta de vídeo." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. Todos los derechos reservados", + "privacy": "Política de privacidad" + } + } + }, + "components": { + "widgetCreator": { + "title": "Crear gráfico o widget", + "helpText": "Describe tu nuevo widget o gráfico en lenguaje natural. Por ejemplo: \"Número de usuarios administradores\" O \"gráfico rojo con el número de contratos cerrados agrupados por mes\"", + "settingsTitle": "Configuración del creador de widgets", + "settingsDescription": "¿Para qué rol estamos mostrando y creando widgets?", + "doneButton": "Listo", + "loading": "Cargando..." + }, + "search": { + "placeholder": "Buscar", + "required": "Obligatorio", + "minLength": "Longitud mínima: {{count}} caracteres" + } + } +} diff --git a/frontend/public/locales/fr/common.json b/frontend/public/locales/fr/common.json new file mode 100644 index 0000000..8b8494a --- /dev/null +++ b/frontend/public/locales/fr/common.json @@ -0,0 +1,55 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Tableau de bord", + "overview": "Vue d'ensemble", + "loadingWidgets": "Chargement des widgets...", + "loading": "Chargement..." + }, + "login": { + "pageTitle": "Connexion", + + "sampleCredentialsAdmin": "Utilisez {{email}} / {{password}} pour vous connecter en tant qu’administrateur", + "sampleCredentialsUser": "Utilisez {{email}} / {{password}} pour vous connecter en tant qu’utilisateur", + + "form": { + "loginLabel": "Identifiant", + "loginHelp": "Veuillez saisir votre identifiant", + "passwordLabel": "Mot de passe", + "passwordHelp": "Veuillez saisir votre mot de passe", + "remember": "Se souvenir de moi", + "forgotPassword": "Mot de passe oublié ?", + "loginButton": "Se connecter", + "loading": "Chargement…", + "noAccountYet": "Vous n’avez pas encore de compte ?", + "newAccount": "Créer un compte" + }, + + "pexels": { + "photoCredit": "Photo de {{photographer}} sur Pexels", + "videoCredit": "Vidéo de {{name}} sur Pexels", + "videoUnsupported": "Votre navigateur ne prend pas en charge la balise vidéo." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. Tous droits réservés", + "privacy": "Politique de confidentialité" + } + } + }, + "components": { + "widgetCreator": { + "title": "Créer un graphique ou un widget", + "helpText": "Décrivez votre nouveau widget ou graphique en langage naturel. Par exemple : \"Nombre d'utilisateurs administrateurs\" OU \"graphique rouge avec le nombre de contrats clôturés regroupés par mois\"", + "settingsTitle": "Paramètres du créateur de widget", + "settingsDescription": "Pour quel rôle affichons-nous et créons-nous des widgets ?", + "doneButton": "Terminé", + "loading": "Chargement..." + }, + "search": { + "placeholder": "Rechercher", + "required": "Champ requis", + "minLength": "Longueur minimale : {{count}} caractères" + } + } +} diff --git a/frontend/src/colors.ts b/frontend/src/colors.ts new file mode 100644 index 0000000..0fea7b8 --- /dev/null +++ b/frontend/src/colors.ts @@ -0,0 +1,145 @@ +import type { ColorButtonKey } from './interfaces'; + +export const gradientBgBase = 'bg-gradient-to-tr'; +export const colorBgBase = 'bg-violet-50/50'; +export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`; +export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`; +export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`; +export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`; + +export const colorsBgLight = { + white: 'bg-white text-black', + light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white', + contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black', + success: + 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', + danger: 'bg-red-500 border-red-500 text-white', + warning: 'bg-yellow-500 border-yellow-500 text-white', + info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', +}; + +export const colorsText = { + white: 'text-black dark:text-slate-100', + light: 'text-gray-700 dark:text-slate-400', + contrast: 'dark:text-white', + success: 'text-emerald-500', + danger: 'text-red-500', + warning: 'text-yellow-500', + info: 'text-blue-500', +}; + +export const colorsOutline = { + white: [colorsText.white, 'border-gray-100'].join(' '), + light: [colorsText.light, 'border-gray-100'].join(' '), + contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join( + ' ', + ), + success: [colorsText.success, 'border-emerald-500'].join(' '), + danger: [colorsText.danger, 'border-red-500'].join(' '), + warning: [colorsText.warning, 'border-yellow-500'].join(' '), + info: [colorsText.info, 'border-blue-500'].join(' '), +}; + +export const getButtonColor = ( + color: ColorButtonKey, + isOutlined: boolean, + hasHover: boolean, + isActive = false, +) => { + if (color === 'void') { + return ''; + } + + const colors = { + ring: { + white: 'ring-gray-200 dark:ring-gray-500', + whiteDark: 'ring-gray-200 dark:ring-dark-500', + lightDark: 'ring-gray-200 dark:ring-gray-500', + contrast: 'ring-gray-300 dark:ring-gray-400', + success: 'ring-emerald-300 dark:ring-pavitra-blue', + danger: 'ring-red-300 dark:ring-red-700', + warning: 'ring-yellow-300 dark:ring-yellow-700', + info: 'ring-blue-300 dark:ring-pavitra-blue', + }, + active: { + white: 'bg-gray-100', + whiteDark: 'bg-gray-100 dark:bg-dark-800', + lightDark: 'bg-gray-200 dark:bg-slate-700', + contrast: 'bg-gray-700 dark:bg-slate-100', + success: 'bg-emerald-700 dark:bg-pavitra-blue', + danger: 'bg-red-700 dark:bg-red-600', + warning: 'bg-yellow-700 dark:bg-yellow-600', + info: 'bg-blue-700 dark:bg-pavitra-blue', + }, + bg: { + white: 'bg-white text-black', + whiteDark: 'bg-white text-black dark:bg-dark-900 dark:text-white', + lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white', + contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black', + success: 'bg-emerald-600 dark:bg-pavitra-blue text-white', + danger: 'bg-red-600 text-white dark:bg-red-500 ', + warning: 'bg-yellow-600 dark:bg-yellow-500 text-white', + info: ' bg-blue-600 dark:bg-pavitra-blue text-white ', + }, + bgHover: { + white: 'hover:bg-gray-100', + whiteDark: 'hover:bg-gray-100 hover:dark:bg-dark-800', + lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700', + contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100', + success: + 'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue', + danger: + 'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600', + warning: + 'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600', + info: 'hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80', + }, + borders: { + white: 'border-white', + whiteDark: 'border-white dark:border-dark-900', + lightDark: 'border-gray-100 dark:border-slate-800', + contrast: 'border-gray-800 dark:border-white', + success: 'border-emerald-600 dark:border-pavitra-blue', + danger: 'border-red-600 dark:border-red-500', + warning: 'border-yellow-600 dark:border-yellow-500', + info: 'border-blue-600 border-blue-600 dark:border-pavitra-blue', + }, + text: { + contrast: 'dark:text-slate-100', + success: 'text-emerald-600 dark:text-pavitra-blue', + danger: 'text-red-600 dark:text-red-500', + warning: 'text-yellow-600 dark:text-yellow-500', + info: 'text-blue-600 dark:text-pavitra-blue', + }, + outlineHover: { + contrast: + 'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black', + success: + 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue', + danger: + 'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600', + warning: + 'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600', + info: 'hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue', + }, + }; + + const isOutlinedProcessed = + isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0; + + const base = [colors.borders[color], colors.ring[color]]; + + if (isActive) { + base.push(colors.active[color]); + } else { + base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color]); + } + + if (hasHover) { + base.push( + isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color], + ); + } + + return base.join(' '); +}; diff --git a/frontend/src/components/Agences/CardAgences.tsx b/frontend/src/components/Agences/CardAgences.tsx new file mode 100644 index 0000000..4213921 --- /dev/null +++ b/frontend/src/components/Agences/CardAgences.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + agences: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardAgences = ({ + agences, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_AGENCES'); + + return ( +
+ {loading && } +
    + {!loading && + agences.map((item, index) => ( +
  • +
    + + {item.name} + + +
    + +
    +
    +
    +
    +
    Name
    +
    +
    {item.name}
    +
    +
    +
    +
  • + ))} + {!loading && agences.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardAgences; diff --git a/frontend/src/components/Agences/ListAgences.tsx b/frontend/src/components/Agences/ListAgences.tsx new file mode 100644 index 0000000..a490ac8 --- /dev/null +++ b/frontend/src/components/Agences/ListAgences.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + agences: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListAgences = ({ + agences, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_AGENCES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + agences.map((item) => ( +
+ +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Name

+

{item.name}

+
+ + +
+
+
+ ))} + {!loading && agences.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListAgences; diff --git a/frontend/src/components/Agences/TableAgences.tsx b/frontend/src/components/Agences/TableAgences.tsx new file mode 100644 index 0000000..812f907 --- /dev/null +++ b/frontend/src/components/Agences/TableAgences.tsx @@ -0,0 +1,481 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/agences/agencesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureAgencesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleAgences = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + agences, + loading, + count, + notify: agencesNotify, + refetch, + } = useAppSelector((state) => state.agences); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (agencesNotify.showNotification) { + notify(agencesNotify.typeNotification, agencesNotify.textNotification); + } + }, [agencesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `agences`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={agences ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleAgences; diff --git a/frontend/src/components/Agences/configureAgencesCols.tsx b/frontend/src/components/Agences/configureAgencesCols.tsx new file mode 100644 index 0000000..c54bcf4 --- /dev/null +++ b/frontend/src/components/Agences/configureAgencesCols.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_AGENCES'); + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
+ +
, + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/AsideMenu.tsx b/frontend/src/components/AsideMenu.tsx new file mode 100644 index 0000000..0a1c120 --- /dev/null +++ b/frontend/src/components/AsideMenu.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { MenuAsideItem } from '../interfaces'; +import AsideMenuLayer from './AsideMenuLayer'; +import OverlayLayer from './OverlayLayer'; + +type Props = { + menu: MenuAsideItem[]; + isAsideMobileExpanded: boolean; + isAsideLgActive: boolean; + onAsideLgClose: () => void; +}; + +export default function AsideMenu({ + isAsideMobileExpanded = false, + isAsideLgActive = false, + ...props +}: Props) { + return ( + <> + + {isAsideLgActive && ( + + )} + + ); +} diff --git a/frontend/src/components/AsideMenuItem.tsx b/frontend/src/components/AsideMenuItem.tsx new file mode 100644 index 0000000..c5a8b8a --- /dev/null +++ b/frontend/src/components/AsideMenuItem.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useState } from 'react'; +import { mdiMinus, mdiPlus } from '@mdi/js'; +import BaseIcon from './BaseIcon'; +import Link from 'next/link'; +import { getButtonColor } from '../colors'; +import AsideMenuList from './AsideMenuList'; +import { MenuAsideItem } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; +import { useRouter } from 'next/router'; + +type Props = { + item: MenuAsideItem; + isDropdownList?: boolean; +}; + +const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { + const [isLinkActive, setIsLinkActive] = useState(false); + const [isDropdownActive, setIsDropdownActive] = useState(false); + + const asideMenuItemStyle = useAppSelector( + (state) => state.style.asideMenuItemStyle, + ); + const asideMenuDropdownStyle = useAppSelector( + (state) => state.style.asideMenuDropdownStyle, + ); + const asideMenuItemActiveStyle = useAppSelector( + (state) => state.style.asideMenuItemActiveStyle, + ); + const borders = useAppSelector((state) => state.style.borders); + const activeLinkColor = useAppSelector( + (state) => state.style.activeLinkColor, + ); + const activeClassAddon = + !item.color && isLinkActive ? asideMenuItemActiveStyle : ''; + + const { asPath, isReady } = useRouter(); + + useEffect(() => { + if (item.href && isReady) { + const linkPathName = new URL(item.href, location.href).pathname + '/'; + const activePathname = new URL(asPath, location.href).pathname; + + const activeView = activePathname.split('/')[1]; + const linkPathNameView = linkPathName.split('/')[1]; + + setIsLinkActive(linkPathNameView === activeView); + } + }, [item.href, isReady, asPath]); + + const asideMenuItemInnerContents = ( + <> + {item.icon && ( + + )} + + {item.label} + + {item.menu && ( + + )} + + ); + + const componentClass = [ + 'flex cursor-pointer py-1.5 ', + isDropdownList ? 'px-6 text-sm' : '', + item.color + ? getButtonColor(item.color, false, true) + : `${asideMenuItemStyle}`, + isLinkActive + ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` + : '', + ].join(' '); + + return ( +
  • + {item.withDevider &&
    } + {item.href && ( + + {asideMenuItemInnerContents} + + )} + {!item.href && ( +
    setIsDropdownActive(!isDropdownActive)} + > + {asideMenuItemInnerContents} +
    + )} + {item.menu && ( + + )} +
  • + ); +}; + +export default AsideMenuItem; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx new file mode 100644 index 0000000..59aec77 --- /dev/null +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { mdiLogout, mdiClose } from '@mdi/js'; +import BaseIcon from './BaseIcon'; +import AsideMenuList from './AsideMenuList'; +import { MenuAsideItem } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; +import Link from 'next/link'; + +import { useAppDispatch } from '../stores/hooks'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; + +type Props = { + menu: MenuAsideItem[]; + className?: string; + onAsideLgCloseClick: () => void; +}; + +export default function AsideMenuLayer({ + menu, + className = '', + ...props +}: Props) { + const corners = useAppSelector((state) => state.style.corners); + const asideStyle = useAppSelector((state) => state.style.asideStyle); + const asideBrandStyle = useAppSelector( + (state) => state.style.asideBrandStyle, + ); + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const darkMode = useAppSelector((state) => state.style.darkMode); + + const handleAsideLgCloseClick = (e: React.MouseEvent) => { + e.preventDefault(); + props.onAsideLgCloseClick(); + }; + + const dispatch = useAppDispatch(); + const { currentUser } = useAppSelector((state) => state.auth); + const agencesId = currentUser?.agences?.id; + const [organizations, setOrganizations] = React.useState(null); + + const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => { + try { + const response = await axios.get('/org-for-auth'); + setOrganizations(response.data); + return response.data; + } catch (error) { + console.error(error.response); + throw error; + } + }); + + React.useEffect(() => { + dispatch(fetchOrganizations()); + }, [dispatch]); + + let organizationName = organizations?.find( + (item) => item.id === agencesId, + )?.name; + if (organizationName?.length > 25) { + organizationName = organizationName?.substring(0, 25) + '...'; + } + + return ( + + ); +} diff --git a/frontend/src/components/AsideMenuList.tsx b/frontend/src/components/AsideMenuList.tsx new file mode 100644 index 0000000..9f0434e --- /dev/null +++ b/frontend/src/components/AsideMenuList.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { MenuAsideItem } from '../interfaces'; +import AsideMenuItem from './AsideMenuItem'; +import { useAppSelector } from '../stores/hooks'; +import { hasPermission } from '../helpers/userPermissions'; + +type Props = { + menu: MenuAsideItem[]; + isDropdownList?: boolean; + className?: string; +}; + +export default function AsideMenuList({ + menu, + isDropdownList = false, + className = '', +}: Props) { + const { currentUser } = useAppSelector((state) => state.auth); + + if (!currentUser) return null; + + return ( +
      + {menu.map((item, index) => { + if (!hasPermission(currentUser, item.permissions)) return null; + + return ( +
      + +
      + ); + })} +
    + ); +} diff --git a/frontend/src/components/BaseButton.tsx b/frontend/src/components/BaseButton.tsx new file mode 100644 index 0000000..cb87f90 --- /dev/null +++ b/frontend/src/components/BaseButton.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import Link from 'next/link'; +import { getButtonColor } from '../colors'; +import BaseIcon from './BaseIcon'; +import type { ColorButtonKey } from '../interfaces'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + icon?: string; + iconSize?: string | number; + href?: string; + target?: string; + type?: string; + color?: ColorButtonKey; + className?: string; + iconClassName?: string; + asAnchor?: boolean; + small?: boolean; + outline?: boolean; + active?: boolean; + disabled?: boolean; + roundedFull?: boolean; + onClick?: (e: React.MouseEvent) => void; +}; + +export default function BaseButton({ + label, + icon, + iconSize, + href, + target, + type, + color = 'white', + className = '', + iconClassName = '', + asAnchor = false, + small = false, + outline = false, + active = false, + disabled = false, + roundedFull = false, + onClick, +}: Props) { + const corners = useAppSelector((state) => state.style.corners); + const componentClass = [ + 'inline-flex', + 'justify-center', + 'items-center', + 'whitespace-nowrap', + 'focus:outline-none', + 'transition-colors', + 'focus:ring', + 'duration-150', + 'border', + disabled ? 'cursor-not-allowed' : 'cursor-pointer', + roundedFull ? 'rounded-full' : `${corners}`, + getButtonColor(color, outline, !disabled, active), + className, + ]; + + if (!label && icon) { + componentClass.push('p-1'); + } else if (small) { + componentClass.push('text-sm', roundedFull ? 'px-3 py-1' : 'p-1'); + } else { + componentClass.push('py-2', roundedFull ? 'px-6' : 'px-3'); + } + + if (disabled) { + componentClass.push(outline ? 'opacity-50' : 'opacity-70'); + } + + const componentClassString = componentClass.join(' '); + + const componentChildren = ( + <> + {icon && ( + + )} + {label && ( + {label} + )} + + ); + + if (href && !disabled) { + return ( + + {componentChildren} + + ); + } + + return React.createElement( + asAnchor ? 'a' : 'button', + { + className: componentClassString, + type: type ?? 'button', + target, + disabled, + onClick, + }, + componentChildren, + ); +} diff --git a/frontend/src/components/BaseButtons.tsx b/frontend/src/components/BaseButtons.tsx new file mode 100644 index 0000000..c017c92 --- /dev/null +++ b/frontend/src/components/BaseButtons.tsx @@ -0,0 +1,40 @@ +import { Children, cloneElement, ReactElement } from 'react'; +import type { ReactNode } from 'react'; + +type Props = { + type?: string; + mb?: string; + noWrap?: boolean; + classAddon?: string; + children?: ReactNode; + className?: string; +}; + +const BaseButtons = ({ + type = 'justify-end', + mb = '-mb-3', + classAddon = 'mr-3 last:mr-0 mb-3', + noWrap = false, + children, + className, +}: Props) => { + return ( +
    + {Children.map(children, (child: ReactElement) => + child + ? cloneElement(child as ReactElement<{ className?: string }>, { + className: `${classAddon} ${ + (child.props as { className?: string }).className || '' + }`, + }) + : null, + )} +
    + ); +}; + +export default BaseButtons; diff --git a/frontend/src/components/BaseDivider.tsx b/frontend/src/components/BaseDivider.tsx new file mode 100644 index 0000000..52e7f29 --- /dev/null +++ b/frontend/src/components/BaseDivider.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useAppSelector } from '../stores/hooks'; +type Props = { + navBar?: boolean; +}; + +export default function BaseDivider({ navBar = false }: Props) { + const borders = useAppSelector((state) => state.style.borders); + const classAddon = navBar + ? 'hidden lg:block lg:my-0.5 dark:border-dark-700' + : 'my-6 -mx-6 dark:border-dark-800'; + + return
    ; +} diff --git a/frontend/src/components/BaseIcon.tsx b/frontend/src/components/BaseIcon.tsx new file mode 100644 index 0000000..d26fe1c --- /dev/null +++ b/frontend/src/components/BaseIcon.tsx @@ -0,0 +1,39 @@ +import React, { ReactNode } from 'react'; + +type Props = { + path: string; + w?: string; + h?: string; + fill?: string; + size?: string | number | null; + className?: string; + children?: ReactNode; +}; + +export default function BaseIcon({ + path, + fill, + w = 'w-6', + h = 'h-6', + size = null, + className = '', + children, +}: Props) { + const iconSize = size ?? 16; + + return ( + + + + + {children} + + ); +} diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx new file mode 100644 index 0000000..0b26ffe --- /dev/null +++ b/frontend/src/components/BigCalendar.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useMemo, useState, useRef } from 'react'; +import { + Calendar, + Views, + momentLocalizer, + SlotInfo, + EventProps, +} from 'react-big-calendar'; +import moment from 'moment'; +import 'react-big-calendar/lib/css/react-big-calendar.css'; +import ListActionsPopover from './ListActionsPopover'; +import Link from 'next/link'; + +import { useAppSelector } from '../stores/hooks'; +import { hasPermission } from '../helpers/userPermissions'; + +const localizer = momentLocalizer(moment); + +type TEvent = { + id: string; + title: string; + start: Date; + end: Date; +}; + +type Props = { + events: any[]; + handleDeleteAction: (id: string) => void; + handleCreateEventAction: (slotInfo: SlotInfo) => void; + onDateRangeChange: (range: { start: string; end: string }) => void; + entityName: string; + showField: string; + pathEdit?: string; + pathView?: string; + 'start-data-key': string; + 'end-data-key': string; +}; + +const BigCalendar = ({ + events, + handleDeleteAction, + handleCreateEventAction, + onDateRangeChange, + entityName, + showField, + pathEdit, + pathView, + 'start-data-key': startDataKey, + 'end-data-key': endDataKey, +}: Props) => { + const [myEvents, setMyEvents] = useState([]); + const prevRange = useRef<{ start: string; end: string } | null>(null); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = + currentUser && + hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`); + const hasCreatePermission = + currentUser && + hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`); + + const { defaultDate, scrollToTime } = useMemo( + () => ({ + defaultDate: new Date(), + scrollToTime: new Date(1970, 1, 1, 6), + }), + [], + ); + + useEffect(() => { + if (!events || !Array.isArray(events) || !events?.length) return; + + const formattedEvents = events.map((event) => ({ + ...event, + start: new Date(event[startDataKey]), + end: new Date(event[endDataKey]), + title: event[showField], + })); + + setMyEvents(formattedEvents); + }, [endDataKey, events, startDataKey, showField]); + + const onRangeChange = (range: Date[] | { start: Date; end: Date }) => { + const newRange = { start: '', end: '' }; + const format = 'YYYY-MM-DDTHH:mm'; + + if (Array.isArray(range)) { + newRange.start = moment(range[0]).format(format); + newRange.end = moment(range[range.length - 1]).format(format); + } else { + newRange.start = moment(range.start).format(format); + newRange.end = moment(range.end).format(format); + } + + if (newRange.start === newRange.end) { + newRange.end = moment(newRange.end).add(1, 'days').format(format); + } + + // check if the range fits in the previous range + if ( + prevRange.current && + prevRange.current.start <= newRange.start && + prevRange.current.end >= newRange.end + ) { + return; + } + + prevRange.current = { start: newRange.start, end: newRange.end }; + onDateRangeChange(newRange); + }; + + return ( +
    + ( + + ), + }} + /> +
    + ); +}; + +const MyCustomEvent = ( + props: { + onDelete: (id: string) => void; + hasUpdatePermission: boolean; + pathEdit?: string; + pathView?: string; + } & EventProps, +) => { + const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = + props; + + return ( +
    + + {title} + + +
    + ); +}; + +export default BigCalendar; diff --git a/frontend/src/components/CardBox.tsx b/frontend/src/components/CardBox.tsx new file mode 100644 index 0000000..dea7269 --- /dev/null +++ b/frontend/src/components/CardBox.tsx @@ -0,0 +1,70 @@ +import React, { ReactNode } from 'react'; +import CardBoxComponentBody from './CardBoxComponentBody'; +import CardBoxComponentFooter from './CardBoxComponentFooter'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + rounded?: string; + flex?: string; + className?: string; + hasComponentLayout?: boolean; + cardBoxClassName?: string; + hasTable?: boolean; + isHoverable?: boolean; + isModal?: boolean; + children?: ReactNode; + footer?: ReactNode; + isList?: boolean; + id?: string; + onClick?: (e: React.MouseEvent) => void; +}; + +export default function CardBox({ + rounded = 'rounded', + flex = 'flex-col', + className = '', + hasComponentLayout = false, + cardBoxClassName = '', + hasTable = false, + isHoverable = false, + isList = false, + isModal = false, + children, + footer, + id = '', + onClick, +}: Props) { + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const componentClass = [ + `flex dark:border-dark-700 dark:bg-dark-900`, + className, + corners !== 'rounded-full' ? corners : 'rounded-3xl', + flex, + isList ? '' : `${cardsStyle}`, + hasTable ? '' : `border-dark-700 dark:border-dark-700`, + ]; + + if (isHoverable) { + componentClass.push('hover:shadow-lg transition-shadow duration-500'); + } + + return React.createElement( + 'div', + { className: componentClass.join(' '), onClick }, + hasComponentLayout ? ( + children + ) : ( + <> + + {children} + + {footer && {footer}} + + ), + ); +} diff --git a/frontend/src/components/CardBoxComponentBody.tsx b/frontend/src/components/CardBoxComponentBody.tsx new file mode 100644 index 0000000..12448d8 --- /dev/null +++ b/frontend/src/components/CardBoxComponentBody.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; + +type Props = { + noPadding?: boolean; + className?: string; + children?: ReactNode; + id?: string; +}; + +export default function CardBoxComponentBody({ + noPadding = false, + className, + children, + id, +}: Props) { + return ( +
    + {children} +
    + ); +} diff --git a/frontend/src/components/CardBoxComponentEmpty.tsx b/frontend/src/components/CardBoxComponentEmpty.tsx new file mode 100644 index 0000000..c9072bb --- /dev/null +++ b/frontend/src/components/CardBoxComponentEmpty.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const CardBoxComponentEmpty = () => { + return ( +
    +

    Nothing's here…

    +
    + ); +}; + +export default CardBoxComponentEmpty; diff --git a/frontend/src/components/CardBoxComponentFooter.tsx b/frontend/src/components/CardBoxComponentFooter.tsx new file mode 100644 index 0000000..184a058 --- /dev/null +++ b/frontend/src/components/CardBoxComponentFooter.tsx @@ -0,0 +1,10 @@ +import React, { ReactNode } from 'react'; + +type Props = { + className?: string; + children?: ReactNode; +}; + +export default function CardBoxComponentFooter({ className, children }: Props) { + return
    {children}
    ; +} diff --git a/frontend/src/components/CardBoxComponentTitle.tsx b/frontend/src/components/CardBoxComponentTitle.tsx new file mode 100644 index 0000000..20990e6 --- /dev/null +++ b/frontend/src/components/CardBoxComponentTitle.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; + +type Props = { + title: string; + children?: ReactNode; +}; + +const CardBoxComponentTitle = ({ title, children }: Props) => { + return ( +
    +

    {title}

    + {children} +
    + ); +}; + +export default CardBoxComponentTitle; diff --git a/frontend/src/components/CardBoxModal.tsx b/frontend/src/components/CardBoxModal.tsx new file mode 100644 index 0000000..200b4e7 --- /dev/null +++ b/frontend/src/components/CardBoxModal.tsx @@ -0,0 +1,75 @@ +import { mdiClose } from '@mdi/js'; +import { ReactNode } from 'react'; +import type { ColorButtonKey } from '../interfaces'; +import BaseButton from './BaseButton'; +import BaseButtons from './BaseButtons'; +import CardBox from './CardBox'; +import CardBoxComponentTitle from './CardBoxComponentTitle'; +import OverlayLayer from './OverlayLayer'; + +type Props = { + title: string; + buttonColor: ColorButtonKey; + buttonLabel: string; + isActive: boolean; + children?: ReactNode; + onConfirm: () => void; + onCancel?: () => void; +}; + +const CardBoxModal = ({ + title, + buttonColor, + buttonLabel, + isActive, + children, + onConfirm, + onCancel, +}: Props) => { + if (!isActive) { + return null; + } + + const footer = ( + + + {!!onCancel && ( + + )} + + ); + + return ( + + + + {!!onCancel && ( + + )} + + +
    {children}
    +
    +
    + ); +}; + +export default CardBoxModal; diff --git a/frontend/src/components/ChartLineSample/config.ts b/frontend/src/components/ChartLineSample/config.ts new file mode 100644 index 0000000..c29cbdd --- /dev/null +++ b/frontend/src/components/ChartLineSample/config.ts @@ -0,0 +1,54 @@ +export const chartColors = { + default: { + primary: '#00D1B2', + info: '#209CEE', + danger: '#FF3860', + }, +}; + +const randomChartData = (n: number) => { + const data = []; + + for (let i = 0; i < n; i++) { + data.push(Math.round(Math.random() * 200)); + } + + return data; +}; + +const datasetObject = (color: string, points: number) => { + return { + fill: false, + borderColor: chartColors.default[color], + borderWidth: 2, + borderDash: [], + borderDashOffset: 0.0, + pointBackgroundColor: chartColors.default[color], + pointBorderColor: 'rgba(255,255,255,0)', + pointHoverBackgroundColor: chartColors.default[color], + pointBorderWidth: 20, + pointHoverRadius: 4, + pointHoverBorderWidth: 15, + pointRadius: 4, + data: randomChartData(points), + tension: 0.5, + cubicInterpolationMode: 'default', + }; +}; + +export const sampleChartData = (points = 9) => { + const labels = []; + + for (let i = 1; i <= points; i++) { + labels.push(`0${i}`); + } + + return { + labels, + datasets: [ + datasetObject('primary', points), + datasetObject('info', points), + datasetObject('danger', points), + ], + }; +}; diff --git a/frontend/src/components/ChartLineSample/index.tsx b/frontend/src/components/ChartLineSample/index.tsx new file mode 100644 index 0000000..0761549 --- /dev/null +++ b/frontend/src/components/ChartLineSample/index.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { + Chart, + LineElement, + PointElement, + LineController, + LinearScale, + CategoryScale, + Tooltip, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; + +Chart.register( + LineElement, + PointElement, + LineController, + LinearScale, + CategoryScale, + Tooltip, +); + +const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + display: false, + }, + x: { + display: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, +}; + +const ChartLineSample = ({ data }) => { + return ; +}; + +export default ChartLineSample; diff --git a/frontend/src/components/Claims/CardClaims.tsx b/frontend/src/components/Claims/CardClaims.tsx new file mode 100644 index 0000000..35d0023 --- /dev/null +++ b/frontend/src/components/Claims/CardClaims.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + claims: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardClaims = ({ + claims, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CLAIMS'); + + return ( +
    + {loading && } +
      + {!loading && + claims.map((item, index) => ( +
    • +
      + + {item.category} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + Client +
      +
      +
      + {dataFormatter.clientsOneListFormatter(item.client)} +
      +
      +
      + +
      +
      + Reservation +
      +
      +
      + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +
      +
      +
      + +
      +
      + Category +
      +
      +
      + {item.category} +
      +
      +
      + +
      +
      + Description +
      +
      +
      + {item.description} +
      +
      +
      + +
      +
      + Status +
      +
      +
      + {item.status} +
      +
      +
      + +
      +
      + Attachments +
      +
      +
      + {item.attachments} +
      +
      +
      +
      +
    • + ))} + {!loading && claims.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardClaims; diff --git a/frontend/src/components/Claims/ListClaims.tsx b/frontend/src/components/Claims/ListClaims.tsx new file mode 100644 index 0000000..3476148 --- /dev/null +++ b/frontend/src/components/Claims/ListClaims.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + claims: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListClaims = ({ + claims, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CLAIMS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + claims.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Client

    +

    + {dataFormatter.clientsOneListFormatter(item.client)} +

    +
    + +
    +

    Reservation

    +

    + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +

    +
    + +
    +

    Category

    +

    {item.category}

    +
    + +
    +

    Description

    +

    {item.description}

    +
    + +
    +

    Status

    +

    {item.status}

    +
    + +
    +

    Attachments

    +

    {item.attachments}

    +
    + + +
    +
    +
    + ))} + {!loading && claims.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListClaims; diff --git a/frontend/src/components/Claims/TableClaims.tsx b/frontend/src/components/Claims/TableClaims.tsx new file mode 100644 index 0000000..d2c899f --- /dev/null +++ b/frontend/src/components/Claims/TableClaims.tsx @@ -0,0 +1,494 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/claims/claimsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureClaimsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListClaims from './ListClaims'; + +const perPage = 10; + +const TableSampleClaims = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + claims, + loading, + count, + notify: claimsNotify, + refetch, + } = useAppSelector((state) => state.claims); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (claimsNotify.showNotification) { + notify(claimsNotify.typeNotification, claimsNotify.textNotification); + } + }, [claimsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `claims`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={claims ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {claims && Array.isArray(claims) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleClaims; diff --git a/frontend/src/components/Claims/configureClaimsCols.tsx b/frontend/src/components/Claims/configureClaimsCols.tsx new file mode 100644 index 0000000..3ec2723 --- /dev/null +++ b/frontend/src/components/Claims/configureClaimsCols.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_CLAIMS'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'client', + headerName: 'Client', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('clients'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'reservation', + headerName: 'Reservation', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('reservations'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'category', + headerName: 'Category', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'description', + headerName: 'Description', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'attachments', + headerName: 'Attachments', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/ClickOutside.tsx b/frontend/src/components/ClickOutside.tsx new file mode 100644 index 0000000..4e031ad --- /dev/null +++ b/frontend/src/components/ClickOutside.tsx @@ -0,0 +1,45 @@ +import React, { + useCallback, + useEffect, + useRef, + ReactNode, + MutableRefObject, +} from 'react'; + +interface ClickOutsideProps { + children?: ReactNode; + onClickOutside: () => void; + excludedElements: MutableRefObject[]; +} + +const ClickOutside = ({ + children, + onClickOutside, + excludedElements, +}: ClickOutsideProps) => { + const wrapperRef = useRef(null); + + const handleClickOutside = useCallback( + (event) => { + if ( + wrapperRef.current && + !wrapperRef.current.contains(event.target) && + !excludedElements.some((el) => el.current.contains(event.target)) + ) { + onClickOutside(); + } + }, + [wrapperRef, onClickOutside, ...excludedElements], + ); + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleClickOutside]); + + return
    {children}
    ; +}; + +export default ClickOutside; diff --git a/frontend/src/components/Clients/CardClients.tsx b/frontend/src/components/Clients/CardClients.tsx new file mode 100644 index 0000000..8d4257a --- /dev/null +++ b/frontend/src/components/Clients/CardClients.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + clients: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardClients = ({ + clients, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CLIENTS'); + + return ( +
    + {loading && } +
      + {!loading && + clients.map((item, index) => ( +
    • +
      + + {item.first_name} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + FirstName +
      +
      +
      + {item.first_name} +
      +
      +
      + +
      +
      + LastName +
      +
      +
      + {item.last_name} +
      +
      +
      + +
      +
      + BirthDate +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.birth_date)} +
      +
      +
      + +
      +
      Phone
      +
      +
      {item.phone}
      +
      +
      + +
      +
      + Address +
      +
      +
      + {item.address} +
      +
      +
      + +
      +
      + IDType +
      +
      +
      + {item.id_type} +
      +
      +
      + +
      +
      + IDNumber +
      +
      +
      + {item.id_number} +
      +
      +
      + +
      +
      + IDIssueDate +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.id_issue_date)} +
      +
      +
      + +
      +
      + LicenseNumber +
      +
      +
      + {item.license_number} +
      +
      +
      + +
      +
      + LicenseIssueDate +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.license_issue_date)} +
      +
      +
      + +
      +
      + IDCopyURL +
      +
      +
      + {item.id_copy_url} +
      +
      +
      + +
      +
      + LicenseCopyURL +
      +
      +
      + {item.license_copy_url} +
      +
      +
      +
      +
    • + ))} + {!loading && clients.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardClients; diff --git a/frontend/src/components/Clients/ListClients.tsx b/frontend/src/components/Clients/ListClients.tsx new file mode 100644 index 0000000..d358f1a --- /dev/null +++ b/frontend/src/components/Clients/ListClients.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + clients: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListClients = ({ + clients, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CLIENTS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + clients.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    FirstName

    +

    {item.first_name}

    +
    + +
    +

    LastName

    +

    {item.last_name}

    +
    + +
    +

    BirthDate

    +

    + {dataFormatter.dateTimeFormatter(item.birth_date)} +

    +
    + +
    +

    Phone

    +

    {item.phone}

    +
    + +
    +

    Address

    +

    {item.address}

    +
    + +
    +

    IDType

    +

    {item.id_type}

    +
    + +
    +

    IDNumber

    +

    {item.id_number}

    +
    + +
    +

    IDIssueDate

    +

    + {dataFormatter.dateTimeFormatter(item.id_issue_date)} +

    +
    + +
    +

    + LicenseNumber +

    +

    {item.license_number}

    +
    + +
    +

    + LicenseIssueDate +

    +

    + {dataFormatter.dateTimeFormatter( + item.license_issue_date, + )} +

    +
    + +
    +

    IDCopyURL

    +

    {item.id_copy_url}

    +
    + +
    +

    + LicenseCopyURL +

    +

    {item.license_copy_url}

    +
    + + +
    +
    +
    + ))} + {!loading && clients.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListClients; diff --git a/frontend/src/components/Clients/TableClients.tsx b/frontend/src/components/Clients/TableClients.tsx new file mode 100644 index 0000000..b6a2ca0 --- /dev/null +++ b/frontend/src/components/Clients/TableClients.tsx @@ -0,0 +1,494 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/clients/clientsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureClientsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListClients from './ListClients'; + +const perPage = 10; + +const TableSampleClients = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + clients, + loading, + count, + notify: clientsNotify, + refetch, + } = useAppSelector((state) => state.clients); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (clientsNotify.showNotification) { + notify(clientsNotify.typeNotification, clientsNotify.textNotification); + } + }, [clientsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `clients`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={clients ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {clients && Array.isArray(clients) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleClients; diff --git a/frontend/src/components/Clients/configureClientsCols.tsx b/frontend/src/components/Clients/configureClientsCols.tsx new file mode 100644 index 0000000..6317058 --- /dev/null +++ b/frontend/src/components/Clients/configureClientsCols.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_CLIENTS'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'first_name', + headerName: 'FirstName', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'last_name', + headerName: 'LastName', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'birth_date', + headerName: 'BirthDate', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.birth_date), + }, + + { + field: 'phone', + headerName: 'Phone', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'address', + headerName: 'Address', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'id_type', + headerName: 'IDType', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'id_number', + headerName: 'IDNumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'id_issue_date', + headerName: 'IDIssueDate', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.id_issue_date), + }, + + { + field: 'license_number', + headerName: 'LicenseNumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'license_issue_date', + headerName: 'LicenseIssueDate', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.license_issue_date), + }, + + { + field: 'id_copy_url', + headerName: 'IDCopyURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'license_copy_url', + headerName: 'LicenseCopyURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Company_profiles/CardCompany_profiles.tsx b/frontend/src/components/Company_profiles/CardCompany_profiles.tsx new file mode 100644 index 0000000..bf4da92 --- /dev/null +++ b/frontend/src/components/Company_profiles/CardCompany_profiles.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + company_profiles: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardCompany_profiles = ({ + company_profiles, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_COMPANY_PROFILES', + ); + + return ( +
    + {loading && } +
      + {!loading && + company_profiles.map((item, index) => ( +
    • +
      + + {item.legal_name} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + LegalName +
      +
      +
      + {item.legal_name} +
      +
      +
      + +
      +
      + Address +
      +
      +
      + {item.address} +
      +
      +
      + +
      +
      Phone
      +
      +
      {item.phone}
      +
      +
      + +
      +
      Email
      +
      +
      {item.email}
      +
      +
      + +
      +
      + Website +
      +
      +
      + {item.website} +
      +
      +
      + +
      +
      RC
      +
      +
      {item.rc}
      +
      +
      + +
      +
      ICE
      +
      +
      {item.ice}
      +
      +
      + +
      +
      + Patente +
      +
      +
      + {item.patente} +
      +
      +
      + +
      +
      + LogoURL +
      +
      +
      + {item.logo_url} +
      +
      +
      + +
      +
      + PDFFooter +
      +
      +
      + {item.pdf_footer} +
      +
      +
      +
      +
    • + ))} + {!loading && company_profiles.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardCompany_profiles; diff --git a/frontend/src/components/Company_profiles/ListCompany_profiles.tsx b/frontend/src/components/Company_profiles/ListCompany_profiles.tsx new file mode 100644 index 0000000..095648f --- /dev/null +++ b/frontend/src/components/Company_profiles/ListCompany_profiles.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + company_profiles: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListCompany_profiles = ({ + company_profiles, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_COMPANY_PROFILES', + ); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + company_profiles.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    LegalName

    +

    {item.legal_name}

    +
    + +
    +

    Address

    +

    {item.address}

    +
    + +
    +

    Phone

    +

    {item.phone}

    +
    + +
    +

    Email

    +

    {item.email}

    +
    + +
    +

    Website

    +

    {item.website}

    +
    + +
    +

    RC

    +

    {item.rc}

    +
    + +
    +

    ICE

    +

    {item.ice}

    +
    + +
    +

    Patente

    +

    {item.patente}

    +
    + +
    +

    LogoURL

    +

    {item.logo_url}

    +
    + +
    +

    PDFFooter

    +

    {item.pdf_footer}

    +
    + + +
    +
    +
    + ))} + {!loading && company_profiles.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListCompany_profiles; diff --git a/frontend/src/components/Company_profiles/TableCompany_profiles.tsx b/frontend/src/components/Company_profiles/TableCompany_profiles.tsx new file mode 100644 index 0000000..99d72e0 --- /dev/null +++ b/frontend/src/components/Company_profiles/TableCompany_profiles.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/company_profiles/company_profilesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureCompany_profilesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListCompany_profiles from './ListCompany_profiles'; + +const perPage = 10; + +const TableSampleCompany_profiles = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + company_profiles, + loading, + count, + notify: company_profilesNotify, + refetch, + } = useAppSelector((state) => state.company_profiles); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (company_profilesNotify.showNotification) { + notify( + company_profilesNotify.typeNotification, + company_profilesNotify.textNotification, + ); + } + }, [company_profilesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `company_profiles`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={company_profiles ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {company_profiles && Array.isArray(company_profiles) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleCompany_profiles; diff --git a/frontend/src/components/Company_profiles/configureCompany_profilesCols.tsx b/frontend/src/components/Company_profiles/configureCompany_profilesCols.tsx new file mode 100644 index 0000000..c685357 --- /dev/null +++ b/frontend/src/components/Company_profiles/configureCompany_profilesCols.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_COMPANY_PROFILES'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'legal_name', + headerName: 'LegalName', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'address', + headerName: 'Address', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'phone', + headerName: 'Phone', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'email', + headerName: 'Email', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'website', + headerName: 'Website', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'rc', + headerName: 'RC', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'ice', + headerName: 'ICE', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'patente', + headerName: 'Patente', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'logo_url', + headerName: 'LogoURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'pdf_footer', + headerName: 'PDFFooter', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/DataGridMultiSelect.tsx b/frontend/src/components/DataGridMultiSelect.tsx new file mode 100644 index 0000000..bb82434 --- /dev/null +++ b/frontend/src/components/DataGridMultiSelect.tsx @@ -0,0 +1,55 @@ +import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid'; +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { MenuItem, Select } from '@mui/material'; + +interface Props { + entityName: string; +} + +const DataGridMultiSelect = (props: GridRenderEditCellParams & Props) => { + const { id, value, field, entityName } = props; + const apiRef = useGridApiContext(); + const [options, setOptions] = useState([]); + + async function callApi(entityName: string) { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } + + useEffect(() => { + callApi(entityName).then((data) => { + setOptions(data); + }); + }, []); + + const handleChange = (event) => { + const eventValue = event.target.value; // The new value entered by the user + + const newValue = + typeof eventValue === 'string' ? value.split(',') : eventValue; + + apiRef.current.setEditCellValue({ + id, + field, + value: newValue.filter((x) => x !== ''), + }); + }; + + return ( + + ); +}; + +export default DataGridMultiSelect; diff --git a/frontend/src/components/DevModeBadge.tsx b/frontend/src/components/DevModeBadge.tsx new file mode 100644 index 0000000..2d226a0 --- /dev/null +++ b/frontend/src/components/DevModeBadge.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import useDevCompilationStatus from '../hooks/useDevCompilationStatus'; +const DevModeBadge: React.FC = () => { + const [isVisible, setIsVisible] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(true); + const compilationStatus = useDevCompilationStatus(); + + const [badgeStyles, setBadgeStyles] = useState({ + position: 'fixed', + bottom: '20px', + left: '70px', + background: 'rgba(0, 0, 0, 0.85)', + color: 'white', + padding: '15px', + borderRadius: '8px', + fontFamily: 'sans-serif', + fontSize: '14px', + lineHeight: '1.5', + textAlign: 'left', + zIndex: 2147483647, + boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)', + whiteSpace: 'pre-wrap', + transition: + 'width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), padding 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease-in-out', // Improved transition for width + opacity: 0, + pointerEvents: 'none', + width: '340px', + maxWidth: '340px', + height: 'auto', + overflow: 'hidden', + cursor: 'pointer', + }); + + const fullText = `🚧 Your app is running in development mode. +Current request is compiling and may take a few moments. + +💡 Tip: Set up a stable environment to run your app in production mode—pages will load instantly without compilation delays.`; + + const collapsedText = '🚧 DEV stage'; + + useEffect(() => { + if (compilationStatus === 'ready') { + setIsCollapsed(true); + } else { + setIsCollapsed(false); + } + }, [compilationStatus]); + + useEffect(() => { + if ( + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'dev_stage' + ) { + setIsVisible(true); + + setBadgeStyles((prev) => ({ + ...prev, + opacity: 1, + width: '120px', + maxWidth: '120px', + padding: '6px 10px', + borderRadius: '18px', + whiteSpace: 'nowrap', + fontSize: '12px', + cursor: 'pointer', + pointerEvents: 'auto', + })); + } else { + setIsVisible(false); + setBadgeStyles((prev) => ({ ...prev, opacity: 0 })); + } + }, []); + + useEffect(() => { + if (!isVisible) return; + + if (isCollapsed) { + setBadgeStyles((prev) => ({ + ...prev, + width: '140px', + maxWidth: '160px', + padding: '6px 20px', + borderRadius: '18px', + whiteSpace: 'nowrap', + fontSize: '12px', + })); + } else { + setBadgeStyles((prev) => ({ + ...prev, + width: '340px', + maxWidth: '340px', + padding: '15px', + borderRadius: '8px', + whiteSpace: 'pre-wrap', + fontSize: '14px', + })); + } + }, [isCollapsed, isVisible]); + + const handleToggleCollapse = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsCollapsed((prev) => !prev); + }; + + if (!isVisible) { + return null; + } + + return ( +
    + + + {!isCollapsed &&
    {fullText}
    } + {isCollapsed && ( +
    {collapsedText}
    + )} +
    + ); +}; + +export default DevModeBadge; diff --git a/frontend/src/components/DragDropFilePicker.tsx b/frontend/src/components/DragDropFilePicker.tsx new file mode 100644 index 0000000..821570d --- /dev/null +++ b/frontend/src/components/DragDropFilePicker.tsx @@ -0,0 +1,124 @@ +import React, { ChangeEvent, useEffect, useState } from 'react'; +import BaseIcon from './BaseIcon'; +import { mdiFileUploadOutline } from '@mdi/js'; + +type Props = { + file: File | null; + setFile: (file: File) => void; + formats?: string; +}; + +const DragDropFilePicker = ({ file, setFile, formats = '' }: Props) => { + const [highlight, setHighlight] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const fileInput = React.createRef(); + + useEffect(() => { + if (!file && fileInput) fileInput.current.value = ''; + }, [file, fileInput]); + + function onFilesAdded(files: FileList | null) { + if (files && files[0]) { + const newFile = files[0]; + const fileExtension = newFile.name.split('.').pop().toLowerCase(); + + if (formats.includes(fileExtension) || !formats) { + setFile(newFile); + setErrorMessage(''); + } else { + setErrorMessage(`Allowed formats: ${formats}`); + } + } + } + + function onDragOver(e) { + e.preventDefault(); + setHighlight(true); + } + + function onDragLeave() { + setHighlight(false); + } + + function onDrop(e) { + e.preventDefault(); + + const files = e.dataTransfer.files; + + onFilesAdded(files); + setHighlight(false); + } + + const onClear = () => { + setFile(null); + setErrorMessage(''); + }; + + return ( +
    + +
    + ); +}; + +export default DragDropFilePicker; diff --git a/frontend/src/components/Drivers/CardDrivers.tsx b/frontend/src/components/Drivers/CardDrivers.tsx new file mode 100644 index 0000000..a760a59 --- /dev/null +++ b/frontend/src/components/Drivers/CardDrivers.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + drivers: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardDrivers = ({ + drivers, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_DRIVERS'); + + return ( +
    + {loading && } +
      + {!loading && + drivers.map((item, index) => ( +
    • +
      + + {item.full_name} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + Reservation +
      +
      +
      + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +
      +
      +
      + +
      +
      + FullName +
      +
      +
      + {item.full_name} +
      +
      +
      + +
      +
      + LicenseNumber +
      +
      +
      + {item.license_number} +
      +
      +
      + +
      +
      + LicenseIssueDate +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.license_issue_date)} +
      +
      +
      + +
      +
      + IDCopyURL +
      +
      +
      + {item.id_copy_url} +
      +
      +
      + +
      +
      + LicenseCopyURL +
      +
      +
      + {item.license_copy_url} +
      +
      +
      +
      +
    • + ))} + {!loading && drivers.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardDrivers; diff --git a/frontend/src/components/Drivers/ListDrivers.tsx b/frontend/src/components/Drivers/ListDrivers.tsx new file mode 100644 index 0000000..d9e04e1 --- /dev/null +++ b/frontend/src/components/Drivers/ListDrivers.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + drivers: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListDrivers = ({ + drivers, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_DRIVERS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + drivers.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Reservation

    +

    + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +

    +
    + +
    +

    FullName

    +

    {item.full_name}

    +
    + +
    +

    + LicenseNumber +

    +

    {item.license_number}

    +
    + +
    +

    + LicenseIssueDate +

    +

    + {dataFormatter.dateTimeFormatter( + item.license_issue_date, + )} +

    +
    + +
    +

    IDCopyURL

    +

    {item.id_copy_url}

    +
    + +
    +

    + LicenseCopyURL +

    +

    {item.license_copy_url}

    +
    + + +
    +
    +
    + ))} + {!loading && drivers.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListDrivers; diff --git a/frontend/src/components/Drivers/TableDrivers.tsx b/frontend/src/components/Drivers/TableDrivers.tsx new file mode 100644 index 0000000..55a9975 --- /dev/null +++ b/frontend/src/components/Drivers/TableDrivers.tsx @@ -0,0 +1,494 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/drivers/driversSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureDriversCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListDrivers from './ListDrivers'; + +const perPage = 10; + +const TableSampleDrivers = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + drivers, + loading, + count, + notify: driversNotify, + refetch, + } = useAppSelector((state) => state.drivers); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (driversNotify.showNotification) { + notify(driversNotify.typeNotification, driversNotify.textNotification); + } + }, [driversNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `drivers`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={drivers ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {drivers && Array.isArray(drivers) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleDrivers; diff --git a/frontend/src/components/Drivers/configureDriversCols.tsx b/frontend/src/components/Drivers/configureDriversCols.tsx new file mode 100644 index 0000000..31fa271 --- /dev/null +++ b/frontend/src/components/Drivers/configureDriversCols.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_DRIVERS'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'reservation', + headerName: 'Reservation', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('reservations'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'full_name', + headerName: 'FullName', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'license_number', + headerName: 'LicenseNumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'license_issue_date', + headerName: 'LicenseIssueDate', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.license_issue_date), + }, + + { + field: 'id_copy_url', + headerName: 'IDCopyURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'license_copy_url', + headerName: 'LicenseCopyURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..490bd95 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,218 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { mdiAlertCircle } from '@mdi/js'; +import BaseIcon from './BaseIcon'; + +// Define the props and state interfaces +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + showStack: boolean; +} + +// Class-based ErrorBoundary Component +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + + // Define state variables + this.state = { + hasError: false, + error: null, + errorInfo: null, + showStack: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // Update state so the next render will show the fallback UI + return { + hasError: true, + error: error, + }; + } + + componentDidUpdate( + prevProps: Readonly, + prevState: Readonly, + snapshot?: any, + ) { + if (process.env.NODE_ENV !== 'production') { + console.log('componentDidUpdate'); + } + } + + async componentWillUnmount() { + if (process.env.NODE_ENV !== 'production') { + console.log('componentWillUnmount'); + const response = await fetch('/api/logError', { + method: 'DELETE', + }); + + const data = await response.json(); + console.log('Error logs cleared:', data); + } + } + + async componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // Update state with error details (always needed for UI) + this.setState({ + errorInfo: errorInfo, + }); + + // Only perform logging in non-production environments + if (process.env.NODE_ENV !== 'production') { + console.log('Error caught in boundary:', error, errorInfo); + + // Function to log errors to the server + const logErrorToServer = async () => { + try { + const response = await fetch('/api/logError', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: error.message, + stack: errorInfo.componentStack, + }), + }); + + const data = await response.json(); + console.log('Error logged:', data); + } catch (err) { + console.error('Failed to log error:', err); + } + }; + + // Function to fetch logged errors (optional) + const fetchLoggedErrors = async () => { + try { + const response = await fetch('/api/logError'); + const data = await response.json(); + console.log('Fetched logs:', data); + } catch (err) { + console.error('Failed to fetch logs:', err); + } + }; + + await logErrorToServer(); + await fetchLoggedErrors(); + } + } + + toggleStack = () => { + this.setState((prevState) => ({ + showStack: !prevState.showStack, + })); + }; + + resetError = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showStack: false, + }); + }; + + tryAgain = async () => { + // Only clear error logs in non-production environments + if (process.env.NODE_ENV !== 'production') { + try { + const response = await fetch('/api/logError', { + method: 'DELETE', + }); + + const data = await response.json(); + console.log('Error logs cleared:', data); + } catch (e) { + console.error('Failed to clear error logs:', e); + } + } + + // Always reset the error state (needed for UI recovery) + this.setState({ hasError: false }); + }; + + render() { + if (this.state.hasError) { + // Extract error details + const { error, errorInfo, showStack } = this.state; + const errorMessage = error?.message || 'An unexpected error occurred'; + const stackTrace = + errorInfo?.componentStack || error?.stack || 'No stack trace available'; + + return ( +
    +
    +
    +
    + +
    + +
    +

    + Something went wrong +

    +

    + We're sorry, but we encountered an unexpected error. +

    +
    + +
    +

    + {errorMessage} +

    + +
    + + + {showStack && ( +
    +                      {stackTrace}
    +                    
    + )} +
    +
    + +
    + + + +
    +
    +
    +
    + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Expenses/CardExpenses.tsx b/frontend/src/components/Expenses/CardExpenses.tsx new file mode 100644 index 0000000..fa25bac --- /dev/null +++ b/frontend/src/components/Expenses/CardExpenses.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + expenses: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardExpenses = ({ + expenses, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_EXPENSES'); + + return ( +
    + {loading && } +
      + {!loading && + expenses.map((item, index) => ( +
    • +
      + + {item.category} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      Type
      +
      +
      {item.type}
      +
      +
      + +
      +
      + Category +
      +
      +
      + {item.category} +
      +
      +
      + +
      +
      + Amount +
      +
      +
      + {item.amount} +
      +
      +
      + +
      +
      Date
      +
      +
      + {dataFormatter.dateTimeFormatter(item.date)} +
      +
      +
      + +
      +
      + Vehicle +
      +
      +
      + {dataFormatter.vehiclesOneListFormatter(item.vehicle)} +
      +
      +
      + +
      +
      Note
      +
      +
      {item.note}
      +
      +
      + +
      +
      + AttachmentURL +
      +
      +
      + {item.attachment_url} +
      +
      +
      +
      +
    • + ))} + {!loading && expenses.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardExpenses; diff --git a/frontend/src/components/Expenses/ListExpenses.tsx b/frontend/src/components/Expenses/ListExpenses.tsx new file mode 100644 index 0000000..de50ffc --- /dev/null +++ b/frontend/src/components/Expenses/ListExpenses.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + expenses: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListExpenses = ({ + expenses, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_EXPENSES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + expenses.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Type

    +

    {item.type}

    +
    + +
    +

    Category

    +

    {item.category}

    +
    + +
    +

    Amount

    +

    {item.amount}

    +
    + +
    +

    Date

    +

    + {dataFormatter.dateTimeFormatter(item.date)} +

    +
    + +
    +

    Vehicle

    +

    + {dataFormatter.vehiclesOneListFormatter(item.vehicle)} +

    +
    + +
    +

    Note

    +

    {item.note}

    +
    + +
    +

    + AttachmentURL +

    +

    {item.attachment_url}

    +
    + + +
    +
    +
    + ))} + {!loading && expenses.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListExpenses; diff --git a/frontend/src/components/Expenses/TableExpenses.tsx b/frontend/src/components/Expenses/TableExpenses.tsx new file mode 100644 index 0000000..b223cf9 --- /dev/null +++ b/frontend/src/components/Expenses/TableExpenses.tsx @@ -0,0 +1,494 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/expenses/expensesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureExpensesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListExpenses from './ListExpenses'; + +const perPage = 10; + +const TableSampleExpenses = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + expenses, + loading, + count, + notify: expensesNotify, + refetch, + } = useAppSelector((state) => state.expenses); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (expensesNotify.showNotification) { + notify(expensesNotify.typeNotification, expensesNotify.textNotification); + } + }, [expensesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `expenses`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={expenses ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {expenses && Array.isArray(expenses) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleExpenses; diff --git a/frontend/src/components/Expenses/configureExpensesCols.tsx b/frontend/src/components/Expenses/configureExpensesCols.tsx new file mode 100644 index 0000000..b66336c --- /dev/null +++ b/frontend/src/components/Expenses/configureExpensesCols.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_EXPENSES'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'type', + headerName: 'Type', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'category', + headerName: 'Category', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'amount', + headerName: 'Amount', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'date', + headerName: 'Date', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => new Date(params.row.date), + }, + + { + field: 'vehicle', + headerName: 'Vehicle', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('vehicles'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'note', + headerName: 'Note', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'attachment_url', + headerName: 'AttachmentURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx new file mode 100644 index 0000000..7027c3d --- /dev/null +++ b/frontend/src/components/FooterBar.tsx @@ -0,0 +1,37 @@ +import React, { ReactNode } from 'react'; +import LanguageSwitcher from './LanguageSwitcher'; +import { containerMaxW } from '../config'; +import Logo from './Logo'; + +type Props = { + children?: ReactNode; +}; + +export default function FooterBar({ children }: Props) { + const year = new Date().getFullYear(); + + return ( + + ); +} diff --git a/frontend/src/components/FormCheckRadio.tsx b/frontend/src/components/FormCheckRadio.tsx new file mode 100644 index 0000000..17c00ea --- /dev/null +++ b/frontend/src/components/FormCheckRadio.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react'; + +type Props = { + children: ReactNode; + type: 'checkbox' | 'radio' | 'switch'; + label?: string; + className?: string; +}; + +const FormCheckRadio = (props: Props) => { + return ( + + ); +}; + +export default FormCheckRadio; diff --git a/frontend/src/components/FormCheckRadioGroup.tsx b/frontend/src/components/FormCheckRadioGroup.tsx new file mode 100644 index 0000000..4d1aa12 --- /dev/null +++ b/frontend/src/components/FormCheckRadioGroup.tsx @@ -0,0 +1,26 @@ +import { Children, cloneElement, ReactElement, ReactNode } from 'react'; + +type Props = { + isColumn?: boolean; + children: ReactNode; +}; + +const FormCheckRadioGroup = (props: Props) => { + return ( +
    + {Children.map(props.children, (child: ReactElement) => + cloneElement(child as ReactElement<{ className?: string }>, { + className: `mr-6 mb-3 last:mr-0 ${ + (child.props as { className?: string }).className || '' + }`, + }), + )} +
    + ); +}; + +export default FormCheckRadioGroup; diff --git a/frontend/src/components/FormField.tsx b/frontend/src/components/FormField.tsx new file mode 100644 index 0000000..ea50717 --- /dev/null +++ b/frontend/src/components/FormField.tsx @@ -0,0 +1,92 @@ +import { Children, cloneElement, ReactElement, ReactNode } from 'react'; +import BaseIcon from './BaseIcon'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + labelFor?: string; + help?: string; + icons?: string[] | null[]; + isBorderless?: boolean; + isTransparent?: boolean; + hasTextareaHeight?: boolean; + children: ReactNode; + disabled?: boolean; + borderButtom?: boolean; + diversity?: boolean; + websiteBg?: boolean; +}; + +const FormField = ({ icons = [], ...props }: Props) => { + const childrenCount = Children.count(props.children); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const corners = useAppSelector((state) => state.style.corners); + const bgWebsiteColor = useAppSelector((state) => state.style.bgLayoutColor); + let elementWrapperClass = ''; + + switch (childrenCount) { + case 2: + elementWrapperClass = 'grid grid-cols-1 gap-3 md:grid-cols-2'; + break; + case 3: + elementWrapperClass = 'grid grid-cols-1 gap-3 md:grid-cols-3'; + } + + const controlClassName = [ + `px-3 py-2 max-w-full border-gray-300 dark:border-dark-700 ${corners} w-full dark:placeholder-gray-400`, + `${focusRing}`, + props.hasTextareaHeight ? 'h-24' : 'h-12', + props.isBorderless ? 'border-0' : 'border', + props.isTransparent + ? 'bg-transparent' + : `${props.websiteBg ? ` bg-white` : bgColor} dark:bg-dark-800`, + props.disabled ? 'bg-gray-200 text-gray-100 dark:bg-dark-900 disabled' : '', + props.borderButtom + ? `border-0 border-b ${ + props.diversity + ? 'border-gray-400' + : ' placeholder-white border-gray-300/10 border-white ' + } rounded-none focus:ring-0` + : '', + ].join(' '); + + return ( +
    + {props.label && ( + + )} +
    + {Children.map(props.children, (child: ReactElement, index) => ( +
    + {cloneElement(child as ReactElement<{ className?: string }>, { + className: `${controlClassName} ${icons[index] ? 'pl-10' : ''}`, + })} + {icons[index] && ( + + )} +
    + ))} +
    + {props.help && ( +
    + {props.help} +
    + )} +
    + ); +}; + +export default FormField; diff --git a/frontend/src/components/FormFilePicker.tsx b/frontend/src/components/FormFilePicker.tsx new file mode 100644 index 0000000..7be6c35 --- /dev/null +++ b/frontend/src/components/FormFilePicker.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { ColorButtonKey } from '../interfaces'; +import BaseButton from './BaseButton'; +import FileUploader from './Uploaders/UploadService'; +import { mdiReload } from '@mdi/js'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + icon?: string; + accept?: string; + color: ColorButtonKey; + isRoundIcon?: boolean; + path: string; + schema: object; + field: any; + form: any; +}; + +const FormFilePicker = ({ + label, + icon, + accept, + color, + isRoundIcon, + path, + schema, + form, + field, +}: Props) => { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + let cornersRight; + if (corners === 'rounded') { + cornersRight = 'rounded-r'; + } else if (corners === 'rounded-lg') { + cornersRight = 'rounded-r-lg'; + } else if (corners === 'rounded-full') { + cornersRight = 'rounded-r-3xl'; + } else { + cornersRight = ''; + } + + useEffect(() => { + if (field.value) { + setFile(field.value[0]); + } + }, [field.value]); + const handleFileChange = async (event) => { + const file = event.currentTarget.files[0]; + setFile(file); + + FileUploader.validate(file, schema); + setLoading(true); + const remoteFile = await FileUploader.upload(path, file, schema); + + form.setFieldValue(field.name, [{ ...remoteFile }]); + setLoading(false); + }; + + const showFilename = !isRoundIcon && file; + + return ( +
    + + {showFilename && !loading && ( +
    + + {file.name} + +
    + )} +
    + ); +}; + +export default FormFilePicker; diff --git a/frontend/src/components/FormImagePicker.tsx b/frontend/src/components/FormImagePicker.tsx new file mode 100644 index 0000000..e8a8dac --- /dev/null +++ b/frontend/src/components/FormImagePicker.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import { ColorButtonKey } from '../interfaces'; +import BaseButton from './BaseButton'; +import ImagesUploader from './Uploaders/ImagesUploader'; +import FileUploader from './Uploaders/UploadService'; +import { mdiReload } from '@mdi/js'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + label?: string; + icon?: string; + accept?: string; + color: ColorButtonKey; + isRoundIcon?: boolean; + path: string; + schema: object; + field: any; + form: any; +}; + +const FormImagePicker = ({ + label, + icon, + accept, + color, + isRoundIcon, + path, + schema, + form, + field, +}: Props) => { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + let cornersRight; + if (corners === 'rounded') { + cornersRight = 'rounded-r'; + } else if (corners === 'rounded-lg') { + cornersRight = 'rounded-r-lg'; + } else if (corners === 'rounded-full') { + cornersRight = 'rounded-r-3xl'; + } else { + cornersRight = ''; + } + + useEffect(() => { + if (field.value) { + setFile(field.value[0]); + } + }, [field.value]); + const handleFileChange = async (event) => { + const file = event.currentTarget.files[0]; + setFile(file); + + FileUploader.validate(file, schema); + setLoading(true); + const remoteFile = await FileUploader.upload(path, file, schema); + + form.setFieldValue(field.name, [{ ...remoteFile }]); + setLoading(false); + }; + + const showFilename = !isRoundIcon && file; + + return ( +
    + + {showFilename && !loading && ( +
    + + {file.name} + +
    + )} +
    + ); +}; + +export default FormImagePicker; diff --git a/frontend/src/components/Guarantees/CardGuarantees.tsx b/frontend/src/components/Guarantees/CardGuarantees.tsx new file mode 100644 index 0000000..6366806 --- /dev/null +++ b/frontend/src/components/Guarantees/CardGuarantees.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + guarantees: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardGuarantees = ({ + guarantees, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_GUARANTEES'); + + return ( +
    + {loading && } +
      + {!loading && + guarantees.map((item, index) => ( +
    • +
      + + {item.method} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + Reservation +
      +
      +
      + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +
      +
      +
      + +
      +
      + Method +
      +
      +
      + {item.method} +
      +
      +
      + +
      +
      + Amount +
      +
      +
      + {item.amount} +
      +
      +
      + +
      +
      + Status +
      +
      +
      + {item.status} +
      +
      +
      + +
      +
      + ProofURL +
      +
      +
      + {item.proof_url} +
      +
      +
      +
      +
    • + ))} + {!loading && guarantees.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardGuarantees; diff --git a/frontend/src/components/Guarantees/ListGuarantees.tsx b/frontend/src/components/Guarantees/ListGuarantees.tsx new file mode 100644 index 0000000..203108a --- /dev/null +++ b/frontend/src/components/Guarantees/ListGuarantees.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + guarantees: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListGuarantees = ({ + guarantees, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_GUARANTEES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + guarantees.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Reservation

    +

    + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +

    +
    + +
    +

    Method

    +

    {item.method}

    +
    + +
    +

    Amount

    +

    {item.amount}

    +
    + +
    +

    Status

    +

    {item.status}

    +
    + +
    +

    ProofURL

    +

    {item.proof_url}

    +
    + + +
    +
    +
    + ))} + {!loading && guarantees.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListGuarantees; diff --git a/frontend/src/components/Guarantees/TableGuarantees.tsx b/frontend/src/components/Guarantees/TableGuarantees.tsx new file mode 100644 index 0000000..f9eb1f8 --- /dev/null +++ b/frontend/src/components/Guarantees/TableGuarantees.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/guarantees/guaranteesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureGuaranteesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListGuarantees from './ListGuarantees'; + +const perPage = 10; + +const TableSampleGuarantees = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + guarantees, + loading, + count, + notify: guaranteesNotify, + refetch, + } = useAppSelector((state) => state.guarantees); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (guaranteesNotify.showNotification) { + notify( + guaranteesNotify.typeNotification, + guaranteesNotify.textNotification, + ); + } + }, [guaranteesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `guarantees`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={guarantees ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {guarantees && Array.isArray(guarantees) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleGuarantees; diff --git a/frontend/src/components/Guarantees/configureGuaranteesCols.tsx b/frontend/src/components/Guarantees/configureGuaranteesCols.tsx new file mode 100644 index 0000000..ef52e61 --- /dev/null +++ b/frontend/src/components/Guarantees/configureGuaranteesCols.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_GUARANTEES'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'reservation', + headerName: 'Reservation', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('reservations'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'method', + headerName: 'Method', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'amount', + headerName: 'Amount', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'proof_url', + headerName: 'ProofURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/IconRounded.tsx b/frontend/src/components/IconRounded.tsx new file mode 100644 index 0000000..7ec5864 --- /dev/null +++ b/frontend/src/components/IconRounded.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ColorKey } from '../interfaces'; +import { colorsBgLight, colorsText } from '../colors'; +import BaseIcon from './BaseIcon'; + +type Props = { + icon: string; + color: ColorKey; + w?: string; + h?: string; + bg?: boolean; + className?: string; +}; + +export default function IconRounded({ + icon, + color, + w = 'w-12', + h = 'h-12', + bg = false, + className = '', +}: Props) { + const classAddon = bg + ? colorsBgLight[color] + : `${colorsText[color]} bg-gray-50 dark:bg-slate-800`; + + return ( + + ); +} diff --git a/frontend/src/components/ImageField.tsx b/frontend/src/components/ImageField.tsx new file mode 100644 index 0000000..2bf42ae --- /dev/null +++ b/frontend/src/components/ImageField.tsx @@ -0,0 +1,51 @@ +/* eslint-disable @next/next/no-img-element */ +// Why disabled: +// avatars.dicebear.com provides svg avatars +// next/image needs dangerouslyAllowSVG option for that + +import React, { ReactNode } from 'react'; +import { mdiImageOutline } from '@mdi/js'; +import BaseIcon from './BaseIcon'; + +type Props = { + name: string; + image?: object | null; + api?: string; + className?: string; + imageClassName?: string; + children?: ReactNode; +}; + +export default function ImageField({ + name, + image, + className = '', + imageClassName = '', + children, +}: Props) { + const imageSrc = image && image[0] ? `${image[0].publicUrl}` : ''; + + return ( +
    + {imageSrc ? ( + {name} + ) : ( +
    + +
    + )} + + {children} +
    + ); +} diff --git a/frontend/src/components/Infractions/CardInfractions.tsx b/frontend/src/components/Infractions/CardInfractions.tsx new file mode 100644 index 0000000..11fd26c --- /dev/null +++ b/frontend/src/components/Infractions/CardInfractions.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + infractions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardInfractions = ({ + infractions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_INFRACTIONS'); + + return ( +
    + {loading && } +
      + {!loading && + infractions.map((item, index) => ( +
    • +
      + + {item.ref_no} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + Vehicle +
      +
      +
      + {dataFormatter.vehiclesOneListFormatter(item.vehicle)} +
      +
      +
      + +
      +
      + Reservation +
      +
      +
      + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +
      +
      +
      + +
      +
      + ReferenceNumber +
      +
      +
      + {item.ref_no} +
      +
      +
      + +
      +
      Date
      +
      +
      + {dataFormatter.dateTimeFormatter(item.date)} +
      +
      +
      + +
      +
      + Location +
      +
      +
      + {item.location} +
      +
      +
      + +
      +
      + Amount +
      +
      +
      + {item.amount} +
      +
      +
      + +
      +
      + Status +
      +
      +
      + {item.status} +
      +
      +
      + +
      +
      + ProofURL +
      +
      +
      + {item.proof_url} +
      +
      +
      +
      +
    • + ))} + {!loading && infractions.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardInfractions; diff --git a/frontend/src/components/Infractions/ListInfractions.tsx b/frontend/src/components/Infractions/ListInfractions.tsx new file mode 100644 index 0000000..ea92134 --- /dev/null +++ b/frontend/src/components/Infractions/ListInfractions.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + infractions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListInfractions = ({ + infractions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_INFRACTIONS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + infractions.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Vehicle

    +

    + {dataFormatter.vehiclesOneListFormatter(item.vehicle)} +

    +
    + +
    +

    Reservation

    +

    + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +

    +
    + +
    +

    + ReferenceNumber +

    +

    {item.ref_no}

    +
    + +
    +

    Date

    +

    + {dataFormatter.dateTimeFormatter(item.date)} +

    +
    + +
    +

    Location

    +

    {item.location}

    +
    + +
    +

    Amount

    +

    {item.amount}

    +
    + +
    +

    Status

    +

    {item.status}

    +
    + +
    +

    ProofURL

    +

    {item.proof_url}

    +
    + + +
    +
    +
    + ))} + {!loading && infractions.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListInfractions; diff --git a/frontend/src/components/Infractions/TableInfractions.tsx b/frontend/src/components/Infractions/TableInfractions.tsx new file mode 100644 index 0000000..c18ac0d --- /dev/null +++ b/frontend/src/components/Infractions/TableInfractions.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/infractions/infractionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureInfractionsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListInfractions from './ListInfractions'; + +const perPage = 10; + +const TableSampleInfractions = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + infractions, + loading, + count, + notify: infractionsNotify, + refetch, + } = useAppSelector((state) => state.infractions); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (infractionsNotify.showNotification) { + notify( + infractionsNotify.typeNotification, + infractionsNotify.textNotification, + ); + } + }, [infractionsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `infractions`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={infractions ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {infractions && Array.isArray(infractions) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleInfractions; diff --git a/frontend/src/components/Infractions/configureInfractionsCols.tsx b/frontend/src/components/Infractions/configureInfractionsCols.tsx new file mode 100644 index 0000000..1daabf3 --- /dev/null +++ b/frontend/src/components/Infractions/configureInfractionsCols.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_INFRACTIONS'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'vehicle', + headerName: 'Vehicle', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('vehicles'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'reservation', + headerName: 'Reservation', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('reservations'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'ref_no', + headerName: 'ReferenceNumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'date', + headerName: 'Date', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => new Date(params.row.date), + }, + + { + field: 'location', + headerName: 'Location', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'amount', + headerName: 'Amount', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'proof_url', + headerName: 'ProofURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/IntroGuide.tsx b/frontend/src/components/IntroGuide.tsx new file mode 100644 index 0000000..7271c83 --- /dev/null +++ b/frontend/src/components/IntroGuide.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Steps, Hints } from 'intro.js-react'; +import { useRouter } from 'next/router'; +interface IntroGuideProps { + steps: Array<{ + element: string; + intro: string; + position?: string; + }>; + disableInteraction?: boolean; + stepsEnabled: boolean; + stepsName: string; + onExit: () => void; +} + +const IntroGuide: React.FC = ({ + steps, + stepsEnabled, + onExit, + stepsName, +}) => { + const router = useRouter(); + const handleStepChange = (stepIndex: number) => { + if (stepIndex === 7 && stepsName === 'appSteps') { + onExit(); + router.push('/users/users-list/'); + } else if (stepIndex === 2 && stepsName === 'usersSteps') { + onExit(); + router.push('/roles/roles-list/'); + } + }; + + const handleExit = () => { + localStorage.setItem(`completed_${stepsName}`, 'true'); + onExit(); + }; + return ( + <> + + + ); +}; + +export default IntroGuide; diff --git a/frontend/src/components/Invoices_saas/CardInvoices_saas.tsx b/frontend/src/components/Invoices_saas/CardInvoices_saas.tsx new file mode 100644 index 0000000..f2562a7 --- /dev/null +++ b/frontend/src/components/Invoices_saas/CardInvoices_saas.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + invoices_saas: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardInvoices_saas = ({ + invoices_saas, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_INVOICES_SAAS', + ); + + return ( +
    + {loading && } +
      + {!loading && + invoices_saas.map((item, index) => ( +
    • +
      + + {item.invoice_number} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + Subscription +
      +
      +
      + {dataFormatter.subscriptionsOneListFormatter( + item.subscription, + )} +
      +
      +
      + +
      +
      + InvoiceNumber +
      +
      +
      + {item.invoice_number} +
      +
      +
      + +
      +
      + AmountMAD +
      +
      +
      + {item.amount_mad} +
      +
      +
      + +
      +
      + Status +
      +
      +
      + {item.status} +
      +
      +
      + +
      +
      + IssuedAt +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.issued_at)} +
      +
      +
      + +
      +
      + PDFURL +
      +
      +
      + {item.pdf_url} +
      +
      +
      +
      +
    • + ))} + {!loading && invoices_saas.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardInvoices_saas; diff --git a/frontend/src/components/Invoices_saas/ListInvoices_saas.tsx b/frontend/src/components/Invoices_saas/ListInvoices_saas.tsx new file mode 100644 index 0000000..4a5d71e --- /dev/null +++ b/frontend/src/components/Invoices_saas/ListInvoices_saas.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + invoices_saas: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListInvoices_saas = ({ + invoices_saas, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_INVOICES_SAAS', + ); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + invoices_saas.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Subscription

    +

    + {dataFormatter.subscriptionsOneListFormatter( + item.subscription, + )} +

    +
    + +
    +

    + InvoiceNumber +

    +

    {item.invoice_number}

    +
    + +
    +

    AmountMAD

    +

    {item.amount_mad}

    +
    + +
    +

    Status

    +

    {item.status}

    +
    + +
    +

    IssuedAt

    +

    + {dataFormatter.dateTimeFormatter(item.issued_at)} +

    +
    + +
    +

    PDFURL

    +

    {item.pdf_url}

    +
    + + +
    +
    +
    + ))} + {!loading && invoices_saas.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListInvoices_saas; diff --git a/frontend/src/components/Invoices_saas/TableInvoices_saas.tsx b/frontend/src/components/Invoices_saas/TableInvoices_saas.tsx new file mode 100644 index 0000000..3d74f57 --- /dev/null +++ b/frontend/src/components/Invoices_saas/TableInvoices_saas.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/invoices_saas/invoices_saasSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureInvoices_saasCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListInvoices_saas from './ListInvoices_saas'; + +const perPage = 10; + +const TableSampleInvoices_saas = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + invoices_saas, + loading, + count, + notify: invoices_saasNotify, + refetch, + } = useAppSelector((state) => state.invoices_saas); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (invoices_saasNotify.showNotification) { + notify( + invoices_saasNotify.typeNotification, + invoices_saasNotify.textNotification, + ); + } + }, [invoices_saasNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `invoices_saas`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={invoices_saas ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {invoices_saas && Array.isArray(invoices_saas) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleInvoices_saas; diff --git a/frontend/src/components/Invoices_saas/configureInvoices_saasCols.tsx b/frontend/src/components/Invoices_saas/configureInvoices_saasCols.tsx new file mode 100644 index 0000000..1a75d85 --- /dev/null +++ b/frontend/src/components/Invoices_saas/configureInvoices_saasCols.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_INVOICES_SAAS'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'subscription', + headerName: 'Subscription', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('subscriptions'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'invoice_number', + headerName: 'InvoiceNumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'amount_mad', + headerName: 'AmountMAD', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'issued_at', + headerName: 'IssuedAt', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.issued_at), + }, + + { + field: 'pdf_url', + headerName: 'PDFURL', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/KanbanBoard/KanbanBoard.tsx b/frontend/src/components/KanbanBoard/KanbanBoard.tsx new file mode 100644 index 0000000..3fdcf7d --- /dev/null +++ b/frontend/src/components/KanbanBoard/KanbanBoard.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import KanbanColumn from './KanbanColumn'; +import { AsyncThunk } from '@reduxjs/toolkit'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +type Props = { + columns: Array<{ id: string; label: string }>; + filtersQuery: string; + entityName: string; + columnFieldName: string; + showFieldName: string; + deleteThunk: AsyncThunk; + updateThunk: AsyncThunk; +}; + +const KanbanBoard = ({ + columns, + entityName, + columnFieldName, + filtersQuery, + showFieldName, + deleteThunk, + updateThunk, +}: Props) => { + return ( +
    + + {columns.map((column) => ( +
    + +
    + ))} +
    +
    + ); +}; + +export default KanbanBoard; diff --git a/frontend/src/components/KanbanBoard/KanbanCard.tsx b/frontend/src/components/KanbanBoard/KanbanCard.tsx new file mode 100644 index 0000000..5441aea --- /dev/null +++ b/frontend/src/components/KanbanBoard/KanbanCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Link from 'next/link'; +import moment from 'moment'; +import ListActionsPopover from '../ListActionsPopover'; +import { DragSourceMonitor, useDrag } from 'react-dnd'; + +type Props = { + item: any; + column: { id: string; label: string }; + entityName: string; + showFieldName: string; + setItemIdToDelete: (id: string) => void; +}; + +const KanbanCard = ({ + item, + entityName, + showFieldName, + setItemIdToDelete, + column, +}: Props) => { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: 'box', + item: { item, column }, + collect: (monitor: DragSourceMonitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [item], + ); + + return ( +
    +
    + + {item[showFieldName] ?? 'No data'} + +
    +
    +

    {moment(item.createdAt).format('MMM DD hh:mm a')}

    + setItemIdToDelete(id)} + hasUpdatePermission={true} + className={'w-2 h-2 text-white'} + iconClassName={'w-5'} + /> +
    +
    + ); +}; + +export default KanbanCard; diff --git a/frontend/src/components/KanbanBoard/KanbanColumn.tsx b/frontend/src/components/KanbanBoard/KanbanColumn.tsx new file mode 100644 index 0000000..86f8c7e --- /dev/null +++ b/frontend/src/components/KanbanBoard/KanbanColumn.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import Axios from 'axios'; +import CardBox from '../CardBox'; +import CardBoxModal from '../CardBoxModal'; +import { AsyncThunk } from '@reduxjs/toolkit'; +import { useDrop } from 'react-dnd'; +import KanbanCard from './KanbanCard'; + +type Props = { + column: { id: string; label: string }; + entityName: string; + columnFieldName: string; + showFieldName: string; + filtersQuery: any; + deleteThunk: AsyncThunk; + updateThunk: AsyncThunk; +}; + +type DropResult = { + sourceColumn: { id: string; label: string }; + item: any; +}; + +const perPage = 10; + +const KanbanColumn = ({ + column, + entityName, + columnFieldName, + showFieldName, + filtersQuery, + deleteThunk, + updateThunk, +}: Props) => { + const [currentPage, setCurrentPage] = useState(0); + const [count, setCount] = useState(0); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [itemIdToDelete, setItemIdToDelete] = useState(''); + const currentUser = useAppSelector((state) => state.auth.currentUser); + const listInnerRef = useRef(null); + const dispatch = useAppDispatch(); + + const [{ dropResult }, drop] = useDrop< + { + item: any; + column: { + id: string; + label: string; + }; + }, + unknown, + { + dropResult: DropResult; + } + >( + () => ({ + accept: 'box', + drop: ({ + item, + column: sourceColumn, + }: { + item: any; + column: { id: string; label: string }; + }) => { + if (sourceColumn.id === column.id) return; + + dispatch( + updateThunk({ + id: item.id, + data: { + [columnFieldName]: column.id, + }, + }), + ).then((res) => { + setData((prevState) => (prevState ? [...prevState, item] : [item])); + setCount((prevState) => prevState + 1); + }); + + return { sourceColumn, item }; + }, + collect: (monitor) => ({ + dropResult: monitor.getDropResult(), + }), + }), + [], + ); + + const loadData = useCallback( + (page: number, filters = '') => { + const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`; + setLoading(true); + Axios.get(`${entityName}${query}`) + .then((res) => { + setData((prevState) => + page === 0 ? res.data.rows : [...prevState, ...res.data.rows], + ); + setCount(res.data.count); + setCurrentPage(page); + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + setLoading(false); + }); + }, + [currentUser, column], + ); + + useEffect(() => { + if (!currentUser) return; + loadData(0, filtersQuery); + }, [currentUser, loadData, filtersQuery]); + + useEffect(() => { + loadData(0, filtersQuery); + }, [loadData, filtersQuery]); + + useEffect(() => { + if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) { + setData((prevState) => + prevState.filter((item) => item.id !== dropResult.item.id), + ); + setCount((prevState) => prevState - 1); + } + }, [dropResult]); + + const onScroll = () => { + if (listInnerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current; + if (Math.floor(scrollTop + clientHeight) === scrollHeight) { + if (data.length < count && !loading) { + loadData(currentPage + 1, filtersQuery); + } + } + } + }; + + const onDeleteConfirm = () => { + if (!itemIdToDelete) return; + + dispatch(deleteThunk(itemIdToDelete)) + .then((res) => { + if (res.meta.requestStatus === 'fulfilled') { + setItemIdToDelete(''); + loadData(0, filtersQuery); + } + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + setItemIdToDelete(''); + }); + }; + + return ( + <> + +
    +

    {column.label}

    +

    {count}

    +
    +
    { + drop(node); + listInnerRef.current = node; + }} + className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'} + onScroll={onScroll} + > + {data?.map((item) => ( +
    + +
    + ))} + {!data?.length && ( +

    + No data +

    + )} +
    +
    + setItemIdToDelete('')} + > +

    Are you sure you want to delete this item?

    +
    + + ); +}; + +export default KanbanColumn; diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..c311bcf --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import Select, { + components, + SingleValueProps, + OptionProps, +} from 'react-select'; + +type LanguageOption = { label: string; value: string }; + +const LANGS: LanguageOption[] = [ + { value: 'en', label: '🇬🇧 EN' }, + { value: 'fr', label: '🇫🇷 FR' }, + { value: 'es', label: '🇪🇸 ES' }, + { value: 'de', label: '🇩🇪 DE' }, +]; + +const Option = (props: OptionProps) => ( + + {props.data.label} + +); + +const SingleVal = (props: SingleValueProps) => ( + + {props.data.label} + +); + +const LanguageSwitcher: React.FC = () => { + const { i18n } = useTranslation(); + const [mounted, setMounted] = useState(false); + const [selected, setSelected] = useState(LANGS[0]); + + useEffect(() => { + setMounted(true); + setSelected(LANGS.find((l) => l.value === i18n.language) ?? LANGS[0]); + }, [i18n.language]); + + const handleChange = (opt: LanguageOption | null) => { + if (!opt) return; + setSelected(opt); + i18n.changeLanguage(opt.value); + }; + + if (!mounted) return null; + + return ( +
    + + + ); + + return ( +
    + {readonly || (max && fileList().length >= max) ? null : uploadButton} + + {valuesArr() && valuesArr().length ? ( +
    + {valuesArr().map((item) => { + return ( +
    + + + {item.name} + + + {!readonly && ( + + )} +
    + ); + })} +
    + ) : null} +
    + ); +}; + +FilesUploader.propTypes = { + readonly: PropTypes.bool, + path: PropTypes.string, + max: PropTypes.number, + schema: PropTypes.shape({ + image: PropTypes.bool, + size: PropTypes.number, + formats: PropTypes.arrayOf(PropTypes.string), + }), + value: PropTypes.any, + onChange: PropTypes.func, +}; + +export default FilesUploader; diff --git a/frontend/src/components/Uploaders/ImagesUploader.js b/frontend/src/components/Uploaders/ImagesUploader.js new file mode 100644 index 0000000..f4e14d0 --- /dev/null +++ b/frontend/src/components/Uploaders/ImagesUploader.js @@ -0,0 +1,227 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import Button from '@mui/material/Button'; +import CloseIcon from '@mui/icons-material/Close'; +import SearchIcon from '@mui/icons-material/Search'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import Dialog from '@mui/material/Dialog'; +import FileUploader from 'components/FormItems/uploaders/UploadService'; +import Errors from '../../../components/FormItems/error/errors'; +import { makeStyles } from '@mui/styles'; + +const useStyles = makeStyles({ + actionButtonsWrapper: { + position: 'relative', + }, + previewContent: { + padding: '0px !important', + }, + imageItem: { + '&.MuiGrid-root': { + margin: 10, + boxShadow: '2px 2px 8px 0 rgb(0 0 0 / 40%)', + borderRadius: 10, + }, + height: '100px', + }, + actionButtons: { + position: 'absolute', + bottom: 5, + right: 4, + }, + previewContainer: { + '& button': { + position: 'absolute', + top: 10, + right: 10, + '& svg': { + height: 50, + width: 50, + fill: '#FFF', + stroke: '#909090', + strokeWidth: 0.5, + }, + }, + }, + button: { + padding: '0px !important', + minWidth: '45px !important', + '& svg': { + height: 36, + width: 36, + fill: '#FFF', + stroke: '#909090', + strokeWidth: 0.5, + }, + }, +}); + +const ImagesUploader = (props) => { + const classes = useStyles(); + const { value, onChange, schema, path, max, readonly, name } = props; + + const [loading, setLoading] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [imageMeta, setImageMeta] = useState({ + imageSrc: null, + imageAlt: null, + }); + const inputElement = useRef(null); + + const valuesArr = () => { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; + }; + + const fileList = () => { + return valuesArr().map((item) => ({ + uid: item.id || undefined, + name: item.name, + status: 'done', + url: item.publicUrl, + })); + }; + + const handleRemove = (id) => { + onChange(valuesArr().filter((item) => item.id !== id)); + }; + + const handleChange = async (event) => { + try { + const files = event.target.files; + + if (!files || !files.length) { + return; + } + + let file = files[0]; + + FileUploader.validate(file, schema); + + setLoading(true); + + file = await FileUploader.upload(path, file, schema); + + inputElement.current.value = ''; + setLoading(false); + onChange([...valuesArr(), file]); + } catch (error) { + inputElement.current.value = ''; + console.log('error', error); + setLoading(false); + Errors.showMessage(error); + } + }; + + const doPreviewImage = (image) => { + setImageMeta({ + imageSrc: image.publicUrl, + imageAlt: image.name, + }); + setShowPreview(true); + }; + + const doCloseImageModal = () => { + setImageMeta({ + imageSrc: null, + imageAlt: null, + }); + setShowPreview(false); + }; + + const uploadButton = ( + + + + ); + + return ( + + {readonly || (max && fileList().length >= max) ? null : uploadButton} + + {valuesArr() && valuesArr().length ? ( + + {valuesArr().map((item) => { + return ( + + {item.name} + +
    +
    + + {!readonly && ( + + )} +
    +
    +
    + ); + })} +
    + ) : null} + + + {imageMeta.imageAlt} + +
    + ); +}; + +ImagesUploader.propTypes = { + readonly: PropTypes.bool, + path: PropTypes.string, + max: PropTypes.number, + schema: PropTypes.shape({ + image: PropTypes.bool, + size: PropTypes.number, + formats: PropTypes.arrayOf(PropTypes.string), + }), + value: PropTypes.any, + onChange: PropTypes.func, + name: PropTypes.string, +}; + +export default ImagesUploader; diff --git a/frontend/src/components/Uploaders/UploadService.js b/frontend/src/components/Uploaders/UploadService.js new file mode 100644 index 0000000..19c5304 --- /dev/null +++ b/frontend/src/components/Uploaders/UploadService.js @@ -0,0 +1,82 @@ +import { v4 as uuidv4 } from 'uuid'; +import Axios from 'axios'; +import { baseURLApi } from '../../config'; + +function extractExtensionFrom(filename) { + if (!filename) { + return null; + } + + const regex = /(?:\.([^.]+))?$/; + return regex.exec(filename)[1]; +} + +export default class FileUploader { + static validate(file, schema) { + if (!schema) { + return; + } + + if (schema.image) { + if (!file.type.startsWith('image')) { + throw new Error('You must upload an image'); + } + } + + if (schema.size && file.size > schema.size) { + throw new Error('File is too big.'); + } + + const extension = extractExtensionFrom(file.name); + + if (schema.formats && !schema.formats.includes(extension)) { + throw new Error('Invalid format'); + } + } + + static async upload(path, file, schema) { + try { + this.validate(file, schema); + } catch (error) { + return Promise.reject(error); + } + + const extension = extractExtensionFrom(file.name); + const id = uuidv4(); + const filename = `${id}.${extension}`; + const privateUrl = `${path}/${filename}`; + + const publicUrl = await this.uploadToServer(file, path, filename); + + return { + id: id, + name: file.name, + sizeInBytes: file.size, + privateUrl, + publicUrl, + new: true, + }; + } + + static async uploadToServer(file, path, filename) { + const formData = new FormData(); + formData.append('file', file); + formData.append('filename', filename); + const uri = `/file/upload/${path}`; + await Axios.post(uri, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const privateUrl = `${path}/${filename}`; + + console.log( + 'process.env.NODE_ENV in uploadToServer function', + process.env.NODE_ENV, + ); + console.log('baseURLApi in uploadToServer function', baseURLApi); + + return `${baseURLApi}/file/download?privateUrl=${privateUrl}`; + } +} diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 0000000..a517a4c --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,48 @@ +/* eslint-disable @next/next/no-img-element */ +// Why disabled: +// avatars.dicebear.com provides svg avatars +// next/image needs dangerouslyAllowSVG option for that + +import React, { ReactNode } from 'react'; +import BaseIcon from './BaseIcon'; +import { mdiAccountCircleOutline } from '@mdi/js'; + +type Props = { + username: string; + avatar?: string | null; + image?: object | null; + api?: string; + className?: string; + children?: ReactNode; +}; + +export default function UserAvatar({ + username, + image, + avatar, + className = '', + children, +}: Props) { + const avatarImage = image && image[0] ? `${image[0].publicUrl}` : '#'; + + return ( +
    + {avatarImage === '#' ? ( + + ) : ( + {username} + )} + {children} +
    + ); +} diff --git a/frontend/src/components/UserAvatarCurrentUser.tsx b/frontend/src/components/UserAvatarCurrentUser.tsx new file mode 100644 index 0000000..1bcf833 --- /dev/null +++ b/frontend/src/components/UserAvatarCurrentUser.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode, useEffect, useState } from 'react'; +import { useAppSelector } from '../stores/hooks'; +import UserAvatar from './UserAvatar'; + +type Props = { + className?: string; + children?: ReactNode; +}; + +export default function UserAvatarCurrentUser({ + className = '', + children, +}: Props) { + const userName = useAppSelector((state) => state.main.userName); + const userAvatar = useAppSelector((state) => state.main.userAvatar); + const { currentUser, isFetching, token } = useAppSelector( + (state) => state.auth, + ); + const { users, loading } = useAppSelector((state) => state.users); + + const [avatar, setAvatar] = useState(null); + + useEffect(() => { + currentUserAvatarCheck(); + }, []); + + useEffect(() => { + currentUserAvatarCheck(); + }, [currentUser?.id, users]); + + const currentUserAvatarCheck = () => { + if (currentUser?.id) { + const image = currentUser?.avatar; + setAvatar(image); + } + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/UserCard.tsx b/frontend/src/components/UserCard.tsx new file mode 100644 index 0000000..15fc7e5 --- /dev/null +++ b/frontend/src/components/UserCard.tsx @@ -0,0 +1,47 @@ +import { mdiCheckDecagram } from '@mdi/js'; +import { Field, Form, Formik } from 'formik'; +import { useAppSelector } from '../stores/hooks'; +import CardBox from './CardBox'; +import FormCheckRadio from './FormCheckRadio'; +import UserAvatarCurrentUser from './UserAvatarCurrentUser'; + +type Props = { + className?: string; +}; + +const UserCard = ({ className }: Props) => { + const userName = useAppSelector((state) => state.main.userName); + + return ( + +
    + +
    +
    + alert(JSON.stringify(values, null, 2))} + > +
    + + + +
    +
    +
    +

    + Howdy, {userName}! +

    +

    + Last login 12 mins ago from 127.0.0.1 +

    +
    Verified
    +
    +
    +
    + ); +}; + +export default UserCard; diff --git a/frontend/src/components/Users/CardUsers.tsx b/frontend/src/components/Users/CardUsers.tsx new file mode 100644 index 0000000..75d43d9 --- /dev/null +++ b/frontend/src/components/Users/CardUsers.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + users: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardUsers = ({ + users, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS'); + + return ( +
    + {loading && } +
      + {!loading && + users.map((item, index) => ( +
    • +
      + + +

      {item.firstName}

      + + +
      + +
      +
      +
      +
      +
      + First Name +
      +
      +
      + {item.firstName} +
      +
      +
      + +
      +
      + Last Name +
      +
      +
      + {item.lastName} +
      +
      +
      + +
      +
      + Phone Number +
      +
      +
      + {item.phoneNumber} +
      +
      +
      + +
      +
      + E-Mail +
      +
      +
      {item.email}
      +
      +
      + +
      +
      + Disabled +
      +
      +
      + {dataFormatter.booleanFormatter(item.disabled)} +
      +
      +
      + +
      +
      + Avatar +
      +
      +
      + +
      +
      +
      + +
      +
      + App Role +
      +
      +
      + {dataFormatter.rolesOneListFormatter(item.app_role)} +
      +
      +
      + +
      +
      + Custom Permissions +
      +
      +
      + {dataFormatter + .permissionsManyListFormatter(item.custom_permissions) + .join(', ')} +
      +
      +
      + +
      +
      + Agences +
      +
      +
      + {dataFormatter.agencesOneListFormatter(item.agences)} +
      +
      +
      +
      +
    • + ))} + {!loading && users.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardUsers; diff --git a/frontend/src/components/Users/ListUsers.tsx b/frontend/src/components/Users/ListUsers.tsx new file mode 100644 index 0000000..68d3e0b --- /dev/null +++ b/frontend/src/components/Users/ListUsers.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + users: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListUsers = ({ + users, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + users.map((item) => ( +
    + +
    + + + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    First Name

    +

    {item.firstName}

    +
    + +
    +

    Last Name

    +

    {item.lastName}

    +
    + +
    +

    Phone Number

    +

    {item.phoneNumber}

    +
    + +
    +

    E-Mail

    +

    {item.email}

    +
    + +
    +

    Disabled

    +

    + {dataFormatter.booleanFormatter(item.disabled)} +

    +
    + +
    +

    Avatar

    + +
    + +
    +

    App Role

    +

    + {dataFormatter.rolesOneListFormatter(item.app_role)} +

    +
    + +
    +

    + Custom Permissions +

    +

    + {dataFormatter + .permissionsManyListFormatter(item.custom_permissions) + .join(', ')} +

    +
    + +
    +

    Agences

    +

    + {dataFormatter.agencesOneListFormatter(item.agences)} +

    +
    + + +
    +
    +
    + ))} + {!loading && users.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListUsers; diff --git a/frontend/src/components/Users/TableUsers.tsx b/frontend/src/components/Users/TableUsers.tsx new file mode 100644 index 0000000..f39c7f5 --- /dev/null +++ b/frontend/src/components/Users/TableUsers.tsx @@ -0,0 +1,482 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/users/usersSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureUsersCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleUsers = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + users, + loading, + count, + notify: usersNotify, + refetch, + } = useAppSelector((state) => state.users); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (usersNotify.showNotification) { + notify(usersNotify.typeNotification, usersNotify.textNotification); + } + }, [usersNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `users`, currentUser).then((newCols) => + setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + delete data?.password; + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={users ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleUsers; diff --git a/frontend/src/components/Users/configureUsersCols.tsx b/frontend/src/components/Users/configureUsersCols.tsx new file mode 100644 index 0000000..3e048a3 --- /dev/null +++ b/frontend/src/components/Users/configureUsersCols.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_USERS'); + + return [ + { + field: 'firstName', + headerName: 'First Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'lastName', + headerName: 'Last Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'phoneNumber', + headerName: 'Phone Number', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'email', + headerName: 'E-Mail', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'disabled', + headerName: 'Disabled', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'boolean', + }, + + { + field: 'avatar', + headerName: 'Avatar', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + renderCell: (params: GridValueGetterParams) => ( + + ), + }, + + { + field: 'app_role', + headerName: 'App Role', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('roles'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'custom_permissions', + headerName: 'Custom Permissions', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + type: 'singleSelect', + valueFormatter: ({ value }) => + dataFormatter.permissionsManyListFormatter(value).join(', '), + renderEditCell: (params) => ( + + ), + }, + + { + field: 'agences', + headerName: 'Agences', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('agences'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Vehicle_conditions/CardVehicle_conditions.tsx b/frontend/src/components/Vehicle_conditions/CardVehicle_conditions.tsx new file mode 100644 index 0000000..4250793 --- /dev/null +++ b/frontend/src/components/Vehicle_conditions/CardVehicle_conditions.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + vehicle_conditions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardVehicle_conditions = ({ + vehicle_conditions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_VEHICLE_CONDITIONS', + ); + + return ( +
    + {loading && } +
      + {!loading && + vehicle_conditions.map((item, index) => ( +
    • +
      + + {item.before} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + Reservation +
      +
      +
      + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +
      +
      +
      + +
      +
      + Before +
      +
      +
      + {item.before} +
      +
      +
      + +
      +
      After
      +
      +
      {item.after}
      +
      +
      +
      +
    • + ))} + {!loading && vehicle_conditions.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardVehicle_conditions; diff --git a/frontend/src/components/Vehicle_conditions/ListVehicle_conditions.tsx b/frontend/src/components/Vehicle_conditions/ListVehicle_conditions.tsx new file mode 100644 index 0000000..fc933e0 --- /dev/null +++ b/frontend/src/components/Vehicle_conditions/ListVehicle_conditions.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + vehicle_conditions: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListVehicle_conditions = ({ + vehicle_conditions, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission( + currentUser, + 'UPDATE_VEHICLE_CONDITIONS', + ); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + vehicle_conditions.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    Reservation

    +

    + {dataFormatter.reservationsOneListFormatter( + item.reservation, + )} +

    +
    + +
    +

    Before

    +

    {item.before}

    +
    + +
    +

    After

    +

    {item.after}

    +
    + + +
    +
    +
    + ))} + {!loading && vehicle_conditions.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListVehicle_conditions; diff --git a/frontend/src/components/Vehicle_conditions/TableVehicle_conditions.tsx b/frontend/src/components/Vehicle_conditions/TableVehicle_conditions.tsx new file mode 100644 index 0000000..d9af1f2 --- /dev/null +++ b/frontend/src/components/Vehicle_conditions/TableVehicle_conditions.tsx @@ -0,0 +1,499 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/vehicle_conditions/vehicle_conditionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureVehicle_conditionsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListVehicle_conditions from './ListVehicle_conditions'; + +const perPage = 10; + +const TableSampleVehicle_conditions = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + vehicle_conditions, + loading, + count, + notify: vehicle_conditionsNotify, + refetch, + } = useAppSelector((state) => state.vehicle_conditions); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (vehicle_conditionsNotify.showNotification) { + notify( + vehicle_conditionsNotify.typeNotification, + vehicle_conditionsNotify.textNotification, + ); + } + }, [vehicle_conditionsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns( + handleDeleteModalAction, + `vehicle_conditions`, + currentUser, + ).then((newCols) => setColumns(newCols)); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={vehicle_conditions ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {vehicle_conditions && Array.isArray(vehicle_conditions) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleVehicle_conditions; diff --git a/frontend/src/components/Vehicle_conditions/configureVehicle_conditionsCols.tsx b/frontend/src/components/Vehicle_conditions/configureVehicle_conditionsCols.tsx new file mode 100644 index 0000000..9875e61 --- /dev/null +++ b/frontend/src/components/Vehicle_conditions/configureVehicle_conditionsCols.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_VEHICLE_CONDITIONS'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'reservation', + headerName: 'Reservation', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('reservations'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'before', + headerName: 'Before', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'after', + headerName: 'After', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Vehicles/CardVehicles.tsx b/frontend/src/components/Vehicles/CardVehicles.tsx new file mode 100644 index 0000000..c668faa --- /dev/null +++ b/frontend/src/components/Vehicles/CardVehicles.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + vehicles: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardVehicles = ({ + vehicles, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_VEHICLES'); + + return ( +
    + {loading && } +
      + {!loading && + vehicles.map((item, index) => ( +
    • +
      + + {item.plate_number} + + +
      + +
      +
      +
      +
      +
      + Tenant +
      +
      +
      + {dataFormatter.tenantsOneListFormatter(item.tenant)} +
      +
      +
      + +
      +
      + PlateNumber +
      +
      +
      + {item.plate_number} +
      +
      +
      + +
      +
      Brand
      +
      +
      {item.brand}
      +
      +
      + +
      +
      Model
      +
      +
      {item.model}
      +
      +
      + +
      +
      Year
      +
      +
      {item.year}
      +
      +
      + +
      +
      Color
      +
      +
      {item.color}
      +
      +
      + +
      +
      + Category +
      +
      +
      + {item.category} +
      +
      +
      + +
      +
      + FuelType +
      +
      +
      + {item.fuel_type} +
      +
      +
      + +
      +
      + Transmission +
      +
      +
      + {item.transmission} +
      +
      +
      + +
      +
      + Mileage +
      +
      +
      + {item.mileage} +
      +
      +
      + +
      +
      + DailyPrice +
      +
      +
      + {item.daily_price} +
      +
      +
      + +
      +
      + Status +
      +
      +
      + {item.status} +
      +
      +
      + +
      +
      + InsuranceExpiry +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.insurance_expiry)} +
      +
      +
      + +
      +
      + TechVisitExpiry +
      +
      +
      + {dataFormatter.dateTimeFormatter(item.tech_visit_expiry)} +
      +
      +
      + +
      +
      + Photos +
      +
      +
      + {item.photos} +
      +
      +
      +
      +
    • + ))} + {!loading && vehicles.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardVehicles; diff --git a/frontend/src/components/Vehicles/ListVehicles.tsx b/frontend/src/components/Vehicles/ListVehicles.tsx new file mode 100644 index 0000000..94305a5 --- /dev/null +++ b/frontend/src/components/Vehicles/ListVehicles.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + vehicles: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListVehicles = ({ + vehicles, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_VEHICLES'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
    + {loading && } + {!loading && + vehicles.map((item) => ( +
    + +
    + dark:divide-dark-700 overflow-x-auto' + } + > +
    +

    Tenant

    +

    + {dataFormatter.tenantsOneListFormatter(item.tenant)} +

    +
    + +
    +

    PlateNumber

    +

    {item.plate_number}

    +
    + +
    +

    Brand

    +

    {item.brand}

    +
    + +
    +

    Model

    +

    {item.model}

    +
    + +
    +

    Year

    +

    {item.year}

    +
    + +
    +

    Color

    +

    {item.color}

    +
    + +
    +

    Category

    +

    {item.category}

    +
    + +
    +

    FuelType

    +

    {item.fuel_type}

    +
    + +
    +

    Transmission

    +

    {item.transmission}

    +
    + +
    +

    Mileage

    +

    {item.mileage}

    +
    + +
    +

    DailyPrice

    +

    {item.daily_price}

    +
    + +
    +

    Status

    +

    {item.status}

    +
    + +
    +

    + InsuranceExpiry +

    +

    + {dataFormatter.dateTimeFormatter(item.insurance_expiry)} +

    +
    + +
    +

    + TechVisitExpiry +

    +

    + {dataFormatter.dateTimeFormatter( + item.tech_visit_expiry, + )} +

    +
    + +
    +

    Photos

    +

    {item.photos}

    +
    + + +
    +
    +
    + ))} + {!loading && vehicles.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ); +}; + +export default ListVehicles; diff --git a/frontend/src/components/Vehicles/TableVehicles.tsx b/frontend/src/components/Vehicles/TableVehicles.tsx new file mode 100644 index 0000000..5e4e00f --- /dev/null +++ b/frontend/src/components/Vehicles/TableVehicles.tsx @@ -0,0 +1,494 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/vehicles/vehiclesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureVehiclesCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListVehicles from './ListVehicles'; + +const perPage = 10; + +const TableSampleVehicles = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + vehicles, + loading, + count, + notify: vehiclesNotify, + refetch, + } = useAppSelector((state) => state.vehicles); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (vehiclesNotify.showNotification) { + notify(vehiclesNotify.typeNotification, vehiclesNotify.textNotification); + } + }, [vehiclesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `vehicles`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
    + `datagrid--row`} + rows={vehicles ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
    + <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
    +
    +
    + Filter +
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
    +
    Value
    + + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    + To +
    + +
    +
    + ) : ( +
    +
    + Contains +
    + +
    + )} +
    +
    + Action +
    + { + deleteFilter(filterItem.id); + }} + /> +
    +
    + ); + })} +
    + + +
    + +
    +
    +
    + ) : null} + +

    Are you sure you want to delete this item?

    +
    + + {vehicles && Array.isArray(vehicles) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleVehicles; diff --git a/frontend/src/components/Vehicles/configureVehiclesCols.tsx b/frontend/src/components/Vehicles/configureVehiclesCols.tsx new file mode 100644 index 0000000..6574db9 --- /dev/null +++ b/frontend/src/components/Vehicles/configureVehiclesCols.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_VEHICLES'); + + return [ + { + field: 'tenant', + headerName: 'Tenant', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('tenants'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'plate_number', + headerName: 'PlateNumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'brand', + headerName: 'Brand', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'model', + headerName: 'Model', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'year', + headerName: 'Year', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'color', + headerName: 'Color', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'category', + headerName: 'Category', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'fuel_type', + headerName: 'FuelType', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'transmission', + headerName: 'Transmission', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'mileage', + headerName: 'Mileage', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'daily_price', + headerName: 'DailyPrice', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'insurance_expiry', + headerName: 'InsuranceExpiry', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.insurance_expiry), + }, + + { + field: 'tech_visit_expiry', + headerName: 'TechVisitExpiry', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + new Date(params.row.tech_visit_expiry), + }, + + { + field: 'photos', + headerName: 'Photos', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
    + +
    , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/WidgetCreator/RoleSelect.tsx b/frontend/src/components/WidgetCreator/RoleSelect.tsx new file mode 100644 index 0000000..c2da317 --- /dev/null +++ b/frontend/src/components/WidgetCreator/RoleSelect.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useId, useState } from 'react'; +import { AsyncPaginate } from 'react-select-async-paginate'; +import axios from 'axios'; + +export const RoleSelect = ({ + options, + field, + form, + itemRef, + disabled, + currentUser, +}) => { + const [value, setValue] = useState(null); + const PAGE_SIZE = 100; + + React.useEffect(() => { + if (currentUser.app_role.id) { + setValue({ + value: currentUser.app_role.id, + label: currentUser.app_role.name, + }); + } + }, [currentUser]); + + useEffect(() => { + if (options?.value && options?.label) { + setValue({ value: options.value, label: options.label }); + } + }, [options?.id, field?.value?.id]); + + const mapResponseToValuesAndLabels = (data) => ({ + value: data.id, + label: data.label, + }); + const handleChange = (option) => { + form.setFieldValue(field.name, option); + setValue(option); + }; + + async function callApi(inputValue: string, loadedOptions: any[]) { + const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${ + loadedOptions.length + }${inputValue ? `&query=${inputValue}` : ''}`; + const { data } = await axios(path); + return { + options: data.map(mapResponseToValuesAndLabels), + hasMore: data.length === PAGE_SIZE, + }; + } + return ( + 'px-1 py-2', + }} + classNamePrefix={'react-select'} + instanceId={useId()} + value={value} + debounceTimeout={1000} + loadOptions={callApi} + onChange={handleChange} + defaultOptions + isDisabled={disabled} + /> + ); +}; diff --git a/frontend/src/components/WidgetCreator/WidgetCreator.tsx b/frontend/src/components/WidgetCreator/WidgetCreator.tsx new file mode 100644 index 0000000..d39a226 --- /dev/null +++ b/frontend/src/components/WidgetCreator/WidgetCreator.tsx @@ -0,0 +1,156 @@ +import CardBox from '../CardBox'; +import { mdiCog } from '@mdi/js'; +import { Field, Form, Formik } from 'formik'; +import { ToastContainer, toast } from 'react-toastify'; +import FormField from '../FormField'; +import React from 'react'; +import { useTranslation } from 'next-i18next'; +import { + aiPrompt, + setErrorNotification, + resetNotify, +} from '../../stores/openAiSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; + +import { fetchWidgets } from '../../stores/roles/rolesSlice'; + +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import { RoleSelect } from './RoleSelect'; + +export const WidgetCreator = ({ + currentUser, + isFetchingQuery, + setWidgetsRole, + widgetsRole, +}) => { + const { t } = useTranslation('common'); + const dispatch = useAppDispatch(); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const { notify: openAiNotify } = useAppSelector((state) => state.openAi); + + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + React.useEffect(() => { + if (openAiNotify.showNotification) { + notify(openAiNotify.typeNotification, openAiNotify.textNotification); + dispatch(resetNotify()); + } + }, [openAiNotify.showNotification]); + + const openModal = (): void => { + setIsModalOpen(true); + }; + + const handleCloseModal = (value = {}) => { + setWidgetsRole(value); + setIsModalOpen(false); + }; + + const getWidgets = async () => { + await dispatch(fetchWidgets(widgetsRole?.role?.value || '')); + }; + + const smartSearch = async ( + values: { description: string }, + resetForm: any, + ) => { + const description = values.description; + const projectId = '35035'; + + const payload = { + roleId: widgetsRole?.role?.value, + description, + projectId, + userId: currentUser?.id, + }; + const { payload: responcePayload, error }: any = await dispatch( + aiPrompt(payload), + ); + + await getWidgets().then(); + + resetForm({ values: { description: '' } }); + if (responcePayload.data?.error || error) { + const errorMessage = + responcePayload.data?.error?.message || error?.message; + await dispatch( + setErrorNotification(errorMessage || 'Error with widget creation'), + ); + } + }; + + return ( + <> + + + smartSearch(values, resetForm)} + > +
    + + + +
    +
    +
    + handleCloseModal(values)} + > + {({ submitForm }) => ( + setIsModalOpen(false)} + > +

    What role are we showing and creating widgets for?

    + +
    + + + +
    +
    + )} +
    + + + ); +}; diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..56947f1 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,22 @@ +export const hostApi = + process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API + ? 'http://localhost' + : ''; +export const portApi = + process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API + ? 8080 + : ''; +export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`; + +export const localStorageDarkModeKey = 'darkMode'; + +export const localStorageStyleKey = 'style'; + +export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'; + +export const appTitle = 'created by Flatlogic generator!'; + +export const getPageTitle = (currentPageTitle: string) => + `${currentPageTitle} — ${appTitle}`; + +export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''; diff --git a/frontend/src/css/_app.css b/frontend/src/css/_app.css new file mode 100644 index 0000000..4179b23 --- /dev/null +++ b/frontend/src/css/_app.css @@ -0,0 +1,34 @@ +html { + @apply h-full; +} + +body { + @apply pt-14 xl:pl-60 h-full; +} + +#app { + @apply w-screen transition-position lg:w-auto h-full flex flex-col; +} + +.dropdown { + @apply cursor-pointer; +} + +li.stack-item:not(:last-child):after { + content: '/'; + @apply inline-block pl-2; +} + +.m-clipped, +.m-clipped body { + @apply overflow-hidden lg:overflow-visible; +} + +.full-screen body { + @apply p-0; +} + +.main-navbar, +.app-sidebar-brand { + box-shadow: 0px -1px 40px rgba(112, 144, 176, 0.2); +} diff --git a/frontend/src/css/_calendar.css b/frontend/src/css/_calendar.css new file mode 100644 index 0000000..79f8fe3 --- /dev/null +++ b/frontend/src/css/_calendar.css @@ -0,0 +1,37 @@ +.rbc-event { + @apply bg-blue-600 !important; +} + +.rbc-show-more { + @apply dark:text-white bg-transparent !important; +} + +.rbc-btn-group button { + @apply dark:text-white !important; +} + +.rbc-btn-group button:hover { + @apply text-white dark:bg-dark-700 !important; +} + +.rbc-btn-group button.rbc-active { + @apply text-black dark:bg-blue-600 !important; +} + +.rbc-btn-group button:focus { + @apply dark:bg-blue-600 !important; +} + +.rbc-day-bg.rbc-off-range-bg { + @apply dark:bg-dark-800 !important; +} +.rbc-current-time-indicator { + @apply h-1 !important; +} +.rbc-today { + @apply dark:bg-dark-600/40 !important; +} + +.rbc-day-bg.rbc-selected-cell { + @apply dark:bg-dark-500 !important; +} diff --git a/frontend/src/css/_checkbox-radio-switch.css b/frontend/src/css/_checkbox-radio-switch.css new file mode 100644 index 0000000..f05523e --- /dev/null +++ b/frontend/src/css/_checkbox-radio-switch.css @@ -0,0 +1,73 @@ +@layer components { + .checkbox, + .radio, + .switch { + @apply inline-flex items-center cursor-pointer relative; + } + + .checkbox input[type='checkbox'], + .radio input[type='radio'], + .switch input[type='checkbox'] { + @apply absolute left-0 opacity-0 -z-1; + } + + .checkbox input[type='checkbox'] + .check, + .radio input[type='radio'] + .check, + .switch input[type='checkbox'] + .check { + @apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800; + } + + .checkbox input[type='checkbox']:focus + .check, + .radio input[type='radio']:focus + .check, + .switch input[type='checkbox']:focus + .check { + @apply ring ring-blue-700; + } + + .checkbox input[type='checkbox'] + .check, + .radio input[type='radio'] + .check { + @apply block w-5 h-5; + } + + .checkbox input[type='checkbox'] + .check { + @apply rounded; + } + + .switch input[type='checkbox'] + .check { + @apply flex items-center shrink-0 w-12 h-6 p-0.5 bg-gray-200; + } + + .radio input[type='radio'] + .check, + .switch input[type='checkbox'] + .check, + .switch input[type='checkbox'] + .check:before { + @apply rounded-full; + } + + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply bg-no-repeat bg-center border-4; + } + + .checkbox input[type='checkbox']:checked + .check { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E"); + } + + .radio input[type='radio']:checked + .check { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z' /%3E%3C/svg%3E"); + } + + .switch input[type='checkbox']:checked + .check, + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply bg-blue-600 border-blue-600; + } + + .switch input[type='checkbox'] + .check:before { + content: ''; + @apply block w-5 h-5 bg-white border border-gray-700; + } + + .switch input[type='checkbox']:checked + .check:before { + transform: translate3d(110%, 0, 0); + @apply border-blue-600; + } +} diff --git a/frontend/src/css/_helper.css b/frontend/src/css/_helper.css new file mode 100644 index 0000000..9425a7c --- /dev/null +++ b/frontend/src/css/_helper.css @@ -0,0 +1,24 @@ +.helper-container { + right: 0; + top: 70px; + transform: translateX(100%); + + .tab { + top: 0; + left: 0; + transform: translateX(-100%); + } + + .tab:hover { + @apply bg-gray-900 cursor-pointer; + } +} + +.helper-container.open { + transform: translateX(0); +} + +.react-datepicker-wrapper, +.react-datepicker-popper { + z-index: 10 !important; +} diff --git a/frontend/src/css/_progress.css b/frontend/src/css/_progress.css new file mode 100644 index 0000000..d419f78 --- /dev/null +++ b/frontend/src/css/_progress.css @@ -0,0 +1,21 @@ +@layer base { + progress { + @apply h-3 rounded-full overflow-hidden; + } + + progress::-webkit-progress-bar { + @apply bg-blue-200; + } + + progress::-webkit-progress-value { + @apply bg-blue-500; + } + + progress::-moz-progress-bar { + @apply bg-blue-500; + } + + progress::-ms-fill { + @apply bg-blue-500 border-0; + } +} diff --git a/frontend/src/css/_rich-text.css b/frontend/src/css/_rich-text.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/css/_scrollbars.css b/frontend/src/css/_scrollbars.css new file mode 100644 index 0000000..11445a9 --- /dev/null +++ b/frontend/src/css/_scrollbars.css @@ -0,0 +1,41 @@ +@layer base { + html { + scrollbar-width: thin; + scrollbar-color: rgb(156, 163, 175) rgb(249, 250, 251); + } + + body::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + body::-webkit-scrollbar-track { + @apply bg-gray-50; + } + + body::-webkit-scrollbar-thumb { + @apply bg-gray-400 rounded; + } + + body::-webkit-scrollbar-thumb:hover { + @apply bg-gray-500; + } +} + +@layer utilities { + .dark-scrollbars-compat { + scrollbar-color: rgb(71, 85, 105) rgb(30, 41, 59); + } + + .dark-scrollbars::-webkit-scrollbar-track { + @apply bg-slate-800; + } + + .dark-scrollbars::-webkit-scrollbar-thumb { + @apply bg-slate-600; + } + + .dark-scrollbars::-webkit-scrollbar-thumb:hover { + @apply bg-slate-500; + } +} diff --git a/frontend/src/css/_select-dropdown.css b/frontend/src/css/_select-dropdown.css new file mode 100644 index 0000000..980f207 --- /dev/null +++ b/frontend/src/css/_select-dropdown.css @@ -0,0 +1,32 @@ +.react-select__control { + @apply dark:bg-dark-800 dark:border-dark-700 !important; +} + +.react-select__single-value { + @apply dark:text-white !important; +} + +.react-select__menu { + @apply dark:border-dark-700; +} + +.react-select__menu-list { + @apply dark:bg-dark-800 dark:border-dark-700 dark:rounded !important; +} + +.react-select__option { + @apply cursor-pointer hover:bg-gray-200 dark:hover:bg-dark-700 !important; +} + +.react-select__option--is-focused { + @apply dark:bg-dark-800 dark:text-white hover:dark:bg-dark-700 hover:dark:text-white !important; +} + +.react-select__option--is-selected, +.react-select__option--is-selected:hover { + @apply dark:bg-dark-600 !important; +} + +.react-select__multi-value__remove { + @apply dark:bg-dark-600 dark:text-white hover:dark:bg-red-300 hover:dark:text-red-600 !important; +} diff --git a/frontend/src/css/_table.css b/frontend/src/css/_table.css new file mode 100644 index 0000000..3e62e83 --- /dev/null +++ b/frontend/src/css/_table.css @@ -0,0 +1,117 @@ +@layer base { + table { + @apply w-full; + } + + thead { + @apply hidden lg:table-header-group; + } + + tr { + @apply max-w-full block relative border-b-4 border-gray-100 + lg:table-row lg:border-b-0 dark:border-slate-800; + } + + tr:last-child { + @apply border-b-0; + } + + td:not(:first-child) { + @apply lg:border-l lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-gray-100 lg:dark:border-slate-700; + } + + th { + @apply lg:text-left lg:p-3 border-b; + } + + th.sortable { + cursor: pointer; + } + + th.sortable:hover:after { + transition: all 1s; + position: absolute; + + content: '↕'; + + margin-left: 1rem; + } + + th.sortable.asc:hover:after { + content: '↑'; + } + th.sortable.desc:hover:after { + content: '↓'; + } + + td { + @apply flex justify-between text-right py-3 px-4 align-top border-b border-gray-100 + lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800 dark:text-white; + } + + td:last-child { + @apply border-b-0; + } + + tbody tr, + tbody tr:nth-child(odd) { + @apply lg:hover:bg-pavitra-300/70; + } + + tbody tr:nth-child(even) { + @apply lg:bg-pavitra-300 dark:bg-pavitra-300/70; + } + + td:before { + content: attr(data-label); + @apply font-semibold pr-3 text-left lg:hidden; + } + + tbody tr td { + @apply text-sm font-normal text-pavitra-900 dark:text-white; + } + + .datagrid--table, + .MuiDataGrid-root { + @apply rounded border-none !important; + } + + .datagrid--header { + @apply uppercase !important; + } + + .datagrid--header, + .datagrid--header .MuiIconButton-root, + .datagrid--cell, + .datagrid--cell .MuiIconButton-root { + @apply dark:text-white; + } + + .datagrid--cell .MuiDataGrid-booleanCell { + @apply dark:text-white !important; + } + + .datagrid--cell .MuiIconButton-root:hover { + @apply dark:text-white dark:bg-dark-700; + } + + .datagrid--row { + @apply even:bg-gray-100 dark:even:bg-[#1B1D22] dark:odd:bg-dark-900 !important; + } + + .datagrid--table .MuiTablePagination-root { + @apply dark:text-white; + } + + .datagrid--table .MuiTablePagination-root .MuiButtonBase-root:disabled { + @apply dark:text-dark-700; + } + + .datagrid--table .MuiTablePagination-root .MuiButtonBase-root:hover { + @apply dark:bg-dark-700; + } + + .MuiButton-colorInherit { + @apply text-blue-600 dark:text-dark-700 !important; + } +} diff --git a/frontend/src/css/_theme.css b/frontend/src/css/_theme.css new file mode 100644 index 0000000..765b8d3 --- /dev/null +++ b/frontend/src/css/_theme.css @@ -0,0 +1,105 @@ +.theme-pink { + .app-sidebar { + @apply bg-pavitra-900 text-white; + + .menu-title, + .menu-item-icon, + .menu-item-link { + @apply text-white; + } + } + + .app-sidebar-brand { + @apply bg-white; + } + + .bg-blue-600 { + @apply bg-pavitra-800; + } + + .border-blue-700 { + @apply border-pink-700; + } + + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply border-pavitra-800; + } + + .helper-container .tab { + @apply bg-pavitra-900; + } + + .focus\:ring:focus { + --tw-ring-color: #14142a; + } + + .checkbox input[type='checkbox']:focus + .check, + .radio input[type='radio']:focus + .check, + .switch input[type='checkbox']:focus + .check { + --tw-ring-color: #14142a; + } +} + +.theme-green { + .app-sidebar { + @apply bg-pavitra-800 text-white; + + .menu-title, + .menu-item-icon, + .menu-item-link { + @apply text-white; + } + } + + .app-sidebar-brand { + @apply bg-white; + } + + .bg-blue-600 { + @apply bg-pavitra-800; + } + + .border-blue-700 { + @apply bg-pavitra-700; + } + + .hover\:bg-blue-700:hover { + @apply bg-pavitra-700; + } + + .text-blue-600 { + @apply text-pavitra-900; + } + + .checkbox input[type='checkbox']:checked + .check, + .radio input[type='radio']:checked + .check { + @apply border-pavitra-800; + } + + .helper-container .tab { + @apply bg-pavitra-700; + } + + .focus\:ring:focus { + --tw-ring-color: #4e4b66; + } + + .checkbox input[type='checkbox']:focus + .check, + .radio input[type='radio']:focus + .check, + .switch input[type='checkbox']:focus + .check { + --tw-ring-color: #4e4b66; + } + + .text-blue-500 { + @apply text-pavitra-800; + } + + .hover\:text-blue-600:hover { + @apply text-pavitra-800; + } + + .active\:text-blue-700:active { + @apply text-pavitra-800; + } +} diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css new file mode 100644 index 0000000..62d1477 --- /dev/null +++ b/frontend/src/css/main.css @@ -0,0 +1,34 @@ +@import 'tailwind/_base.css'; +@import 'tailwind/_components.css'; +@import 'tailwind/_utilities.css'; +@import 'intro.js/introjs.css'; +@import '_checkbox-radio-switch.css'; +@import '_progress.css'; +@import '_scrollbars.css'; +@import '_table.css'; +@import '_helper.css'; +@import '_calendar.css'; +@import '_select-dropdown.css'; +@import '_theme.css'; +@import '_rich-text.css'; + +.introjs-tooltip { + @apply min-w-[400px] max-w-[480px] p-2 !important; +} + +.good-img { + @apply -mt-96 !important; +} +.end-img { + @apply -mt-72 !important; +} +.introjs-button { + @apply bg-blue-600 text-white !important; + text-shadow: none !important; +} +.introjs-bullets ul li a.active { + @apply bg-blue-600 !important; +} +.introjs-prevbutton { + @apply bg-transparent border border-blue-600 text-blue-600 !important; +} diff --git a/frontend/src/css/tailwind/_base.css b/frontend/src/css/tailwind/_base.css new file mode 100644 index 0000000..2f02db5 --- /dev/null +++ b/frontend/src/css/tailwind/_base.css @@ -0,0 +1 @@ +@tailwind base; diff --git a/frontend/src/css/tailwind/_components.css b/frontend/src/css/tailwind/_components.css new file mode 100644 index 0000000..020aaba --- /dev/null +++ b/frontend/src/css/tailwind/_components.css @@ -0,0 +1 @@ +@tailwind components; diff --git a/frontend/src/css/tailwind/_utilities.css b/frontend/src/css/tailwind/_utilities.css new file mode 100644 index 0000000..65dd5f6 --- /dev/null +++ b/frontend/src/css/tailwind/_utilities.css @@ -0,0 +1 @@ +@tailwind utilities; diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js new file mode 100644 index 0000000..15810ae --- /dev/null +++ b/frontend/src/helpers/dataFormatter.js @@ -0,0 +1,212 @@ +import dayjs from 'dayjs'; +import _ from 'lodash'; + +export default { + filesFormatter(arr) { + if (!arr || !arr.length) return []; + return arr.map((item) => item); + }, + imageFormatter(arr) { + if (!arr || !arr.length) return []; + return arr.map((item) => ({ + publicUrl: item.publicUrl || '', + })); + }, + oneImageFormatter(arr) { + if (!arr || !arr.length) return ''; + return arr[0].publicUrl || ''; + }, + dateFormatter(date) { + if (!date) return ''; + return dayjs(date).format('YYYY-MM-DD'); + }, + dateTimeFormatter(date) { + if (!date) return ''; + return dayjs(date).format('YYYY-MM-DD HH:mm'); + }, + booleanFormatter(val) { + return val ? 'Yes' : 'No'; + }, + dataGridEditFormatter(obj) { + return _.transform(obj, (result, value, key) => { + if (_.isArray(value)) { + result[key] = _.map(value, 'id'); + } else if (_.isObject(value)) { + result[key] = value.id; + } else { + result[key] = value; + } + }); + }, + + clientsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.first_name); + }, + clientsOneListFormatter(val) { + if (!val) return ''; + return val.first_name; + }, + clientsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.first_name }; + }); + }, + clientsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.first_name, id: val.id }; + }, + + plansManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + plansOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + plansManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + plansOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, + + reservationsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.ref); + }, + reservationsOneListFormatter(val) { + if (!val) return ''; + return val.ref; + }, + reservationsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.ref }; + }); + }, + reservationsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.ref, id: val.id }; + }, + + subscriptionsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.provider_customer_id); + }, + subscriptionsOneListFormatter(val) { + if (!val) return ''; + return val.provider_customer_id; + }, + subscriptionsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.provider_customer_id }; + }); + }, + subscriptionsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.provider_customer_id, id: val.id }; + }, + + tenantsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + tenantsOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + tenantsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + tenantsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, + + vehiclesManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.plate_number); + }, + vehiclesOneListFormatter(val) { + if (!val) return ''; + return val.plate_number; + }, + vehiclesManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.plate_number }; + }); + }, + vehiclesOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.plate_number, id: val.id }; + }, + + rolesManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + rolesOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + rolesManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + rolesOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, + + permissionsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + permissionsOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + permissionsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + permissionsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, + + agencesManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + agencesOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + agencesManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + agencesOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, +}; diff --git a/frontend/src/helpers/fileSaver.ts b/frontend/src/helpers/fileSaver.ts new file mode 100644 index 0000000..242b540 --- /dev/null +++ b/frontend/src/helpers/fileSaver.ts @@ -0,0 +1,6 @@ +import { saveAs } from 'file-saver'; + +export const saveFile = (e, url: string, name: string) => { + e.stopPropagation(); + saveAs(url, name); +}; diff --git a/frontend/src/helpers/humanize.ts b/frontend/src/helpers/humanize.ts new file mode 100644 index 0000000..61b6407 --- /dev/null +++ b/frontend/src/helpers/humanize.ts @@ -0,0 +1,12 @@ +export function humanize(str: string) { + if (!str) { + return ''; + } + return str + .toString() + .replace(/^[\s_]+|[\s_]+$/g, '') + .replace(/[_\s]+/g, ' ') + .replace(/^[a-z]/, function (m) { + return m.toUpperCase(); + }); +} diff --git a/frontend/src/helpers/notifyStateHandler.ts b/frontend/src/helpers/notifyStateHandler.ts new file mode 100644 index 0000000..ae21899 --- /dev/null +++ b/frontend/src/helpers/notifyStateHandler.ts @@ -0,0 +1,32 @@ +export const resetNotify = (state) => { + state.notify.showNotification = false; + state.notify.typeNotification = ''; + state.notify.textNotification = ''; +}; +export const rejectNotify = (state, action) => { + if (typeof action.payload === 'string') { + state.notify.textNotification = action.payload; + } else if (typeof action === 'object') { + const obj = { ...action.payload?.errors }; + delete obj['_errors']; + + let msg = ''; + + for (const key in obj) { + msg += `${key}: ${obj[key]['_errors']}; \n `; + } + + state.notify.textNotification = msg; + } else { + state.notify.textNotification = 'Network error'; + } + state.notify.textNotification = + state.notify.textNotification || 'Network error'; + state.notify.typeNotification = 'error'; + state.notify.showNotification = true; +}; +export const fulfilledNotify = (state, msg) => { + state.notify.textNotification = msg; + state.notify.typeNotification = 'success'; + state.notify.showNotification = true; +}; diff --git a/frontend/src/helpers/pexels.ts b/frontend/src/helpers/pexels.ts new file mode 100644 index 0000000..524a1b2 --- /dev/null +++ b/frontend/src/helpers/pexels.ts @@ -0,0 +1,75 @@ +import axios from 'axios'; + +export async function getPexelsImage() { + try { + const response = await axios.get(`/pexels/image`); + return response.data; + } catch (error) { + console.error('Error fetching image:', error); + return null; + } +} + +export async function getPexelsVideo() { + try { + const response = await axios.get(`/pexels/video`); + return response.data; + } catch (error) { + console.error('Error fetching video:', error); + return null; + } +} + +let localStorageLock = false; + +export async function getMultiplePexelsImages( + queries = ['home', 'apple', 'pizza', 'mountains', 'cat'], +) { + const normalizeQuery = (query) => + query.trim().toLowerCase().replace(/\s+/g, ''); + + while (localStorageLock) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + localStorageLock = true; + + const cachedImages = + JSON.parse(localStorage.getItem('pexelsImagesCache')) || {}; + + const isImageCached = (query) => { + const normalizedQuery = normalizeQuery(query); + const cached = cachedImages[normalizedQuery]; + const isCached = + cached && cached.src && cached.photographer && cached.photographer_url; + return isCached; + }; + + const missingQueries = queries.filter((query) => !isImageCached(query)); + + if (missingQueries.length > 0) { + const queryString = missingQueries.join(','); + + try { + const response = await axios.get(`/pexels/multiple-images`, { + params: { queries: queryString }, + }); + + missingQueries.forEach((query, index) => { + const normalizedQuery = normalizeQuery(query); + if (!cachedImages[normalizedQuery]) { + cachedImages[normalizedQuery] = response.data[index]; + } + }); + + localStorage.setItem('pexelsImagesCache', JSON.stringify(cachedImages)); + } catch (error) { + console.error(error); + } + } + + const result = queries.map((query) => cachedImages[normalizeQuery(query)]); + + localStorageLock = false; + + return result; +} diff --git a/frontend/src/helpers/userPermissions.ts b/frontend/src/helpers/userPermissions.ts new file mode 100644 index 0000000..c2d9032 --- /dev/null +++ b/frontend/src/helpers/userPermissions.ts @@ -0,0 +1,16 @@ +export function hasPermission(user, permission_name: string | string[]) { + if (!user?.app_role?.name) return false; + if (!permission_name) { + return true; + } + const permissions = new Set([ + ...(user?.custom_permissions ?? []).map((p) => p.name), + ...(user?.app_role_permissions ?? []).map((p) => p.name), + ]); + + if (typeof permission_name === 'string') { + return permissions.has(permission_name) || user.app_role.globalAccess; + } else { + return permission_name.some((permission) => permissions.has(permission)); + } +} diff --git a/frontend/src/hooks/sampleData.ts b/frontend/src/hooks/sampleData.ts new file mode 100644 index 0000000..8c74ad5 --- /dev/null +++ b/frontend/src/hooks/sampleData.ts @@ -0,0 +1,22 @@ +import useSWR from 'swr'; +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export const useSampleClients = () => { + const { data, error } = useSWR('/data-sources/clients.json', fetcher); + + return { + clients: data?.data ?? [], + isLoading: !error && !data, + isError: error, + }; +}; + +export const useSampleTransactions = () => { + const { data, error } = useSWR('/data-sources/history.json', fetcher); + + return { + transactions: data?.data ?? [], + isLoading: !error && !data, + isError: error, + }; +}; diff --git a/frontend/src/hooks/useDevCompilationStatus.ts b/frontend/src/hooks/useDevCompilationStatus.ts new file mode 100644 index 0000000..c1b9356 --- /dev/null +++ b/frontend/src/hooks/useDevCompilationStatus.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; + +type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial'; + +const useDevCompilationStatus = (): CompilationStatus => { + const router = useRouter(); + const [status, setStatus] = useState('initial'); + + useEffect(() => { + if (process.env.NODE_ENV !== 'development') { + setStatus('ready'); + return; + } + + const handleRouteChangeStart = () => { + setStatus('compiling'); + }; + + const handleRouteChangeComplete = () => { + setTimeout(() => setStatus('ready'), 300); + }; + + const handleRouteChangeError = () => { + setTimeout(() => setStatus('error'), 300); + }; + + router.events.on('routeChangeStart', handleRouteChangeStart); + router.events.on('routeChangeComplete', handleRouteChangeComplete); + router.events.on('routeChangeError', handleRouteChangeError); + + setStatus('ready'); + + return () => { + router.events.off('routeChangeStart', handleRouteChangeStart); + router.events.off('routeChangeComplete', handleRouteChangeComplete); + router.events.off('routeChangeError', handleRouteChangeError); + }; + }, [router]); + + return status; +}; + +export default useDevCompilationStatus; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..0b55f95 --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,21 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import HttpApi from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(HttpApi) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + detection: { + order: ['localStorage', 'navigator'], + lookupLocalStorage: 'app_lang_35035', + caches: ['localStorage'], + }, + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + interpolation: { escapeValue: false }, + }); diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts new file mode 100644 index 0000000..75048f3 --- /dev/null +++ b/frontend/src/interfaces/index.ts @@ -0,0 +1,122 @@ +export type UserPayloadObject = { + name: string; + email: string; + avatar: string; +}; + +export type MenuAsideItem = { + label: string; + icon?: string; + href?: string; + target?: string; + color?: ColorButtonKey; + isLogout?: boolean; + withDevider?: boolean; + menu?: MenuAsideItem[]; + permissions?: string | string[]; +}; + +export type MenuNavBarItem = { + label?: string; + icon?: string; + href?: string; + target?: string; + isDivider?: boolean; + isLogout?: boolean; + isDesktopNoLabel?: boolean; + isToggleLightDark?: boolean; + isCurrentUser?: boolean; + menu?: MenuNavBarItem[]; +}; + +export type ColorKey = + | 'white' + | 'light' + | 'contrast' + | 'success' + | 'danger' + | 'warning' + | 'info'; + +export type ColorButtonKey = + | 'white' + | 'whiteDark' + | 'lightDark' + | 'contrast' + | 'success' + | 'danger' + | 'warning' + | 'info' + | 'void'; + +export type BgKey = 'purplePink' | 'pinkRed' | 'violet'; + +export type TrendType = + | 'up' + | 'down' + | 'success' + | 'danger' + | 'warning' + | 'info'; + +export type TransactionType = 'withdraw' | 'deposit' | 'invoice' | 'payment'; + +export type Transaction = { + id: number; + amount: number; + account: string; + name: string; + date: string; + type: TransactionType; + business: string; +}; + +export type Client = { + id: number; + avatar: string; + login: string; + name: string; + city: string; + company: string; + firstName: string; + lastName: string; + phoneNumber: string; + email: string; + progress: number; + role: string; + disabled: boolean; + created: string; + created_mm_dd_yyyy: string; +}; + +export interface User { + id: string; + firstName: string; + lastName?: any; + phoneNumber?: any; + email: string; + role: string; + disabled: boolean; + password: string; + emailVerified: boolean; + emailVerificationToken?: any; + emailVerificationTokenExpiresAt?: any; + passwordResetToken?: any; + passwordResetTokenExpiresAt?: any; + provider: string; + importHash?: any; + createdAt: Date; + updatedAt: Date; + deletedAt?: any; + createdById?: any; + updatedById?: any; + avatar: any[]; + notes: any[]; +} + +export type StyleKey = 'white' | 'basic'; + +export type UserForm = { + name: string; + email: string; +}; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx new file mode 100644 index 0000000..7d616a3 --- /dev/null +++ b/frontend/src/layouts/Authenticated.tsx @@ -0,0 +1,132 @@ +import React, { ReactNode, useEffect } from 'react'; +import { useState } from 'react'; +import jwt from 'jsonwebtoken'; +import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'; +import menuAside from '../menuAside'; +import menuNavBar from '../menuNavBar'; +import BaseIcon from '../components/BaseIcon'; +import NavBar from '../components/NavBar'; +import NavBarItemPlain from '../components/NavBarItemPlain'; +import AsideMenu from '../components/AsideMenu'; +import FooterBar from '../components/FooterBar'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import Search from '../components/Search'; +import { useRouter } from 'next/router'; +import { findMe, logoutUser } from '../stores/authSlice'; + +import { hasPermission } from '../helpers/userPermissions'; + +type Props = { + children: ReactNode; + + permission?: string; +}; + +export default function LayoutAuthenticated({ + children, + + permission, +}: Props) { + const dispatch = useAppDispatch(); + const router = useRouter(); + const { token, currentUser } = useAppSelector((state) => state.auth); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + let localToken; + if (typeof window !== 'undefined') { + // Perform localStorage action + localToken = localStorage.getItem('token'); + } + + const isTokenValid = () => { + const token = localStorage.getItem('token'); + if (!token) return; + const date = new Date().getTime() / 1000; + const data = jwt.decode(token); + if (!data) return; + return date < data.exp; + }; + + useEffect(() => { + dispatch(findMe()); + if (!isTokenValid()) { + dispatch(logoutUser()); + router.push('/login'); + } + }, [token, localToken]); + + useEffect(() => { + if (!permission || !currentUser) return; + + if (!hasPermission(currentUser, permission)) router.push('/error'); + }, [currentUser, permission]); + + const darkMode = useAppSelector((state) => state.style.darkMode); + + const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false); + const [isAsideLgActive, setIsAsideLgActive] = useState(false); + + useEffect(() => { + const handleRouteChangeStart = () => { + setIsAsideMobileExpanded(false); + setIsAsideLgActive(false); + }; + + router.events.on('routeChangeStart', handleRouteChangeStart); + + // If the component is unmounted, unsubscribe + // from the event with the `off` method: + return () => { + router.events.off('routeChangeStart', handleRouteChangeStart); + }; + }, [router.events, dispatch]); + + const layoutAsidePadding = 'xl:pl-60'; + + return ( +
    +
    + + setIsAsideMobileExpanded(!isAsideMobileExpanded)} + > + + + setIsAsideLgActive(true)} + > + + + + + + + setIsAsideLgActive(false)} + /> + {children} + Hand-crafted & Made with ❤️ +
    +
    + ); +} diff --git a/frontend/src/layouts/Guest.tsx b/frontend/src/layouts/Guest.tsx new file mode 100644 index 0000000..49ac1b0 --- /dev/null +++ b/frontend/src/layouts/Guest.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; +import { useAppSelector } from '../stores/hooks'; + +type Props = { + children: ReactNode; +}; + +export default function LayoutGuest({ children }: Props) { + const darkMode = useAppSelector((state) => state.style.darkMode); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + + return ( +
    +
    + {children} +
    +
    + ); +} diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts new file mode 100644 index 0000000..067f3b3 --- /dev/null +++ b/frontend/src/menuAside.ts @@ -0,0 +1,234 @@ +import * as icon from '@mdi/js'; +import { MenuAsideItem } from './interfaces'; + +const menuAside: MenuAsideItem[] = [ + { + href: '/dashboard', + icon: icon.mdiViewDashboardOutline, + label: 'Dashboard', + }, + + { + href: '/users/users-list', + label: 'Users', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiAccountGroup ?? icon.mdiTable, + permissions: 'READ_USERS', + }, + { + href: '/claims/claims-list', + label: 'Claims', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCommentAlert' in icon + ? icon['mdiCommentAlert' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_CLAIMS', + }, + { + href: '/clients/clients-list', + label: 'Clients', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiAccount' in icon + ? icon['mdiAccount' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_CLIENTS', + }, + { + href: '/company_profiles/company_profiles-list', + label: 'Company profiles', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiDomain' in icon + ? icon['mdiDomain' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_COMPANY_PROFILES', + }, + { + href: '/drivers/drivers-list', + label: 'Drivers', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCar' in icon + ? icon['mdiCar' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_DRIVERS', + }, + { + href: '/expenses/expenses-list', + label: 'Expenses', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCurrencyUsd' in icon + ? icon['mdiCurrencyUsd' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_EXPENSES', + }, + { + href: '/guarantees/guarantees-list', + label: 'Guarantees', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiShieldCheck' in icon + ? icon['mdiShieldCheck' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_GUARANTEES', + }, + { + href: '/infractions/infractions-list', + label: 'Infractions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiAlertCircle' in icon + ? icon['mdiAlertCircle' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_INFRACTIONS', + }, + { + href: '/invoices_saas/invoices_saas-list', + label: 'Invoices saas', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiFileDocument' in icon + ? icon['mdiFileDocument' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_INVOICES_SAAS', + }, + { + href: '/maintenance/maintenance-list', + label: 'Maintenance', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiWrench' in icon + ? icon['mdiWrench' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_MAINTENANCE', + }, + { + href: '/payments/payments-list', + label: 'Payments', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCash' in icon + ? icon['mdiCash' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PAYMENTS', + }, + { + href: '/plans/plans-list', + label: 'Plans', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiPackageVariant' in icon + ? icon['mdiPackageVariant' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PLANS', + }, + { + href: '/reservations/reservations-list', + label: 'Reservations', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCalendar' in icon + ? icon['mdiCalendar' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_RESERVATIONS', + }, + { + href: '/subscriptions/subscriptions-list', + label: 'Subscriptions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiReceipt' in icon + ? icon['mdiReceipt' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_SUBSCRIPTIONS', + }, + { + href: '/tenants/tenants-list', + label: 'Tenants', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiOfficeBuilding' in icon + ? icon['mdiOfficeBuilding' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_TENANTS', + }, + { + href: '/vehicle_conditions/vehicle_conditions-list', + label: 'Vehicle conditions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCar' in icon + ? icon['mdiCar' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_VEHICLE_CONDITIONS', + }, + { + href: '/vehicles/vehicles-list', + label: 'Vehicles', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCar' in icon + ? icon['mdiCar' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_VEHICLES', + }, + { + href: '/roles/roles-list', + label: 'Roles', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, + permissions: 'READ_ROLES', + }, + { + href: '/permissions/permissions-list', + label: 'Permissions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, + permissions: 'READ_PERMISSIONS', + }, + { + href: '/agences/agences-list', + label: 'Agences', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_AGENCES', + }, + { + href: '/profile', + label: 'Profile', + icon: icon.mdiAccountCircle, + }, + + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: icon.mdiFileCode, + permissions: 'READ_API_DOCS', + }, +]; + +export default menuAside; diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts new file mode 100644 index 0000000..940a495 --- /dev/null +++ b/frontend/src/menuNavBar.ts @@ -0,0 +1,51 @@ +import { + mdiMenu, + mdiClockOutline, + mdiCloud, + mdiCrop, + mdiAccount, + mdiCogOutline, + mdiEmail, + mdiLogout, + mdiThemeLightDark, + mdiGithub, + mdiVuejs, +} from '@mdi/js'; +import { MenuNavBarItem } from './interfaces'; + +const menuNavBar: MenuNavBarItem[] = [ + { + isCurrentUser: true, + menu: [ + { + icon: mdiAccount, + label: 'My Profile', + href: '/profile', + }, + { + isDivider: true, + }, + { + icon: mdiLogout, + label: 'Log Out', + isLogout: true, + }, + ], + }, + { + icon: mdiThemeLightDark, + label: 'Light/Dark', + isDesktopNoLabel: true, + isToggleLightDark: true, + }, + { + icon: mdiLogout, + label: 'Log out', + isDesktopNoLabel: true, + isLogout: true, + }, +]; + +export const webPagesNavBar = []; + +export default menuNavBar; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx new file mode 100644 index 0000000..ce74989 --- /dev/null +++ b/frontend/src/pages/_app.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import type { AppProps } from 'next/app'; +import type { ReactElement, ReactNode } from 'react'; +import type { NextPage } from 'next'; +import Head from 'next/head'; +import { store } from '../stores/store'; +import { Provider } from 'react-redux'; +import '../css/main.css'; +import axios from 'axios'; +import { baseURLApi } from '../config'; +import { useRouter } from 'next/router'; +import ErrorBoundary from '../components/ErrorBoundary'; +import DevModeBadge from '../components/DevModeBadge'; +import 'intro.js/introjs.css'; +import { appWithTranslation } from 'next-i18next'; +import '../i18n'; +import IntroGuide from '../components/IntroGuide'; +import { + appSteps, + loginSteps, + usersSteps, + rolesSteps, +} from '../stores/introSteps'; + +// Initialize axios +axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API + ? process.env.NEXT_PUBLIC_BACK_API + : baseURLApi; + +axios.defaults.headers.common['Content-Type'] = 'application/json'; + +export type NextPageWithLayout

    , IP = P> = NextPage< + P, + IP +> & { + getLayout?: (page: ReactElement) => ReactNode; +}; + +type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayout; +}; + +function MyApp({ Component, pageProps }: AppPropsWithLayout) { + // Use the layout defined at the page level, if available + const getLayout = Component.getLayout || ((page) => page); + const router = useRouter(); + const [stepsEnabled, setStepsEnabled] = React.useState(false); + const [stepName, setStepName] = React.useState(''); + const [steps, setSteps] = React.useState([]); + + axios.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } else { + delete config.headers.Authorization; + } + + return config; + }, + (error) => { + return Promise.reject(error); + }, + ); + + // TODO: Remove this code in future releases + React.useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (event.data === 'getLocation') { + event.source?.postMessage( + { iframeLocation: window.location.pathname }, + event.origin, + ); + return; + } + + if (event.data === 'getScreenshot') { + try { + const html2canvas = (await import('html2canvas')).default; + const canvas = await html2canvas(document.body, { useCORS: true }); + const url = canvas.toDataURL('image/jpeg', 0.8); + event.source?.postMessage({ iframeScreenshot: url }, event.origin); + } catch (e) { + console.error('html2canvas failed', e); + event.source?.postMessage({ iframeScreenshot: null }, event.origin); + } + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + React.useEffect(() => { + const isCompleted = (stepKey: string) => { + return localStorage.getItem(`completed_${stepKey}`) === 'true'; + }; + if (router.pathname === '/login' && !isCompleted('loginSteps')) { + setSteps(loginSteps); + setStepName('loginSteps'); + setStepsEnabled(true); + } else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) { + setTimeout(() => { + setSteps(appSteps); + setStepName('appSteps'); + setStepsEnabled(true); + }, 1000); + } else if ( + router.pathname === '/users/users-list' && + !isCompleted('usersSteps') + ) { + setTimeout(() => { + setSteps(usersSteps); + setStepName('usersSteps'); + setStepsEnabled(true); + }, 1000); + } else if ( + router.pathname === '/roles/roles-list' && + !isCompleted('rolesSteps') + ) { + setTimeout(() => { + setSteps(rolesSteps); + setStepName('rolesSteps'); + setStepsEnabled(true); + }, 1000); + } else { + setSteps([]); + setStepsEnabled(false); + } + }, [router.pathname]); + + const handleExit = () => { + setStepsEnabled(false); + }; + + const title = 'Car zone'; + const description = 'Car zone generated by Flatlogic'; + const url = 'https://flatlogic.com/'; + const image = `https://flatlogic.com/logo.svg`; + const imageWidth = '1920'; + const imageHeight = '960'; + + return ( + + {getLayout( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + {(process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'dev_stage') && } + , + )} + + ); +} + +export default appWithTranslation(MyApp); diff --git a/frontend/src/pages/agences/[agencesId].tsx b/frontend/src/pages/agences/[agencesId].tsx new file mode 100644 index 0000000..f8e57a1 --- /dev/null +++ b/frontend/src/pages/agences/[agencesId].tsx @@ -0,0 +1,128 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/agences/agencesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditAgences = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { agences } = useAppSelector((state) => state.agences); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { agencesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: agencesId })); + }, [agencesId]); + + useEffect(() => { + if (typeof agences === 'object') { + setInitialValues(agences); + } + }, [agences]); + + useEffect(() => { + if (typeof agences === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = agences[el])); + + setInitialValues(newInitialVal); + } + }, [agences]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: agencesId, data })); + await router.push('/agences/agences-list'); + }; + + return ( + <> + + {getPageTitle('Edit agences')} + + + + {''} + + + handleSubmit(values)} + > +

    + + + + + + + + + router.push('/agences/agences-list')} + /> + + + + + + + ); +}; + +EditAgences.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditAgences; diff --git a/frontend/src/pages/agences/agences-edit.tsx b/frontend/src/pages/agences/agences-edit.tsx new file mode 100644 index 0000000..373a97e --- /dev/null +++ b/frontend/src/pages/agences/agences-edit.tsx @@ -0,0 +1,126 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/agences/agencesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditAgencesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { agences } = useAppSelector((state) => state.agences); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof agences === 'object') { + setInitialValues(agences); + } + }, [agences]); + + useEffect(() => { + if (typeof agences === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = agences[el])); + setInitialValues(newInitialVal); + } + }, [agences]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/agences/agences-list'); + }; + + return ( + <> + + {getPageTitle('Edit agences')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/agences/agences-list')} + /> + + +
    +
    +
    + + ); +}; + +EditAgencesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditAgencesPage; diff --git a/frontend/src/pages/agences/agences-list.tsx b/frontend/src/pages/agences/agences-list.tsx new file mode 100644 index 0000000..cbbf6b0 --- /dev/null +++ b/frontend/src/pages/agences/agences-list.tsx @@ -0,0 +1,162 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableAgences from '../../components/Agences/TableAgences'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/agences/agencesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const AgencesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_AGENCES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getAgencesCSV = async () => { + const response = await axios({ + url: '/agences?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'agencesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Agences')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + + +
    + + + + + ); +}; + +AgencesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AgencesTablesPage; diff --git a/frontend/src/pages/agences/agences-new.tsx b/frontend/src/pages/agences/agences-new.tsx new file mode 100644 index 0000000..2d7bccb --- /dev/null +++ b/frontend/src/pages/agences/agences-new.tsx @@ -0,0 +1,98 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/agences/agencesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + name: '', +}; + +const AgencesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/agences/agences-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/agences/agences-list')} + /> + + +
    +
    +
    + + ); +}; + +AgencesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AgencesNew; diff --git a/frontend/src/pages/agences/agences-table.tsx b/frontend/src/pages/agences/agences-table.tsx new file mode 100644 index 0000000..43b8257 --- /dev/null +++ b/frontend/src/pages/agences/agences-table.tsx @@ -0,0 +1,161 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableAgences from '../../components/Agences/TableAgences'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/agences/agencesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const AgencesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_AGENCES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getAgencesCSV = async () => { + const response = await axios({ + url: '/agences?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'agencesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Agences')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + +
    + + + + + ); +}; + +AgencesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AgencesTablesPage; diff --git a/frontend/src/pages/agences/agences-view.tsx b/frontend/src/pages/agences/agences-view.tsx new file mode 100644 index 0000000..4c31f6a --- /dev/null +++ b/frontend/src/pages/agences/agences-view.tsx @@ -0,0 +1,1216 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/agences/agencesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const AgencesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { agences } = useAppSelector((state) => state.agences); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View agences')} + + + + + + +
    +

    Name

    +

    {agences?.name}

    +
    + + <> +

    Users Agences

    + +
    + + + + + + + + + + + + + + + + {agences.users_agences && + Array.isArray(agences.users_agences) && + agences.users_agences.map((item: any) => ( + + router.push(`/users/users-view/?id=${item.id}`) + } + > + + + + + + + + + + + ))} + +
    First NameLast NamePhone NumberE-MailDisabled
    {item.firstName}{item.lastName}{item.phoneNumber}{item.email} + {dataFormatter.booleanFormatter(item.disabled)} +
    +
    + {!agences?.users_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Claims agences

    + +
    + + + + + + + + + + + + + + {agences.claims_agences && + Array.isArray(agences.claims_agences) && + agences.claims_agences.map((item: any) => ( + + router.push(`/claims/claims-view/?id=${item.id}`) + } + > + + + + + + + + + ))} + +
    CategoryDescriptionStatusAttachments
    {item.category}{item.description}{item.status}{item.attachments}
    +
    + {!agences?.claims_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Clients agences

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {agences.clients_agences && + Array.isArray(agences.clients_agences) && + agences.clients_agences.map((item: any) => ( + + router.push(`/clients/clients-view/?id=${item.id}`) + } + > + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
    FirstNameLastNameBirthDatePhoneAddressIDTypeIDNumberIDIssueDateLicenseNumberLicenseIssueDateIDCopyURLLicenseCopyURL
    {item.first_name}{item.last_name} + {dataFormatter.dateTimeFormatter(item.birth_date)} + {item.phone}{item.address}{item.id_type}{item.id_number} + {dataFormatter.dateTimeFormatter( + item.id_issue_date, + )} + + {item.license_number} + + {dataFormatter.dateTimeFormatter( + item.license_issue_date, + )} + {item.id_copy_url} + {item.license_copy_url} +
    +
    + {!agences?.clients_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Company_profiles agences

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + {agences.company_profiles_agences && + Array.isArray(agences.company_profiles_agences) && + agences.company_profiles_agences.map((item: any) => ( + + router.push( + `/company_profiles/company_profiles-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + + + + + + + + + ))} + +
    LegalNameAddressPhoneEmailWebsiteRCICEPatenteLogoURLPDFFooter
    {item.legal_name}{item.address}{item.phone}{item.email}{item.website}{item.rc}{item.ice}{item.patente}{item.logo_url}{item.pdf_footer}
    +
    + {!agences?.company_profiles_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Drivers agences

    + +
    + + + + + + + + + + + + + + + + {agences.drivers_agences && + Array.isArray(agences.drivers_agences) && + agences.drivers_agences.map((item: any) => ( + + router.push(`/drivers/drivers-view/?id=${item.id}`) + } + > + + + + + + + + + + + ))} + +
    FullNameLicenseNumberLicenseIssueDateIDCopyURLLicenseCopyURL
    {item.full_name} + {item.license_number} + + {dataFormatter.dateTimeFormatter( + item.license_issue_date, + )} + {item.id_copy_url} + {item.license_copy_url} +
    +
    + {!agences?.drivers_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Expenses agences

    + +
    + + + + + + + + + + + + + + + + + + {agences.expenses_agences && + Array.isArray(agences.expenses_agences) && + agences.expenses_agences.map((item: any) => ( + + router.push( + `/expenses/expenses-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + ))} + +
    TypeCategoryAmountDateNoteAttachmentURL
    {item.type}{item.category}{item.amount} + {dataFormatter.dateTimeFormatter(item.date)} + {item.note} + {item.attachment_url} +
    +
    + {!agences?.expenses_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Guarantees agences

    + +
    + + + + + + + + + + + + + + {agences.guarantees_agences && + Array.isArray(agences.guarantees_agences) && + agences.guarantees_agences.map((item: any) => ( + + router.push( + `/guarantees/guarantees-view/?id=${item.id}`, + ) + } + > + + + + + + + + + ))} + +
    MethodAmountStatusProofURL
    {item.method}{item.amount}{item.status}{item.proof_url}
    +
    + {!agences?.guarantees_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Infractions agences

    + +
    + + + + + + + + + + + + + + + + + + {agences.infractions_agences && + Array.isArray(agences.infractions_agences) && + agences.infractions_agences.map((item: any) => ( + + router.push( + `/infractions/infractions-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + ))} + +
    ReferenceNumberDateLocationAmountStatusProofURL
    {item.ref_no} + {dataFormatter.dateTimeFormatter(item.date)} + {item.location}{item.amount}{item.status}{item.proof_url}
    +
    + {!agences?.infractions_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Invoices_saas agences

    + +
    + + + + + + + + + + + + + + + + {agences.invoices_saas_agences && + Array.isArray(agences.invoices_saas_agences) && + agences.invoices_saas_agences.map((item: any) => ( + + router.push( + `/invoices_saas/invoices_saas-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + ))} + +
    InvoiceNumberAmountMADStatusIssuedAtPDFURL
    + {item.invoice_number} + {item.amount_mad}{item.status} + {dataFormatter.dateTimeFormatter(item.issued_at)} + {item.pdf_url}
    +
    + {!agences?.invoices_saas_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Maintenance agences

    + +
    + + + + + + + + + + + + + + + + + + {agences.maintenance_agences && + Array.isArray(agences.maintenance_agences) && + agences.maintenance_agences.map((item: any) => ( + + router.push( + `/maintenance/maintenance-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + ))} + +
    TypeScheduledAtOdometerCostNotesAttachments
    {item.type} + {dataFormatter.dateTimeFormatter(item.scheduled_at)} + {item.odometer}{item.cost}{item.notes}{item.attachments}
    +
    + {!agences?.maintenance_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Payments agences

    + +
    + + + + + + + + + + + + + + + + {agences.payments_agences && + Array.isArray(agences.payments_agences) && + agences.payments_agences.map((item: any) => ( + + router.push( + `/payments/payments-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + ))} + +
    MethodAmountPaidAtReceiptReferenceAttachmentURL
    {item.method}{item.amount} + {dataFormatter.dateTimeFormatter(item.paid_at)} + {item.receipt_ref} + {item.attachment_url} +
    +
    + {!agences?.payments_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Plans agences

    + +
    + + + + + + + + + + + + + + + + {agences.plans_agences && + Array.isArray(agences.plans_agences) && + agences.plans_agences.map((item: any) => ( + + router.push(`/plans/plans-view/?id=${item.id}`) + } + > + + + + + + + + + + + ))} + +
    CodeNamePeriodPriceMADLimits
    {item.code}{item.name}{item.period}{item.price_mad}{item.limits}
    +
    + {!agences?.plans_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Reservations agences

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {agences.reservations_agences && + Array.isArray(agences.reservations_agences) && + agences.reservations_agences.map((item: any) => ( + + router.push( + `/reservations/reservations-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
    ReferenceStatusManualStatusComputedStartAtEndAtPickupAddressReturnAddressOptionsDaysPricePerDayTotalPricePaidAmountBalanceAmountGuaranteeMethodGuaranteeAmountGuaranteeStatusDepartureValidatedAtReturnValidatedAtNotes
    {item.ref} + {item.status_manual} + + {item.status_computed} + + {dataFormatter.dateTimeFormatter(item.start_at)} + + {dataFormatter.dateTimeFormatter(item.end_at)} + + {item.pickup_address} + + {item.return_address} + {item.options}{item.days} + {item.price_per_day} + {item.total_price}{item.paid_amount} + {item.balance_amount} + + {item.guarantee_method} + + {item.guarantee_amount} + + {item.guarantee_status} + + {dataFormatter.dateTimeFormatter( + item.departure_validated_at, + )} + + {dataFormatter.dateTimeFormatter( + item.return_validated_at, + )} + {item.notes}
    +
    + {!agences?.reservations_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Subscriptions agences

    + +
    + + + + + + + + + + + + + + + + + + {agences.subscriptions_agences && + Array.isArray(agences.subscriptions_agences) && + agences.subscriptions_agences.map((item: any) => ( + + router.push( + `/subscriptions/subscriptions-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + ))} + +
    StatusCurrentPeriodStartCurrentPeriodEndPaymentProviderProviderCustomerIDProviderSubscriptionID
    {item.status} + {dataFormatter.dateTimeFormatter( + item.current_period_start, + )} + + {dataFormatter.dateTimeFormatter( + item.current_period_end, + )} + + {item.payment_provider} + + {item.provider_customer_id} + + {item.provider_subscription_id} +
    +
    + {!agences?.subscriptions_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Tenants agences

    + +
    + + + + + + + + + + + + + + + + + + {agences.tenants_agences && + Array.isArray(agences.tenants_agences) && + agences.tenants_agences.map((item: any) => ( + + router.push(`/tenants/tenants-view/?id=${item.id}`) + } + > + + + + + + + + + + + + + ))} + +
    NameSubdomainStatusTrialEndsAtBillingEmailBillingPhone
    {item.name}{item.subdomain}{item.status} + {dataFormatter.dateTimeFormatter( + item.trial_ends_at, + )} + + {item.billing_email} + + {item.billing_phone} +
    +
    + {!agences?.tenants_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Vehicle_conditions agences

    + +
    + + + + + + + + + + {agences.vehicle_conditions_agences && + Array.isArray(agences.vehicle_conditions_agences) && + agences.vehicle_conditions_agences.map((item: any) => ( + + router.push( + `/vehicle_conditions/vehicle_conditions-view/?id=${item.id}`, + ) + } + > + + + + + ))} + +
    BeforeAfter
    {item.before}{item.after}
    +
    + {!agences?.vehicle_conditions_agences?.length && ( +
    No data
    + )} +
    + + + <> +

    Vehicles agences

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {agences.vehicles_agences && + Array.isArray(agences.vehicles_agences) && + agences.vehicles_agences.map((item: any) => ( + + router.push( + `/vehicles/vehicles-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
    PlateNumberBrandModelYearColorCategoryFuelTypeTransmissionMileageDailyPriceStatusInsuranceExpiryTechVisitExpiryPhotos
    {item.plate_number}{item.brand}{item.model}{item.year}{item.color}{item.category}{item.fuel_type}{item.transmission}{item.mileage}{item.daily_price}{item.status} + {dataFormatter.dateTimeFormatter( + item.insurance_expiry, + )} + + {dataFormatter.dateTimeFormatter( + item.tech_visit_expiry, + )} + {item.photos}
    +
    + {!agences?.vehicles_agences?.length && ( +
    No data
    + )} +
    + + + + + router.push('/agences/agences-list')} + /> +
    +
    + + ); +}; + +AgencesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default AgencesView; diff --git a/frontend/src/pages/api/hello.js b/frontend/src/pages/api/hello.js new file mode 100644 index 0000000..1c39e1f --- /dev/null +++ b/frontend/src/pages/api/hello.js @@ -0,0 +1,5 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction + +export default function helloAPI(req, res) { + res.status(200).json({ name: 'John Doe' }); +} diff --git a/frontend/src/pages/api/logError.ts b/frontend/src/pages/api/logError.ts new file mode 100644 index 0000000..b391ffc --- /dev/null +++ b/frontend/src/pages/api/logError.ts @@ -0,0 +1,83 @@ +import fsPromises from 'fs/promises'; +import path from 'path'; + +const dataFilePath = path.join(process.cwd(), 'json/runtimeError.json'); + +export default async function handler(req, res) { + // Ensure directory exists + try { + await fsPromises.mkdir(path.dirname(dataFilePath), { recursive: true }); + } catch (error) { + // Ignore if directory already exists + } + + if (req.method === 'GET') { + try { + // Check if file exists + try { + await fsPromises.access(dataFilePath); + } catch (error) { + // File doesn't exist, return empty object + return res.status(200).json({}); + } + + // Read the existing data from the JSON file + const jsonData = await fsPromises.readFile(dataFilePath, 'utf-8'); + + // Handle empty file + if (!jsonData || jsonData.trim() === '') { + // Write empty JSON object to file + await fsPromises.writeFile(dataFilePath, '{}', 'utf-8'); + return res.status(200).json({}); + } + + // Parse JSON data + try { + const objectData = JSON.parse(jsonData); + return res.status(200).json(objectData); + } catch (parseError) { + console.error('Error parsing JSON from file:', parseError); + // Reset the file with valid JSON if parsing fails + await fsPromises.writeFile(dataFilePath, '{}', 'utf-8'); + return res.status(200).json({}); + } + } catch (error) { + console.error('Error in GET handler:', error); + return res.status(200).json({}); // Return empty object instead of error + } + } else if (req.method === 'POST') { + try { + const updatedData = JSON.stringify(req.body); + + // Create directory if it doesn't exist + await fsPromises.mkdir(path.dirname(dataFilePath), { recursive: true }); + + // Write the updated data to the JSON file + await fsPromises.writeFile(dataFilePath, updatedData); + + // Send a success response + res.status(200).json({ message: 'Data stored successfully' }); + } catch (error) { + console.error('Error in POST handler:', error); + // Send an error response + res.status(500).json({ message: 'Error storing data' }); + } + } else if (req.method === 'DELETE') { + try { + // Create directory if it doesn't exist + await fsPromises.mkdir(path.dirname(dataFilePath), { recursive: true }); + + // Write empty JSON object to file + await fsPromises.writeFile(dataFilePath, '{}'); + + // Send a success response + res.status(200).json({ message: 'Data deleted successfully' }); + } catch (error) { + console.error('Error in DELETE handler:', error); + // Send an error response + res.status(500).json({ message: 'Error deleting data' }); + } + } else { + res.status(405).json({ message: 'Method not allowed' }); + } +} diff --git a/frontend/src/pages/claims/[claimsId].tsx b/frontend/src/pages/claims/[claimsId].tsx new file mode 100644 index 0000000..51d0188 --- /dev/null +++ b/frontend/src/pages/claims/[claimsId].tsx @@ -0,0 +1,204 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/claims/claimsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditClaims = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + client: null, + + reservation: null, + + category: '', + + description: '', + + status: '', + + attachments: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { claims } = useAppSelector((state) => state.claims); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { claimsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: claimsId })); + }, [claimsId]); + + useEffect(() => { + if (typeof claims === 'object') { + setInitialValues(claims); + } + }, [claims]); + + useEffect(() => { + if (typeof claims === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = claims[el])); + + setInitialValues(newInitialVal); + } + }, [claims]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: claimsId, data })); + await router.push('/claims/claims-list'); + }; + + return ( + <> + + {getPageTitle('Edit claims')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/claims/claims-list')} + /> + + +
    +
    +
    + + ); +}; + +EditClaims.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditClaims; diff --git a/frontend/src/pages/claims/claims-edit.tsx b/frontend/src/pages/claims/claims-edit.tsx new file mode 100644 index 0000000..9879d76 --- /dev/null +++ b/frontend/src/pages/claims/claims-edit.tsx @@ -0,0 +1,202 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/claims/claimsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditClaimsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + client: null, + + reservation: null, + + category: '', + + description: '', + + status: '', + + attachments: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { claims } = useAppSelector((state) => state.claims); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof claims === 'object') { + setInitialValues(claims); + } + }, [claims]); + + useEffect(() => { + if (typeof claims === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = claims[el])); + setInitialValues(newInitialVal); + } + }, [claims]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/claims/claims-list'); + }; + + return ( + <> + + {getPageTitle('Edit claims')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/claims/claims-list')} + /> + + +
    +
    +
    + + ); +}; + +EditClaimsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditClaimsPage; diff --git a/frontend/src/pages/claims/claims-list.tsx b/frontend/src/pages/claims/claims-list.tsx new file mode 100644 index 0000000..743e37a --- /dev/null +++ b/frontend/src/pages/claims/claims-list.tsx @@ -0,0 +1,181 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableClaims from '../../components/Claims/TableClaims'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/claims/claimsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClaimsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Category', title: 'category' }, + { label: 'Description', title: 'description' }, + { label: 'Attachments', title: 'attachments' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Client', title: 'client' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['ouverte', 'en_cours', 'resolue'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CLAIMS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getClaimsCSV = async () => { + const response = await axios({ + url: '/claims?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'claimsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Claims')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +ClaimsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default ClaimsTablesPage; diff --git a/frontend/src/pages/claims/claims-new.tsx b/frontend/src/pages/claims/claims-new.tsx new file mode 100644 index 0000000..dd0eaf6 --- /dev/null +++ b/frontend/src/pages/claims/claims-new.tsx @@ -0,0 +1,170 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/claims/claimsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + client: '', + + reservation: '', + + category: '', + + description: '', + + status: 'ouverte', + + attachments: '', + + agences: '', +}; + +const ClaimsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/claims/claims-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/claims/claims-list')} + /> + + +
    +
    +
    + + ); +}; + +ClaimsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClaimsNew; diff --git a/frontend/src/pages/claims/claims-table.tsx b/frontend/src/pages/claims/claims-table.tsx new file mode 100644 index 0000000..152c6e2 --- /dev/null +++ b/frontend/src/pages/claims/claims-table.tsx @@ -0,0 +1,180 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableClaims from '../../components/Claims/TableClaims'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/claims/claimsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClaimsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Category', title: 'category' }, + { label: 'Description', title: 'description' }, + { label: 'Attachments', title: 'attachments' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Client', title: 'client' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['ouverte', 'en_cours', 'resolue'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CLAIMS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getClaimsCSV = async () => { + const response = await axios({ + url: '/claims?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'claimsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Claims')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +ClaimsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default ClaimsTablesPage; diff --git a/frontend/src/pages/claims/claims-view.tsx b/frontend/src/pages/claims/claims-view.tsx new file mode 100644 index 0000000..3480579 --- /dev/null +++ b/frontend/src/pages/claims/claims-view.tsx @@ -0,0 +1,124 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/claims/claimsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClaimsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { claims } = useAppSelector((state) => state.claims); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View claims')} + + + + + + +
    +

    Tenant

    + +

    {claims?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Client

    + +

    {claims?.client?.first_name ?? 'No data'}

    +
    + +
    +

    Reservation

    + +

    {claims?.reservation?.ref ?? 'No data'}

    +
    + +
    +

    Category

    +

    {claims?.category}

    +
    + +
    +

    Description

    +

    {claims?.description}

    +
    + +
    +

    Status

    +

    {claims?.status ?? 'No data'}

    +
    + +
    +

    Attachments

    +

    {claims?.attachments}

    +
    + +
    +

    agences

    + +

    {claims?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/claims/claims-list')} + /> +
    +
    + + ); +}; + +ClaimsView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default ClaimsView; diff --git a/frontend/src/pages/clients/[clientsId].tsx b/frontend/src/pages/clients/[clientsId].tsx new file mode 100644 index 0000000..e4e8559 --- /dev/null +++ b/frontend/src/pages/clients/[clientsId].tsx @@ -0,0 +1,272 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/clients/clientsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditClients = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + first_name: '', + + last_name: '', + + birth_date: new Date(), + + phone: '', + + address: '', + + id_type: '', + + id_number: '', + + id_issue_date: new Date(), + + license_number: '', + + license_issue_date: new Date(), + + id_copy_url: '', + + license_copy_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { clients } = useAppSelector((state) => state.clients); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { clientsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: clientsId })); + }, [clientsId]); + + useEffect(() => { + if (typeof clients === 'object') { + setInitialValues(clients); + } + }, [clients]); + + useEffect(() => { + if (typeof clients === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = clients[el])); + + setInitialValues(newInitialVal); + } + }, [clients]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: clientsId, data })); + await router.push('/clients/clients-list'); + }; + + return ( + <> + + {getPageTitle('Edit clients')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, birth_date: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, id_issue_date: date }) + } + /> + + + + + + + + + setInitialValues({ + ...initialValues, + license_issue_date: date, + }) + } + /> + + + + + + + + + + + + + + + + + + + router.push('/clients/clients-list')} + /> + + +
    +
    +
    + + ); +}; + +EditClients.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditClients; diff --git a/frontend/src/pages/clients/clients-edit.tsx b/frontend/src/pages/clients/clients-edit.tsx new file mode 100644 index 0000000..dae87b0 --- /dev/null +++ b/frontend/src/pages/clients/clients-edit.tsx @@ -0,0 +1,270 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/clients/clientsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditClientsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + first_name: '', + + last_name: '', + + birth_date: new Date(), + + phone: '', + + address: '', + + id_type: '', + + id_number: '', + + id_issue_date: new Date(), + + license_number: '', + + license_issue_date: new Date(), + + id_copy_url: '', + + license_copy_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { clients } = useAppSelector((state) => state.clients); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof clients === 'object') { + setInitialValues(clients); + } + }, [clients]); + + useEffect(() => { + if (typeof clients === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = clients[el])); + setInitialValues(newInitialVal); + } + }, [clients]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/clients/clients-list'); + }; + + return ( + <> + + {getPageTitle('Edit clients')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, birth_date: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, id_issue_date: date }) + } + /> + + + + + + + + + setInitialValues({ + ...initialValues, + license_issue_date: date, + }) + } + /> + + + + + + + + + + + + + + + + + + + router.push('/clients/clients-list')} + /> + + +
    +
    +
    + + ); +}; + +EditClientsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditClientsPage; diff --git a/frontend/src/pages/clients/clients-list.tsx b/frontend/src/pages/clients/clients-list.tsx new file mode 100644 index 0000000..6b6825b --- /dev/null +++ b/frontend/src/pages/clients/clients-list.tsx @@ -0,0 +1,188 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableClients from '../../components/Clients/TableClients'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/clients/clientsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClientsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'FirstName', title: 'first_name' }, + { label: 'LastName', title: 'last_name' }, + { label: 'Phone', title: 'phone' }, + { label: 'Address', title: 'address' }, + { label: 'IDNumber', title: 'id_number' }, + { label: 'LicenseNumber', title: 'license_number' }, + { label: 'IDCopyURL', title: 'id_copy_url' }, + { label: 'LicenseCopyURL', title: 'license_copy_url' }, + + { label: 'BirthDate', title: 'birth_date', date: 'true' }, + { label: 'IDIssueDate', title: 'id_issue_date', date: 'true' }, + { label: 'LicenseIssueDate', title: 'license_issue_date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { + label: 'IDType', + title: 'id_type', + type: 'enum', + options: ['CIN', 'Passeport'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CLIENTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getClientsCSV = async () => { + const response = await axios({ + url: '/clients?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'clientsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Clients')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +ClientsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsTablesPage; diff --git a/frontend/src/pages/clients/clients-new.tsx b/frontend/src/pages/clients/clients-new.tsx new file mode 100644 index 0000000..d8be1ff --- /dev/null +++ b/frontend/src/pages/clients/clients-new.tsx @@ -0,0 +1,204 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/clients/clientsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + first_name: '', + + last_name: '', + + birth_date: '', + + phone: '', + + address: '', + + id_type: 'CIN', + + id_number: '', + + id_issue_date: '', + + license_number: '', + + license_issue_date: '', + + id_copy_url: '', + + license_copy_url: '', + + agences: '', +}; + +const ClientsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/clients/clients-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/clients/clients-list')} + /> + + +
    +
    +
    + + ); +}; + +ClientsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsNew; diff --git a/frontend/src/pages/clients/clients-table.tsx b/frontend/src/pages/clients/clients-table.tsx new file mode 100644 index 0000000..36934c1 --- /dev/null +++ b/frontend/src/pages/clients/clients-table.tsx @@ -0,0 +1,187 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableClients from '../../components/Clients/TableClients'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/clients/clientsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClientsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'FirstName', title: 'first_name' }, + { label: 'LastName', title: 'last_name' }, + { label: 'Phone', title: 'phone' }, + { label: 'Address', title: 'address' }, + { label: 'IDNumber', title: 'id_number' }, + { label: 'LicenseNumber', title: 'license_number' }, + { label: 'IDCopyURL', title: 'id_copy_url' }, + { label: 'LicenseCopyURL', title: 'license_copy_url' }, + + { label: 'BirthDate', title: 'birth_date', date: 'true' }, + { label: 'IDIssueDate', title: 'id_issue_date', date: 'true' }, + { label: 'LicenseIssueDate', title: 'license_issue_date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { + label: 'IDType', + title: 'id_type', + type: 'enum', + options: ['CIN', 'Passeport'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CLIENTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getClientsCSV = async () => { + const response = await axios({ + url: '/clients?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'clientsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Clients')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +ClientsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsTablesPage; diff --git a/frontend/src/pages/clients/clients-view.tsx b/frontend/src/pages/clients/clients-view.tsx new file mode 100644 index 0000000..0c2b8cf --- /dev/null +++ b/frontend/src/pages/clients/clients-view.tsx @@ -0,0 +1,384 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/clients/clientsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ClientsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { clients } = useAppSelector((state) => state.clients); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View clients')} + + + + + + +
    +

    Tenant

    + +

    {clients?.tenant?.name ?? 'No data'}

    +
    + +
    +

    FirstName

    +

    {clients?.first_name}

    +
    + +
    +

    LastName

    +

    {clients?.last_name}

    +
    + + + {clients.birth_date ? ( + + ) : ( +

    No BirthDate

    + )} +
    + +
    +

    Phone

    +

    {clients?.phone}

    +
    + +
    +

    Address

    +

    {clients?.address}

    +
    + +
    +

    IDType

    +

    {clients?.id_type ?? 'No data'}

    +
    + +
    +

    IDNumber

    +

    {clients?.id_number}

    +
    + + + {clients.id_issue_date ? ( + + ) : ( +

    No IDIssueDate

    + )} +
    + +
    +

    LicenseNumber

    +

    {clients?.license_number}

    +
    + + + {clients.license_issue_date ? ( + + ) : ( +

    No LicenseIssueDate

    + )} +
    + +
    +

    IDCopyURL

    +

    {clients?.id_copy_url}

    +
    + +
    +

    LicenseCopyURL

    +

    {clients?.license_copy_url}

    +
    + +
    +

    agences

    + +

    {clients?.agences?.name ?? 'No data'}

    +
    + + <> +

    Claims Client

    + +
    + + + + + + + + + + + + + + {clients.claims_client && + Array.isArray(clients.claims_client) && + clients.claims_client.map((item: any) => ( + + router.push(`/claims/claims-view/?id=${item.id}`) + } + > + + + + + + + + + ))} + +
    CategoryDescriptionStatusAttachments
    {item.category}{item.description}{item.status}{item.attachments}
    +
    + {!clients?.claims_client?.length && ( +
    No data
    + )} +
    + + + <> +

    Reservations Client

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {clients.reservations_client && + Array.isArray(clients.reservations_client) && + clients.reservations_client.map((item: any) => ( + + router.push( + `/reservations/reservations-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
    ReferenceStatusManualStatusComputedStartAtEndAtPickupAddressReturnAddressOptionsDaysPricePerDayTotalPricePaidAmountBalanceAmountGuaranteeMethodGuaranteeAmountGuaranteeStatusDepartureValidatedAtReturnValidatedAtNotes
    {item.ref} + {item.status_manual} + + {item.status_computed} + + {dataFormatter.dateTimeFormatter(item.start_at)} + + {dataFormatter.dateTimeFormatter(item.end_at)} + + {item.pickup_address} + + {item.return_address} + {item.options}{item.days} + {item.price_per_day} + {item.total_price}{item.paid_amount} + {item.balance_amount} + + {item.guarantee_method} + + {item.guarantee_amount} + + {item.guarantee_status} + + {dataFormatter.dateTimeFormatter( + item.departure_validated_at, + )} + + {dataFormatter.dateTimeFormatter( + item.return_validated_at, + )} + {item.notes}
    +
    + {!clients?.reservations_client?.length && ( +
    No data
    + )} +
    + + + + + router.push('/clients/clients-list')} + /> +
    +
    + + ); +}; + +ClientsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClientsView; diff --git a/frontend/src/pages/company_profiles/[company_profilesId].tsx b/frontend/src/pages/company_profiles/[company_profilesId].tsx new file mode 100644 index 0000000..309e6cc --- /dev/null +++ b/frontend/src/pages/company_profiles/[company_profilesId].tsx @@ -0,0 +1,217 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { + update, + fetch, +} from '../../stores/company_profiles/company_profilesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditCompany_profiles = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + legal_name: '', + + address: '', + + phone: '', + + email: '', + + website: '', + + rc: '', + + ice: '', + + patente: '', + + logo_url: '', + + pdf_footer: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { company_profiles } = useAppSelector( + (state) => state.company_profiles, + ); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { company_profilesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: company_profilesId })); + }, [company_profilesId]); + + useEffect(() => { + if (typeof company_profiles === 'object') { + setInitialValues(company_profiles); + } + }, [company_profiles]); + + useEffect(() => { + if (typeof company_profiles === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = company_profiles[el]), + ); + + setInitialValues(newInitialVal); + } + }, [company_profiles]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: company_profilesId, data })); + await router.push('/company_profiles/company_profiles-list'); + }; + + return ( + <> + + {getPageTitle('Edit company_profiles')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/company_profiles/company_profiles-list') + } + /> + + +
    +
    +
    + + ); +}; + +EditCompany_profiles.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditCompany_profiles; diff --git a/frontend/src/pages/company_profiles/company_profiles-edit.tsx b/frontend/src/pages/company_profiles/company_profiles-edit.tsx new file mode 100644 index 0000000..4a7416b --- /dev/null +++ b/frontend/src/pages/company_profiles/company_profiles-edit.tsx @@ -0,0 +1,215 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { + update, + fetch, +} from '../../stores/company_profiles/company_profilesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditCompany_profilesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + legal_name: '', + + address: '', + + phone: '', + + email: '', + + website: '', + + rc: '', + + ice: '', + + patente: '', + + logo_url: '', + + pdf_footer: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { company_profiles } = useAppSelector( + (state) => state.company_profiles, + ); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof company_profiles === 'object') { + setInitialValues(company_profiles); + } + }, [company_profiles]); + + useEffect(() => { + if (typeof company_profiles === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = company_profiles[el]), + ); + setInitialValues(newInitialVal); + } + }, [company_profiles]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/company_profiles/company_profiles-list'); + }; + + return ( + <> + + {getPageTitle('Edit company_profiles')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/company_profiles/company_profiles-list') + } + /> + + +
    +
    +
    + + ); +}; + +EditCompany_profilesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditCompany_profilesPage; diff --git a/frontend/src/pages/company_profiles/company_profiles-list.tsx b/frontend/src/pages/company_profiles/company_profiles-list.tsx new file mode 100644 index 0000000..82605a4 --- /dev/null +++ b/frontend/src/pages/company_profiles/company_profiles-list.tsx @@ -0,0 +1,184 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableCompany_profiles from '../../components/Company_profiles/TableCompany_profiles'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/company_profiles/company_profilesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const Company_profilesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'LegalName', title: 'legal_name' }, + { label: 'Address', title: 'address' }, + { label: 'Phone', title: 'phone' }, + { label: 'Email', title: 'email' }, + { label: 'Website', title: 'website' }, + { label: 'RC', title: 'rc' }, + { label: 'ICE', title: 'ice' }, + { label: 'Patente', title: 'patente' }, + { label: 'LogoURL', title: 'logo_url' }, + { label: 'PDFFooter', title: 'pdf_footer' }, + + { label: 'Tenant', title: 'tenant' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_COMPANY_PROFILES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getCompany_profilesCSV = async () => { + const response = await axios({ + url: '/company_profiles?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'company_profilesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Company_profiles')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + + Switch to Table + +
    +
    + + + + +
    + + + + + ); +}; + +Company_profilesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Company_profilesTablesPage; diff --git a/frontend/src/pages/company_profiles/company_profiles-new.tsx b/frontend/src/pages/company_profiles/company_profiles-new.tsx new file mode 100644 index 0000000..dde54e3 --- /dev/null +++ b/frontend/src/pages/company_profiles/company_profiles-new.tsx @@ -0,0 +1,178 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/company_profiles/company_profilesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + legal_name: '', + + address: '', + + phone: '', + + email: '', + + website: '', + + rc: '', + + ice: '', + + patente: '', + + logo_url: '', + + pdf_footer: '', + + agences: '', +}; + +const Company_profilesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/company_profiles/company_profiles-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/company_profiles/company_profiles-list') + } + /> + + +
    +
    +
    + + ); +}; + +Company_profilesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Company_profilesNew; diff --git a/frontend/src/pages/company_profiles/company_profiles-table.tsx b/frontend/src/pages/company_profiles/company_profiles-table.tsx new file mode 100644 index 0000000..033b21b --- /dev/null +++ b/frontend/src/pages/company_profiles/company_profiles-table.tsx @@ -0,0 +1,181 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableCompany_profiles from '../../components/Company_profiles/TableCompany_profiles'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/company_profiles/company_profilesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const Company_profilesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'LegalName', title: 'legal_name' }, + { label: 'Address', title: 'address' }, + { label: 'Phone', title: 'phone' }, + { label: 'Email', title: 'email' }, + { label: 'Website', title: 'website' }, + { label: 'RC', title: 'rc' }, + { label: 'ICE', title: 'ice' }, + { label: 'Patente', title: 'patente' }, + { label: 'LogoURL', title: 'logo_url' }, + { label: 'PDFFooter', title: 'pdf_footer' }, + + { label: 'Tenant', title: 'tenant' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_COMPANY_PROFILES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getCompany_profilesCSV = async () => { + const response = await axios({ + url: '/company_profiles?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'company_profilesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Company_profiles')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +Company_profilesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Company_profilesTablesPage; diff --git a/frontend/src/pages/company_profiles/company_profiles-view.tsx b/frontend/src/pages/company_profiles/company_profiles-view.tsx new file mode 100644 index 0000000..b688047 --- /dev/null +++ b/frontend/src/pages/company_profiles/company_profiles-view.tsx @@ -0,0 +1,148 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/company_profiles/company_profilesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const Company_profilesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { company_profiles } = useAppSelector( + (state) => state.company_profiles, + ); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View company_profiles')} + + + + + + +
    +

    Tenant

    + +

    {company_profiles?.tenant?.name ?? 'No data'}

    +
    + +
    +

    LegalName

    +

    {company_profiles?.legal_name}

    +
    + +
    +

    Address

    +

    {company_profiles?.address}

    +
    + +
    +

    Phone

    +

    {company_profiles?.phone}

    +
    + +
    +

    Email

    +

    {company_profiles?.email}

    +
    + +
    +

    Website

    +

    {company_profiles?.website}

    +
    + +
    +

    RC

    +

    {company_profiles?.rc}

    +
    + +
    +

    ICE

    +

    {company_profiles?.ice}

    +
    + +
    +

    Patente

    +

    {company_profiles?.patente}

    +
    + +
    +

    LogoURL

    +

    {company_profiles?.logo_url}

    +
    + +
    +

    PDFFooter

    +

    {company_profiles?.pdf_footer}

    +
    + +
    +

    agences

    + +

    {company_profiles?.agences?.name ?? 'No data'}

    +
    + + + + + router.push('/company_profiles/company_profiles-list') + } + /> +
    +
    + + ); +}; + +Company_profilesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Company_profilesView; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx new file mode 100644 index 0000000..26cec71 --- /dev/null +++ b/frontend/src/pages/dashboard.tsx @@ -0,0 +1,935 @@ +import * as icon from '@mdi/js'; +import Head from 'next/head'; +import React from 'react'; +import axios from 'axios'; +import type { ReactElement } from 'react'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import BaseIcon from '../components/BaseIcon'; +import { getPageTitle } from '../config'; +import Link from 'next/link'; +import { useTranslation } from 'next-i18next'; + +import { hasPermission } from '../helpers/userPermissions'; +import { fetchWidgets } from '../stores/roles/rolesSlice'; +import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; +import { SmartWidget } from '../components/SmartWidget/SmartWidget'; + +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +const Dashboard = () => { + const { t } = useTranslation('common'); + const dispatch = useAppDispatch(); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + + const loadingMessage = t('pages.dashboard.loading', { + defaultValue: 'Loading...', + }); + + const [users, setUsers] = React.useState(loadingMessage); + const [claims, setClaims] = React.useState(loadingMessage); + const [clients, setClients] = React.useState(loadingMessage); + const [company_profiles, setCompany_profiles] = + React.useState(loadingMessage); + const [drivers, setDrivers] = React.useState(loadingMessage); + const [expenses, setExpenses] = React.useState(loadingMessage); + const [guarantees, setGuarantees] = React.useState(loadingMessage); + const [infractions, setInfractions] = React.useState(loadingMessage); + const [invoices_saas, setInvoices_saas] = React.useState(loadingMessage); + const [maintenance, setMaintenance] = React.useState(loadingMessage); + const [payments, setPayments] = React.useState(loadingMessage); + const [plans, setPlans] = React.useState(loadingMessage); + const [reservations, setReservations] = React.useState(loadingMessage); + const [subscriptions, setSubscriptions] = React.useState(loadingMessage); + const [tenants, setTenants] = React.useState(loadingMessage); + const [vehicle_conditions, setVehicle_conditions] = + React.useState(loadingMessage); + const [vehicles, setVehicles] = React.useState(loadingMessage); + const [roles, setRoles] = React.useState(loadingMessage); + const [permissions, setPermissions] = React.useState(loadingMessage); + const [agences, setAgences] = React.useState(loadingMessage); + + const [widgetsRole, setWidgetsRole] = React.useState({ + role: { value: '', label: '' }, + }); + const { currentUser } = useAppSelector((state) => state.auth); + const { isFetchingQuery } = useAppSelector((state) => state.openAi); + + const { rolesWidgets, loading } = useAppSelector((state) => state.roles); + + const organizationId = currentUser?.agences?.id; + + async function loadData() { + const entities = [ + 'users', + 'claims', + 'clients', + 'company_profiles', + 'drivers', + 'expenses', + 'guarantees', + 'infractions', + 'invoices_saas', + 'maintenance', + 'payments', + 'plans', + 'reservations', + 'subscriptions', + 'tenants', + 'vehicle_conditions', + 'vehicles', + 'roles', + 'permissions', + 'agences', + ]; + const fns = [ + setUsers, + setClaims, + setClients, + setCompany_profiles, + setDrivers, + setExpenses, + setGuarantees, + setInfractions, + setInvoices_saas, + setMaintenance, + setPayments, + setPlans, + setReservations, + setSubscriptions, + setTenants, + setVehicle_conditions, + setVehicles, + setRoles, + setPermissions, + setAgences, + ]; + + const requests = entities.map((entity, index) => { + if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { + return axios.get(`/${entity.toLowerCase()}/count`); + } else { + fns[index](null); + return Promise.resolve({ data: { count: null } }); + } + }); + + Promise.allSettled(requests).then((results) => { + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + fns[i](result.value.data.count); + } else { + fns[i](result.reason.message); + } + }); + }); + } + + async function getWidgets(roleId) { + await dispatch(fetchWidgets(roleId)); + } + React.useEffect(() => { + if (!currentUser) return; + loadData().then(); + setWidgetsRole({ + role: { + value: currentUser?.app_role?.id, + label: currentUser?.app_role?.name, + }, + }); + }, [currentUser]); + + React.useEffect(() => { + if (!currentUser || !widgetsRole?.role?.value) return; + getWidgets(widgetsRole?.role?.value || '').then(); + }, [widgetsRole?.role?.value]); + + return ( + <> + + + {getPageTitle( + t('pages.dashboard.pageTitle', { defaultValue: 'Overview' }), + )} + + + + + {''} + + + {hasPermission(currentUser, 'CREATE_ROLES') && ( + + )} + {!!rolesWidgets.length && + hasPermission(currentUser, 'CREATE_ROLES') && ( +

    + {`${widgetsRole?.role?.label || 'Users'}'s widgets`} +

    + )} + +
    + {(isFetchingQuery || loading) && ( +
    + {' '} + {t('pages.dashboard.loadingWidgets', { + defaultValue: 'Loading widgets...', + })} +
    + )} + + {rolesWidgets && + rolesWidgets.map((widget) => ( + + ))} +
    + + {!!rolesWidgets.length &&
    } + +
    + {hasPermission(currentUser, 'READ_USERS') && ( + +
    +
    +
    +
    + Users +
    +
    + {users} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_CLAIMS') && ( + +
    +
    +
    +
    + Claims +
    +
    + {claims} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_CLIENTS') && ( + +
    +
    +
    +
    + Clients +
    +
    + {clients} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_COMPANY_PROFILES') && ( + +
    +
    +
    +
    + Company profiles +
    +
    + {company_profiles} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_DRIVERS') && ( + +
    +
    +
    +
    + Drivers +
    +
    + {drivers} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_EXPENSES') && ( + +
    +
    +
    +
    + Expenses +
    +
    + {expenses} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_GUARANTEES') && ( + +
    +
    +
    +
    + Guarantees +
    +
    + {guarantees} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_INFRACTIONS') && ( + +
    +
    +
    +
    + Infractions +
    +
    + {infractions} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_INVOICES_SAAS') && ( + +
    +
    +
    +
    + Invoices saas +
    +
    + {invoices_saas} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_MAINTENANCE') && ( + +
    +
    +
    +
    + Maintenance +
    +
    + {maintenance} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_PAYMENTS') && ( + +
    +
    +
    +
    + Payments +
    +
    + {payments} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_PLANS') && ( + +
    +
    +
    +
    + Plans +
    +
    + {plans} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_RESERVATIONS') && ( + +
    +
    +
    +
    + Reservations +
    +
    + {reservations} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_SUBSCRIPTIONS') && ( + +
    +
    +
    +
    + Subscriptions +
    +
    + {subscriptions} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_TENANTS') && ( + +
    +
    +
    +
    + Tenants +
    +
    + {tenants} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_VEHICLE_CONDITIONS') && ( + +
    +
    +
    +
    + Vehicle conditions +
    +
    + {vehicle_conditions} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_VEHICLES') && ( + +
    +
    +
    +
    + Vehicles +
    +
    + {vehicles} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_ROLES') && ( + +
    +
    +
    +
    + Roles +
    +
    + {roles} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_PERMISSIONS') && ( + +
    +
    +
    +
    + Permissions +
    +
    + {permissions} +
    +
    +
    + +
    +
    +
    + + )} + + {hasPermission(currentUser, 'READ_AGENCES') && ( + +
    +
    +
    +
    + Agences +
    +
    + {agences} +
    +
    +
    + +
    +
    +
    + + )} +
    +
    + + ); +}; + +Dashboard.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Dashboard; diff --git a/frontend/src/pages/drivers/[driversId].tsx b/frontend/src/pages/drivers/[driversId].tsx new file mode 100644 index 0000000..e778cdb --- /dev/null +++ b/frontend/src/pages/drivers/[driversId].tsx @@ -0,0 +1,209 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/drivers/driversSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditDrivers = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + reservation: null, + + full_name: '', + + license_number: '', + + license_issue_date: new Date(), + + id_copy_url: '', + + license_copy_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { drivers } = useAppSelector((state) => state.drivers); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { driversId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: driversId })); + }, [driversId]); + + useEffect(() => { + if (typeof drivers === 'object') { + setInitialValues(drivers); + } + }, [drivers]); + + useEffect(() => { + if (typeof drivers === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = drivers[el])); + + setInitialValues(newInitialVal); + } + }, [drivers]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: driversId, data })); + await router.push('/drivers/drivers-list'); + }; + + return ( + <> + + {getPageTitle('Edit drivers')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + setInitialValues({ + ...initialValues, + license_issue_date: date, + }) + } + /> + + + + + + + + + + + + + + + + + + + router.push('/drivers/drivers-list')} + /> + + +
    +
    +
    + + ); +}; + +EditDrivers.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditDrivers; diff --git a/frontend/src/pages/drivers/drivers-edit.tsx b/frontend/src/pages/drivers/drivers-edit.tsx new file mode 100644 index 0000000..c54ad77 --- /dev/null +++ b/frontend/src/pages/drivers/drivers-edit.tsx @@ -0,0 +1,207 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/drivers/driversSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditDriversPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + reservation: null, + + full_name: '', + + license_number: '', + + license_issue_date: new Date(), + + id_copy_url: '', + + license_copy_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { drivers } = useAppSelector((state) => state.drivers); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof drivers === 'object') { + setInitialValues(drivers); + } + }, [drivers]); + + useEffect(() => { + if (typeof drivers === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = drivers[el])); + setInitialValues(newInitialVal); + } + }, [drivers]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/drivers/drivers-list'); + }; + + return ( + <> + + {getPageTitle('Edit drivers')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + setInitialValues({ + ...initialValues, + license_issue_date: date, + }) + } + /> + + + + + + + + + + + + + + + + + + + router.push('/drivers/drivers-list')} + /> + + +
    +
    +
    + + ); +}; + +EditDriversPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditDriversPage; diff --git a/frontend/src/pages/drivers/drivers-list.tsx b/frontend/src/pages/drivers/drivers-list.tsx new file mode 100644 index 0000000..ede15ea --- /dev/null +++ b/frontend/src/pages/drivers/drivers-list.tsx @@ -0,0 +1,177 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableDrivers from '../../components/Drivers/TableDrivers'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/drivers/driversSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const DriversTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'FullName', title: 'full_name' }, + { label: 'LicenseNumber', title: 'license_number' }, + { label: 'IDCopyURL', title: 'id_copy_url' }, + { label: 'LicenseCopyURL', title: 'license_copy_url' }, + + { label: 'LicenseIssueDate', title: 'license_issue_date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Reservation', title: 'reservation' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_DRIVERS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getDriversCSV = async () => { + const response = await axios({ + url: '/drivers?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'driversCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Drivers')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +DriversTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default DriversTablesPage; diff --git a/frontend/src/pages/drivers/drivers-new.tsx b/frontend/src/pages/drivers/drivers-new.tsx new file mode 100644 index 0000000..c2a90ee --- /dev/null +++ b/frontend/src/pages/drivers/drivers-new.tsx @@ -0,0 +1,162 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/drivers/driversSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + reservation: '', + + full_name: '', + + license_number: '', + + license_issue_date: '', + + id_copy_url: '', + + license_copy_url: '', + + agences: '', +}; + +const DriversNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/drivers/drivers-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/drivers/drivers-list')} + /> + + +
    +
    +
    + + ); +}; + +DriversNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default DriversNew; diff --git a/frontend/src/pages/drivers/drivers-table.tsx b/frontend/src/pages/drivers/drivers-table.tsx new file mode 100644 index 0000000..f98af8a --- /dev/null +++ b/frontend/src/pages/drivers/drivers-table.tsx @@ -0,0 +1,176 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableDrivers from '../../components/Drivers/TableDrivers'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/drivers/driversSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const DriversTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'FullName', title: 'full_name' }, + { label: 'LicenseNumber', title: 'license_number' }, + { label: 'IDCopyURL', title: 'id_copy_url' }, + { label: 'LicenseCopyURL', title: 'license_copy_url' }, + + { label: 'LicenseIssueDate', title: 'license_issue_date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Reservation', title: 'reservation' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_DRIVERS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getDriversCSV = async () => { + const response = await axios({ + url: '/drivers?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'driversCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Drivers')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +DriversTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default DriversTablesPage; diff --git a/frontend/src/pages/drivers/drivers-view.tsx b/frontend/src/pages/drivers/drivers-view.tsx new file mode 100644 index 0000000..12cd7cd --- /dev/null +++ b/frontend/src/pages/drivers/drivers-view.tsx @@ -0,0 +1,141 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/drivers/driversSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const DriversView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { drivers } = useAppSelector((state) => state.drivers); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View drivers')} + + + + + + +
    +

    Tenant

    + +

    {drivers?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Reservation

    + +

    {drivers?.reservation?.ref ?? 'No data'}

    +
    + +
    +

    FullName

    +

    {drivers?.full_name}

    +
    + +
    +

    LicenseNumber

    +

    {drivers?.license_number}

    +
    + + + {drivers.license_issue_date ? ( + + ) : ( +

    No LicenseIssueDate

    + )} +
    + +
    +

    IDCopyURL

    +

    {drivers?.id_copy_url}

    +
    + +
    +

    LicenseCopyURL

    +

    {drivers?.license_copy_url}

    +
    + +
    +

    agences

    + +

    {drivers?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/drivers/drivers-list')} + /> +
    +
    + + ); +}; + +DriversView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default DriversView; diff --git a/frontend/src/pages/error.tsx b/frontend/src/pages/error.tsx new file mode 100644 index 0000000..0e80c27 --- /dev/null +++ b/frontend/src/pages/error.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +export default function Error() { + return ( + <> + + {getPageTitle('Error')} + + + + } + > +
    +

    Unhandled exception

    + +

    An Error Occurred

    +
    +
    +
    + + ); +} + +Error.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/expenses/[expensesId].tsx b/frontend/src/pages/expenses/[expensesId].tsx new file mode 100644 index 0000000..89bf719 --- /dev/null +++ b/frontend/src/pages/expenses/[expensesId].tsx @@ -0,0 +1,214 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/expenses/expensesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditExpenses = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + type: '', + + category: '', + + amount: '', + + date: new Date(), + + vehicle: null, + + note: '', + + attachment_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { expenses } = useAppSelector((state) => state.expenses); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { expensesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: expensesId })); + }, [expensesId]); + + useEffect(() => { + if (typeof expenses === 'object') { + setInitialValues(expenses); + } + }, [expenses]); + + useEffect(() => { + if (typeof expenses === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = expenses[el])); + + setInitialValues(newInitialVal); + } + }, [expenses]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: expensesId, data })); + await router.push('/expenses/expenses-list'); + }; + + return ( + <> + + {getPageTitle('Edit expenses')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, date: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + router.push('/expenses/expenses-list')} + /> + + +
    +
    +
    + + ); +}; + +EditExpenses.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditExpenses; diff --git a/frontend/src/pages/expenses/expenses-edit.tsx b/frontend/src/pages/expenses/expenses-edit.tsx new file mode 100644 index 0000000..5cd701b --- /dev/null +++ b/frontend/src/pages/expenses/expenses-edit.tsx @@ -0,0 +1,212 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/expenses/expensesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditExpensesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + type: '', + + category: '', + + amount: '', + + date: new Date(), + + vehicle: null, + + note: '', + + attachment_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { expenses } = useAppSelector((state) => state.expenses); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof expenses === 'object') { + setInitialValues(expenses); + } + }, [expenses]); + + useEffect(() => { + if (typeof expenses === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = expenses[el])); + setInitialValues(newInitialVal); + } + }, [expenses]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/expenses/expenses-list'); + }; + + return ( + <> + + {getPageTitle('Edit expenses')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, date: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + router.push('/expenses/expenses-list')} + /> + + +
    +
    +
    + + ); +}; + +EditExpensesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditExpensesPage; diff --git a/frontend/src/pages/expenses/expenses-list.tsx b/frontend/src/pages/expenses/expenses-list.tsx new file mode 100644 index 0000000..36613d5 --- /dev/null +++ b/frontend/src/pages/expenses/expenses-list.tsx @@ -0,0 +1,184 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableExpenses from '../../components/Expenses/TableExpenses'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/expenses/expensesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ExpensesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Category', title: 'category' }, + { label: 'Note', title: 'note' }, + { label: 'AttachmentURL', title: 'attachment_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + { label: 'Date', title: 'date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Vehicle', title: 'vehicle' }, + + { + label: 'Type', + title: 'type', + type: 'enum', + options: ['depense', 'revenu'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_EXPENSES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getExpensesCSV = async () => { + const response = await axios({ + url: '/expenses?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'expensesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Expenses')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +ExpensesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ExpensesTablesPage; diff --git a/frontend/src/pages/expenses/expenses-new.tsx b/frontend/src/pages/expenses/expenses-new.tsx new file mode 100644 index 0000000..b5bac71 --- /dev/null +++ b/frontend/src/pages/expenses/expenses-new.tsx @@ -0,0 +1,168 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/expenses/expensesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + type: 'depense', + + category: '', + + amount: '', + + date: '', + + vehicle: '', + + note: '', + + attachment_url: '', + + agences: '', +}; + +const ExpensesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/expenses/expenses-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/expenses/expenses-list')} + /> + + +
    +
    +
    + + ); +}; + +ExpensesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ExpensesNew; diff --git a/frontend/src/pages/expenses/expenses-table.tsx b/frontend/src/pages/expenses/expenses-table.tsx new file mode 100644 index 0000000..40d3fab --- /dev/null +++ b/frontend/src/pages/expenses/expenses-table.tsx @@ -0,0 +1,183 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableExpenses from '../../components/Expenses/TableExpenses'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/expenses/expensesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ExpensesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Category', title: 'category' }, + { label: 'Note', title: 'note' }, + { label: 'AttachmentURL', title: 'attachment_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + { label: 'Date', title: 'date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Vehicle', title: 'vehicle' }, + + { + label: 'Type', + title: 'type', + type: 'enum', + options: ['depense', 'revenu'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_EXPENSES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getExpensesCSV = async () => { + const response = await axios({ + url: '/expenses?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'expensesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Expenses')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +ExpensesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ExpensesTablesPage; diff --git a/frontend/src/pages/expenses/expenses-view.tsx b/frontend/src/pages/expenses/expenses-view.tsx new file mode 100644 index 0000000..a3c1ab3 --- /dev/null +++ b/frontend/src/pages/expenses/expenses-view.tsx @@ -0,0 +1,142 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/expenses/expensesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const ExpensesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { expenses } = useAppSelector((state) => state.expenses); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View expenses')} + + + + + + +
    +

    Tenant

    + +

    {expenses?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Type

    +

    {expenses?.type ?? 'No data'}

    +
    + +
    +

    Category

    +

    {expenses?.category}

    +
    + +
    +

    Amount

    +

    {expenses?.amount || 'No data'}

    +
    + + + {expenses.date ? ( + + ) : ( +

    No Date

    + )} +
    + +
    +

    Vehicle

    + +

    {expenses?.vehicle?.plate_number ?? 'No data'}

    +
    + +
    +

    Note

    +

    {expenses?.note}

    +
    + +
    +

    AttachmentURL

    +

    {expenses?.attachment_url}

    +
    + +
    +

    agences

    + +

    {expenses?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/expenses/expenses-list')} + /> +
    +
    + + ); +}; + +ExpensesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ExpensesView; diff --git a/frontend/src/pages/forgot.tsx b/frontend/src/pages/forgot.tsx new file mode 100644 index 0000000..071239b --- /dev/null +++ b/frontend/src/pages/forgot.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; +import axios from 'axios'; + +export default function Forgot() { + const [loading, setLoading] = React.useState(false); + const router = useRouter(); + const notify = (type, msg) => toast(msg, { type }); + + const handleSubmit = async (value) => { + setLoading(true); + try { + const { data: response } = await axios.post( + '/auth/send-password-reset-email', + value, + ); + setLoading(false); + notify('success', 'Please check your email for verification link'); + setTimeout(async () => { + await router.push('/login'); + }, 3000); + } catch (error) { + setLoading(false); + console.log('error: ', error); + notify('error', 'Something was wrong. Try again'); + } + }; + + return ( + <> + + {getPageTitle('Login')} + + + + + handleSubmit(values)} + > +
    + + + + + + + + + + + +
    +
    +
    + + + ); +} + +Forgot.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/forms.tsx b/frontend/src/pages/forms.tsx new file mode 100644 index 0000000..45de29b --- /dev/null +++ b/frontend/src/pages/forms.tsx @@ -0,0 +1,162 @@ +import { + mdiAccount, + mdiBallotOutline, + mdiGithub, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import { Field, Form, Formik } from 'formik'; +import Head from 'next/head'; +import { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import CardBox from '../components/CardBox'; +import FormCheckRadio from '../components/FormCheckRadio'; +import FormCheckRadioGroup from '../components/FormCheckRadioGroup'; +import FormField from '../components/FormField'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitle from '../components/SectionTitle'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; + +const FormsPage = () => { + return ( + <> + + {getPageTitle('Forms')} + + + + + {''} + + + + alert(JSON.stringify(values, null, 2))} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + Custom elements + + + + null} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + + ); +}; + +FormsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default FormsPage; diff --git a/frontend/src/pages/guarantees/[guaranteesId].tsx b/frontend/src/pages/guarantees/[guaranteesId].tsx new file mode 100644 index 0000000..a2ae5f4 --- /dev/null +++ b/frontend/src/pages/guarantees/[guaranteesId].tsx @@ -0,0 +1,199 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/guarantees/guaranteesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditGuarantees = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + reservation: null, + + method: '', + + amount: '', + + status: '', + + proof_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { guarantees } = useAppSelector((state) => state.guarantees); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { guaranteesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: guaranteesId })); + }, [guaranteesId]); + + useEffect(() => { + if (typeof guarantees === 'object') { + setInitialValues(guarantees); + } + }, [guarantees]); + + useEffect(() => { + if (typeof guarantees === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = guarantees[el]), + ); + + setInitialValues(newInitialVal); + } + }, [guarantees]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: guaranteesId, data })); + await router.push('/guarantees/guarantees-list'); + }; + + return ( + <> + + {getPageTitle('Edit guarantees')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/guarantees/guarantees-list')} + /> + + +
    +
    +
    + + ); +}; + +EditGuarantees.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditGuarantees; diff --git a/frontend/src/pages/guarantees/guarantees-edit.tsx b/frontend/src/pages/guarantees/guarantees-edit.tsx new file mode 100644 index 0000000..752b217 --- /dev/null +++ b/frontend/src/pages/guarantees/guarantees-edit.tsx @@ -0,0 +1,197 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/guarantees/guaranteesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditGuaranteesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + reservation: null, + + method: '', + + amount: '', + + status: '', + + proof_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { guarantees } = useAppSelector((state) => state.guarantees); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof guarantees === 'object') { + setInitialValues(guarantees); + } + }, [guarantees]); + + useEffect(() => { + if (typeof guarantees === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = guarantees[el]), + ); + setInitialValues(newInitialVal); + } + }, [guarantees]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/guarantees/guarantees-list'); + }; + + return ( + <> + + {getPageTitle('Edit guarantees')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/guarantees/guarantees-list')} + /> + + +
    +
    +
    + + ); +}; + +EditGuaranteesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditGuaranteesPage; diff --git a/frontend/src/pages/guarantees/guarantees-list.tsx b/frontend/src/pages/guarantees/guarantees-list.tsx new file mode 100644 index 0000000..60cd55e --- /dev/null +++ b/frontend/src/pages/guarantees/guarantees-list.tsx @@ -0,0 +1,187 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableGuarantees from '../../components/Guarantees/TableGuarantees'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/guarantees/guaranteesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const GuaranteesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'ProofURL', title: 'proof_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Method', + title: 'method', + type: 'enum', + options: ['especes', 'carte_bloquee', 'cheque'], + }, + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['retenue', 'restituee', 'partielle'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_GUARANTEES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getGuaranteesCSV = async () => { + const response = await axios({ + url: '/guarantees?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'guaranteesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Guarantees')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +GuaranteesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default GuaranteesTablesPage; diff --git a/frontend/src/pages/guarantees/guarantees-new.tsx b/frontend/src/pages/guarantees/guarantees-new.tsx new file mode 100644 index 0000000..ab9c981 --- /dev/null +++ b/frontend/src/pages/guarantees/guarantees-new.tsx @@ -0,0 +1,164 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/guarantees/guaranteesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + reservation: '', + + method: 'especes', + + amount: '', + + status: 'retenue', + + proof_url: '', + + agences: '', +}; + +const GuaranteesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/guarantees/guarantees-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/guarantees/guarantees-list')} + /> + + +
    +
    +
    + + ); +}; + +GuaranteesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default GuaranteesNew; diff --git a/frontend/src/pages/guarantees/guarantees-table.tsx b/frontend/src/pages/guarantees/guarantees-table.tsx new file mode 100644 index 0000000..74059f4 --- /dev/null +++ b/frontend/src/pages/guarantees/guarantees-table.tsx @@ -0,0 +1,186 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableGuarantees from '../../components/Guarantees/TableGuarantees'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/guarantees/guaranteesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const GuaranteesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'ProofURL', title: 'proof_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Method', + title: 'method', + type: 'enum', + options: ['especes', 'carte_bloquee', 'cheque'], + }, + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['retenue', 'restituee', 'partielle'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_GUARANTEES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getGuaranteesCSV = async () => { + const response = await axios({ + url: '/guarantees?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'guaranteesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Guarantees')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +GuaranteesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default GuaranteesTablesPage; diff --git a/frontend/src/pages/guarantees/guarantees-view.tsx b/frontend/src/pages/guarantees/guarantees-view.tsx new file mode 100644 index 0000000..0ff0e9a --- /dev/null +++ b/frontend/src/pages/guarantees/guarantees-view.tsx @@ -0,0 +1,120 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/guarantees/guaranteesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const GuaranteesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { guarantees } = useAppSelector((state) => state.guarantees); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View guarantees')} + + + + + + +
    +

    Tenant

    + +

    {guarantees?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Reservation

    + +

    {guarantees?.reservation?.ref ?? 'No data'}

    +
    + +
    +

    Method

    +

    {guarantees?.method ?? 'No data'}

    +
    + +
    +

    Amount

    +

    {guarantees?.amount || 'No data'}

    +
    + +
    +

    Status

    +

    {guarantees?.status ?? 'No data'}

    +
    + +
    +

    ProofURL

    +

    {guarantees?.proof_url}

    +
    + +
    +

    agences

    + +

    {guarantees?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/guarantees/guarantees-list')} + /> +
    +
    + + ); +}; + +GuaranteesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default GuaranteesView; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx new file mode 100644 index 0000000..6142530 --- /dev/null +++ b/frontend/src/pages/index.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { getPageTitle } from '../config'; +import { useAppSelector } from '../stores/hooks'; +import CardBoxComponentTitle from '../components/CardBoxComponentTitle'; +import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; + +export default function Starter() { + const [illustrationImage, setIllustrationImage] = useState({ + src: undefined, + photographer: undefined, + photographer_url: undefined, + }); + const [illustrationVideo, setIllustrationVideo] = useState({ + video_files: [], + }); + const [contentType, setContentType] = useState('video'); + const [contentPosition, setContentPosition] = useState('right'); + const textColor = useAppSelector((state) => state.style.linkColor); + + const title = 'Car zone'; + + // Fetch Pexels image/video + useEffect(() => { + async function fetchData() { + const image = await getPexelsImage(); + const video = await getPexelsVideo(); + setIllustrationImage(image); + setIllustrationVideo(video); + } + fetchData(); + }, []); + + const imageBlock = (image) => ( + + ); + + const videoBlock = (video) => { + if (video?.video_files?.length > 0) { + return ( +
    + + +
    + ); + } + }; + + return ( +
    + + {getPageTitle('Starter Page')} + + + +
    + {contentType === 'image' && contentPosition !== 'background' + ? imageBlock(illustrationImage) + : null} + {contentType === 'video' && contentPosition !== 'background' + ? videoBlock(illustrationVideo) + : null} +
    + + + +
    +

    + This is a React.js/Node.js app generated by the{' '} + + Flatlogic Web App Generator + +

    +

    + For guides and documentation please check your local README.md + and the{' '} + + Flatlogic documentation + +

    +
    + + + + + +
    +
    +
    +
    +
    +

    + © 2024 {title}. All rights reserved +

    + + Privacy Policy + +
    +
    + ); +} + +Starter.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/infractions/[infractionsId].tsx b/frontend/src/pages/infractions/[infractionsId].tsx new file mode 100644 index 0000000..4f83438 --- /dev/null +++ b/frontend/src/pages/infractions/[infractionsId].tsx @@ -0,0 +1,231 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/infractions/infractionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditInfractions = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + vehicle: null, + + reservation: null, + + ref_no: '', + + date: new Date(), + + location: '', + + amount: '', + + status: '', + + proof_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { infractions } = useAppSelector((state) => state.infractions); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { infractionsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: infractionsId })); + }, [infractionsId]); + + useEffect(() => { + if (typeof infractions === 'object') { + setInitialValues(infractions); + } + }, [infractions]); + + useEffect(() => { + if (typeof infractions === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = infractions[el]), + ); + + setInitialValues(newInitialVal); + } + }, [infractions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: infractionsId, data })); + await router.push('/infractions/infractions-list'); + }; + + return ( + <> + + {getPageTitle('Edit infractions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, date: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/infractions/infractions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditInfractions.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditInfractions; diff --git a/frontend/src/pages/infractions/infractions-edit.tsx b/frontend/src/pages/infractions/infractions-edit.tsx new file mode 100644 index 0000000..e471e86 --- /dev/null +++ b/frontend/src/pages/infractions/infractions-edit.tsx @@ -0,0 +1,229 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/infractions/infractionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditInfractionsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + vehicle: null, + + reservation: null, + + ref_no: '', + + date: new Date(), + + location: '', + + amount: '', + + status: '', + + proof_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { infractions } = useAppSelector((state) => state.infractions); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof infractions === 'object') { + setInitialValues(infractions); + } + }, [infractions]); + + useEffect(() => { + if (typeof infractions === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = infractions[el]), + ); + setInitialValues(newInitialVal); + } + }, [infractions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/infractions/infractions-list'); + }; + + return ( + <> + + {getPageTitle('Edit infractions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, date: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/infractions/infractions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditInfractionsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditInfractionsPage; diff --git a/frontend/src/pages/infractions/infractions-list.tsx b/frontend/src/pages/infractions/infractions-list.tsx new file mode 100644 index 0000000..1f32e9a --- /dev/null +++ b/frontend/src/pages/infractions/infractions-list.tsx @@ -0,0 +1,189 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableInfractions from '../../components/Infractions/TableInfractions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/infractions/infractionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const InfractionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'ReferenceNumber', title: 'ref_no' }, + { label: 'Location', title: 'location' }, + { label: 'ProofURL', title: 'proof_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + { label: 'Date', title: 'date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Vehicle', title: 'vehicle' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['ouverte', 'payee', 'conteste'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_INFRACTIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getInfractionsCSV = async () => { + const response = await axios({ + url: '/infractions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'infractionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Infractions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +InfractionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default InfractionsTablesPage; diff --git a/frontend/src/pages/infractions/infractions-new.tsx b/frontend/src/pages/infractions/infractions-new.tsx new file mode 100644 index 0000000..9ae7656 --- /dev/null +++ b/frontend/src/pages/infractions/infractions-new.tsx @@ -0,0 +1,182 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/infractions/infractionsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + vehicle: '', + + reservation: '', + + ref_no: '', + + date: '', + + location: '', + + amount: '', + + status: 'ouverte', + + proof_url: '', + + agences: '', +}; + +const InfractionsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/infractions/infractions-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/infractions/infractions-list')} + /> + + +
    +
    +
    + + ); +}; + +InfractionsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default InfractionsNew; diff --git a/frontend/src/pages/infractions/infractions-table.tsx b/frontend/src/pages/infractions/infractions-table.tsx new file mode 100644 index 0000000..a4dea45 --- /dev/null +++ b/frontend/src/pages/infractions/infractions-table.tsx @@ -0,0 +1,188 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableInfractions from '../../components/Infractions/TableInfractions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/infractions/infractionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const InfractionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'ReferenceNumber', title: 'ref_no' }, + { label: 'Location', title: 'location' }, + { label: 'ProofURL', title: 'proof_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + { label: 'Date', title: 'date', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Vehicle', title: 'vehicle' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['ouverte', 'payee', 'conteste'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_INFRACTIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getInfractionsCSV = async () => { + const response = await axios({ + url: '/infractions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'infractionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Infractions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +InfractionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default InfractionsTablesPage; diff --git a/frontend/src/pages/infractions/infractions-view.tsx b/frontend/src/pages/infractions/infractions-view.tsx new file mode 100644 index 0000000..5d90d59 --- /dev/null +++ b/frontend/src/pages/infractions/infractions-view.tsx @@ -0,0 +1,150 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/infractions/infractionsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const InfractionsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { infractions } = useAppSelector((state) => state.infractions); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View infractions')} + + + + + + +
    +

    Tenant

    + +

    {infractions?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Vehicle

    + +

    {infractions?.vehicle?.plate_number ?? 'No data'}

    +
    + +
    +

    Reservation

    + +

    {infractions?.reservation?.ref ?? 'No data'}

    +
    + +
    +

    ReferenceNumber

    +

    {infractions?.ref_no}

    +
    + + + {infractions.date ? ( + + ) : ( +

    No Date

    + )} +
    + +
    +

    Location

    +

    {infractions?.location}

    +
    + +
    +

    Amount

    +

    {infractions?.amount || 'No data'}

    +
    + +
    +

    Status

    +

    {infractions?.status ?? 'No data'}

    +
    + +
    +

    ProofURL

    +

    {infractions?.proof_url}

    +
    + +
    +

    agences

    + +

    {infractions?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/infractions/infractions-list')} + /> +
    +
    + + ); +}; + +InfractionsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default InfractionsView; diff --git a/frontend/src/pages/invoices_saas/[invoices_saasId].tsx b/frontend/src/pages/invoices_saas/[invoices_saasId].tsx new file mode 100644 index 0000000..b4d38f7 --- /dev/null +++ b/frontend/src/pages/invoices_saas/[invoices_saasId].tsx @@ -0,0 +1,222 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/invoices_saas/invoices_saasSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditInvoices_saas = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + subscription: null, + + invoice_number: '', + + amount_mad: '', + + status: '', + + issued_at: new Date(), + + pdf_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { invoices_saas } = useAppSelector((state) => state.invoices_saas); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { invoices_saasId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: invoices_saasId })); + }, [invoices_saasId]); + + useEffect(() => { + if (typeof invoices_saas === 'object') { + setInitialValues(invoices_saas); + } + }, [invoices_saas]); + + useEffect(() => { + if (typeof invoices_saas === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = invoices_saas[el]), + ); + + setInitialValues(newInitialVal); + } + }, [invoices_saas]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: invoices_saasId, data })); + await router.push('/invoices_saas/invoices_saas-list'); + }; + + return ( + <> + + {getPageTitle('Edit invoices_saas')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, issued_at: date }) + } + /> + + + + + + + + + + + + + + + + router.push('/invoices_saas/invoices_saas-list') + } + /> + + +
    +
    +
    + + ); +}; + +EditInvoices_saas.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditInvoices_saas; diff --git a/frontend/src/pages/invoices_saas/invoices_saas-edit.tsx b/frontend/src/pages/invoices_saas/invoices_saas-edit.tsx new file mode 100644 index 0000000..a6f1510 --- /dev/null +++ b/frontend/src/pages/invoices_saas/invoices_saas-edit.tsx @@ -0,0 +1,220 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/invoices_saas/invoices_saasSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditInvoices_saasPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + subscription: null, + + invoice_number: '', + + amount_mad: '', + + status: '', + + issued_at: new Date(), + + pdf_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { invoices_saas } = useAppSelector((state) => state.invoices_saas); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof invoices_saas === 'object') { + setInitialValues(invoices_saas); + } + }, [invoices_saas]); + + useEffect(() => { + if (typeof invoices_saas === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = invoices_saas[el]), + ); + setInitialValues(newInitialVal); + } + }, [invoices_saas]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/invoices_saas/invoices_saas-list'); + }; + + return ( + <> + + {getPageTitle('Edit invoices_saas')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, issued_at: date }) + } + /> + + + + + + + + + + + + + + + + router.push('/invoices_saas/invoices_saas-list') + } + /> + + +
    +
    +
    + + ); +}; + +EditInvoices_saasPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditInvoices_saasPage; diff --git a/frontend/src/pages/invoices_saas/invoices_saas-list.tsx b/frontend/src/pages/invoices_saas/invoices_saas-list.tsx new file mode 100644 index 0000000..8d4921d --- /dev/null +++ b/frontend/src/pages/invoices_saas/invoices_saas-list.tsx @@ -0,0 +1,188 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableInvoices_saas from '../../components/Invoices_saas/TableInvoices_saas'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/invoices_saas/invoices_saasSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const Invoices_saasTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'InvoiceNumber', title: 'invoice_number' }, + { label: 'PDFURL', title: 'pdf_url' }, + + { label: 'AmountMAD', title: 'amount_mad', number: 'true' }, + { label: 'IssuedAt', title: 'issued_at', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Subscription', title: 'subscription' }, + + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['draft', 'sent', 'paid', 'void'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_INVOICES_SAAS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getInvoices_saasCSV = async () => { + const response = await axios({ + url: '/invoices_saas?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'invoices_saasCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Invoices_saas')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + + Switch to Table + +
    +
    + + + + +
    + + + + + ); +}; + +Invoices_saasTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Invoices_saasTablesPage; diff --git a/frontend/src/pages/invoices_saas/invoices_saas-new.tsx b/frontend/src/pages/invoices_saas/invoices_saas-new.tsx new file mode 100644 index 0000000..fbca1ee --- /dev/null +++ b/frontend/src/pages/invoices_saas/invoices_saas-new.tsx @@ -0,0 +1,176 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/invoices_saas/invoices_saasSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + subscription: '', + + invoice_number: '', + + amount_mad: '', + + status: 'draft', + + issued_at: '', + + pdf_url: '', + + agences: '', +}; + +const Invoices_saasNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/invoices_saas/invoices_saas-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/invoices_saas/invoices_saas-list') + } + /> + + +
    +
    +
    + + ); +}; + +Invoices_saasNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Invoices_saasNew; diff --git a/frontend/src/pages/invoices_saas/invoices_saas-table.tsx b/frontend/src/pages/invoices_saas/invoices_saas-table.tsx new file mode 100644 index 0000000..9d14c1c --- /dev/null +++ b/frontend/src/pages/invoices_saas/invoices_saas-table.tsx @@ -0,0 +1,185 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableInvoices_saas from '../../components/Invoices_saas/TableInvoices_saas'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/invoices_saas/invoices_saasSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const Invoices_saasTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'InvoiceNumber', title: 'invoice_number' }, + { label: 'PDFURL', title: 'pdf_url' }, + + { label: 'AmountMAD', title: 'amount_mad', number: 'true' }, + { label: 'IssuedAt', title: 'issued_at', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Subscription', title: 'subscription' }, + + { + label: 'Status', + title: 'status', + type: 'enum', + options: ['draft', 'sent', 'paid', 'void'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_INVOICES_SAAS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getInvoices_saasCSV = async () => { + const response = await axios({ + url: '/invoices_saas?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'invoices_saasCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Invoices_saas')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +Invoices_saasTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Invoices_saasTablesPage; diff --git a/frontend/src/pages/invoices_saas/invoices_saas-view.tsx b/frontend/src/pages/invoices_saas/invoices_saas-view.tsx new file mode 100644 index 0000000..3a55e96 --- /dev/null +++ b/frontend/src/pages/invoices_saas/invoices_saas-view.tsx @@ -0,0 +1,143 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/invoices_saas/invoices_saasSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const Invoices_saasView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { invoices_saas } = useAppSelector((state) => state.invoices_saas); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View invoices_saas')} + + + + + + +
    +

    Tenant

    + +

    {invoices_saas?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Subscription

    + +

    + {invoices_saas?.subscription?.provider_customer_id ?? 'No data'} +

    +
    + +
    +

    InvoiceNumber

    +

    {invoices_saas?.invoice_number}

    +
    + +
    +

    AmountMAD

    +

    {invoices_saas?.amount_mad || 'No data'}

    +
    + +
    +

    Status

    +

    {invoices_saas?.status ?? 'No data'}

    +
    + + + {invoices_saas.issued_at ? ( + + ) : ( +

    No IssuedAt

    + )} +
    + +
    +

    PDFURL

    +

    {invoices_saas?.pdf_url}

    +
    + +
    +

    agences

    + +

    {invoices_saas?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/invoices_saas/invoices_saas-list')} + /> +
    +
    + + ); +}; + +Invoices_saasView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default Invoices_saasView; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx new file mode 100644 index 0000000..38bc708 --- /dev/null +++ b/frontend/src/pages/login.tsx @@ -0,0 +1,380 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import { useTranslation } from 'next-i18next'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import BaseIcon from '../components/BaseIcon'; +import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import FormCheckRadio from '../components/FormCheckRadio'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; +import { findMe, loginUser, resetAction } from '../stores/authSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import Link from 'next/link'; +import { toast, ToastContainer } from 'react-toastify'; +import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; + +export default function Login() { + const { t } = useTranslation('common'); + const router = useRouter(); + const dispatch = useAppDispatch(); + const textColor = useAppSelector((state) => state.style.linkColor); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const notify = (type, msg) => toast(msg, { type }); + const [illustrationImage, setIllustrationImage] = useState({ + src: undefined, + photographer: undefined, + photographer_url: undefined, + }); + const [illustrationVideo, setIllustrationVideo] = useState({ + video_files: [], + }); + const [contentType, setContentType] = useState('video'); + const [contentPosition, setContentPosition] = useState('right'); + const [showPassword, setShowPassword] = useState(false); + const { + currentUser, + isFetching, + errorMessage, + token, + notify: notifyState, + } = useAppSelector((state) => state.auth); + const [initialValues, setInitialValues] = React.useState({ + email: 'super_admin@flatlogic.com', + password: '9023ec63', + remember: true, + }); + + const title = 'Car zone'; + + // Fetch Pexels image/video + useEffect(() => { + async function fetchData() { + const image = await getPexelsImage(); + const video = await getPexelsVideo(); + setIllustrationImage(image); + setIllustrationVideo(video); + } + fetchData(); + }, []); + // Fetch user data + useEffect(() => { + if (token) { + dispatch(findMe()); + } + }, [token, dispatch]); + // Redirect to dashboard if user is logged in + useEffect(() => { + if (currentUser?.id) { + router.push('/dashboard'); + } + }, [currentUser?.id, router]); + // Show error message if there is one + useEffect(() => { + if (errorMessage) { + notify('error', errorMessage); + } + }, [errorMessage]); + // Show notification if there is one + useEffect(() => { + if (notifyState?.showNotification) { + notify('success', notifyState?.textNotification); + dispatch(resetAction()); + } + }, [notifyState?.showNotification]); + + const togglePasswordVisibility = () => { + setShowPassword(!showPassword); + }; + + const handleSubmit = async (value) => { + const { remember, ...rest } = value; + await dispatch(loginUser(rest)); + }; + + const setLogin = (target: HTMLElement) => { + setInitialValues((prev) => ({ + ...prev, + email: target.innerText.trim(), + password: target.dataset.password ?? '', + })); + }; + + const imageBlock = (image) => ( + + ); + + const videoBlock = (video) => { + if (video?.video_files?.length > 0) { + return ( +
    + + +
    + ); + } + }; + + return ( +
    + + + {getPageTitle(t('pages.login.pageTitle', { defaultValue: 'Login' }))} + + + + +
    + {contentType === 'image' && contentPosition !== 'background' + ? imageBlock(illustrationImage) + : null} + {contentType === 'video' && contentPosition !== 'background' + ? videoBlock(illustrationVideo) + : null} +
    + +

    {title}

    + +
    +
    +

    + Use{' '} + setLogin(e.target)} + > + super_admin@flatlogic.com + + {' / '} + 9023ec63 + {' / '} + to login as Super Admin +

    + +

    + Use{' '} + setLogin(e.target)} + > + admin@flatlogic.com + + {' / '} + 9023ec63 + {' / '} + to login as Admin +

    +

    + Use{' '} + setLogin(e.target)} + > + client@hello.com + + {' / '} + 4d8ac698b0e7 + {' / '} + to login as User +

    +
    +
    + +
    +
    +
    + + + handleSubmit(values)} + > +
    + + + + +
    + + + +
    + +
    +
    + +
    + + + + + + {t('pages.login.form.forgotPassword', { + defaultValue: 'Forgot password?', + })} + +
    + + + + + + +
    +

    + {t('pages.login.form.noAccountYet', { + defaultValue: 'Don’t have an account yet?', + })}{' '} + + {t('pages.login.form.newAccount', { + defaultValue: 'New Account', + })} + +

    + +
    +
    +
    +
    +
    +
    +

    + © 2024 {title}.{' '} + {t('pages.login.footer.copyright', { + defaultValue: '© All rights reserved', + })} +

    + + {t('pages.login.footer.privacy', { defaultValue: 'Privacy Policy' })} + +
    + +
    + ); +} + +Login.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/maintenance/[maintenanceId].tsx b/frontend/src/pages/maintenance/[maintenanceId].tsx new file mode 100644 index 0000000..0baf265 --- /dev/null +++ b/frontend/src/pages/maintenance/[maintenanceId].tsx @@ -0,0 +1,218 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/maintenance/maintenanceSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditMaintenance = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + vehicle: null, + + type: '', + + scheduled_at: new Date(), + + odometer: '', + + cost: '', + + notes: '', + + attachments: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { maintenance } = useAppSelector((state) => state.maintenance); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { maintenanceId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: maintenanceId })); + }, [maintenanceId]); + + useEffect(() => { + if (typeof maintenance === 'object') { + setInitialValues(maintenance); + } + }, [maintenance]); + + useEffect(() => { + if (typeof maintenance === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = maintenance[el]), + ); + + setInitialValues(newInitialVal); + } + }, [maintenance]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: maintenanceId, data })); + await router.push('/maintenance/maintenance-list'); + }; + + return ( + <> + + {getPageTitle('Edit maintenance')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, scheduled_at: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/maintenance/maintenance-list')} + /> + + +
    +
    +
    + + ); +}; + +EditMaintenance.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditMaintenance; diff --git a/frontend/src/pages/maintenance/maintenance-edit.tsx b/frontend/src/pages/maintenance/maintenance-edit.tsx new file mode 100644 index 0000000..fffc451 --- /dev/null +++ b/frontend/src/pages/maintenance/maintenance-edit.tsx @@ -0,0 +1,216 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/maintenance/maintenanceSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditMaintenancePage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + vehicle: null, + + type: '', + + scheduled_at: new Date(), + + odometer: '', + + cost: '', + + notes: '', + + attachments: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { maintenance } = useAppSelector((state) => state.maintenance); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof maintenance === 'object') { + setInitialValues(maintenance); + } + }, [maintenance]); + + useEffect(() => { + if (typeof maintenance === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = maintenance[el]), + ); + setInitialValues(newInitialVal); + } + }, [maintenance]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/maintenance/maintenance-list'); + }; + + return ( + <> + + {getPageTitle('Edit maintenance')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, scheduled_at: date }) + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/maintenance/maintenance-list')} + /> + + +
    +
    +
    + + ); +}; + +EditMaintenancePage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditMaintenancePage; diff --git a/frontend/src/pages/maintenance/maintenance-list.tsx b/frontend/src/pages/maintenance/maintenance-list.tsx new file mode 100644 index 0000000..0383265 --- /dev/null +++ b/frontend/src/pages/maintenance/maintenance-list.tsx @@ -0,0 +1,186 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableMaintenance from '../../components/Maintenance/TableMaintenance'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/maintenance/maintenanceSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MaintenanceTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Notes', title: 'notes' }, + { label: 'Attachments', title: 'attachments' }, + { label: 'Odometer', title: 'odometer', number: 'true' }, + { label: 'Cost', title: 'cost', number: 'true' }, + { label: 'ScheduledAt', title: 'scheduled_at', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Vehicle', title: 'vehicle' }, + + { + label: 'Type', + title: 'type', + type: 'enum', + options: ['preventive', 'corrective'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_MAINTENANCE'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getMaintenanceCSV = async () => { + const response = await axios({ + url: '/maintenance?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'maintenanceCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Maintenance')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +MaintenanceTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MaintenanceTablesPage; diff --git a/frontend/src/pages/maintenance/maintenance-new.tsx b/frontend/src/pages/maintenance/maintenance-new.tsx new file mode 100644 index 0000000..be8bb67 --- /dev/null +++ b/frontend/src/pages/maintenance/maintenance-new.tsx @@ -0,0 +1,172 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/maintenance/maintenanceSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + vehicle: '', + + type: 'preventive', + + scheduled_at: '', + + odometer: '', + + cost: '', + + notes: '', + + attachments: '', + + agences: '', +}; + +const MaintenanceNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/maintenance/maintenance-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/maintenance/maintenance-list')} + /> + + +
    +
    +
    + + ); +}; + +MaintenanceNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MaintenanceNew; diff --git a/frontend/src/pages/maintenance/maintenance-table.tsx b/frontend/src/pages/maintenance/maintenance-table.tsx new file mode 100644 index 0000000..a791098 --- /dev/null +++ b/frontend/src/pages/maintenance/maintenance-table.tsx @@ -0,0 +1,185 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableMaintenance from '../../components/Maintenance/TableMaintenance'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/maintenance/maintenanceSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MaintenanceTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Notes', title: 'notes' }, + { label: 'Attachments', title: 'attachments' }, + { label: 'Odometer', title: 'odometer', number: 'true' }, + { label: 'Cost', title: 'cost', number: 'true' }, + { label: 'ScheduledAt', title: 'scheduled_at', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Vehicle', title: 'vehicle' }, + + { + label: 'Type', + title: 'type', + type: 'enum', + options: ['preventive', 'corrective'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_MAINTENANCE'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getMaintenanceCSV = async () => { + const response = await axios({ + url: '/maintenance?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'maintenanceCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Maintenance')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +MaintenanceTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MaintenanceTablesPage; diff --git a/frontend/src/pages/maintenance/maintenance-view.tsx b/frontend/src/pages/maintenance/maintenance-view.tsx new file mode 100644 index 0000000..3d8e6fb --- /dev/null +++ b/frontend/src/pages/maintenance/maintenance-view.tsx @@ -0,0 +1,146 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/maintenance/maintenanceSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MaintenanceView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { maintenance } = useAppSelector((state) => state.maintenance); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View maintenance')} + + + + + + +
    +

    Tenant

    + +

    {maintenance?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Vehicle

    + +

    {maintenance?.vehicle?.plate_number ?? 'No data'}

    +
    + +
    +

    Type

    +

    {maintenance?.type ?? 'No data'}

    +
    + + + {maintenance.scheduled_at ? ( + + ) : ( +

    No ScheduledAt

    + )} +
    + +
    +

    Odometer

    +

    {maintenance?.odometer || 'No data'}

    +
    + +
    +

    Cost

    +

    {maintenance?.cost || 'No data'}

    +
    + +
    +

    Notes

    +

    {maintenance?.notes}

    +
    + +
    +

    Attachments

    +

    {maintenance?.attachments}

    +
    + +
    +

    agences

    + +

    {maintenance?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/maintenance/maintenance-list')} + /> +
    +
    + + ); +}; + +MaintenanceView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MaintenanceView; diff --git a/frontend/src/pages/password-reset.tsx b/frontend/src/pages/password-reset.tsx new file mode 100644 index 0000000..e75c2f4 --- /dev/null +++ b/frontend/src/pages/password-reset.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import LayoutGuest from '../layouts/Guest'; +import PasswordSetOrReset from '../components/PasswordSetOrReset'; + +export default function Reset() { + return ; +} + +Reset.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/payments/[paymentsId].tsx b/frontend/src/pages/payments/[paymentsId].tsx new file mode 100644 index 0000000..bed6dea --- /dev/null +++ b/frontend/src/pages/payments/[paymentsId].tsx @@ -0,0 +1,214 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/payments/paymentsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditPayments = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + reservation: null, + + method: '', + + amount: '', + + paid_at: new Date(), + + receipt_ref: '', + + attachment_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { payments } = useAppSelector((state) => state.payments); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { paymentsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: paymentsId })); + }, [paymentsId]); + + useEffect(() => { + if (typeof payments === 'object') { + setInitialValues(payments); + } + }, [payments]); + + useEffect(() => { + if (typeof payments === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = payments[el])); + + setInitialValues(newInitialVal); + } + }, [payments]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: paymentsId, data })); + await router.push('/payments/payments-list'); + }; + + return ( + <> + + {getPageTitle('Edit payments')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, paid_at: date }) + } + /> + + + + + + + + + + + + + + + + + + + router.push('/payments/payments-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPayments.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPayments; diff --git a/frontend/src/pages/payments/payments-edit.tsx b/frontend/src/pages/payments/payments-edit.tsx new file mode 100644 index 0000000..87c9af4 --- /dev/null +++ b/frontend/src/pages/payments/payments-edit.tsx @@ -0,0 +1,212 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/payments/paymentsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditPaymentsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + tenant: null, + + reservation: null, + + method: '', + + amount: '', + + paid_at: new Date(), + + receipt_ref: '', + + attachment_url: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { payments } = useAppSelector((state) => state.payments); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof payments === 'object') { + setInitialValues(payments); + } + }, [payments]); + + useEffect(() => { + if (typeof payments === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = payments[el])); + setInitialValues(newInitialVal); + } + }, [payments]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/payments/payments-list'); + }; + + return ( + <> + + {getPageTitle('Edit payments')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + setInitialValues({ ...initialValues, paid_at: date }) + } + /> + + + + + + + + + + + + + + + + + + + router.push('/payments/payments-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPaymentsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPaymentsPage; diff --git a/frontend/src/pages/payments/payments-list.tsx b/frontend/src/pages/payments/payments-list.tsx new file mode 100644 index 0000000..75d1e4e --- /dev/null +++ b/frontend/src/pages/payments/payments-list.tsx @@ -0,0 +1,183 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TablePayments from '../../components/Payments/TablePayments'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/payments/paymentsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PaymentsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'ReceiptReference', title: 'receipt_ref' }, + { label: 'AttachmentURL', title: 'attachment_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + { label: 'PaidAt', title: 'paid_at', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Method', + title: 'method', + type: 'enum', + options: ['especes', 'carte', 'virement', 'cheque'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PAYMENTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPaymentsCSV = async () => { + const response = await axios({ + url: '/payments?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'paymentsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Payments')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +PaymentsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PaymentsTablesPage; diff --git a/frontend/src/pages/payments/payments-new.tsx b/frontend/src/pages/payments/payments-new.tsx new file mode 100644 index 0000000..4e39770 --- /dev/null +++ b/frontend/src/pages/payments/payments-new.tsx @@ -0,0 +1,170 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/payments/paymentsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + tenant: '', + + reservation: '', + + method: 'especes', + + amount: '', + + paid_at: '', + + receipt_ref: '', + + attachment_url: '', + + agences: '', +}; + +const PaymentsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/payments/payments-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/payments/payments-list')} + /> + + +
    +
    +
    + + ); +}; + +PaymentsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PaymentsNew; diff --git a/frontend/src/pages/payments/payments-table.tsx b/frontend/src/pages/payments/payments-table.tsx new file mode 100644 index 0000000..01e9336 --- /dev/null +++ b/frontend/src/pages/payments/payments-table.tsx @@ -0,0 +1,182 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TablePayments from '../../components/Payments/TablePayments'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/payments/paymentsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PaymentsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'ReceiptReference', title: 'receipt_ref' }, + { label: 'AttachmentURL', title: 'attachment_url' }, + + { label: 'Amount', title: 'amount', number: 'true' }, + { label: 'PaidAt', title: 'paid_at', date: 'true' }, + + { label: 'Tenant', title: 'tenant' }, + + { label: 'Reservation', title: 'reservation' }, + + { + label: 'Method', + title: 'method', + type: 'enum', + options: ['especes', 'carte', 'virement', 'cheque'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PAYMENTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPaymentsCSV = async () => { + const response = await axios({ + url: '/payments?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'paymentsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Payments')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +PaymentsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PaymentsTablesPage; diff --git a/frontend/src/pages/payments/payments-view.tsx b/frontend/src/pages/payments/payments-view.tsx new file mode 100644 index 0000000..428a06e --- /dev/null +++ b/frontend/src/pages/payments/payments-view.tsx @@ -0,0 +1,139 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/payments/paymentsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PaymentsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { payments } = useAppSelector((state) => state.payments); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View payments')} + + + + + + +
    +

    Tenant

    + +

    {payments?.tenant?.name ?? 'No data'}

    +
    + +
    +

    Reservation

    + +

    {payments?.reservation?.ref ?? 'No data'}

    +
    + +
    +

    Method

    +

    {payments?.method ?? 'No data'}

    +
    + +
    +

    Amount

    +

    {payments?.amount || 'No data'}

    +
    + + + {payments.paid_at ? ( + + ) : ( +

    No PaidAt

    + )} +
    + +
    +

    ReceiptReference

    +

    {payments?.receipt_ref}

    +
    + +
    +

    AttachmentURL

    +

    {payments?.attachment_url}

    +
    + +
    +

    agences

    + +

    {payments?.agences?.name ?? 'No data'}

    +
    + + + + router.push('/payments/payments-list')} + /> +
    +
    + + ); +}; + +PaymentsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PaymentsView; diff --git a/frontend/src/pages/permissions/[permissionsId].tsx b/frontend/src/pages/permissions/[permissionsId].tsx new file mode 100644 index 0000000..c49d722 --- /dev/null +++ b/frontend/src/pages/permissions/[permissionsId].tsx @@ -0,0 +1,130 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/permissions/permissionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditPermissions = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { permissions } = useAppSelector((state) => state.permissions); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { permissionsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: permissionsId })); + }, [permissionsId]); + + useEffect(() => { + if (typeof permissions === 'object') { + setInitialValues(permissions); + } + }, [permissions]); + + useEffect(() => { + if (typeof permissions === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = permissions[el]), + ); + + setInitialValues(newInitialVal); + } + }, [permissions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: permissionsId, data })); + await router.push('/permissions/permissions-list'); + }; + + return ( + <> + + {getPageTitle('Edit permissions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/permissions/permissions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPermissions.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPermissions; diff --git a/frontend/src/pages/permissions/permissions-edit.tsx b/frontend/src/pages/permissions/permissions-edit.tsx new file mode 100644 index 0000000..c540bf7 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-edit.tsx @@ -0,0 +1,128 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/permissions/permissionsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditPermissionsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { permissions } = useAppSelector((state) => state.permissions); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof permissions === 'object') { + setInitialValues(permissions); + } + }, [permissions]); + + useEffect(() => { + if (typeof permissions === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = permissions[el]), + ); + setInitialValues(newInitialVal); + } + }, [permissions]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/permissions/permissions-list'); + }; + + return ( + <> + + {getPageTitle('Edit permissions')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/permissions/permissions-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPermissionsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPermissionsPage; diff --git a/frontend/src/pages/permissions/permissions-list.tsx b/frontend/src/pages/permissions/permissions-list.tsx new file mode 100644 index 0000000..97955e4 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-list.tsx @@ -0,0 +1,165 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TablePermissions from '../../components/Permissions/TablePermissions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/permissions/permissionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PermissionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPermissionsCSV = async () => { + const response = await axios({ + url: '/permissions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'permissionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Permissions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + + +
    + + + + + ); +}; + +PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsTablesPage; diff --git a/frontend/src/pages/permissions/permissions-new.tsx b/frontend/src/pages/permissions/permissions-new.tsx new file mode 100644 index 0000000..e5a9eb0 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-new.tsx @@ -0,0 +1,98 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/permissions/permissionsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + name: '', +}; + +const PermissionsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/permissions/permissions-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + router.push('/permissions/permissions-list')} + /> + + +
    +
    +
    + + ); +}; + +PermissionsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsNew; diff --git a/frontend/src/pages/permissions/permissions-table.tsx b/frontend/src/pages/permissions/permissions-table.tsx new file mode 100644 index 0000000..4f54815 --- /dev/null +++ b/frontend/src/pages/permissions/permissions-table.tsx @@ -0,0 +1,164 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TablePermissions from '../../components/Permissions/TablePermissions'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { + setRefetch, + uploadCsv, +} from '../../stores/permissions/permissionsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PermissionsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPermissionsCSV = async () => { + const response = await axios({ + url: '/permissions?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'permissionsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Permissions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    +
    + + + +
    + + + + + ); +}; + +PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsTablesPage; diff --git a/frontend/src/pages/permissions/permissions-view.tsx b/frontend/src/pages/permissions/permissions-view.tsx new file mode 100644 index 0000000..aae7c5e --- /dev/null +++ b/frontend/src/pages/permissions/permissions-view.tsx @@ -0,0 +1,87 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/permissions/permissionsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PermissionsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { permissions } = useAppSelector((state) => state.permissions); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View permissions')} + + + + + + +
    +

    Name

    +

    {permissions?.name}

    +
    + + + + router.push('/permissions/permissions-list')} + /> +
    +
    + + ); +}; + +PermissionsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsView; diff --git a/frontend/src/pages/plans/[plansId].tsx b/frontend/src/pages/plans/[plansId].tsx new file mode 100644 index 0000000..8d74211 --- /dev/null +++ b/frontend/src/pages/plans/[plansId].tsx @@ -0,0 +1,171 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/plans/plansSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditPlans = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + code: '', + + name: '', + + period: '', + + price_mad: '', + + limits: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { plans } = useAppSelector((state) => state.plans); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { plansId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: plansId })); + }, [plansId]); + + useEffect(() => { + if (typeof plans === 'object') { + setInitialValues(plans); + } + }, [plans]); + + useEffect(() => { + if (typeof plans === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = plans[el])); + + setInitialValues(newInitialVal); + } + }, [plans]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: plansId, data })); + await router.push('/plans/plans-list'); + }; + + return ( + <> + + {getPageTitle('Edit plans')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/plans/plans-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPlans.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPlans; diff --git a/frontend/src/pages/plans/plans-edit.tsx b/frontend/src/pages/plans/plans-edit.tsx new file mode 100644 index 0000000..8eebea4 --- /dev/null +++ b/frontend/src/pages/plans/plans-edit.tsx @@ -0,0 +1,169 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/plans/plansSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditPlansPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + code: '', + + name: '', + + period: '', + + price_mad: '', + + limits: '', + + agences: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { plans } = useAppSelector((state) => state.plans); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof plans === 'object') { + setInitialValues(plans); + } + }, [plans]); + + useEffect(() => { + if (typeof plans === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = plans[el])); + setInitialValues(newInitialVal); + } + }, [plans]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/plans/plans-list'); + }; + + return ( + <> + + {getPageTitle('Edit plans')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/plans/plans-list')} + /> + + +
    +
    +
    + + ); +}; + +EditPlansPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditPlansPage; diff --git a/frontend/src/pages/plans/plans-list.tsx b/frontend/src/pages/plans/plans-list.tsx new file mode 100644 index 0000000..cd19e24 --- /dev/null +++ b/frontend/src/pages/plans/plans-list.tsx @@ -0,0 +1,177 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TablePlans from '../../components/Plans/TablePlans'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/plans/plansSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PlansTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Code', title: 'code' }, + { label: 'Name', title: 'name' }, + { label: 'Limits', title: 'limits' }, + + { label: 'PriceMAD', title: 'price_mad', number: 'true' }, + + { + label: 'Period', + title: 'period', + type: 'enum', + options: ['monthly', 'yearly', 'both'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PLANS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPlansCSV = async () => { + const response = await axios({ + url: '/plans?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'plansCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Plans')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    +
    + +
    + Switch to Table +
    +
    + + + + +
    + + + + + ); +}; + +PlansTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default PlansTablesPage; diff --git a/frontend/src/pages/plans/plans-new.tsx b/frontend/src/pages/plans/plans-new.tsx new file mode 100644 index 0000000..d83871e --- /dev/null +++ b/frontend/src/pages/plans/plans-new.tsx @@ -0,0 +1,140 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/plans/plansSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + code: '', + + name: '', + + period: 'monthly', + + price_mad: '', + + limits: '', + + agences: '', +}; + +const PlansNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/plans/plans-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/plans/plans-list')} + /> + + +
    +
    +
    + + ); +}; + +PlansNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PlansNew; diff --git a/frontend/src/pages/plans/plans-table.tsx b/frontend/src/pages/plans/plans-table.tsx new file mode 100644 index 0000000..698d698 --- /dev/null +++ b/frontend/src/pages/plans/plans-table.tsx @@ -0,0 +1,176 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TablePlans from '../../components/Plans/TablePlans'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/plans/plansSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PlansTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Code', title: 'code' }, + { label: 'Name', title: 'name' }, + { label: 'Limits', title: 'limits' }, + + { label: 'PriceMAD', title: 'price_mad', number: 'true' }, + + { + label: 'Period', + title: 'period', + type: 'enum', + options: ['monthly', 'yearly', 'both'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_PLANS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getPlansCSV = async () => { + const response = await axios({ + url: '/plans?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'plansCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Plans')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to list + +
    +
    + + + +
    + + + + + ); +}; + +PlansTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default PlansTablesPage; diff --git a/frontend/src/pages/plans/plans-view.tsx b/frontend/src/pages/plans/plans-view.tsx new file mode 100644 index 0000000..6354105 --- /dev/null +++ b/frontend/src/pages/plans/plans-view.tsx @@ -0,0 +1,245 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/plans/plansSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const PlansView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { plans } = useAppSelector((state) => state.plans); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View plans')} + + + + + + +
    +

    Code

    +

    {plans?.code}

    +
    + +
    +

    Name

    +

    {plans?.name}

    +
    + +
    +

    Period

    +

    {plans?.period ?? 'No data'}

    +
    + +
    +

    PriceMAD

    +

    {plans?.price_mad || 'No data'}

    +
    + +
    +

    Limits

    +

    {plans?.limits}

    +
    + +
    +

    agences

    + +

    {plans?.agences?.name ?? 'No data'}

    +
    + + <> +

    Subscriptions Plan

    + +
    + + + + + + + + + + + + + + + + + + {plans.subscriptions_plan && + Array.isArray(plans.subscriptions_plan) && + plans.subscriptions_plan.map((item: any) => ( + + router.push( + `/subscriptions/subscriptions-view/?id=${item.id}`, + ) + } + > + + + + + + + + + + + + + ))} + +
    StatusCurrentPeriodStartCurrentPeriodEndPaymentProviderProviderCustomerIDProviderSubscriptionID
    {item.status} + {dataFormatter.dateTimeFormatter( + item.current_period_start, + )} + + {dataFormatter.dateTimeFormatter( + item.current_period_end, + )} + + {item.payment_provider} + + {item.provider_customer_id} + + {item.provider_subscription_id} +
    +
    + {!plans?.subscriptions_plan?.length && ( +
    No data
    + )} +
    + + + <> +

    Tenants Plan

    + +
    + + + + + + + + + + + + + + + + + + {plans.tenants_plan && + Array.isArray(plans.tenants_plan) && + plans.tenants_plan.map((item: any) => ( + + router.push(`/tenants/tenants-view/?id=${item.id}`) + } + > + + + + + + + + + + + + + ))} + +
    NameSubdomainStatusTrialEndsAtBillingEmailBillingPhone
    {item.name}{item.subdomain}{item.status} + {dataFormatter.dateTimeFormatter( + item.trial_ends_at, + )} + + {item.billing_email} + + {item.billing_phone} +
    +
    + {!plans?.tenants_plan?.length && ( +
    No data
    + )} +
    + + + + + router.push('/plans/plans-list')} + /> +
    +
    + + ); +}; + +PlansView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default PlansView; diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx new file mode 100644 index 0000000..6452bc1 --- /dev/null +++ b/frontend/src/pages/privacy-policy.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +export default function PrivacyPolicy() { + const title = 'Car zone'; + const [projectUrl, setProjectUrl] = useState(''); + + useEffect(() => { + setProjectUrl(location.origin); + }, []); + + const Introduction = () => { + return ( + <> +

    1. Introduction

    +

    + {/* eslint-disable-next-line react/no-unescaped-entities */} + We at {title} ("we", "us", "our") are committed to + protecting your privacy. This Privacy Policy explains how we collect, + use, disclose, and safeguard your information when you visit our + website {projectUrl}, use our services, or + interact with us in other ways. By using our services, you agree to + the collection and use of information in accordance with this policy. +

    + + ); + }; + + const Information = () => { + return ( + <> +

    2. Information We Collect

    +
    +

    2.1 Personal Identification Information

    +

    + We collect various types of personal information in connection with + the services we provide, including: +

    +
      +
    • + Contact Information: Name, email address, phone number, mailing + address. +
    • +
    • Account Information: Username, password, profile picture.
    • +
    • Payment Information: Credit card details, billing address.
    • +
    • Demographic Information: Age, gender, interests.
    • +
    +

    2.2 Technical Data

    +

    + We automatically collect certain information when you visit, use, or + navigate our services. This information may include: +

    +
      +
    • + Device Information: IP address, browser type, operating system, + device type. +
    • +
    • + Usage Data: Pages visited, time spent on each page, links clicked, + and other actions taken on our site. +
    • +
    +

    2.3 Cookies and Tracking Technologies

    +

    + We use cookies and similar tracking technologies to track the + activity on our service and hold certain information. You can + instruct your browser to refuse all cookies or to indicate when a + cookie is being sent. +

    +
    + + ); + }; + + const HowToUser = () => { + return ( + <> +

    3. How We Use Your Information

    +

    We use the information we collect in various ways, including to:

    +
      +
    • Provide, operate, and maintain our website and services.
    • +
    • Improve, personalize, and expand our website and services.
    • +
    • Understand and analyze how you use our website and services.
    • +
    • Develop new products, services, features, and functionality.
    • +
    • + Communicate with you, either directly or through one of our + partners, including for customer service, to provide you with + updates and other information relating to the website, and for + marketing and promotional purposes. +
    • +
    • + Process your transactions and send you related information, + including purchase confirmations and invoices. +
    • +
    • Find and prevent fraud.
    • +
    • Comply with legal obligations.
    • +
    + + ); + }; + + const DataProtection = () => { + return ( + <> +

    4. Data Protection and Security

    +

    + We implement a variety of security measures to maintain the safety of + your personal information. These measures include: +

    +
      +
    • + Encryption: We use encryption to protect sensitive information + transmitted online. Access Controls: We restrict access to your + personal data to authorized personnel only. Regular Security Audits: + We conduct regular audits to identify and address potential security + vulnerabilities. +
    • +
    + + ); + }; + + const Sharing = () => { + return ( + <> +

    5. Sharing Your Information

    +

    + We do not sell, trade, or otherwise transfer your Personally + Identifiable Information to outside parties without your consent, + except in the following cases: +

    +
      +
    • + Service Providers: We may share your information with third-party + service providers who perform services on our behalf, such as + payment processing, data analysis, email delivery, hosting services, + customer service, and marketing assistance. +
    • +
    • + Business Transfers: In the event of a merger, acquisition, or sale + of all or a portion of our assets, your information may be + transferred as part of that transaction. +
    • +
    • + Legal Requirements: We may disclose your information if required to + do so by law or in response to valid requests by public authorities + (e.g., a court or a government agency). +
    • +
    + + ); + }; + + const ProtectionRights = () => { + return ( + <> +

    6. Your Data Protection Rights

    +

    + Depending on your location, you may have the following rights + regarding your personal data: +

    +
      +
    • + The Right to Access: You have the right to request copies of your + personal data. +
    • +
    • + The Right to Rectification: You have the right to request that we + correct any information you believe is inaccurate or complete + information you believe is incomplete. +
    • +
    • + The Right to Erasure: You have the right to request that we erase + your personal data, under certain conditions. +
    • +
    • + The Right to Restrict Processing: You have the right to request that + we restrict the processing of your personal data, under certain + conditions. +
    • +
    • + The Right to Object to Processing: You have the right to object to + our processing of your personal data, under certain conditions. +
    • +
    • + The Right to Data Portability: You have the right to request that we + transfer the data that we have collected to another organization, or + directly to you, under certain conditions. +
    • +
    + + ); + }; + + const DataTransfers = () => { + return ( + <> +

    7. International Data Transfers

    +

    + Your information, including personal data, may be transferred to — and + maintained on — computers located outside of your state, province, + country, or other governmental jurisdiction where the data protection + laws may differ from those of your jurisdiction. We will take all + steps reasonably necessary to ensure that your data is treated + securely and in accordance with this Privacy Policy. +

    + + ); + }; + + const RetentionOfData = () => { + return ( + <> +

    8. Retention of Data

    +

    + We will retain your personal data only for as long as is necessary for + the purposes set out in this Privacy Policy. We will retain and use + your personal data to the extent necessary to comply with our legal + obligations, resolve disputes, and enforce our policies. +

    + + ); + }; + + const ChangePrivacy = () => { + return ( + <> +

    9. Changes to This Privacy Policy

    +

    + We may update our Privacy Policy from time to time. We will notify you + of any changes by posting the new Privacy Policy on this page. You are + advised to review this Privacy Policy periodically for any changes. + Changes to this Privacy Policy are effective when they are posted on + this page. +

    + + ); + }; + + const ContactUs = () => { + return ( + <> +

    10. Contact Us

    +

    + If you have any questions about this Privacy Policy, please contact + us: +

    +
    + By email:{' '} + [support@flatlogic.com] +
    +
    + By visiting this page on our website:{' '} + Contact Us +
    + + ); + }; + + return ( +
    + + {getPageTitle('Privacy Policy')} + + +
    +
    +
    +

    Privacy Policy

    + + + + + + + + + + +
    +
    +
    +
    + ); +} + +PrivacyPolicy.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx new file mode 100644 index 0000000..efc9070 --- /dev/null +++ b/frontend/src/pages/profile.tsx @@ -0,0 +1,178 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +import CardBox from '../components/CardBox'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import BaseButton from '../components/BaseButton'; +import FormCheckRadio from '../components/FormCheckRadio'; +import FormCheckRadioGroup from '../components/FormCheckRadioGroup'; +import FormImagePicker from '../components/FormImagePicker'; +import { SwitchField } from '../components/SwitchField'; +import { SelectField } from '../components/SelectField'; + +import { update, fetch } from '../stores/users/usersSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { useRouter } from 'next/router'; +import { findMe } from '../stores/authSlice'; + +const EditUsers = () => { + const { currentUser, isFetching, token } = useAppSelector( + (state) => state.auth, + ); + const router = useRouter(); + const dispatch = useAppDispatch(); + const notify = (type, msg) => toast(msg, { type }); + const initVals = { + firstName: '', + lastName: '', + phoneNumber: '', + email: '', + app_role: '', + disabled: false, + avatar: [], + password: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + useEffect(() => { + if (currentUser?.id && typeof currentUser === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = currentUser[el]), + ); + + setInitialValues(newInitialVal); + } + }, [currentUser]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: currentUser.id, data })); + await dispatch(findMe()); + await router.push('/users/users-list'); + notify('success', 'Profile was updated!'); + }; + + return ( + <> + + {getPageTitle('Edit profile')} + + + + {''} + + + {currentUser?.avatar[0]?.publicUrl && ( +
    +
    + Avatar +
    +
    + )} + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/users/users-list')} + /> + + +
    +
    +
    + + ); +}; + +EditUsers.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default EditUsers; diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx new file mode 100644 index 0000000..35bf2d4 --- /dev/null +++ b/frontend/src/pages/register.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import Head from 'next/head'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { Field, Form, Formik } from 'formik'; +import FormField from '../components/FormField'; +import BaseDivider from '../components/BaseDivider'; +import BaseButtons from '../components/BaseButtons'; +import { useRouter } from 'next/router'; +import { getPageTitle } from '../config'; + +import Select from 'react-select'; +import { useAppDispatch } from '../stores/hooks'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import axios from 'axios'; + +export default function Register() { + const [loading, setLoading] = React.useState(false); + const router = useRouter(); + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const [organizations, setOrganizations] = React.useState(null); + const [selectedOrganization, setSelectedOrganization] = React.useState(null); + const dispatch = useAppDispatch(); + const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => { + try { + const response = await axios.get('/org-for-auth'); + setOrganizations(response.data); + return response.data; + } catch (error) { + console.error(error.response); + throw error; + } + }); + React.useEffect(() => { + dispatch(fetchOrganizations()); + }, [dispatch]); + const options = organizations?.map((org) => ({ + value: org.id, + label: org.name, + })); + + const handleSubmit = async (value) => { + setLoading(true); + try { + const formData = { ...value, organizationId: selectedOrganization.value }; + + const { data: response } = await axios.post('/auth/signup', formData); + await router.push('/login'); + setLoading(false); + notify('success', 'Please check your email for verification link'); + } catch (error) { + setLoading(false); + console.log('error: ', error); + notify('error', 'Something was wrong. Try again'); + } + }; + + return ( + <> + + {getPageTitle('Login')} + + + + + handleSubmit(values)} + > +
    + + +