diff --git a/.github/workflows/deploy-golang-develop.yml b/.github/workflows/deploy-golang-develop.yml index d9d628b7..94cc9459 100644 --- a/.github/workflows/deploy-golang-develop.yml +++ b/.github/workflows/deploy-golang-develop.yml @@ -20,64 +20,7 @@ defaults: working-directory: occupi-backend jobs: - lint: - name: Lint - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' # Specify the Go version you are using - - - name: Install golangci-lint - run: | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - - - name: Run golangci-lint - run: | - golangci-lint run - - test: - needs: lint - name: Test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' # Specify the Go version you are using - - - name: Decrypt env variables - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 .env.gpg > .env - - - name: Decrypt key file - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 key.pem.gpg > key.pem - - - name: Decrypt cert file - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 cert.pem.gpg > cert.pem - - - name: Run tests - run: | - go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware ./tests/... -coverprofile=coverage.out - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - build-test: - needs: test name: Build runs-on: ubuntu-latest @@ -115,10 +58,14 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Decrypt env variables + - name: Decrypt default variables + run: | + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 configs/config.yaml.gpg > configs/config.yaml + + - name: Decrypt test variables run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 .dev.env.gpg > .dev.env - + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 configs/dev.deployed.yaml.gpg > configs/dev.deployed.yaml + - name: Build and push Docker image uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/deploy-golang-prod.yml b/.github/workflows/deploy-golang-prod.yml index 9cfa7e78..f3a5c685 100644 --- a/.github/workflows/deploy-golang-prod.yml +++ b/.github/workflows/deploy-golang-prod.yml @@ -12,64 +12,7 @@ defaults: working-directory: occupi-backend jobs: - lint: - name: Lint - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' # Specify the Go version you are using - - - name: Install golangci-lint - run: | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - - - name: Run golangci-lint - run: | - golangci-lint run - - test: - needs: lint - name: Test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' # Specify the Go version you are using - - - name: Decrypt env variables - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 .env.gpg > .env - - - name: Decrypt key file - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 key.pem.gpg > key.pem - - - name: Decrypt cert file - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 cert.pem.gpg > cert.pem - - - name: Run tests - run: | - go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware ./tests/... -coverprofile=coverage.out - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - build: - needs: test name: Build runs-on: ubuntu-latest @@ -107,10 +50,14 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Decrypt env variables + - name: Decrypt default variables + run: | + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 configs/config.yaml.gpg > configs/config.yaml + + - name: Decrypt test variables run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 .prod.env.gpg > .prod.env - + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 configs/prod.yaml.gpg > configs/prod.yaml + - name: Build and push Docker image uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/lint-test-build-golang.yml b/.github/workflows/lint-test-build-golang.yml index ec411998..bb04c963 100644 --- a/.github/workflows/lint-test-build-golang.yml +++ b/.github/workflows/lint-test-build-golang.yml @@ -44,27 +44,69 @@ jobs: name: Test runs-on: ubuntu-latest + services: + mongo: + image: mongo:latest + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.adminCommand({ping: 1})'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout code uses: actions/checkout@v4 + - name: Install mongosh + run: | + wget -qO - https://www.mongodb.org/static/pgp/server-5.0.asc | sudo apt-key add - + echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/5.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-5.0.list + sudo apt-get update + sudo apt-get install -y mongodb-mongosh + + + - name: Wait for MongoDB to be ready + run: | + for i in {1..30}; do + if mongosh --eval 'db.adminCommand({ping: 1})' ${{ secrets.MONGO_DB_TEST_URL }}; then + echo "MongoDB is up" + break + fi + echo "Waiting for MongoDB to be ready..." + sleep 2 + done + + - name: Create MongoDB User + env: + MONGO_INITDB_ROOT_USERNAME: ${{ secrets.MONGO_DB_TEST_USERNAME }} + MONGO_INITDB_ROOT_PASSWORD: ${{ secrets.MONGO_DB_TEST_PASSWORD }} + MONGO_INITDB_DATABASE: ${{ secrets.MONGO_DB_TEST_DB }} + run: | + mongosh ${{ secrets.MONGO_DB_TEST_URL }}/admin --eval " + db.createUser({ + user: '${MONGO_INITDB_ROOT_USERNAME}', + pwd: '${MONGO_INITDB_ROOT_PASSWORD}', + roles: [ + { role: 'readWrite', db: '${MONGO_INITDB_DATABASE}' } + ] + }); + " + - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.21' # Specify the Go version you are using - - name: Decrypt env variables - run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 .env.gpg > .env - - - name: Decrypt key file + - name: Decrypt default variables run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 key.pem.gpg > key.pem + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 configs/config.yaml.gpg > configs/config.yaml - - name: Decrypt cert file + - name: Decrypt test variables run: | - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 cert.pem.gpg > cert.pem - + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --quiet --batch --yes --decrypt --passphrase-fd 0 configs/test.yaml.gpg > configs/test.yaml + - name: Run tests run: | go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware ./tests/... -coverprofile=coverage.out diff --git a/.gitignore b/.gitignore index b512c09d..8f7e2474 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -node_modules \ No newline at end of file +/benchmarks \ No newline at end of file diff --git a/README.md b/README.md index d7271613..b534f272 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ ![Codecov](https://img.shields.io/codecov/c/github/COS301-SE-2024/occupi?style=flat-square) ![GitHub License](https://img.shields.io/github/license/COS301-SE-2024/occupi?style=flat-square) ![GitHub repo size](https://img.shields.io/github/repo-size/COS301-SE-2024/occupi?style=flat-square) -![Docs Website](https://img.shields.io/website?url=https%3A%2F%2Fdocs.occupi.tech&style=flat-square) +![Uptime Robot ratio (7 days)](https://img.shields.io/uptimerobot/ratio/7/m797164574-0d7a208e3fca09e737a9619f?style=flat-square) +![Uptime Robot ratio (7 days)](https://img.shields.io/uptimerobot/ratio/7/m797164576-1e9cec32af1ac1e298136819?style=flat-square) +![Uptime Robot ratio (7 days)](https://img.shields.io/uptimerobot/ratio/7/m797164567-64429e97f6d650162e9354dd?style=flat-square) ![GitHub Repo stars](https://img.shields.io/github/stars/COS301-SE-2024/occupi?style=flat-square) [![codecov](https://codecov.io/gh/COS301-SE-2024/occupi/graph/badge.svg?token=71QPCD9NNP)](https://codecov.io/gh/COS301-SE-2024/occupi) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/COS301-SE-2024/occupi?style=flat-square) diff --git a/documentation/occupi-docs/pages/index.mdx b/documentation/occupi-docs/pages/index.mdx index 152aeef5..a758e3ca 100644 --- a/documentation/occupi-docs/pages/index.mdx +++ b/documentation/occupi-docs/pages/index.mdx @@ -21,21 +21,131 @@ To get started, please feel free to explore the top navbar or the sidebar and na ![GitHub repo size](https://img.shields.io/github/repo-size/COS301-SE-2024/occupi?style=flat-square) - ![Docs website](https://img.shields.io/website?url=https%3A%2F%2Fcos301-se-2024.github.io%2Foccupi%2F&style=flat-square) + ![Uptime Robot ratio (7 days)](https://img.shields.io/uptimerobot/ratio/7/m797164574-0d7a208e3fca09e737a9619f?style=flat-square) + + ![Uptime Robot ratio (7 days)](https://img.shields.io/uptimerobot/ratio/7/m797164576-1e9cec32af1ac1e298136819?style=flat-square) + + ![Uptime Robot ratio (7 days)](https://img.shields.io/uptimerobot/ratio/7/m797164567-64429e97f6d650162e9354dd?style=flat-square) ![GitHub Repo stars](https://img.shields.io/github/stars/COS301-SE-2024/occupi?style=flat-square) - ![Deploy Landing page](https://github.com/COS301-SE-2024/occupi/actions/workflows/deploy-landing-page.yml/badge.svg) + [![codecov](https://codecov.io/gh/COS301-SE-2024/occupi/graph/badge.svg?token=71QPCD9NNP)](https://codecov.io/gh/COS301-SE-2024/occupi) - ![Deploy Docs site to Pages](https://github.com/COS301-SE-2024/occupi/actions/workflows/deploy-docs.yml/badge.svg) + ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/COS301-SE-2024/occupi?style=flat-square)
-## Additional info +
+
+ + ![Deploy Docs](https://github.com/COS301-SE-2024/occupi/actions/workflows/deploy-docs.yml/badge.svg) + + ![Deploy Landing page](https://github.com/COS301-SE-2024/occupi/actions/workflows/deploy-landing-page.yml/badge.svg) + + ![Lint Test Occupi-mobile](https://github.com/COS301-SE-2024/occupi/actions/workflows/lint-test-mobile.yml/badge.svg) -//stub + ![Lint Test and Build Golang](https://github.com/COS301-SE-2024/occupi/actions/workflows/lint-test-build-golang.yml/badge.svg) + + ![Deploy api](https://github.com/COS301-SE-2024/occupi/actions/workflows/deploy-golang-develop.yml/badge.svg) + + ![Lint Test and Build Occupi-Web](https://github.com/COS301-SE-2024/occupi/actions/workflows/lint-test-build-web.yml/badge.svg) + +
+ +
+ +# Meet the team behind occupi + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

Kamo

+

Project Manager, Fullstack Engineer


+

Kamo is a skilled software developer with a strong focus on database engineering and architecture. He is proficient in both front-end and back-end development, making him a well-rounded contributor to any team.

+

+ + + + + + +
+ + +

Carey

+

Frontend Engineer, UI Engineer, Business Analyst, Testing Engineer


+

Carey has a solid understanding of business principles, business analysis and systems analysis with a strong support for technology-centered strategies that promote interactive learning and easier access to resources for independent study.

+

+ + + + + + +
+ + +

Michael

+

Infrastructure management, Designer, Backend Engineer, DevOps, Frontend Engineer


+

Michael is a computer science enthusiast with a keen interest in creating engaging and aesthetically pleasing CS-related projects. His strengths lie in UI design, FullStack development, DevOps, and Systems Design, making him a versatile contributor to any team.

+

+ + + + + + +
+ + +

Tinashe

+

Frontend Engineer, UI engineer, Business Analyst, Testing Engineer


+

Tinashe is a BSc Computer Science Major and Multimedia Minor student with a keen interest in UI/UX design and Human Computer Interaction. She loves learning and tackling new challenges. Her resilience makes her an asset in any team.

+

+ + + + + + +
+ + +

Reta

+

Fullstack Engineer


+

Rethakgetse has a comprehensive skill set that spans both frontend and backend development, making him a well-rounded software developer. He is driven by a passion for problem-solving and a desire to make a tangible impact. He embraces the limitless possibilities offered by technology and is committed to leveraging his skills to develop transformative applications that address pressing industry needs, enhance operational efficiency, and improve customer satisfaction.

+

+ + + + + + +
## Sponsors and Stakeholders

@@ -64,4 +174,4 @@ To get started, please feel free to explore the top navbar or the sidebar and na
- \ No newline at end of file + diff --git a/documentation/occupi-docs/theme.config.jsx b/documentation/occupi-docs/theme.config.jsx index 6d377946..2d5b736f 100644 --- a/documentation/occupi-docs/theme.config.jsx +++ b/documentation/occupi-docs/theme.config.jsx @@ -6,6 +6,7 @@ export default { + Occupi ), logo: ( @@ -26,5 +27,15 @@ export default { link: 'https://discord.com', }, docsRepositoryBase: 'https://github.com/COS301-SE-2024/occupi/documentation/occupi-docs', - footerText: `MIT ${new Date().getFullYear()} © Occupi.`, + footer: { + text: ( + + MIT {new Date().getFullYear()} ©{' '} + + occupi + + . + + ) + } } \ No newline at end of file diff --git a/frontend/occupi-mobile4/.gitignore b/frontend/occupi-mobile4/.gitignore index 6623142a..d3e535a6 100644 --- a/frontend/occupi-mobile4/.gitignore +++ b/frontend/occupi-mobile4/.gitignore @@ -10,6 +10,9 @@ npm-debug.* *.orig.* web-build/ +eas.json +app.json + # macOS .DS_Store diff --git a/frontend/occupi-mobile4/app.json b/frontend/occupi-mobile4/app.json index 891d2e0d..2b11cd34 100644 --- a/frontend/occupi-mobile4/app.json +++ b/frontend/occupi-mobile4/app.json @@ -19,11 +19,15 @@ } }, "android": { - "permissions": ["USE_BIOMETRIC", "USE_FINGERPRINT"], + "permissions": [ + "USE_BIOMETRIC", + "USE_FINGERPRINT" + ], "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#ffffff" - } + }, + "package": "com.y2kodedev.occupi" }, "web": { "bundler": "metro", @@ -35,6 +39,14 @@ ], "experiments": { "typedRoutes": true + }, + "extra": { + "router": { + "origin": false + }, + "eas": { + "projectId": "0127a6f2-e29a-4a67-a134-be8c4daed566" + } } } } diff --git a/frontend/occupi-mobile4/eas.json b/frontend/occupi-mobile4/eas.json new file mode 100644 index 00000000..bea8c5b7 --- /dev/null +++ b/frontend/occupi-mobile4/eas.json @@ -0,0 +1,18 @@ +{ + "cli": { + "version": ">= 10.0.2" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": {} + }, + "submit": { + "production": {} + } +} diff --git a/frontend/occupi-mobile4/package-lock.json b/frontend/occupi-mobile4/package-lock.json index 04a45e25..a40cd5e0 100644 --- a/frontend/occupi-mobile4/package-lock.json +++ b/frontend/occupi-mobile4/package-lock.json @@ -55,6 +55,7 @@ "react-native-responsive-screen": "^1.4.2", "react-native-safe-area-context": "^4.10.1", "react-native-screens": "3.31.1", + "react-native-skeleton-content": "^1.0.13", "react-native-svg": "^15.3.0", "react-native-web": "~0.19.10", "zod": "^3.23.8" @@ -19650,6 +19651,24 @@ "react-native": "*" } }, + "node_modules/react-native-skeleton-content": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/react-native-skeleton-content/-/react-native-skeleton-content-1.0.13.tgz", + "integrity": "sha512-WgwDnBEdgFNh63BkscV3FRvDKHbIFYxu0MiJkuySHYKIiexYu75tnNBe8ONkM0hQEJ9pO6pg5+c8uuUmY/uoXA==", + "dependencies": { + "expo-linear-gradient": "^8.0.0", + "lodash": "^4.17.14" + } + }, + "node_modules/react-native-skeleton-content/node_modules/expo-linear-gradient": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-8.4.0.tgz", + "integrity": "sha512-f9JOXaIl0MR8RBYRIud5UAsEi52oz81XhQs73VUpujemHjOyHmrZa6dqwf399YOwI/WBwbpcINcUIw/mCYE1mA==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-svg": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.3.0.tgz", diff --git a/frontend/occupi-mobile4/package.json b/frontend/occupi-mobile4/package.json index 277e1696..9a6ca019 100644 --- a/frontend/occupi-mobile4/package.json +++ b/frontend/occupi-mobile4/package.json @@ -62,6 +62,7 @@ "react-native-responsive-screen": "^1.4.2", "react-native-safe-area-context": "^4.10.1", "react-native-screens": "3.31.1", + "react-native-skeleton-content": "^1.0.13", "react-native-svg": "^15.3.0", "react-native-web": "~0.19.10", "zod": "^3.23.8" diff --git a/frontend/occupi-mobile4/screens/Booking/BookRoom.tsx b/frontend/occupi-mobile4/screens/Booking/BookRoom.tsx index 4d11382f..4deef071 100644 --- a/frontend/occupi-mobile4/screens/Booking/BookRoom.tsx +++ b/frontend/occupi-mobile4/screens/Booking/BookRoom.tsx @@ -102,90 +102,93 @@ const BookRoom = () => { const roomPairs = groupDataInPairs(roomData); return ( - - - - Book - - - Quick search for an office - - - Rooms - - {layout === "row" ? ( - - - - ) : ( - - - - )} - + <> + + + + Book + + + Quick search for an office + + + Rooms + + {layout === "row" ? ( + + + + ) : ( + + + + )} + + - - {layout === "grid" ? ( - - {roomPairs.map((pair, index) => ( - - {pair.map((room, idx) => ( - router.push({ pathname: '/office-details', params: { roomData: JSON.stringify(room) } })}> - - - {room.roomName} - - - {room.description.length > 20 ? `${room.description.substring(0, 40)}...` : room.description} - - Floor: {room.floorNo === 0 ? 'G' : room.floorNo} - - - {room.minOccupancy} - {room.maxOccupancy} + {layout === "grid" ? ( + + {roomPairs.map((pair, index) => ( + + {pair.map((room, idx) => ( + router.push({ pathname: '/office-details', params: { roomData: JSON.stringify(room) } })}> + + + {room.roomName} + + + {room.description.length > 20 ? `${room.description.substring(0, 40)}...` : room.description} + + Floor: {room.floorNo === 0 ? 'G' : room.floorNo} + + + {room.minOccupancy} - {room.maxOccupancy} + + + + + Available: now + + - - - Available: now - - + + ))} + + ))} + + ) : ( + + {roomData.map((room, idx) => ( + router.push({ pathname: '/office-details', params: { roomData: JSON.stringify(room) } })}> + + + {room.roomName} + + + {room.description.length > 20 ? `${room.description.substring(0, 68)}...` : room.description} + + Floor: {room.floorNo === 0 ? "G" : room.floorNo} + + + {room.minOccupancy} - {room.maxOccupancy} - - ))} - - ))} - - ) : ( - - {roomData.map((room, idx) => ( - router.push({ pathname: '/office-details', params: { roomData: JSON.stringify(room) } })}> - - - {room.roomName} - - - {room.description.length > 20 ? `${room.description.substring(0, 68)}...` : room.description} - - Floor: {room.floorNo === 0 ? "G" : room.floorNo} - - - {room.minOccupancy} - {room.maxOccupancy} + + + Available: now + + - - - Available: now - - - - - - ))} - - )} - - + + ))} + + )} + + + + ); }; diff --git a/frontend/occupi-mobile4/screens/Booking/ViewBookingDetails.tsx b/frontend/occupi-mobile4/screens/Booking/ViewBookingDetails.tsx index e013321d..f918f6b4 100644 --- a/frontend/occupi-mobile4/screens/Booking/ViewBookingDetails.tsx +++ b/frontend/occupi-mobile4/screens/Booking/ViewBookingDetails.tsx @@ -17,7 +17,7 @@ import { EvilIcons, MaterialIcons } from '@expo/vector-icons'; -import { useColorScheme, StyleSheet, TouchableOpacity } from 'react-native'; +import { useColorScheme, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { widthPercentageToDP as wp } from 'react-native-responsive-screen'; @@ -33,6 +33,7 @@ const ViewBookingDetails = (bookingId, roomName) => { const room = JSON.parse(roomData); const router = useRouter(); const [checkedIn, setCheckedIn] = useState(room.checkedIn); + const [isLoading, setIsLoading] = useState(false); const toast = useToast(); console.log("HERE:" + roomData); console.log(checkedIn); @@ -43,6 +44,7 @@ const ViewBookingDetails = (bookingId, roomName) => { "creator": room.creator, "roomId": room.roomId }; + setIsLoading(true); console.log(body); try { const response = await fetch('https://dev.occupi.tech/api/check-in', { @@ -64,19 +66,21 @@ const ViewBookingDetails = (bookingId, roomName) => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); }, }); + setIsLoading(false); } else { + setIsLoading(false); console.log(data); toast.show({ placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -93,6 +97,7 @@ const ViewBookingDetails = (bookingId, roomName) => { "_id": room._id, "creator": room.creator, }; + setIsLoading(true); console.log(body); try { const response = await fetch('https://dev.occupi.tech/api/cancel-booking', { @@ -113,20 +118,22 @@ const ViewBookingDetails = (bookingId, roomName) => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); }, }); + setIsLoading(false); router.push("/home"); } else { + setIsLoading(false); console.log(data); toast.show({ placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -184,25 +191,46 @@ const ViewBookingDetails = (bookingId, roomName) => { ViewBooking - {!checkedIn ? ( - checkin()}> + {isLoading ? ( + - Check in + ) : ( - checkin()}> + !checkedIn ? ( + checkin()}> + + + Check in + + + ) : ( + checkin()}> + + + Check out + + + ) + )} + + + {!isLoading ? ( + cancelBooking()}> - Check out + Delete Booking + + + ) : ( + + + )} - cancelBooking()}> - - Delete Booking - - + ) diff --git a/frontend/occupi-mobile4/screens/Booking/ViewBookings.tsx b/frontend/occupi-mobile4/screens/Booking/ViewBookings.tsx index bc3a8a4c..7e28223a 100644 --- a/frontend/occupi-mobile4/screens/Booking/ViewBookings.tsx +++ b/frontend/occupi-mobile4/screens/Booking/ViewBookings.tsx @@ -1,5 +1,5 @@ -import { React, useEffect, useState } from 'react'; -import { ScrollView, useColorScheme, TouchableOpacity } from 'react-native'; +import React, { useEffect, useState, useCallback } from 'react'; +import { ScrollView, useColorScheme, TouchableOpacity, RefreshControl, StyleSheet } from 'react-native'; import { Icon, View, Text, Input, InputField, Image, Box, ChevronDownIcon, Toast, ToastTitle, @@ -30,6 +30,7 @@ interface Room { minOccupancy: number; maxOccupancy: number; description: string; + emails: string[]; } const getTimeForSlot = (slot) => { @@ -82,7 +83,7 @@ const getTimeForSlot = (slot) => { return { startTime, endTime }; }; -const slotToTime = (slot : number) => { +const slotToTime = (slot: number) => { const { startTime, endTime } = getTimeForSlot(slot); return `${startTime} - ${endTime}` } @@ -96,7 +97,67 @@ const ViewBookings = () => { // const [selectedSort, setSelectedSort] = useState("newest"); // const [email, setEmail] = useState('kamogelomoeketse@gmail.com'); const router = useRouter(); - + const [refreshing, setRefreshing] = useState(false); + + + const onRefresh = React.useCallback(() => { + const fetchAllRooms = async () => { + console.log("heree"); + try { + const response = await fetch(`https://dev.occupi.tech/api/view-bookings?email=kamogelomoeketse@gmail.com`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const data = await response.json(); + if (response.ok) { + setRoomData(data.data || []); // Ensure data is an array + console.log(data); + // toast.show({ + // placement: 'top', + // render: ({ id }) => { + // return ( + // + // {data.message} + // + // ); + // }, + // }); + } else { + console.log(data); + toast.show({ + placement: 'top', + render: ({ id }) => { + return ( + + {data.error.message} + + ); + }, + }); + } + } catch (error) { + console.error('Error:', error); + toast.show({ + placement: 'top', + render: ({ id }) => { + return ( + + Network Error: {error.message} + + ); + }, + }); + } + }; + setRefreshing(true); + setTimeout(() => { + setRefreshing(false); + fetchAllRooms(); + }, 2000); + }, [toast]); + const toggleLayout = () => { setLayout((prevLayout) => (prevLayout === "row" ? "grid" : "row")); }; @@ -180,12 +241,12 @@ const ViewBookings = () => { const roomPairs = groupDataInPairs(roomData); return ( - + - My bookings + My bookings - + { color={textColor} /> - - - Sort by: - + + + Sort by: + setSelectedSort(value)} items={[ @@ -205,10 +266,9 @@ const ViewBookings = () => { { label: 'Newest', value: 'Newest' }, ]} placeholder={{ label: 'Latest', value: null }} - backgroundColor={cardBackgroundColor} + // backgroundColor={cardBackgroundColor} style={{ inputIOS: { - placeholder: "Latest", fontSize: 16, paddingVertical: 8, borderWidth: 1, @@ -218,26 +278,29 @@ const ViewBookings = () => { color: textColor }, inputAndroid: { + alignItems: "center", + width: 130, + height: 60, + fontSize: 10, + // paddingVertical: 4, borderWidth: 1, borderRadius: 10, borderColor: cardBackgroundColor, - paddingRight: 30, // to ensure the text is never behind the icon + padding: 0, // to ensure the text is never behind the icon color: textColor }, }} - Icon={() => { - return ; - }} + /> {layout === "row" ? ( - + ) : ( - + )} @@ -245,10 +308,16 @@ const ViewBookings = () => { {layout === "grid" ? ( - + + } + > {roomPairs.map((pair, index) => ( { }} > {pair.map((room) => ( - router.push({ pathname: '/viewbookingdetails', params: { roomData: JSON.stringify(room) } })} - style={{ - flex: 1, - borderWidth: 1, - borderColor: cardBackgroundColor, - borderRadius: 12, - backgroundColor: cardBackgroundColor, - marginHorizontal: 4, - width: '45%' - }}> + router.push({ pathname: '/viewbookingdetails', params: { roomData: JSON.stringify(room) } })} + style={{ + flex: 1, + borderWidth: 1, + borderColor: cardBackgroundColor, + borderRadius: 12, + backgroundColor: cardBackgroundColor, + marginHorizontal: 4, + width: '45%' + }}> image { > {room.roomName} - - Attendees: {room.emails.length} + + Attendees: {room.emails.length} - Your booking time: + Your booking time: - + - {new Date().toDateString()} + {new Date().toDateString()} {slotToTime(room.slot)} @@ -302,26 +371,32 @@ const ViewBookings = () => { ))} ) : ( - + + } + > {roomData.map((room) => ( - router.push({ pathname: '/viewbookingdetails', params: { roomData: JSON.stringify(room) } })} - style={{ - flex: 1, - borderWidth: 1, - borderColor: cardBackgroundColor, - borderRadius: 12, - height: 160, - backgroundColor: cardBackgroundColor, - marginVertical: 4, - flexDirection: "row" - - }}> + router.push({ pathname: '/viewbookingdetails', params: { roomData: JSON.stringify(room) } })} + style={{ + flex: 1, + borderWidth: 1, + borderColor: cardBackgroundColor, + borderRadius: 12, + height: 160, + backgroundColor: cardBackgroundColor, + marginVertical: 4, + flexDirection: "row" + + }}> image { }} > {room.roomName} - - Attendees: {room.emails.length} + + Attendees: {room.emails.length} - - Your booking time: - + + Your booking time: + - {new Date().toDateString()} + {new Date().toDateString()} {slotToTime(room.slot)} @@ -358,4 +433,16 @@ const ViewBookings = () => { ); }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + backgroundColor: 'pink', + alignItems: 'center', + justifyContent: 'center', + }, +}); + export default ViewBookings; diff --git a/frontend/occupi-mobile4/screens/Dashboard/Dashboard.tsx b/frontend/occupi-mobile4/screens/Dashboard/Dashboard.tsx index 785f2c47..5a232518 100644 --- a/frontend/occupi-mobile4/screens/Dashboard/Dashboard.tsx +++ b/frontend/occupi-mobile4/screens/Dashboard/Dashboard.tsx @@ -51,7 +51,7 @@ const Dashboard = () => { toast.show({ placement: 'top', render: ({ id }) => ( - + Check in successful. Have a productive day! ), @@ -61,7 +61,7 @@ const Dashboard = () => { toast.show({ placement: 'top', render: ({ id }) => ( - + Travel safe. Have a lovely day further! ), @@ -74,7 +74,7 @@ const Dashboard = () => { const cardBackgroundColor = isDarkMode ? '#2C2C2E' : '#F3F3F3'; return ( - + @@ -92,23 +92,23 @@ const Dashboard = () => { style={{ width: wp('8%'), height: wp('8%'), flexDirection: 'column', tintColor: isDarkMode ? 'white' : 'black' }} /> - + - + {numbers[0]} - - {numbers[0]/10+5}% + + {numbers[0]/10+5}% - + {checkedIn ? ( - ) : ( - )} diff --git a/frontend/occupi-mobile4/screens/Login/OtpVerification.tsx b/frontend/occupi-mobile4/screens/Login/OtpVerification.tsx index 4c9dbe1a..6011eb01 100644 --- a/frontend/occupi-mobile4/screens/Login/OtpVerification.tsx +++ b/frontend/occupi-mobile4/screens/Login/OtpVerification.tsx @@ -58,12 +58,12 @@ const OTPVerification = () => { const onSubmit = async () => { console.log(email); const pin = otp.join(''); - const Count = otp.filter((value) => value !== '').length; - if (Count < 6) { - setValidationError('OTP must be at least 6 characters in length'); - return; - } - setValidationError(null); + // const Count = otp.filter((value) => value !== '').length; + // if (Count < 6) { + // setValidationError('OTP must be at least 6 characters in length'); + // return; + // } + // setValidationError(null); console.log(pin); setLoading(true); try { @@ -86,13 +86,13 @@ const OTPVerification = () => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); }, }); - router.push('/home'); + router.push('/login'); } else { setLoading(false); // console.log(data); @@ -100,7 +100,7 @@ const OTPVerification = () => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -211,7 +211,7 @@ const MainText = (email : string) => { fontSize={wp('5%')} fontWeight="$light" > - {' '+email} + {' '+String(email)} diff --git a/frontend/occupi-mobile4/screens/Login/SignIn.tsx b/frontend/occupi-mobile4/screens/Login/SignIn.tsx index eeab7397..4422e20f 100644 --- a/frontend/occupi-mobile4/screens/Login/SignIn.tsx +++ b/frontend/occupi-mobile4/screens/Login/SignIn.tsx @@ -114,7 +114,7 @@ const SignInForm = () => { placement: 'top', render: ({ id }) => { return ( - + Biometric authentication failed {result.error && {result.error.message}} @@ -128,7 +128,7 @@ const SignInForm = () => { placement: 'top', render: ({ id }) => { return ( - + Biometric authentication error {error.message} @@ -142,7 +142,7 @@ const SignInForm = () => { placement: 'top', render: ({ id }) => { return ( - + Biometric authentication not available ); @@ -180,7 +180,7 @@ const SignInForm = () => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -194,7 +194,7 @@ const SignInForm = () => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -283,7 +283,7 @@ const SignInForm = () => { }, }} render={({ field: { onChange, onBlur, value } }) => ( - + { }, }} render={({ field: { onChange, onBlur, value } }) => ( - + { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -120,7 +120,7 @@ const SignUpForm = () => { placement: 'top', render: ({ id }) => { return ( - + {data.error.message} ); @@ -136,7 +136,7 @@ const SignUpForm = () => { placement: 'bottom right', render: ({ id }) => { return ( - + Passwords do not match ); @@ -217,7 +217,7 @@ const SignUpForm = () => { }, }} render={({ field: { onChange, onBlur, value } }) => ( - + { }, }} render={({ field: { onChange, onBlur, value } }) => ( - + { }, }} render={({ field: { onChange, onBlur, value } }) => ( - + { }, }} render={({ field: { onChange, onBlur, value } }) => ( - + { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -163,7 +163,7 @@ const BookingDetails = () => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -295,6 +295,7 @@ const BookingDetails = () => { style={{ flex: 1, backgroundColor: isDark ? "#000" : "#fff", + paddingTop: 50 }} > { }} > navigation.goBack()}> - router.back()} /> + router.back()} /> {/* */} { {currentStep === 1 && ( - + { > */} - HDMI Room + HDMI Room Fast OLED {roomData.minOccupancy} - {roomData.maxOccupancy} Floor {roomData.floorNo === 0 ? 'G' : roomData.floorNo} - - + + Check in: {startTime} - + Check out: {endTime} - - + + ---------------------------------------------- - + - + - Attendees: + Attendees: {attendees.map((email, idx) => ( @@ -612,7 +613,7 @@ const BookingDetails = () => { {/* */} router.push('/home')}> - + Home diff --git a/frontend/occupi-mobile4/screens/Office/OfficeDetails.tsx b/frontend/occupi-mobile4/screens/Office/OfficeDetails.tsx index cc7c231b..d9b34feb 100644 --- a/frontend/occupi-mobile4/screens/Office/OfficeDetails.tsx +++ b/frontend/occupi-mobile4/screens/Office/OfficeDetails.tsx @@ -139,11 +139,10 @@ const OfficeDetails = () => { return ( <> {/* Top Section */} - - navigation.goBack()} /> - - {roomData2.roomName} - + + navigation.goBack()} /> + {roomData2.roomName} + @@ -165,7 +164,7 @@ const OfficeDetails = () => { slide3 */} - + { })} > {pages.map((page, index) => ( - + slide1 @@ -194,7 +193,7 @@ const OfficeDetails = () => { - {roomData2.roomName} + {roomData2.roomName} Fast OLED @@ -230,8 +229,8 @@ const OfficeDetails = () => { {/* Description */} - Description - + Description + {roomData2.description} @@ -280,7 +279,7 @@ const OfficeDetails = () => { /> {/* Check Availability Button */} - router.push({ pathname: '/booking-details', params: { email: userEmail, slot: slot, roomId: roomData2.roomId, floorNo: roomData2.floorNo, roomData: roomData } })}> + router.push({ pathname: '/booking-details', params: { email: userEmail, slot: slot, roomId: roomData2.roomId, floorNo: roomData2.floorNo, roomData: roomData } })}> { onConfirm={handleConfirm} onCancel={hideDatePicker} /> - Check availability + Check availability diff --git a/frontend/occupi-mobile4/screens/Profile/Profile.tsx b/frontend/occupi-mobile4/screens/Profile/Profile.tsx index 36f7ec75..2916da55 100644 --- a/frontend/occupi-mobile4/screens/Profile/Profile.tsx +++ b/frontend/occupi-mobile4/screens/Profile/Profile.tsx @@ -86,7 +86,7 @@ const Profile = () => { router.push('/settings')} /> @@ -131,7 +131,7 @@ const Profile = () => { { { { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -63,7 +63,7 @@ const Settings = () => { placement: 'top', render: ({ id }) => { return ( - + {data.message} ); @@ -125,7 +125,7 @@ const Settings = () => { {item.description} - {item.accessoryRight ? item.accessoryRight() : } + {item.accessoryRight ? item.accessoryRight() : } ); @@ -142,7 +142,7 @@ const Settings = () => { - + {name} {/* handleNavigate('EditProfileScreen')} /> */} @@ -150,7 +150,7 @@ const Settings = () => { - + {data.map((item, index) => ( {renderListItem({ item })} diff --git a/occupi-backend/.dev.env.gpg b/occupi-backend/.dev.env.gpg deleted file mode 100644 index 2ceb8bc6..00000000 --- a/occupi-backend/.dev.env.gpg +++ /dev/null @@ -1,2 +0,0 @@ -  Uv:Ӡе]YEq ~LE>Z&P_$F/b?~ւE* +QNq@M1hZp"_u8p4 pAt H $ 0 { + viper.AddConfigPath(configpath[0]) + } else { + viper.AddConfigPath("./configs") + } + if err := viper.ReadInConfig(); err != nil { + log.Fatalf("Error reading config file: %s", err) + } -// gets the port to start the server on as defined in the .env file + // Merge environment-specific config + viper.SetConfigName(*envtype) + if err := viper.MergeInConfig(); err != nil { + log.Fatalf("Error merging config file: %s", err) + } +} + +// gets the port to start the server on as defined in the config.yaml file func GetPort() string { - port := os.Getenv("PORT") + port := viper.GetString(Port) if port == "" { port = "PORT" } return port } -// gets the mongodb username as defined in the .env file +// gets the mongodb username as defined in the config.yaml file func GetMongoDBUsername() string { - username := os.Getenv("MONGODB_USERNAME") + username := viper.GetString(MongodbUsername) if username == "" { username = "MONGODB_USERNAME" } return username } -// gets the mongodb password as defined in the .env file +// gets the mongodb password as defined in the config.yaml file func GetMongoDBPassword() string { - password := os.Getenv("MONGODB_PASSWORD") + password := viper.GetString(MongodbPassword) if password == "" { password = "MONGODB_PASSWORD" } return password } -// gets the mongodb cluster uri as defined in the .env file +// gets the mongodb cluster uri as defined in the config.yaml file func GetMongoDBCLUSTERURI() string { - uri := os.Getenv("MONGODB_CLUSTERURI") + uri := viper.GetString(MongodbClusteruri) if uri == "" { uri = "MONGODB_CLUSTERURI" } return uri } -// gets the mongodb name as defined in the .env file +// gets the mongodb name as defined in the config.yaml file func GetMongoDBName() string { - name := os.Getenv("MONGODB_DBNAME") + name := viper.GetString(MongodbDbname) if name == "" { name = "MONGODB_DBNAME" } return name } -// gets the mongodb start uri as defined in the .env file +// gets the mongodb start uri as defined in the config.yaml file func GetMongoDBStartURI() string { - startURI := os.Getenv("MONGODB_START_URI") + startURI := viper.GetString(MongodbStartURI) if startURI == "" { startURI = "MONGODB_START_URI" } return startURI } +// gets the log file name as defined in the config.yaml file func GetLogFileName() string { - logFileName := os.Getenv("LOG_FILE_NAME") + logFileName := viper.GetString(LogFileName) if logFileName == "" { logFileName = "LOG_FILE_NAME" } return logFileName } -// gets the system email as defined in the .env file +// gets the system email as defined in the config.yaml file func GetSystemEmail() string { - email := os.Getenv("SYSTEM_EMAIL") + email := viper.GetString(SystemEmail) if email == "" { email = "" } @@ -82,7 +125,7 @@ func GetSystemEmail() string { // GetSMTPPort retrieves the SMTP port from the environment and converts it to an integer. // If the environment variable is not set, it returns the default port 587. func GetSMTPPort() int { - port := os.Getenv("SMTP_PORT") + port := viper.GetString(SMTPPort) if port == "" { return 587 } @@ -95,54 +138,54 @@ func GetSMTPPort() int { return portInt } -// gets the smtp password as defined in the .env file +// gets the smtp password as defined in the config.yaml file func GetSMTPPassword() string { - password := os.Getenv("SMTP_PASSWORD") + password := viper.GetString(SMTPPassword) if password == "" { password = "" } return password } -// gets the smtp host as defined in the .env file +// gets the smtp host as defined in the config.yaml file func GetSMTPHost() string { - host := os.Getenv("SMTP_HOST") + host := viper.GetString(SMTPHost) if host == "" { host = "smtp.gmail.com" } return host } -// gets the certificate file name as defined in the .env file +// gets the certificate file name as defined in the config.yaml file func GetCertFileName() string { - certFileName := os.Getenv("CERTIFICATE_FILE_PATH") + certFileName := viper.GetString(CertificateFilePath) if certFileName == "" { certFileName = "CERTIFICATE_FILE_PATH" } return certFileName } -// gets the key file name as defined in the .env file +// gets the key file name as defined in the config.yaml file func GetKeyFileName() string { - keyFileName := os.Getenv("KEY_FILE_PATH") + keyFileName := viper.GetString(KeyFilePath) if keyFileName == "" { keyFileName = "KEY_FILE_PATH" } return keyFileName } -// gets gins run mode as defined in the .env file +// gets gins run mode as defined in the config.yaml file func GetGinRunMode() string { - ginRunMode := os.Getenv("GIN_RUN_MODE") + ginRunMode := viper.GetString(GinRunMode) if ginRunMode == "" { ginRunMode = "debug" } return ginRunMode } -// gets list of trusted proxies as defined in the .env file +// gets list of trusted proxies as defined in the config.yaml file func GetTrustedProxies() []string { - trustedProxies := os.Getenv("TRUSTED_PROXIES") + trustedProxies := viper.GetString(TrustedProxies) if trustedProxies != "" { proxyList := strings.Split(trustedProxies, ",") return proxyList @@ -150,27 +193,39 @@ func GetTrustedProxies() []string { return []string{""} } +// gets the JWT secret as defined in the config.yaml file func GetJWTSecret() string { - secret := os.Getenv("JWT_SECRET") + secret := viper.GetString(JwtSecret) if secret == "" { secret = "JWT_SECRET" } return secret } +// gets the session secret as defined in the config.yaml file func GetSessionSecret() string { - secret := os.Getenv("SESSION_SECRET") + secret := viper.GetString(SessionSecret) if secret == "" { secret = "SESSION_SECRET" } return secret } +// gets the list of occupi domains as defined in the config.yaml file func GetOccupiDomains() []string { - domains := os.Getenv("OCCUPI_DOMAINS") + domains := viper.GetString(OccupiDomains) if domains != "" { domainList := strings.Split(domains, ",") return domainList } return []string{""} } + +// gets the environment type as defined in the config.yaml file +func GetEnv() string { + env := viper.GetString(Env) + if env == "" { + env = "ENV" + } + return env +} diff --git a/occupi-backend/configs/config.yaml.gpg b/occupi-backend/configs/config.yaml.gpg new file mode 100644 index 00000000..92257cc4 --- /dev/null +++ b/occupi-backend/configs/config.yaml.gpg @@ -0,0 +1 @@ +  G/#=j\>O%"ƫT p[!\ZB>cUkukoP)Af{7zO]7.%"fAg^RF-iA*` go run -v cmd/occupi-backend/main.go" - echo " run prod -> go run cmd/occupi-backend/main.go" - echo " build dev -> go build -v cmd/occupi-backend/main.go" - echo " build prod -> go build cmd/occupi-backend/main.go" - echo " docker build -> docker-compose build" - echo " docker up -> docker-compose up" + echo " run dev -> go run -v cmd/occupi-backend/main.go -env=dev.localhost" + echo " run prod -> go run cmd/occupi-backend/main.go -env=dev.localhost" + echo " build dev -> go build -v cmd/occupi-backend/main.go -env=dev.localhost" + echo " build prod -> go build cmd/occupi-backend/main.go -env=dev.localhost" echo " test -> go test ./tests/..." echo " test codecov -> go test ./tests/... -race -coverprofile=coverage.out -covermode=atomic" echo " lint -> golangci-lint run" @@ -17,17 +15,13 @@ print_help() { } if [ "$1" = "run" ] && [ "$2" = "dev" ]; then - go run -v cmd/occupi-backend/main.go + go run -v cmd/occupi-backend/main.go -env=dev.localhost elif [ "$1" = "run" ] && [ "$2" = "prod" ]; then - go run cmd/occupi-backend/main.go + go run cmd/occupi-backend/main.go -env=dev.localhost elif [ "$1" = "build" ] && [ "$2" = "dev" ]; then - go build -v cmd/occupi-backend/main.go + go build -v cmd/occupi-backend/main.go -env=dev.localhost elif [ "$1" = "build" ] && [ "$2" = "prod" ]; then - go build cmd/occupi-backend/main.go -elif [ "$1" = "docker" ] && [ "$2" = "build" ]; then - docker-compose build -elif [ "$1" = "docker" ] && [ "$2" = "up" ]; then - docker-compose up + go build cmd/occupi-backend/main.go -env=dev.localhost elif [ "$1" = "test" ]; then go test -v ./tests/... elif [ "$1" = "test" ] && [ "$2" = "codecov" ]; then diff --git a/occupi-backend/pkg/constants/constants.go b/occupi-backend/pkg/constants/constants.go index 3f2d0a6a..a3241054 100644 --- a/occupi-backend/pkg/constants/constants.go +++ b/occupi-backend/pkg/constants/constants.go @@ -1,10 +1,13 @@ package constants -const InvalidRequestPayloadCode = "INVALID_REQUEST_PAYLOAD" -const BadRequestCode = "BAD_REQUEST" -const InvalidAuthCode = "INVALID_AUTH" -const IncompleteAuthCode = "INCOMPLETE_AUTH" -const InternalServerErrorCode = "INTERNAL_SERVER_ERROR" -const UnAuthorizedCode = "UNAUTHORIZED" -const Admin = "admin" -const Basic = "basic" +const ( + InvalidRequestPayloadCode = "INVALID_REQUEST_PAYLOAD" + BadRequestCode = "BAD_REQUEST" + InvalidAuthCode = "INVALID_AUTH" + IncompleteAuthCode = "INCOMPLETE_AUTH" + InternalServerErrorCode = "INTERNAL_SERVER_ERROR" + UnAuthorizedCode = "UNAUTHORIZED" + Admin = "admin" + Basic = "basic" + AdminDBAccessOption = "authSource=admin" +) diff --git a/occupi-backend/pkg/database/database.go b/occupi-backend/pkg/database/database.go index 24b38db3..3c4b826e 100644 --- a/occupi-backend/pkg/database/database.go +++ b/occupi-backend/pkg/database/database.go @@ -20,7 +20,7 @@ import ( ) // attempts to and establishes a connection with the remote mongodb database -func ConnectToDatabase() *mongo.Client { +func ConnectToDatabase(args ...string) *mongo.Client { // MongoDB connection parameters username := configs.GetMongoDBUsername() password := configs.GetMongoDBPassword() @@ -32,7 +32,12 @@ func ConnectToDatabase() *mongo.Client { escapedPassword := url.QueryEscape(password) // Construct the connection URI - uri := fmt.Sprintf("%s://%s:%s@%s/%s", mongoDBStartURI, username, escapedPassword, clusterURI, dbName) + var uri string + if len(args) > 0 { + uri = fmt.Sprintf("%s://%s:%s@%s/%s?%s", mongoDBStartURI, username, escapedPassword, clusterURI, dbName, args[0]) + } else { + uri = fmt.Sprintf("%s://%s:%s@%s/%s", mongoDBStartURI, username, escapedPassword, clusterURI, dbName) + } // Set client options clientOptions := options.Client().ApplyURI(uri) @@ -40,13 +45,13 @@ func ConnectToDatabase() *mongo.Client { // Connect to MongoDB client, err := mongo.Connect(context.TODO(), clientOptions) if err != nil { - logrus.Error(err) + logrus.Fatal(err) } // Check the connection err = client.Ping(context.TODO(), nil) if err != nil { - logrus.Error(err) + logrus.Fatal(err) } logrus.Info("Connected to MongoDB!") @@ -130,7 +135,6 @@ func ConfirmCheckIn(ctx *gin.Context, db *mongo.Client, checkIn models.CheckIn) // Find the booking by bookingId, roomId, and creator filter := bson.M{ "_id": checkIn.BookingID, - "roomId": checkIn.RoomID, "creator": checkIn.Creator, } @@ -365,8 +369,8 @@ func ConfirmCancellation(ctx *gin.Context, db *mongo.Client, id string, email st } // Gets all rooms available for booking -func GetAllRooms(ctx *gin.Context, db *mongo.Client, floorNo int) ([]models.Room, error) { - collection := db.Database("Occupi").Collection("Rooms") +func GetAllRooms(ctx *gin.Context, db *mongo.Client, floorNo string) ([]models.Room, error) { + collection := db.Database("Occupi").Collection("RoomsV2") var cursor *mongo.Cursor var err error @@ -375,17 +379,10 @@ func GetAllRooms(ctx *gin.Context, db *mongo.Client, floorNo int) ([]models.Room // findOptions.SetLimit(10) // Limit the results to 10 // findOptions.SetSkip(int64(10)) // Skip the specified number of documents for pagination - if floorNo == 0 { - // Find all rooms - filter := bson.M{"floorNo": 0} - // cursor, err = collection.Find(context.TODO(), filter, findOptions) - cursor, err = collection.Find(context.TODO(), filter) - } else { - // Find all rooms on the specified floor - filter := bson.M{"floorNo": floorNo} - // cursor, err = collection.Find(context.TODO(), filter, findOptions) - cursor, err = collection.Find(context.TODO(), filter) - } + // Find all rooms on the specified floor + filter := bson.M{"floorNo": floorNo} + // cursor, err = collection.Find(context.TODO(), filter, findOptions) + cursor, err = collection.Find(context.TODO(), filter) if err != nil { logrus.Error(err) diff --git a/occupi-backend/pkg/database/seed.go b/occupi-backend/pkg/database/seed.go new file mode 100644 index 00000000..910fa8e9 --- /dev/null +++ b/occupi-backend/pkg/database/seed.go @@ -0,0 +1,86 @@ +package database + +import ( + "context" + "log" + "os" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + + "github.com/COS301-SE-2024/occupi/occupi-backend/configs" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/models" +) + +type MockDatabase struct { + OTPS []models.OTP `json:"otps"` + Bookings []models.Booking `json:"bookings"` + Rooms []models.Room `json:"rooms"` + Users []models.User `json:"users"` +} + +func SeedMockDatabase(mockdatafilepath string) { + // connect to the database + db := ConnectToDatabase(constants.AdminDBAccessOption) + + // Read the JSON file + data, err := os.ReadFile(mockdatafilepath) + if err != nil { + log.Fatalf("Failed to read JSON file: %v", err) + } + + // Parse the JSON file + var mockDatabase MockDatabase + if err := bson.UnmarshalExtJSON(data, true, &mockDatabase); err != nil { + log.Fatalf("Failed to unmarshal JSON data: %v", err) + } + + // Insert data into each collection + otpsDocuments := make([]interface{}, 0, len(mockDatabase.OTPS)) + for _, otps := range mockDatabase.OTPS { + otpsDocuments = append(otpsDocuments, otps) + } + insertData(db.Database(configs.GetMongoDBName()).Collection("OTPS"), otpsDocuments) + + BookingsDocuments := make([]interface{}, 0, len(mockDatabase.Bookings)) + for _, roomBooking := range mockDatabase.Bookings { + BookingsDocuments = append(BookingsDocuments, roomBooking) + } + insertData(db.Database(configs.GetMongoDBName()).Collection("RoomBooking"), BookingsDocuments) + + roomsDocuments := make([]interface{}, 0, len(mockDatabase.Rooms)) + for _, rooms := range mockDatabase.Rooms { + roomsDocuments = append(roomsDocuments, rooms) + } + insertData(db.Database(configs.GetMongoDBName()).Collection("Rooms"), roomsDocuments) + + usersDocuments := make([]interface{}, 0, len(mockDatabase.Users)) + for _, users := range mockDatabase.Users { + usersDocuments = append(usersDocuments, users) + } + insertData(db.Database(configs.GetMongoDBName()).Collection("Users"), usersDocuments) + + log.Println("Successfully seeded test data into MongoDB") +} + +// Function to insert data if the collection is empty +func insertData(collection *mongo.Collection, documents []interface{}) { + count, err := collection.CountDocuments(context.Background(), bson.D{}) + if err != nil { + log.Fatalf("Failed to count documents: %v", err) + } + + switch { + case count == 0 && len(documents) > 0: + _, err := collection.InsertMany(context.Background(), documents) + if err != nil { + log.Fatalf("Failed to insert documents into %s collection: %v", collection.Name(), err) + } + log.Printf("Successfully seeded data into %s collection\n", collection.Name()) + case len(documents) == 0: + log.Printf("No documents to insert into %s skipping seeding\n", collection.Name()) + default: + log.Printf("Collection %s already has %d documents, skipping seeding\n", collection.Name(), count) + } +} diff --git a/occupi-backend/pkg/handlers/api_handlers.go b/occupi-backend/pkg/handlers/api_handlers.go index 1c4c9214..0903f0a4 100644 --- a/occupi-backend/pkg/handlers/api_handlers.go +++ b/occupi-backend/pkg/handlers/api_handlers.go @@ -1,13 +1,16 @@ package handlers import ( + "encoding/json" "errors" "io" "net/http" + "reflect" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/models" + "github.com/go-playground/validator/v10" "go.mongodb.org/mongo-driver/bson/primitive" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/mail" @@ -51,47 +54,41 @@ func FetchResourceAuth(ctx *gin.Context, appsession *models.AppSession) { // BookRoom handles booking a room and sends a confirmation email func BookRoom(ctx *gin.Context, appsession *models.AppSession) { - // consider structuring api responses to match that as outlined in our coding standards documentation - //link: https://cos301-se-2024.github.io/occupi/coding-standards/go-coding-standards#response-and-error-handling + var bookingRequest map[string]interface{} + if err := ctx.ShouldBindJSON(&bookingRequest); err != nil { + HandleValidationErrors(ctx, err) + return + } + + // Validate JSON + validatedData, err := utils.ValidateJSON(bookingRequest, reflect.TypeOf(models.Booking{})) + if err != nil { + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.BadRequestCode, err.Error(), nil)) + return + } + + // Convert validated data to Booking struct var booking models.Booking - if err := ctx.ShouldBindJSON(&booking); err != nil { - ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Expected RoomID,Slot,Emails[],Creator,FloorNo ", nil)) - return - } - // // Initialize the validator - // validate := validator.New() - - // // Validate the booking struct - // if err := validate.Struct(booking); err != nil { - // validationErrors := err.(validator.ValidationErrors) - // errorDetails := make(gin.H) - // for _, validationError := range validationErrors { - // errorDetails[validationError.Field()] = validationError.Tag() - // } - // ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Validation failed", constants.InvalidRequestPayloadCode, "Validation errors occurred", errorDetails)) - // return - // } + bookingBytes, _ := json.Marshal(validatedData) + if err := json.Unmarshal(bookingBytes, &booking); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to book", constants.InternalServerErrorCode, "Failed to check in", nil)) + return + } + // Generate a unique ID for the booking booking.ID = primitive.NewObjectID().Hex() booking.OccupiID = utils.GenerateBookingID() booking.CheckedIn = false // Save the booking to the database - _, err := database.SaveBooking(ctx, appsession.DB, booking) + _, err = database.SaveBooking(ctx, appsession.DB, booking) if err != nil { ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to save booking", constants.InternalServerErrorCode, "Failed to save booking", nil)) return } - // Prepare the email content - subject := "Booking Confirmation - Occupi" - body := mail.FormatBookingEmailBodyForBooker(booking.ID, booking.RoomID, booking.Slot, booking.Emails) - - // Send the confirmation email concurrently to all recipients - emailErrors := mail.SendMultipleEmailsConcurrently(booking.Emails, subject, body) - - if len(emailErrors) > 0 { - ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to send confirmation email", constants.InternalServerErrorCode, "Failed to send confirmation email", nil)) + if err := mail.SendBookingEmails(booking); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to send booking email", constants.InternalServerErrorCode, "Failed to send booking email", nil)) return } @@ -102,7 +99,7 @@ func BookRoom(ctx *gin.Context, appsession *models.AppSession) { func ViewBookings(ctx *gin.Context, appsession *models.AppSession) { // Extract the email query parameter email := ctx.Query("email") - if email == "" { + if email == "" || !utils.ValidateEmail(email) { ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Expected Email Address", nil)) return } @@ -110,44 +107,51 @@ func ViewBookings(ctx *gin.Context, appsession *models.AppSession) { // Get all bookings for the userBooking bookings, err := database.GetUserBookings(ctx, appsession.DB, email) if err != nil { - ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to get bookings", constants.InternalServerErrorCode, "Failed to get bookings", nil)) + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusNotFound, "Failed to get bookings", constants.InternalServerErrorCode, "Failed to get bookings", nil)) return } ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "Successfully fetched bookings!", bookings)) } -// Cancel booking handles the cancellation of a booking func CancelBooking(ctx *gin.Context, appsession *models.AppSession) { - var booking models.Booking - if err := ctx.ShouldBindJSON(&booking); err != nil { - ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Expected Booking ID, Room ID, and Email Address", nil)) + var cancelRequest map[string]interface{} + if err := ctx.ShouldBindJSON(&cancelRequest); err != nil { + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Invalid JSON payload", nil)) + return + } + + // Validate JSON + validatedData, err := utils.ValidateJSON(cancelRequest, reflect.TypeOf(models.Cancel{})) + if err != nil { + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.BadRequestCode, err.Error(), nil)) + return + } + + // Convert validated JSON to Cancel struct + var cancel models.Cancel + cancelBytes, _ := json.Marshal(validatedData) + if err := json.Unmarshal(cancelBytes, &cancel); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to cancel", constants.InternalServerErrorCode, "Failed to check in", nil)) return } // Check if the booking exists - exists := database.BookingExists(ctx, appsession.DB, booking.ID) + exists := database.BookingExists(ctx, appsession.DB, cancel.BookingID) if !exists { - ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(404, "Booking not found", constants.InternalServerErrorCode, "Booking not found", nil)) + ctx.JSON(http.StatusNotFound, utils.ErrorResponse(http.StatusNotFound, "Booking not found", constants.InternalServerErrorCode, "Booking not found", nil)) return } // Confirm the cancellation to the database - _, err := database.ConfirmCancellation(ctx, appsession.DB, booking.ID, booking.Creator) + _, err = database.ConfirmCancellation(ctx, appsession.DB, cancel.BookingID, cancel.Creator) if err != nil { ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to cancel booking", constants.InternalServerErrorCode, "Failed to cancel booking", nil)) return } - // Prepare the email content - subject := "Booking Cancelled - Occupi" - body := mail.FormatBookingEmailBody(booking.ID, booking.RoomID, booking.Slot) - - // Send the confirmation email concurrently to all recipients - emailErrors := mail.SendMultipleEmailsConcurrently(booking.Emails, subject, body) - - if len(emailErrors) > 0 { - ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to send cancellation email", constants.InternalServerErrorCode, "Failed to send cancellation email", nil)) + if err := mail.SendCancellationEmails(cancel); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "An error occurred", constants.InternalServerErrorCode, "Failed to send booking email", nil)) return } @@ -156,21 +160,36 @@ func CancelBooking(ctx *gin.Context, appsession *models.AppSession) { // CheckIn handles the check-in process for a booking func CheckIn(ctx *gin.Context, appsession *models.AppSession) { - var checkIn models.CheckIn + var checkInRequest map[string]interface{} + if err := ctx.ShouldBindJSON(&checkInRequest); err != nil { + HandleValidationErrors(ctx, err) + return + } - if err := ctx.ShouldBindJSON(&checkIn); err != nil { - ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Expected Booking ID, Room ID, and Creator Email Address", nil)) + // Validate JSON + validatedData, err := utils.ValidateJSON(checkInRequest, reflect.TypeOf(models.CheckIn{})) + if err != nil { + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.BadRequestCode, err.Error(), nil)) + return + } + + // Convert validated JSON to CheckIn struct + var checkIn models.CheckIn + checkInBytes, _ := json.Marshal(validatedData) + if err := json.Unmarshal(checkInBytes, &checkIn); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to check in", constants.InternalServerErrorCode, "Failed to check in", nil)) return } // Check if the booking exists exists := database.BookingExists(ctx, appsession.DB, checkIn.BookingID) if !exists { - ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to find booking", constants.InternalServerErrorCode, "Failed to find booking", nil)) + ctx.JSON(http.StatusNotFound, utils.ErrorResponse(http.StatusNotFound, "Booking not found", constants.InternalServerErrorCode, "Booking not found", nil)) return } + // Confirm the check-in to the database - _, err := database.ConfirmCheckIn(ctx, appsession.DB, checkIn) + _, err = database.ConfirmCheckIn(ctx, appsession.DB, checkIn) if err != nil { ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to check in", constants.InternalServerErrorCode, "Failed to check in. Email not associated with booking", nil)) return @@ -179,23 +198,37 @@ func CheckIn(ctx *gin.Context, appsession *models.AppSession) { ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "Successfully checked in!", nil)) } -// View all available rooms func ViewRooms(ctx *gin.Context, appsession *models.AppSession) { - var room models.Room - if err := ctx.ShouldBindJSON(&room); err != nil && !errors.Is(err, io.EOF) { - ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Expected Floor No", nil)) + var roomRequest map[string]interface{} + var room models.RoomRequest + if err := ctx.ShouldBindJSON(&roomRequest); err != nil && !errors.Is(err, io.EOF) { + HandleValidationErrors(ctx, err) return } - var rooms []models.Room - var err error - if room.FloorNo != -1 { - // If FloorNo is provided, filter by FloorNo - rooms, err = database.GetAllRooms(ctx, appsession.DB, room.FloorNo) + + // Validate JSON + validatedData, err := utils.ValidateJSON(roomRequest, reflect.TypeOf(models.RoomRequest{})) + if err != nil { + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, err.Error(), constants.BadRequestCode, err.Error(), nil)) + return + } + + // Convert validated JSON to RoomRequest struct + roomBytes, _ := json.Marshal(validatedData) + if err := json.Unmarshal(roomBytes, &room); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to get room", constants.InternalServerErrorCode, "Failed to check in", nil)) + return + } + + var floorNo string + if room.FloorNo == "" { + floorNo = "0" } else { - // If FloorNo is not provided, fetch all rooms on the ground floor - rooms, err = database.GetAllRooms(ctx, appsession.DB, 0) + floorNo = room.FloorNo } + var rooms []models.Room + rooms, err = database.GetAllRooms(ctx, appsession.DB, floorNo) if err != nil { ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(http.StatusInternalServerError, "Failed to get rooms", constants.InternalServerErrorCode, "Failed to get rooms", nil)) return @@ -203,3 +236,18 @@ func ViewRooms(ctx *gin.Context, appsession *models.AppSession) { ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "Successfully fetched rooms!", rooms)) } + +// Helper function to handle validation of requests +func HandleValidationErrors(ctx *gin.Context, err error) { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + out := make([]models.ErrorMsg, len(ve)) + for i, err := range ve { + out[i] = models.ErrorMsg{ + Field: utils.LowercaseFirstLetter(err.Field()), + Message: models.GetErrorMsg(err), + } + } + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(http.StatusBadRequest, "Invalid request payload", constants.InvalidRequestPayloadCode, "Invalid request payload", gin.H{"errors": out})) + } +} diff --git a/occupi-backend/pkg/handlers/auth_handlers.go b/occupi-backend/pkg/handlers/auth_handlers.go index 766785d8..e4c33dcd 100644 --- a/occupi-backend/pkg/handlers/auth_handlers.go +++ b/occupi-backend/pkg/handlers/auth_handlers.go @@ -172,7 +172,7 @@ func Login(ctx *gin.Context, appsession *models.AppSession, role string) { logrus.Error(err) return } - ctx.SetCookie("token", token, int(expirationTime.Unix()), "/", "", false, true) + ctx.SetCookie("token", token, int(time.Until(expirationTime).Seconds()), "/", "", false, true) ctx.JSON(http.StatusOK, utils.SuccessResponse( http.StatusOK, diff --git a/occupi-backend/pkg/mail/email_format.go b/occupi-backend/pkg/mail/email_format.go index 9ea9dc1d..846abae0 100644 --- a/occupi-backend/pkg/mail/email_format.go +++ b/occupi-backend/pkg/mail/email_format.go @@ -21,7 +21,7 @@ func FormatBookingEmailBody(bookingID string, roomID string, slot int) string { } // formats booking email body to send person who booked -func FormatBookingEmailBodyForBooker(bookingID string, roomID string, slot int, attendees []string) string { +func FormatBookingEmailBodyForBooker(bookingID string, roomID string, slot int, attendees []string, email string) string { listOfAttendees := "
    " for _, email := range attendees { listOfAttendees += "
  • " + email + "
  • " @@ -44,6 +44,23 @@ func FormatBookingEmailBodyForBooker(bookingID string, roomID string, slot int, ` + appendFooter() } +// formats cancellation email body to send person who booked +func FormatCancellationEmailBodyForBooker(bookingID string, roomID string, slot int, email string) string { + + return appendHeader("Cancellation") + ` +
    +

    Dear booker,

    +

    + You have successfully cancelled your booked office space. Here are the booking details:

    + Booking ID: ` + bookingID + `
    + Room ID: ` + roomID + `
    + Slot: ` + strconv.Itoa(slot) + `

    + Thank you,
    + The Occupi Team
    +

    +
    ` + appendFooter() +} + // formats booking email body to send attendees func FormatBookingEmailBodyForAttendees(bookingID string, roomID string, slot int, email string) string { return appendHeader("Booking") + ` @@ -61,6 +78,23 @@ func FormatBookingEmailBodyForAttendees(bookingID string, roomID string, slot in ` + appendFooter() } +// formats cancellation email body to send attendees +func FormatCancellationEmailBodyForAttendees(bookingID string, roomID string, slot int, email string) string { + return appendHeader("Booking") + ` +
    +

    Dear attendees,

    +

    + ` + email + ` has cancelled the booked office space with the following details:

    + Booking ID: ` + bookingID + `
    + Room ID: ` + roomID + `
    + Slot: ` + strconv.Itoa(slot) + `

    + If you have any questions, feel free to contact us.

    + Thank you,
    + The Occupi Team
    +

    +
    ` + appendFooter() +} + // formats verification email body func FormatEmailVerificationBody(otp string, email string) string { return appendHeader("Registration") + ` @@ -115,10 +149,11 @@ func appendHeader(title string) string { } func appendFooter() string { - return ` + return ` + ` diff --git a/occupi-backend/pkg/mail/mail.go b/occupi-backend/pkg/mail/mail.go index b6bc67ac..1e7d8c09 100644 --- a/occupi-backend/pkg/mail/mail.go +++ b/occupi-backend/pkg/mail/mail.go @@ -1,9 +1,11 @@ package mail import ( + "errors" "sync" "github.com/COS301-SE-2024/occupi/occupi-backend/configs" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/models" "gopkg.in/gomail.v2" ) @@ -29,7 +31,7 @@ func SendMail(to string, subject string, body string) error { return nil } -func SendMultipleEmailsConcurrently(emails []string, subject, body string) []string { +func SendMultipleEmailsConcurrently(emails []string, subject, body string, creator string) []string { // Use a WaitGroup to wait for all goroutines to complete var wg sync.WaitGroup var emailErrors []string @@ -52,3 +54,65 @@ func SendMultipleEmailsConcurrently(emails []string, subject, body string) []str return emailErrors } + +func SendBookingEmails(booking models.Booking) error { + // Prepare the email content + creatorSubject := "Booking Confirmation - Occupi" + creatorBody := FormatBookingEmailBodyForBooker(booking.ID, booking.RoomID, 0, booking.Emails, booking.Creator) + + // Prepare the email content for attendees + attendeesSubject := "You're invited to a Booking - Occupi" + attendeesBody := FormatBookingEmailBodyForAttendees(booking.ID, booking.RoomID, 0, booking.Creator) + + var attendees []string + for _, email := range booking.Emails { + if email != booking.Creator { + attendees = append(attendees, email) + } + } + + creatorEmailError := SendMail(booking.Creator, creatorSubject, creatorBody) + if creatorEmailError != nil { + return creatorEmailError + } + + // Send the confirmation email concurrently to all recipients + emailErrors := SendMultipleEmailsConcurrently(attendees, attendeesSubject, attendeesBody, booking.Creator) + + if len(emailErrors) > 0 { + return errors.New("failed to send booking emails") + } + + return nil +} + +func SendCancellationEmails(cancel models.Cancel) error { + // Prepare the email content + creatorSubject := "Booking Cancelled - Occupi" + creatorBody := FormatCancellationEmailBodyForBooker(cancel.BookingID, cancel.RoomID, 0, cancel.Creator) + + // Prepare the email content for attendees + attendeesSubject := "Booking Cancelled - Occupi" + attendeesBody := FormatCancellationEmailBodyForAttendees(cancel.BookingID, cancel.RoomID, 0, cancel.Creator) + + var attendees []string + for _, email := range cancel.Emails { + if email != cancel.Creator { + attendees = append(attendees, email) + } + } + + creatorEmailError := SendMail(cancel.Creator, creatorSubject, creatorBody) + if creatorEmailError != nil { + return creatorEmailError + } + + // Send the confirmation email concurrently to all recipients + emailErrors := SendMultipleEmailsConcurrently(attendees, attendeesSubject, attendeesBody, cancel.Creator) + + if len(emailErrors) > 0 { + return errors.New("failed to send cancellation emails") + } + + return nil +} diff --git a/occupi-backend/pkg/models/database.go b/occupi-backend/pkg/models/database.go index 58803367..1ddeb0b5 100644 --- a/occupi-backend/pkg/models/database.go +++ b/occupi-backend/pkg/models/database.go @@ -18,23 +18,32 @@ type User struct { type Booking struct { ID string `json:"_id" bson:"_id,omitempty"` OccupiID string `json:"occupiId" bson:"occupiId,omitempty"` - RoomID string `json:"roomId" bson:"roomId" validate:"required"` - RoomName string `json:"roomName" bson:"roomName" validate:"required"` - Slot int `json:"slot" bson:"slot" validate:"required,min=1"` - Emails []string `json:"emails" bson:"emails" validate:"required,dive,email"` + RoomID string `json:"roomId" bson:"roomId" binding:"required"` + RoomName string `json:"roomName" bson:"roomName" binding:"required"` + Emails []string `json:"emails" bson:"emails" binding:"required,dive,email"` CheckedIn bool `json:"checkedIn" bson:"checkedIn"` - Creator string `json:"creator" bson:"creator" validate:"required,email"` - FloorNo int `json:"floorNo" bson:"floorNo" validate:"required"` - Date time.Time `json:"date" bson:"date,omitempty"` - Start time.Time `json:"start" bson:"start,omitempty"` - End time.Time `json:"end" bson:"end,omitempty"` + Creator string `json:"creator" bson:"creator" binding:"required,email"` + FloorNo string `json:"floorNo" bson:"floorNo" binding:"required"` + Date time.Time `json:"date" bson:"date" binding:"required"` + Start time.Time `json:"start" bson:"start" binding:"required"` + End time.Time `json:"end" bson:"end" binding:"required"` +} +type Cancel struct { + BookingID string `json:"bookingId" bson:"bookingId" binding:"required"` + RoomID string `json:"roomId" bson:"roomId" binding:"required"` + RoomName string `json:"roomName" bson:"roomName" binding:"required"` + Emails []string `json:"emails" bson:"emails" binding:"required,dive,email"` + Creator string `json:"creator" bson:"creator" binding:"required,email"` + FloorNo string `json:"floorNo" bson:"floorNo" binding:"required"` + Date time.Time `json:"date" bson:"date" binding:"required"` + Start time.Time `json:"start" bson:"start" binding:"required"` + End time.Time `json:"end" bson:"end" binding:"required"` } // structure of CheckIn type CheckIn struct { - BookingID string `json:"bookingId" bson:"bookingId"` - Creator string `json:"creator" bson:"creator"` - RoomID string `json:"roomId" bson:"roomId"` + BookingID string `json:"bookingId" bson:"bookingId" binding:"required"` + Creator string `json:"creator" bson:"creator" binding:"required,email"` } type OTP struct { @@ -51,10 +60,14 @@ type ViewBookings struct { type Room struct { ID string `json:"_id" bson:"_id,omitempty"` RoomID string `json:"roomId" bson:"roomId,omitempty"` - RoomNo int `json:"roomNo" bson:"roomNo,omitempty"` - FloorNo int `json:"floorNo" bson:"floorNo,omitempty"` + RoomNo string `json:"roomNo" bson:"roomNo,omitempty"` + FloorNo string `json:"floorNo" bson:"floorNo" binding:"required"` MinOccupancy int `json:"minOccupancy" bson:"minOccupancy,omitempty"` - MaxOccupancy int `json:"maxOccupancy" bson:"maxOccupancy,omitempty"` - Description string `json:"description" bson:"description,omitempty"` - RoomName string `json:"roomName" bson:"roomName,omitempty"` + MaxOccupancy int `json:"maxOccupancy" bson:"maxOccupancy"` + Description string `json:"description" bson:"description"` + RoomName string `json:"roomName" bson:"roomName"` +} + +type RoomRequest struct { + FloorNo string `json:"floorNo" bson:"floorNo" binding:"required"` } diff --git a/occupi-backend/pkg/models/request.go b/occupi-backend/pkg/models/request.go index 30cc9f29..f3ff6973 100644 --- a/occupi-backend/pkg/models/request.go +++ b/occupi-backend/pkg/models/request.go @@ -1,5 +1,10 @@ package models +import ( + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" + "github.com/go-playground/validator/v10" +) + // expected user structure from api requests type RequestUser struct { Email string `json:"email"` @@ -11,3 +16,20 @@ type RequestUserOTP struct { Email string `json:"email"` OTP string `json:"otp"` } + +type ErrorMsg struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func GetErrorMsg(fe validator.FieldError) string { + switch fe.Tag() { + case "required": + return "The " + utils.LowercaseFirstLetter(fe.Field()) + " field is required" + case "email": + return "The " + fe.Field() + " field must be a valid email address" + case "min": + return "The " + fe.Field() + " field must be greater than " + fe.Param() + } + return "The " + fe.Field() + " field is invalid" +} diff --git a/occupi-backend/pkg/utils/response.go b/occupi-backend/pkg/utils/response.go index 263940d5..5e27bc67 100644 --- a/occupi-backend/pkg/utils/response.go +++ b/occupi-backend/pkg/utils/response.go @@ -3,9 +3,8 @@ package utils import ( "net/http" - "github.com/gin-gonic/gin" - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" + "github.com/gin-gonic/gin" ) // creates success response and formats it correctly diff --git a/occupi-backend/pkg/utils/utils.go b/occupi-backend/pkg/utils/utils.go index b119e901..dbbbeadf 100644 --- a/occupi-backend/pkg/utils/utils.go +++ b/occupi-backend/pkg/utils/utils.go @@ -6,7 +6,9 @@ import ( "fmt" "log" "os" + "reflect" "regexp" + "strings" "time" "github.com/alexedwards/argon2id" @@ -164,3 +166,93 @@ func CompareArgon2IDHash(password string, hashedPassword string) (bool, error) { } return match, nil } + +// Helper function to lower first case of a string +func LowercaseFirstLetter(s string) string { + if len(s) == 0 { + return s + } + return strings.ToLower(string(s[0])) + s[1:] +} + +func TypeCheck(value interface{}, expectedType reflect.Type) bool { + valueType := reflect.TypeOf(value) + + // Handle pointer types by dereferencing + if expectedType.Kind() == reflect.Ptr { + expectedType = expectedType.Elem() + if value == nil { + return true + } + } + + if valueType != nil && valueType.Kind() == reflect.Ptr { + valueType = valueType.Elem() + } + + // Handle slices and arrays + if expectedType.Kind() == reflect.Slice || expectedType.Kind() == reflect.Array { + if valueType.Kind() != reflect.Slice && valueType.Kind() != reflect.Array { + return false + } + elemType := expectedType.Elem() + for i := 0; i < reflect.ValueOf(value).Len(); i++ { + if !TypeCheck(reflect.ValueOf(value).Index(i).Interface(), elemType) { + return false + } + } + return true + } + + // Handle time.Time type + if expectedType == reflect.TypeOf(time.Time{}) { + _, ok := value.(string) + if !ok { + return false + } + _, err := time.Parse(time.RFC3339, value.(string)) + return err == nil + } + + return valueType == expectedType +} + +func ValidateJSON(data map[string]interface{}, expectedType reflect.Type) (map[string]interface{}, error) { + validatedData := make(map[string]interface{}) + + for i := 0; i < expectedType.NumField(); i++ { + field := expectedType.Field(i) + jsonTag := field.Tag.Get("json") + validateTag := field.Tag.Get("binding") + + // Check if the JSON field exists + value, exists := data[jsonTag] + if !exists { + if validateTag == "required" { + logrus.Error("missing required field: ", jsonTag) + return nil, fmt.Errorf("missing required field: %s", jsonTag) + } + continue + } + + // Parse date/time strings to time.Time + if field.Type == reflect.TypeOf(time.Time{}) { + parsedTime, err := time.Parse(time.RFC3339, value.(string)) + if err != nil { + logrus.Error("field ", jsonTag, " is of incorrect format") + return nil, fmt.Errorf("field %s is of incorrect format", jsonTag) + } + validatedData[jsonTag] = parsedTime + } else { + validatedData[jsonTag] = value + } + + // Check the field type + if !TypeCheck(value, field.Type) { + logrus.Error("field ", jsonTag, " is of incorrect type") + return nil, fmt.Errorf("field %s is of incorrect type", jsonTag) + } + + } + return validatedData, nil +} diff --git a/occupi-backend/tests/authenticator_test.go b/occupi-backend/tests/authenticator_test.go index 0d97e0d7..10a9cb80 100644 --- a/occupi-backend/tests/authenticator_test.go +++ b/occupi-backend/tests/authenticator_test.go @@ -4,8 +4,6 @@ import ( "testing" "time" - "github.com/joho/godotenv" - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" "github.com/stretchr/testify/assert" @@ -13,11 +11,6 @@ import ( ) func TestGenerateToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - email := "test1@example.com" role := constants.Admin tokenString, expirationTime, err := authenticator.GenerateToken(email, role) @@ -35,11 +28,6 @@ func TestGenerateToken(t *testing.T) { } func TestValidateToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - email := "test2@example.com" role := constants.Admin tokenString, _, err := authenticator.GenerateToken(email, role) @@ -56,11 +44,6 @@ func TestValidateToken(t *testing.T) { } func TestValidateTokenExpired(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - email := "test3@example.com" role := constants.Admin @@ -79,11 +62,6 @@ func TestValidateTokenExpired(t *testing.T) { } func TestInvalidToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - // Test with an invalid token invalidTokenString := "invalid_token" claims, err := authenticator.ValidateToken(invalidTokenString) diff --git a/occupi-backend/tests/handlers_test.go b/occupi-backend/tests/handlers_test.go index 3e272c5d..1dc0fd1f 100644 --- a/occupi-backend/tests/handlers_test.go +++ b/occupi-backend/tests/handlers_test.go @@ -1,6 +1,7 @@ package tests import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -10,7 +11,6 @@ import ( "testing" "time" - "github.com/joho/godotenv" "github.com/stretchr/testify/assert" "github.com/gin-gonic/gin" @@ -21,24 +21,16 @@ import ( "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/router" - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" // "github.com/stretchr/testify/mock" ) +// Tests the ViewBookings handler func TestViewBookingsHandler(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -129,20 +121,51 @@ func TestViewBookingsHandler(t *testing.T) { }) } } -func TestBookRoom(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) + +type testCase struct { + name string + payload string + expectedStatusCode int + expectedMessage string + setupFunc func() string // Return booking ID for valid setup +} + +// Helper function to create a mock booking for testing +func createMockBooking(r *gin.Engine, payload string, cookies []*http.Cookie) (map[string]interface{}, error) { + req, err := http.NewRequest("POST", "/api/book-room", bytes.NewBuffer([]byte(payload))) + if err != nil { + return nil, err } - // setup logger to log all server interactions - utils.SetupLogger() + req.Header.Set("Content-Type", "application/json") + // Add the stored cookies to the request + for _, cookie := range cookies { + req.AddCookie(cookie) + } - // connect to the database - db := database.ConnectToDatabase() + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) - // set gin run mode - gin.SetMode("test") + if rr.Code != http.StatusOK { + return nil, fmt.Errorf("expected status 200 but got %d", rr.Code) + } + + var response map[string]interface{} + err = json.Unmarshal(rr.Body.Bytes(), &response) + if err != nil { + return nil, fmt.Errorf("could not unmarshal response: %v", err) + } + + return response, nil +} + +// SetupTestEnvironment initializes the test environment and returns the router and cookies +func setupTestEnvironment(t *testing.T) (*gin.Engine, []*http.Cookie) { + // Connect to the test database + db := database.ConnectToDatabase(constants.AdminDBAccessOption) + + // Set Gin run mode + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -150,8 +173,10 @@ func TestBookRoom(t *testing.T) { // Register the route router.OccupiRouter(r, db) + // Generate a token token, _, _ := authenticator.GenerateToken("test@example.com", constants.Basic) + // Ping-auth test to ensure everything is set up correctly w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/ping-auth", nil) req.AddCookie(&http.Cookie{Name: "token", Value: token}) @@ -168,46 +193,234 @@ func TestBookRoom(t *testing.T) { // Store the cookies from the login response cookies := req.Cookies() + return r, cookies +} + +// Helper function to send a request and verify the response +func sendRequestAndVerifyResponse(t *testing.T, r *gin.Engine, method, url string, payload string, cookies []*http.Cookie, expectedStatusCode int, expectedMessage string) { + // Create a request to pass to the handler + req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(payload))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + // Add the stored cookies to the request + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + // Create a response recorder to record the response + rr := httptest.NewRecorder() + + // Serve the request + r.ServeHTTP(rr, req) + + // Check the status code is what we expect + assert.Equal(t, expectedStatusCode, rr.Code, "handler returned wrong status code") + + // Check the response message + var actualResponse map[string]interface{} + err = json.Unmarshal(rr.Body.Bytes(), &actualResponse) + if err != nil { + t.Fatalf("could not unmarshal response: %v", err) + } + + assert.Equal(t, expectedMessage, actualResponse["message"], "handler returned unexpected message") +} +func getSharedTestCases(r *gin.Engine, cookies []*http.Cookie) []testCase { + return []testCase{ + { + name: "Valid Request", + payload: `{ + "bookingId": "mock_id", + "creator": "test@example.com" + }`, + expectedStatusCode: http.StatusOK, + expectedMessage: "Successfully checked in!", + setupFunc: func() string { + // Insert a booking to be cancelled using the helper function + bookingPayload := `{ + "roomId": "12345", + "emails": ["test@example.com"], + "creator": "test@example.com", + "floorNo": "1", + "roomName": "Test Room", + "date": "2024-07-01T00:00:00Z", + "start": "2024-07-01T09:00:00Z", + "end": "2024-07-01T10:00:00Z" + }` + response, err := createMockBooking(r, bookingPayload, cookies) + if err != nil { + panic(fmt.Sprintf("could not create mock booking: %v", err)) + } + return response["data"].(string) // Assuming "data" contains the booking ID + }, + }, + { + name: "Invalid Request Payload", + payload: `{ + "bookingID": "", + "creator": "test@example.com" + }`, + expectedStatusCode: http.StatusBadRequest, + expectedMessage: "Invalid request payload", + setupFunc: func() string { return "" }, + }, + { + name: "Booking Not Found", + payload: `{ + "bookingId": "nonexistent", + "creator": "test@example.com" + }`, + expectedStatusCode: http.StatusNotFound, + expectedMessage: "Booking not found", + setupFunc: func() string { return "" }, + }, + } +} + +// Tests the CancelBooking handler +func TestCancelBooking(t *testing.T) { + // Setup the test environment + r, cookies := setupTestEnvironment(t) + + // Define test cases + testCases := []struct { + name string + payload string + expectedStatusCode int + expectedMessage string + setupFunc func() string // Return booking ID for valid setup + }{ + { + name: "Valid Request", + payload: `{ + "bookingId": "mock_id", + "creator": "test@example.com", + "roomId": "12345", + "emails": ["test@example.com"], + "creator": "test@example.com", + "floorNo": "1", + "roomName": "Test Room", + "date": "2024-07-01T09:00:00Z", + "start": "2024-07-01T09:00:00Z", + "end": "2024-07-01T10:00:00Z" + }`, + expectedStatusCode: http.StatusOK, + expectedMessage: "Successfully cancelled booking!", + setupFunc: func() string { + // Insert a booking to be cancelled using the helper function + bookingPayload := `{ + "roomId": "12345", + "emails": ["test@example.com"], + "creator": "test@example.com", + "floorNo": "1", + "roomName": "Test Room", + "date": "2024-07-01T00:00:00Z", + "start": "2024-07-01T09:00:00Z", + "end": "2024-07-01T10:00:00Z" + }` + response, err := createMockBooking(r, bookingPayload, cookies) + if err != nil { + t.Fatalf("could not create mock booking: %v", err) + } + return response["data"].(string) // Assuming "data" contains the booking ID + }, + }, + { + name: "Invalid Request Payload", + payload: `{ + "id": "", + "creator": "" + }`, + expectedStatusCode: http.StatusBadRequest, + expectedMessage: "Invalid request payload", + setupFunc: func() string { return "" }, + }, + { + name: "Booking Not Found", + payload: `{ + "bookingId": "nonexistent", + "creator": "test@example.com", + "roomId": "12345", + "emails": ["test@example.com"], + "creator": "test@example.com", + "floorNo": "1", + "roomName": "Test Room", + "date": "2024-07-01T09:00:00Z", + "start": "2024-07-01T09:00:00Z", + "end": "2024-07-01T10:00:00Z" + }`, + expectedStatusCode: http.StatusNotFound, + expectedMessage: "Booking not found", + setupFunc: func() string { return "" }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup the test case and get the booking ID if applicable + id := tc.setupFunc() + + // Replace the mock_id placeholder in the payload with the actual booking ID + if id != "" { + tc.payload = strings.Replace(tc.payload, "mock_id", id, 1) + } + + sendRequestAndVerifyResponse(t, r, "POST", "/api/cancel-booking", tc.payload, cookies, tc.expectedStatusCode, tc.expectedMessage) + }) + } +} + +// Tests the BookRoom handler +func TestBookRoom(t *testing.T) { + // Setup the test environment + r, cookies := setupTestEnvironment(t) + // Define test cases testCases := []struct { name string payload string expectedStatusCode int expectedMessage string - expectedData gin.H }{ { name: "Valid Request", payload: `{ "roomId": "12345", - "Slot": 1, - "Emails": ["test@example.com"], - "Creator": "test@example.com", - "FloorNo": 1 + "emails": ["test@example.com"], + "creator": "test@example.com", + "floorNo": "1", + "roomName": "Test Room", + "date": "2024-07-01T00:00:00Z", + "start": "2024-07-01T09:00:00Z", + "end": "2024-07-01T10:00:00Z" }`, expectedStatusCode: http.StatusOK, expectedMessage: "Successfully booked!", - expectedData: gin.H{"id": "some_generated_id"}, // The exact value will be replaced dynamically }, { name: "Invalid Request Payload", payload: `{ - "RoomID": "", - "Slot": "", - "Emails": [], - "Creator": "", - "FloorNo": 0 + "roomId": "", + "emails": [], + "creator": "", + "floorNo": "0", + "roomName": "", + "date": "", + "start": "", + "end": "" }`, expectedStatusCode: http.StatusBadRequest, expectedMessage: "Invalid request payload", - expectedData: nil, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a request to pass to the handler - req, err := http.NewRequest("POST", "/api/book-room", strings.NewReader(tc.payload)) + req, err := http.NewRequest("POST", "/api/book-room", bytes.NewBuffer([]byte(tc.payload))) if err != nil { t.Fatal(err) } @@ -226,22 +439,14 @@ func TestBookRoom(t *testing.T) { // Check the status code is what we expect assert.Equal(t, tc.expectedStatusCode, rr.Code, "handler returned wrong status code") - // Define the expected response - expectedResponse := gin.H{ - "message": tc.expectedMessage, - "status": float64(tc.expectedStatusCode), - "data": tc.expectedData, - } - - // Unmarshal the actual response - var actualResponse gin.H - if err := json.Unmarshal(rr.Body.Bytes(), &actualResponse); err != nil { + // Check the response message + var actualResponse map[string]interface{} + err = json.Unmarshal(rr.Body.Bytes(), &actualResponse) + if err != nil { t.Fatalf("could not unmarshal response: %v", err) } - // Check the response message and status - assert.Equal(t, expectedResponse["message"], actualResponse["message"], "handler returned unexpected message") - assert.Equal(t, expectedResponse["status"], actualResponse["status"], "handler returned unexpected status") + assert.Equal(t, tc.expectedMessage, actualResponse["message"], "handler returned unexpected message") // For successful booking, check if the ID is generated if tc.expectedStatusCode == http.StatusOK { @@ -251,20 +456,34 @@ func TestBookRoom(t *testing.T) { } } -func TestPingRoute(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal(fmt.Printf("Error loading .env file with error as %s", err)) - } +// Tests CheckIn handler +func TestCheckIn(t *testing.T) { + // Setup the test environment + r, cookies := setupTestEnvironment(t) - // setup logger to log all server interactions - utils.SetupLogger() + // Define test cases + testCases := getSharedTestCases(r, cookies) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup the test case and get the booking ID if applicable + bookingID := tc.setupFunc() + + // Replace the mock_id placeholder in the payload with the actual booking ID + if bookingID != "" { + tc.payload = strings.Replace(tc.payload, "mock_id", bookingID, 1) + } + sendRequestAndVerifyResponse(t, r, "POST", "/api/check-in", tc.payload, cookies, tc.expectedStatusCode, tc.expectedMessage) + }) + } +} +func TestPingRoute(t *testing.T) { // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router ginRouter := gin.Default() @@ -306,19 +525,11 @@ func TestPingRoute(t *testing.T) { } func TestRateLimit(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal(fmt.Printf("Error loading .env file with error as %s", err)) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router ginRouter := gin.Default() @@ -365,19 +576,11 @@ func TestRateLimit(t *testing.T) { } func TestRateLimitWithMultipleIPs(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal(fmt.Printf("Error loading .env file with error as %s", err)) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router ginRouter := gin.Default() @@ -468,19 +671,11 @@ func TestRateLimitWithMultipleIPs(t *testing.T) { } func TestInvalidLogoutHandler(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router ginRouter := gin.Default() @@ -507,19 +702,11 @@ func TestInvalidLogoutHandler(t *testing.T) { } func TestValidLogoutHandler(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router ginRouter := gin.Default() @@ -575,19 +762,11 @@ func TestValidLogoutHandler(t *testing.T) { } func TestValidLogoutHandlerFromDomains(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router ginRouter := gin.Default() @@ -662,3 +841,45 @@ func TestValidLogoutHandlerFromDomains(t *testing.T) { // Wait for all goroutines to finish wg.Wait() } + +func TestMockDatabase(t *testing.T) { + // connect to the database + db := database.ConnectToDatabase(constants.AdminDBAccessOption) + + // set gin run mode + gin.SetMode(configs.GetGinRunMode()) + + // Create a Gin router + r := gin.Default() + + // Register the route + router.OccupiRouter(r, db) + + token, _, _ := authenticator.GenerateToken("test@example.com", constants.Basic) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/resource-auth", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + /* + Expected response body: + { + "data": [], -> array of data + "message": "Successfully fetched resource!", -> message + "status": 200 -> status code + */ + // check that the data length is greater than 0 after converting the response body to a map + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Errorf("could not unmarshal response: %v", err) + } + + // check that the data length is greater than 0 + data := response["data"].([]interface{}) + assert.Greater(t, len(data), 0) +} diff --git a/occupi-backend/tests/main_test.go b/occupi-backend/tests/main_test.go new file mode 100644 index 00000000..648001b4 --- /dev/null +++ b/occupi-backend/tests/main_test.go @@ -0,0 +1,30 @@ +package tests + +import ( + "log" + "os" + "testing" + + "github.com/COS301-SE-2024/occupi/occupi-backend/configs" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" +) + +func TestMain(m *testing.M) { + + log.Println("Configuring test env") + + // init viper + env := "test" + configs.InitViper(&env, "../configs") + + // setup logger to log all server interactions + utils.SetupLogger() + + // begin seeding the mock database + database.SeedMockDatabase("../data/test_data.json") + + log.Println("Starting up tests") + + os.Exit(m.Run()) +} diff --git a/occupi-backend/tests/middleware_test.go b/occupi-backend/tests/middleware_test.go index d8029bba..69b2f2f3 100644 --- a/occupi-backend/tests/middleware_test.go +++ b/occupi-backend/tests/middleware_test.go @@ -6,33 +6,24 @@ import ( "strings" "testing" - "github.com/joho/godotenv" "github.com/stretchr/testify/assert" "github.com/gin-gonic/gin" + "github.com/COS301-SE-2024/occupi/occupi-backend/configs" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/router" - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" // "github.com/stretchr/testify/mock" ) func TestProtectedRoute(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -57,19 +48,11 @@ func TestProtectedRoute(t *testing.T) { } func TestProtectedRouteInvalidToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -88,19 +71,11 @@ func TestProtectedRouteInvalidToken(t *testing.T) { } func TestProtectedRouteNonMatchingSessionEmailAndToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -140,19 +115,11 @@ func TestProtectedRouteNonMatchingSessionEmailAndToken(t *testing.T) { } func TestAdminRoute(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -177,19 +144,11 @@ func TestAdminRoute(t *testing.T) { } func TestUnauthorizedAccess(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -207,19 +166,11 @@ func TestUnauthorizedAccess(t *testing.T) { } func TestUnauthorizedAdminAccess(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -240,19 +191,11 @@ func TestUnauthorizedAdminAccess(t *testing.T) { } func TestAccessUnprotectedRoute(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -274,19 +217,11 @@ func TestAccessUnprotectedRoute(t *testing.T) { } func TestAccessUnprotectedRouteWithToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() @@ -307,19 +242,11 @@ func TestAccessUnprotectedRouteWithToken(t *testing.T) { } func TestAccessUnprotectedRouteWithSessionInvalidToken(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - // connect to the database - db := database.ConnectToDatabase() + db := database.ConnectToDatabase(constants.AdminDBAccessOption) // set gin run mode - gin.SetMode("test") + gin.SetMode(configs.GetGinRunMode()) // Create a Gin router r := gin.Default() diff --git a/occupi-backend/tests/utils_test.go b/occupi-backend/tests/utils_test.go index 6cc213ad..38380de0 100644 --- a/occupi-backend/tests/utils_test.go +++ b/occupi-backend/tests/utils_test.go @@ -2,7 +2,9 @@ package tests import ( "net/http" + "reflect" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -400,3 +402,142 @@ func TestGenerateOTP(t *testing.T) { t.Logf("Generated OTP: %s", otp) } +func TestLowercaseFirstLetter(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Hello", "hello"}, + {"world", "world"}, + {"Golang", "golang"}, + {"", ""}, + {"A", "a"}, + {"ABC", "aBC"}, + } + + for _, test := range tests { + result := utils.LowercaseFirstLetter(test.input) + if result != test.expected { + t.Errorf("LowercaseFirstLetter(%q) = %q; expected %q", test.input, result, test.expected) + } + } +} + +type SampleStruct struct { + Field1 string `json:"field1" binding:"required"` + Field2 int `json:"field2" binding:"required"` + Field3 time.Time `json:"field3" binding:"required"` +} + +func TestValidateJSON(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + expectedType reflect.Type + expectError bool + errorMessage string + }{ + { + name: "Valid JSON with required fields", + data: map[string]interface{}{ + "field1": "value1", + "field2": 123, + "field3": "2024-07-01T09:00:00Z", + }, + expectedType: reflect.TypeOf(SampleStruct{}), + expectError: false, + }, + { + name: "Missing required field", + data: map[string]interface{}{ + "field2": 123, + "field3": "2024-07-01T09:00:00Z", + }, + expectedType: reflect.TypeOf(SampleStruct{}), + expectError: true, + errorMessage: "missing required field: field1", + }, + { + name: "Invalid type for field", + data: map[string]interface{}{ + "field1": "value1", + "field2": "not-an-int", + "field3": "2024-07-01T09:00:00Z", + }, + expectedType: reflect.TypeOf(SampleStruct{}), + expectError: true, + errorMessage: "field field2 is of incorrect type", + }, + { + name: "Invalid time format", + data: map[string]interface{}{ + "field1": "value1", + "field2": 123, + "field3": "not-a-date", + }, + expectedType: reflect.TypeOf(SampleStruct{}), + expectError: true, + errorMessage: "field field3 is of incorrect format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := utils.ValidateJSON(tt.data, tt.expectedType) + if (err != nil) != tt.expectError { + t.Errorf("ValidateJSON() error = %v, expectError %v", err, tt.expectError) + } + if tt.expectError && err.Error() != tt.errorMessage { + t.Errorf("ValidateJSON() error = %v, errorMessage %v", err.Error(), tt.errorMessage) + } + }) + } +} + +func TestTypeCheck(t *testing.T) { + tests := []struct { + name string + value interface{} + expectedType reflect.Type + expected bool + }{ + // Basic types + {"Match int", 42, reflect.TypeOf(42), true}, + {"Match string", "hello", reflect.TypeOf("hello"), true}, + {"Match float", 3.14, reflect.TypeOf(3.14), true}, + {"Mismatch int", "42", reflect.TypeOf(42), false}, + {"Mismatch string", 42, reflect.TypeOf("hello"), false}, + + // Pointer types + {"Pointer match", new(int), reflect.TypeOf(new(int)), true}, + {"Pointer mismatch", new(string), reflect.TypeOf(new(int)), false}, + {"Nil pointer", nil, reflect.TypeOf((*int)(nil)), true}, + {"Non-nil pointer match", new(int), reflect.TypeOf((*int)(nil)), true}, + {"Nil non-pointer", nil, reflect.TypeOf(42), false}, + + // Time type + {"Time type valid RFC3339", "2024-07-01T09:00:00Z", reflect.TypeOf(time.Time{}), true}, + {"Time type invalid RFC3339", "not-a-date", reflect.TypeOf(time.Time{}), false}, + + // Slices and arrays + {"Match slice int", []int{1, 2, 3}, reflect.TypeOf([]int{}), true}, + {"Match array int", [3]int{1, 2, 3}, reflect.TypeOf([3]int{}), true}, + {"Mismatch slice int", []string{"1", "2", "3"}, reflect.TypeOf([]int{}), false}, + {"Mismatch array int", [3]string{"1", "2", "3"}, reflect.TypeOf([3]int{}), false}, + {"Empty slice", []int{}, reflect.TypeOf([]int{}), true}, + {"Empty array", [0]int{}, reflect.TypeOf([0]int{}), true}, + + // Nested slices/arrays + {"Match nested slice int", [][]int{{1, 2}, {3, 4}}, reflect.TypeOf([][]int{}), true}, + {"Mismatch nested slice int", [][]string{{"1", "2"}, {"3", "4"}}, reflect.TypeOf([][]int{}), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := utils.TypeCheck(tt.value, tt.expectedType) + if result != tt.expected { + t.Errorf("TypeCheck(%v, %v) = %v; want %v", tt.value, tt.expectedType, result, tt.expected) + } + }) + } +} diff --git a/presentation/Occupi/occupi-gradient.png b/presentation/Occupi/occupi-gradient.png new file mode 100644 index 00000000..21c4fb2b Binary files /dev/null and b/presentation/Occupi/occupi-gradient.png differ