├── .nvmrc
├── .yarnrc.yml
├── .prettierrc
├── packages
├── app
│ ├── screens
│ │ ├── onboarding
│ │ │ └── OnboardingStack.js
│ │ ├── landing
│ │ │ ├── TestScreen.js
│ │ │ ├── LandingStack.js
│ │ │ ├── ForgotPasswordScreen.js
│ │ │ └── LandingScreen.js
│ │ ├── group
│ │ │ ├── tabs
│ │ │ │ ├── NewsletterScreen.js
│ │ │ │ ├── LeaderboardScreen.js
│ │ │ │ ├── MembersScreen.js
│ │ │ │ ├── EventsScreen.js
│ │ │ │ └── AboutScreen.js
│ │ │ ├── GroupStack.js
│ │ │ ├── GuildCard.js
│ │ │ ├── event_overview
│ │ │ │ └── EventOverviewScreen.js
│ │ │ ├── GroupScreen.js
│ │ │ └── GuildList.js
│ │ ├── explore
│ │ │ ├── ExploreScreen.js
│ │ │ └── ExploreStack.js
│ │ └── feed
│ │ │ ├── feed_tabs
│ │ │ ├── Profile.js
│ │ │ └── Settings.js
│ │ │ ├── FeedStack.js
│ │ │ ├── FeedDrawer.js
│ │ │ └── FeedScreen.js
│ ├── assets
│ │ ├── icon.png
│ │ ├── favicon.png
│ │ ├── splash.png
│ │ ├── more-icon.png
│ │ ├── no-pfp-icon.png
│ │ ├── adaptive-icon.png
│ │ ├── test-club-icon.png
│ │ ├── test-club-banner.png
│ │ ├── test_card_banner.png
│ │ ├── eye-line-on.js
│ │ ├── eye-line-off.js
│ │ └── google-icon.js
│ ├── __tests__
│ │ ├── todo.test.js
│ │ ├── FeedDrawer.test.js
│ │ ├── GroupScreen.test.js
│ │ └── Login.test.js
│ ├── utils
│ │ ├── constants.js
│ │ ├── SecureStore.js
│ │ ├── datalayer.js
│ │ ├── UserContext.js
│ │ ├── useGoogleLogin.js
│ │ ├── EventContext.js
│ │ ├── FeedContext.js
│ │ └── GuildContext.js
│ ├── jsconfig.json
│ ├── .expo-shared
│ │ └── assets.json
│ ├── .gitignore
│ ├── index.js
│ ├── jest.config.js
│ ├── .eslintrc.json
│ ├── babel.config.js
│ ├── components
│ │ ├── Screen.js
│ │ ├── EventCard
│ │ │ ├── FaceIcon.js
│ │ │ ├── RegisterButton.js
│ │ │ ├── EventCardRegistration.js
│ │ │ └── EventCardText.js
│ │ ├── DividerWithText.js
│ │ ├── MemberCard
│ │ │ ├── RoundedIcon.js
│ │ │ ├── MemberFunctions.js
│ │ │ ├── MemberCard.js
│ │ │ └── ProfilePopup.js
│ │ ├── GroupScreen
│ │ │ ├── GroupTag.js
│ │ │ ├── GroupIcon.js
│ │ │ ├── GroupHeader.js
│ │ │ ├── GroupMediaIcon.js
│ │ │ ├── GroupHeaderInfo.js
│ │ │ └── GroupTabs.js
│ │ ├── Button.js
│ │ ├── EventOverview
│ │ │ ├── EventOverviewRegister.js
│ │ │ └── EventOverviewText.js
│ │ └── TextInput.js
│ ├── eas.json
│ ├── metro.config.js
│ ├── jest-setup.js
│ ├── app.config.js
│ ├── package.json
│ └── Root.js
└── server
│ ├── prisma
│ ├── prisma.js
│ ├── migrations
│ │ ├── migration_lock.toml
│ │ ├── 20240428205018_user_onboarding_fields
│ │ │ └── migration.sql
│ │ ├── 20240429022141_unique_user_email
│ │ │ └── migration.sql
│ │ ├── 20240428201142_update_model_names_and_maps
│ │ │ └── migration.sql
│ │ ├── 20240428195602_update_enum_values
│ │ │ └── migration.sql
│ │ └── 0_init
│ │ │ └── migration.sql
│ ├── seed.js
│ └── schema.prisma
│ ├── jest.config.js
│ ├── utils
│ ├── s3.js
│ ├── flattener.js
│ ├── prismaTS.ts
│ ├── token.js
│ └── redis.js
│ ├── __tests__
│ ├── prisma_mock.js
│ └── controllers
│ │ ├── users.test.js
│ │ ├── auth.test.js
│ │ ├── events.test.js
│ │ └── guilds.test.js
│ ├── .eslintrc.json
│ ├── index.js
│ ├── package.json
│ ├── validators
│ ├── images.js
│ ├── auth.js
│ └── users.js
│ ├── controllers
│ ├── users.js
│ ├── images.js
│ ├── guilds.js
│ ├── events.js
│ └── auth.js
│ ├── scripts
│ └── database.sql
│ └── routes
│ └── api
│ ├── users.js
│ └── images.js
├── .commitlintrc.json
├── assets
└── banner.png
├── .prettierignore
├── .husky
├── pre-commit
├── commit-msg
└── post-checkout
├── .lintstagedrc.json
├── .yarnrc
├── .gitignore
├── .eslintrc.json
├── .github
└── CODEOWNERS
├── package.json
├── README.md
└── scripts
└── run.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20.11.1
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSameLine": true
3 | }
4 |
--------------------------------------------------------------------------------
/packages/app/screens/onboarding/OnboardingStack.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.commitlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@commitlint/config-conventional"]
3 | }
4 |
--------------------------------------------------------------------------------
/assets/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/assets/banner.png
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/node_modules/
2 | .vscode/
3 | **/.expo/
4 | **/.expo-shared/
5 | **/assets/
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
5 |
--------------------------------------------------------------------------------
/packages/app/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/icon.png
--------------------------------------------------------------------------------
/packages/app/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/favicon.png
--------------------------------------------------------------------------------
/packages/app/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/splash.png
--------------------------------------------------------------------------------
/packages/app/assets/more-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/more-icon.png
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx --no -- commitlint --edit ${1}
5 |
--------------------------------------------------------------------------------
/packages/app/assets/no-pfp-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/no-pfp-icon.png
--------------------------------------------------------------------------------
/packages/app/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/packages/app/assets/test-club-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/test-club-icon.png
--------------------------------------------------------------------------------
/packages/app/assets/test-club-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/test-club-banner.png
--------------------------------------------------------------------------------
/packages/app/assets/test_card_banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cppsea/icebreak/HEAD/packages/app/assets/test_card_banner.png
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "*.{js,jsx,ts,tsx,html,css}": "eslint --fix",
3 | "*.{js,json,jsx,ts,tsx,md,html,css}": "prettier --write"
4 | }
5 |
--------------------------------------------------------------------------------
/packages/app/__tests__/todo.test.js:
--------------------------------------------------------------------------------
1 | import { test, expect } from "jest";
2 |
3 | test("test", () => {
4 | expect(true).toBe(true);
5 | });
6 |
--------------------------------------------------------------------------------
/.husky/post-checkout:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | git pull
5 | yarn
6 | yarn workspace server prisma generate
7 |
--------------------------------------------------------------------------------
/packages/app/utils/constants.js:
--------------------------------------------------------------------------------
1 | const URL = "https://4289-2606-40-8eb7-24-00-c60-3a7b.ngrok-free.app";
2 |
3 | export const ENDPOINT = `${URL}/api`;
4 |
--------------------------------------------------------------------------------
/.yarnrc:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | yarn-path ".yarn/releases/yarn-1.22.19.cjs"
6 |
--------------------------------------------------------------------------------
/packages/app/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "@app/*": ["./*"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/server/prisma/prisma.js:
--------------------------------------------------------------------------------
1 | const { PrismaClient } = require("@prisma/client");
2 | const prisma = new PrismaClient();
3 | module.exports = prisma;
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .vscode
3 | node_modules/
4 | yarn-error.log
5 | .expo/
6 | **.env
7 | package-lock.json
8 | .yarn/cache/
9 | .yarn/install-state.gz
10 | .env.*
--------------------------------------------------------------------------------
/packages/server/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/packages/app/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
4 | }
5 |
--------------------------------------------------------------------------------
/packages/app/screens/landing/TestScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View } from "react-native";
3 |
4 | function TestScreen() {
5 | return ;
6 | }
7 |
8 | export default TestScreen;
9 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "sourceType": "script",
4 | "ecmaVersion": 2023
5 | },
6 | "env": {
7 | "node": true
8 | },
9 | "extends": ["prettier", "eslint:recommended"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/app/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 | **.env
13 |
14 | # macOS
15 | .DS_Store
16 |
--------------------------------------------------------------------------------
/packages/app/screens/group/tabs/NewsletterScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View } from "react-native";
3 |
4 | function NewsletterScreen() {
5 | return ;
6 | }
7 |
8 | export default NewsletterScreen;
9 |
--------------------------------------------------------------------------------
/packages/app/screens/group/tabs/LeaderboardScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View } from "react-native";
3 |
4 | function LeaderboardScreen() {
5 | return ;
6 | }
7 |
8 | export default LeaderboardScreen;
9 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | packages/app @MinT-Napkin
2 | packages/server @lxkedinh
3 |
4 | # exclude "packages/app/utils/constants.js" from having a code owner
5 | # because it's constantly overwritten automatically by Ngrok
6 | packages/app/utils/constants.js
7 |
--------------------------------------------------------------------------------
/packages/server/prisma/migrations/20240428205018_user_onboarding_fields/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "users" ADD COLUMN "age" INTEGER,
3 | ADD COLUMN "interests" TEXT[],
4 | ADD COLUMN "pronouns" "user_pronoun" NOT NULL DEFAULT 'They/Them/Their';
5 |
--------------------------------------------------------------------------------
/packages/server/jest.config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | testPathIgnorePatterns: [
3 | "/node_modules/",
4 | "/__tests__/prisma_mock.js",
5 | ],
6 | setupFilesAfterEnv: ["/__tests__/prisma_mock.js"],
7 | };
8 |
9 | module.exports = config;
10 |
--------------------------------------------------------------------------------
/packages/server/utils/s3.js:
--------------------------------------------------------------------------------
1 | const { S3Client } = require("@aws-sdk/client-s3");
2 | const s3Client = new S3Client({ region: "us-west-1" });
3 |
4 | const s3ImagesUrlRegex =
5 | /^https:\/\/icebreak-assets\.s3\.us-west-1\.amazonaws\.com\/.*\.jpg$/;
6 |
7 | module.exports = { s3Client, s3ImagesUrlRegex };
8 |
--------------------------------------------------------------------------------
/packages/app/screens/explore/ExploreScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Text } from "react-native";
3 |
4 | import Screen from "@app/components/Screen";
5 |
6 | function ExploreScreen() {
7 | return (
8 |
9 | explore
10 |
11 | );
12 | }
13 | export default ExploreScreen;
14 |
--------------------------------------------------------------------------------
/packages/server/prisma/migrations/20240429022141_unique_user_email/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[email]` on the table `users` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
9 |
--------------------------------------------------------------------------------
/packages/app/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from 'expo';
2 |
3 | import Root from './Root';
4 |
5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
6 | // It also ensures that whether you load the app in Expo Go or in a native build,
7 | // the environment is set up appropriately
8 | registerRootComponent(Root);
--------------------------------------------------------------------------------
/packages/server/__tests__/prisma_mock.js:
--------------------------------------------------------------------------------
1 | const { mockDeep, mockReset } = require("jest-mock-extended");
2 | const prisma = require("../prisma/prisma");
3 | const prismaMock = prisma;
4 |
5 | jest.mock("../prisma/prisma", () => mockDeep());
6 |
7 | beforeEach(() => {
8 | mockReset(prismaMock);
9 | });
10 |
11 | module.exports = { prismaMock };
12 |
--------------------------------------------------------------------------------
/packages/app/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 | const config = {
3 | verbose: true,
4 | preset: "react-native",
5 | setupFiles: ["./jest-setup.js"],
6 | transformIgnorePatterns: [
7 | "/node_modules/(?!(jest-)?react-native|@react-native-community|@react-navigation)",
8 | ],
9 | };
10 |
11 | module.exports = config;
12 |
--------------------------------------------------------------------------------
/packages/app/utils/SecureStore.js:
--------------------------------------------------------------------------------
1 | import * as SecureStore from "expo-secure-store";
2 |
3 | export async function save(key, value) {
4 | await SecureStore.setItemAsync(key, value);
5 | }
6 |
7 | export async function getValueFor(key) {
8 | let result = await SecureStore.getItemAsync(key);
9 | return result;
10 | }
11 |
12 | export async function remove(key) {
13 | await SecureStore.deleteItemAsync(key);
14 | }
15 |
--------------------------------------------------------------------------------
/packages/app/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["react", "react-native"],
3 | "env": {
4 | "react-native/react-native": true
5 | },
6 | "parserOptions": {
7 | "sourceType": "module",
8 | "ecmaVersion": 2023,
9 | "ecmaFeatures": { "jsx": true }
10 | },
11 | "extends": [
12 | "prettier",
13 | "eslint:recommended",
14 | "plugin:react-native/all",
15 | "plugin:react/recommended"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/packages/server/utils/flattener.js:
--------------------------------------------------------------------------------
1 | // Flattens all object properties to the outermost level
2 | function flatten(obj) {
3 | return Object.entries(obj).reduce((acc, [key, value]) => {
4 | return {
5 | ...acc,
6 | ...(typeof value === "object" && value !== null && !Array.isArray(value)
7 | ? flatten(value)
8 | : { [key]: value }),
9 | };
10 | }, {});
11 | }
12 |
13 | module.exports = { flatten };
14 |
--------------------------------------------------------------------------------
/packages/app/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(false);
3 | return {
4 | presets: ["babel-preset-expo"],
5 | plugins: [
6 | [
7 | require.resolve("babel-plugin-module-resolver"),
8 | {
9 | include: ["."],
10 | root: ["."],
11 | alias: {
12 | "@app": ".",
13 | },
14 | },
15 | ],
16 | "react-native-reanimated/plugin",
17 | ],
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/packages/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "sourceType": "script",
4 | "ecmaVersion": 2023
5 | },
6 | "env": {
7 | "node": true
8 | },
9 | "extends": ["prettier", "eslint:recommended"],
10 | "overrides": [
11 | {
12 | "files": ["__tests__/**", "prisma/prisma_mock.js"],
13 | "plugins": ["jest"],
14 | "extends": ["plugin:jest/recommended"],
15 | "env": {
16 | "jest/globals": true
17 | }
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/app/components/Screen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { SafeAreaView, StatusBar } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | function Screen(props) {
6 | const { children, ...rest } = props;
7 |
8 | return (
9 |
10 |
11 | {children}
12 |
13 | );
14 | }
15 |
16 | Screen.propTypes = {
17 | children: PropTypes.node,
18 | };
19 |
20 | export default Screen;
21 |
--------------------------------------------------------------------------------
/packages/app/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 7.6.0"
4 | },
5 | "build": {
6 | "development": {
7 | "developmentClient": true,
8 | "distribution": "internal"
9 | },
10 | "development-ios-simulator": {
11 | "developmentClient": true,
12 | "distribution": "internal",
13 | "ios": {
14 | "simulator": true
15 | }
16 | },
17 | "preview": {
18 | "distribution": "internal"
19 | },
20 | "production": {}
21 | },
22 | "submit": {
23 | "production": {}
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/app/screens/explore/ExploreStack.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
3 |
4 | import ExploreScreen from "./ExploreScreen";
5 |
6 | const Explore = createNativeStackNavigator();
7 |
8 | function ExploreStack() {
9 | return (
10 |
13 |
14 |
15 | );
16 | }
17 |
18 | export default ExploreStack;
19 |
--------------------------------------------------------------------------------
/packages/app/utils/datalayer.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import * as SecureStore from "./SecureStore";
3 | import { ENDPOINT } from "./constants";
4 |
5 | const server = axios.create({
6 | baseURL: ENDPOINT,
7 | });
8 |
9 | // get user info for local auth only
10 | export async function getUserInfo(token) {
11 | const { data: response } = await server.get("/auth/user", {
12 | headers: {
13 | Authorization: token,
14 | },
15 | });
16 |
17 | return response;
18 | }
19 |
20 | export async function logoutUser() {
21 | await SecureStore.remove("accessToken");
22 | await SecureStore.remove("refreshToken");
23 | }
24 |
--------------------------------------------------------------------------------
/packages/app/components/EventCard/FaceIcon.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, Image } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | function FaceIcon(props) {
6 | const styles = StyleSheet.create({
7 | imageStyle: {
8 | borderRadius: 18,
9 | height: 36,
10 | transform: [{ translateX: 30 - props.index * 20 }],
11 | width: 36,
12 | },
13 | });
14 |
15 | return (
16 |
21 | );
22 | }
23 |
24 | FaceIcon.propTypes = {
25 | iconUrl: PropTypes.string,
26 | index: PropTypes.number,
27 | };
28 |
29 | export default FaceIcon;
30 |
--------------------------------------------------------------------------------
/packages/app/assets/eye-line-on.js:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { Path } from "react-native-svg";
3 |
4 | const EyeOn = (props) => (
5 |
12 |
13 |
17 |
18 | );
19 | export default EyeOn;
--------------------------------------------------------------------------------
/packages/app/screens/feed/feed_tabs/Profile.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, Button, Text, StyleSheet } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | // Placeholder profile screen
6 | function Profile({ navigation }) {
7 | return (
8 |
9 | navigation.navigate("Feed")} title="Back" />
10 | Profile Screen
11 |
12 | );
13 | }
14 |
15 | const styles = StyleSheet.create({
16 | screenContainer: {
17 | alignItems: "center",
18 | flex: 1,
19 | justifyContent: "center",
20 | },
21 | });
22 |
23 | Profile.propTypes = {
24 | navigation: PropTypes.object,
25 | };
26 |
27 | export default Profile;
28 |
--------------------------------------------------------------------------------
/packages/app/screens/feed/feed_tabs/Settings.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, Button, Text, StyleSheet } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | // Placeholder settings screen
6 | function Settings({ navigation }) {
7 | return (
8 |
9 | navigation.navigate("Feed")} title="Back" />
10 | Settings Screen
11 |
12 | );
13 | }
14 |
15 | const styles = StyleSheet.create({
16 | screenContainer: {
17 | alignItems: "center",
18 | flex: 1,
19 | justifyContent: "center",
20 | },
21 | });
22 |
23 | Settings.propTypes = {
24 | navigation: PropTypes.object,
25 | };
26 |
27 | export default Settings;
28 |
--------------------------------------------------------------------------------
/packages/server/prisma/migrations/20240428201142_update_model_names_and_maps/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `event_attendees` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - The primary key for the `guild_members` table will be changed. If it partially fails, the table could be left without primary key constraint.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "event_attendees" DROP CONSTRAINT "pk_event_attendee",
10 | ADD CONSTRAINT "pk_event_attendee" PRIMARY KEY ("event_id", "user_id");
11 |
12 | -- AlterTable
13 | ALTER TABLE "guild_members" DROP CONSTRAINT "pk_guild_member",
14 | ADD CONSTRAINT "pk_guild_member" PRIMARY KEY ("guild_id", "user_id");
15 |
--------------------------------------------------------------------------------
/packages/app/screens/feed/FeedStack.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
3 |
4 | // import FeedScreen from './FeedScreen';
5 | import FeedDrawer from "./FeedDrawer";
6 | import EventOverviewScreen from "../group/event_overview/EventOverviewScreen";
7 |
8 | const Feed = createNativeStackNavigator();
9 |
10 | function FeedStack() {
11 | return (
12 |
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | export default FeedStack;
22 |
--------------------------------------------------------------------------------
/packages/server/utils/prismaTS.ts:
--------------------------------------------------------------------------------
1 | // prisma client initialization once we migrate backend to TypeScript
2 | // delete "prisma.js" and rename this file to "prisma.ts" once migrated to
3 | // TypeScript
4 |
5 | import { PrismaClient } from "@prisma/client";
6 |
7 | declare global {
8 | namespace NodeJS {
9 | interface Global {
10 | prisma: PrismaClient;
11 | }
12 | }
13 | }
14 |
15 | let prisma: PrismaClient;
16 |
17 | if (!global.prisma) {
18 | global.prisma = new PrismaClient({
19 | log: ["info"],
20 | });
21 | }
22 | prisma = global.prisma;
23 |
24 | export default prisma;
25 | // Prevents hitting the limit on number of Prisma Clients instantiated while testing the code locally
26 | // Achieves goal by setting a single global instance of Prisma Client to be used when local testing
27 |
--------------------------------------------------------------------------------
/packages/app/screens/group/GroupStack.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
3 |
4 | import GuildList from "./GuildList";
5 | import GroupScreen from "./GroupScreen";
6 | import EventOverviewScreen from "./event_overview/EventOverviewScreen";
7 | const Group = createNativeStackNavigator();
8 |
9 | function GroupStack() {
10 | return (
11 |
14 |
15 |
16 |
20 |
21 | );
22 | }
23 |
24 | export default GroupStack;
25 |
--------------------------------------------------------------------------------
/packages/app/screens/landing/LandingStack.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
3 |
4 | import LandingScreen from "./LandingScreen";
5 | import SignUpScreen from "./SignUpScreen";
6 | import ForgotPasswordScreen from "./ForgotPasswordScreen";
7 |
8 | const Landing = createNativeStackNavigator();
9 |
10 | function LandingStack() {
11 | return (
12 |
15 |
16 |
17 |
21 |
22 | );
23 | }
24 |
25 | export default LandingStack;
26 |
--------------------------------------------------------------------------------
/packages/app/screens/group/tabs/MembersScreen.js:
--------------------------------------------------------------------------------
1 | import MemberCard from "@app/components/MemberCard/MemberCard";
2 | import { View } from "react-native";
3 | import React from "react";
4 | import { useGuildContext } from "@app/utils/GuildContext";
5 |
6 | function MembersScreen() {
7 | const { guildMembers } = useGuildContext();
8 |
9 | return (
10 |
11 | {guildMembers.map((member) => (
12 |
13 |
22 |
23 | ))}
24 |
25 | );
26 | }
27 |
28 | export default MembersScreen;
29 |
--------------------------------------------------------------------------------
/packages/app/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.dev/guides/monorepos
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("node:path");
4 |
5 | // Find the project and workspace directories
6 | const projectRoot = __dirname;
7 | // This can be replaced with `find-yarn-workspace-root`
8 | const workspaceRoot = path.resolve(projectRoot, "../..");
9 |
10 | const config = getDefaultConfig(projectRoot);
11 |
12 | // 1. Watch all files within the monorepo
13 | config.watchFolders = [workspaceRoot];
14 | // 2. Let Metro know where to resolve packages and in what order
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(projectRoot, "node_modules"),
17 | path.resolve(workspaceRoot, "node_modules"),
18 | ];
19 | // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
20 | config.resolver.disableHierarchicalLookup = true;
21 |
22 | module.exports = config;
23 |
--------------------------------------------------------------------------------
/packages/app/utils/UserContext.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useMemo, useState } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | const initialState = {
5 | isLoggedIn: false,
6 | };
7 |
8 | export const UserContext = createContext(initialState);
9 |
10 | export function UserProvider({ children }) {
11 | const [user, setUser] = useState(initialState);
12 |
13 | const value = useMemo(() => {
14 | return {
15 | user,
16 | setUser,
17 | };
18 | }, [user]);
19 |
20 | return {children} ;
21 | }
22 |
23 | UserProvider.propTypes = {
24 | children: PropTypes.node,
25 | };
26 |
27 | export function useUserContext() {
28 | const user = useContext(UserContext);
29 | if (user === undefined) {
30 | throw new Error(
31 | "Please ensure you're using `useUserContext` within userProvider"
32 | );
33 | }
34 |
35 | return user;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/app/components/EventCard/RegisterButton.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Button } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | export default function RegisterButton({ registerState }) {
6 | const [isRegistered, setRegisterState] = useState(registerState);
7 | const argsIndex = isRegistered ? 1 : 0;
8 |
9 | const buttonArgs = {
10 | title: ["Going", "Cancel"],
11 | color: ["", "red"],
12 | };
13 |
14 | const handleOnRegister = () => {
15 | setRegisterState(!isRegistered);
16 | {
17 | isRegistered
18 | ? alert("Cancelled successfully!")
19 | : alert("Register button works!");
20 | }
21 | };
22 |
23 | return (
24 |
29 | );
30 | }
31 |
32 | RegisterButton.propTypes = {
33 | registerState: PropTypes.bool,
34 | };
35 |
--------------------------------------------------------------------------------
/packages/app/jest-setup.js:
--------------------------------------------------------------------------------
1 | import jest from "jest";
2 | // include this line for mocking react-native-gesture-handler
3 | import "react-native-gesture-handler/jestSetup";
4 |
5 | // include this section and the NativeAnimatedHelper section for mocking react-native-reanimated
6 | jest.mock("react-native-reanimated", () => {
7 | const Reanimated = require("react-native-reanimated/mock");
8 |
9 | // The mock for `call` immediately calls the callback which is incorrect
10 | // So we override it with a no-op
11 | Reanimated.default.call = () => {};
12 |
13 | return Reanimated;
14 | });
15 |
16 | // Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
17 | jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
18 |
19 | // Allows the use of Aynsc Storage with Jest which is required to run
20 | jest.mock("@react-native-async-storage/async-storage", () =>
21 | require("@react-native-async-storage/async-storage/jest/async-storage-mock")
22 | );
23 |
--------------------------------------------------------------------------------
/packages/app/assets/eye-line-off.js:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { Path } from "react-native-svg";
3 |
4 | const EyeOff = (props) => (
5 |
12 |
13 |
17 |
18 | );
19 | export default EyeOff;
20 |
--------------------------------------------------------------------------------
/packages/server/__tests__/controllers/users.test.js:
--------------------------------------------------------------------------------
1 | const { updateNewUser } = require("../../controllers/users");
2 | const { prismaMock } = require("../prisma_mock");
3 |
4 | describe("Onboarding User Unit Tests", () => {
5 | const userId = "testUserId";
6 | const userData = {
7 | name: "John",
8 | age: "25",
9 | email: "john@example.com",
10 | };
11 |
12 | const updatedUser = {
13 | userId: userId,
14 | name: "John",
15 | age: 25,
16 | email: "john@example.com",
17 | isNew: false,
18 | };
19 |
20 | test("should update user data and set isNew flag to false", async () => {
21 | prismaMock.user.update.mockResolvedValue(updatedUser);
22 |
23 | const result = await updateNewUser(userId, userData);
24 |
25 | expect(prismaMock.user.update).toHaveBeenCalledWith({
26 | where: {
27 | userId: userId,
28 | },
29 | data: {
30 | ...userData,
31 | isNew: false,
32 | },
33 | });
34 |
35 | expect(result).toEqual(updatedUser);
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/packages/app/components/DividerWithText.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, View, Text } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | const GRAY = "#c4c4c4";
6 |
7 | const styles = StyleSheet.create({
8 | container: {
9 | alignItems: "center",
10 | flexDirection: "row",
11 | justifyContent: "center",
12 | marginBottom: 10,
13 | marginTop: 10,
14 | },
15 | lineDivider: {
16 | backgroundColor: GRAY,
17 | height: 1,
18 | marginBottom: 10,
19 | marginTop: 10,
20 | width: "40%",
21 | },
22 | textStyle: {
23 | alignItems: "center",
24 | fontWeight: "bold",
25 | paddingLeft: 20,
26 | paddingRight: 20,
27 | },
28 | });
29 |
30 | function DividerWithText(props) {
31 | return (
32 |
33 |
34 | {props.title}
35 |
36 |
37 | );
38 | }
39 |
40 | DividerWithText.propTypes = {
41 | title: PropTypes.string,
42 | };
43 |
44 | export default DividerWithText;
45 |
--------------------------------------------------------------------------------
/packages/app/components/MemberCard/RoundedIcon.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { Image, StyleSheet, TouchableOpacity } from "react-native";
4 |
5 | const RoundedIcon = (props) => {
6 | const styles = StyleSheet.create({
7 | icon: {
8 | alignItems: "center",
9 | borderRadius: 30,
10 | height: 60,
11 | justifyContent: "center",
12 | overflow: "hidden",
13 | width: 60,
14 | },
15 | image: {
16 | aspectRatio: 1,
17 | borderRadius: 15,
18 | height: "100%",
19 | width: "100%",
20 | },
21 | });
22 |
23 | return (
24 |
25 |
33 |
34 | );
35 | };
36 |
37 | RoundedIcon.propTypes = {
38 | image: PropTypes.string,
39 | onIconClick: PropTypes.func,
40 | };
41 |
42 | export default RoundedIcon;
43 |
--------------------------------------------------------------------------------
/packages/app/components/GroupScreen/GroupTag.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet, Text } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | const BLUE = "#3498DB";
6 | const LIGHT_GRAY = "#E4E4E4";
7 |
8 | const styles = StyleSheet.create({
9 | tagContainer: {
10 | alignItems: "center",
11 | backgroundColor: BLUE,
12 | borderRadius: 5,
13 | justifyContent: "center",
14 | marginBottom: 4,
15 | marginRight: 7,
16 | },
17 | textStyle: {
18 | color: LIGHT_GRAY,
19 | fontSize: 12,
20 | paddingHorizontal: 5,
21 | paddingVertical: 2,
22 | },
23 | });
24 |
25 | /**
26 | * A component for GroupHeader that displays a tag for an organization.
27 | *
28 | * @param {object} props - Object that contains properties of this component.
29 | * @param {string} props.text - Text on the tag.
30 | */
31 | function GroupTag(props) {
32 | return (
33 |
34 | {props.text}
35 |
36 | );
37 | }
38 |
39 | GroupTag.propTypes = {
40 | text: PropTypes.string,
41 | };
42 |
43 | export default GroupTag;
44 |
--------------------------------------------------------------------------------
/packages/server/index.js:
--------------------------------------------------------------------------------
1 | const dotenv = require("dotenv");
2 | const express = require("express");
3 | const cors = require("cors");
4 | const cookieParser = require("cookie-parser");
5 |
6 | dotenv.config();
7 |
8 | const app = express();
9 | const PORT = process.env.PORT || 5050;
10 |
11 | const users = require("./routes/api/users");
12 | const guilds = require("./routes/api/guilds");
13 | const events = require("./routes/api/events");
14 | const auth = require("./routes/api/auth");
15 | const images = require("./routes/api/images");
16 |
17 | app.use(
18 | cors({
19 | origin: ["icebreak://", "http://localhost:8081"],
20 | credentials: true,
21 | })
22 | );
23 |
24 | app.use(cookieParser());
25 | app.use(express.json({ limit: "20mb" }));
26 |
27 | app.get("/", async (request, response) => {
28 | response.send("Hello SEA!");
29 | });
30 |
31 | app.use("/api/auth", auth);
32 | app.use("/api/users", users);
33 | app.use("/api/guilds", guilds);
34 | app.use("/api/events", events);
35 | app.use("/api/media/images", images);
36 |
37 | const server = app.listen(PORT, () => {
38 | console.log(`Server listening on port ${PORT}`);
39 | });
40 |
41 | module.exports = server;
42 |
--------------------------------------------------------------------------------
/packages/app/components/Button.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, TouchableHighlight, View, Text } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | const styles = StyleSheet.create({
6 | container: {
7 | alignItems: "center",
8 | flexDirection: "row",
9 | justifyContent: "center",
10 | },
11 | textStyle: {
12 | alignItems: "center",
13 | },
14 | });
15 |
16 | function Button(props) {
17 | return (
18 |
19 |
20 | {props.icon}
21 |
22 |
31 | {props.title}
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | Button.propTypes = {
39 | fontColor: PropTypes.string,
40 | fontWeight: PropTypes.string,
41 | icon: PropTypes.node,
42 | textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
43 | title: PropTypes.string,
44 | };
45 |
46 | export default Button;
47 |
--------------------------------------------------------------------------------
/packages/app/components/MemberCard/MemberFunctions.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Image, StyleSheet } from "react-native";
3 | import OptionsMenu from "react-native-option-menu";
4 | import PropTypes from "prop-types";
5 |
6 | const styles = StyleSheet.create({
7 | buttonDots: {
8 | aspectRatio: 1,
9 | height: "100%",
10 | width: "100%",
11 | },
12 | smallButtonDots: {
13 | aspectRatio: 1,
14 | height: 40,
15 | resizeMode: "contain",
16 | width: 20,
17 | },
18 | });
19 |
20 | const report = () => {
21 | console.log("report");
22 | };
23 | const block = () => {
24 | console.log("blocked");
25 | };
26 |
27 | export const ThreeDotsButton = ({ isProfilePopup }) => {
28 | const buttonStyle = isProfilePopup
29 | ? styles.smallButtonDots
30 | : styles.buttonDots;
31 |
32 | const MoreIcon = (
33 |
34 | );
35 |
36 | return (
37 |
43 | );
44 | };
45 |
46 | ThreeDotsButton.propTypes = {
47 | isProfilePopup: PropTypes.bool,
48 | };
49 |
--------------------------------------------------------------------------------
/packages/app/assets/google-icon.js:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { Path } from "react-native-svg";
3 |
4 | const GoogleIcon = (props) => (
5 |
12 |
16 |
20 |
24 |
28 |
29 | );
30 |
31 | export default GoogleIcon;
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "workspaces": {
4 | "packages": [
5 | "packages/*"
6 | ],
7 | "nohoist": []
8 | },
9 | "name": "icebreak",
10 | "version": "0.0.0",
11 | "scripts": {
12 | "app:ios": "yarn workspace app ios",
13 | "app:android": "yarn workspace app android",
14 | "app:start": "yarn workspace app start",
15 | "app:dev": "yarn workspace app dev",
16 | "app:test": "yarn workspace app test",
17 | "app:link": "cd packages/app/ios; pod install;",
18 | "server:start": "yarn workspace server start",
19 | "server:dev": "yarn workspace server dev",
20 | "server:test": "yarn workspace server test",
21 | "port:forward": "yarn workspace server forward",
22 | "reset": "find . -type dir -name node_modules | xargs rm -rf && yarn",
23 | "dev": "node ./scripts/run.js",
24 | "postinstall": "husky"
25 | },
26 | "description": "organization engagement platform",
27 | "main": "index.js",
28 | "author": "SEA",
29 | "license": "MIT",
30 | "packageManager": "yarn@1.22.19",
31 | "devDependencies": {
32 | "@commitlint/cli": "19.2.0",
33 | "@commitlint/config-conventional": "19.1.0",
34 | "eslint": "8.57.0",
35 | "eslint-config-prettier": "^9.0.0",
36 | "husky": "^9.0.11",
37 | "lint-staged": "15.2.2",
38 | "prettier": "3.2.5"
39 | },
40 | "repository": "https://github.com/cppsea/icebreak.git"
41 | }
42 |
--------------------------------------------------------------------------------
/packages/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "author": "SEA",
6 | "license": "MIT",
7 | "private": true,
8 | "scripts": {
9 | "start": "dotenvx run --env-file=.env.production -- nodemon index.js",
10 | "forward": "ttab node scripts/ngrok.js",
11 | "dev": "dotenvx run --env-file=.env.development -- nodemon index.js",
12 | "test": "jest"
13 | },
14 | "dependencies": {
15 | "@aws-sdk/client-s3": "^3.370.0",
16 | "@aws-sdk/s3-request-presigner": "^3.378.0",
17 | "@dotenvx/dotenvx": "^0.25.1",
18 | "@prisma/client": "^5.7.1",
19 | "bcrypt": "^5.1.0",
20 | "cookie-parser": "^1.4.6",
21 | "cors": "^2.8.5",
22 | "express": "^4.18.1",
23 | "express-session": "^1.17.3",
24 | "express-validator": "^7.0.1",
25 | "google-auth-library": "^9.7.0",
26 | "ioredis": "^5.3.2",
27 | "jsonwebtoken": "^9.0.2",
28 | "nodemailer": "^6.9.10",
29 | "luxon": "^3.4.4",
30 | "pg": "^8.8.0",
31 | "prisma": "^5.7.1",
32 | "qrcode": "^1.5.3",
33 | "redis": "^4.6.7",
34 | "uniqid": "^5.4.0",
35 | "uuid": "^9.0.0"
36 | },
37 | "devDependencies": {
38 | "axios": "^1.3.4",
39 | "eslint-plugin-jest": "^27.6.0",
40 | "jest": "^29.5.0",
41 | "jest-mock-extended": "^3.0.5",
42 | "nodemon": "^3.1.0"
43 | },
44 | "prisma": {
45 | "seed": "node prisma/seed.js"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/app/components/EventCard/EventCardRegistration.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, View } from "react-native";
3 | import FaceIcon from "./FaceIcon";
4 | import PropTypes from "prop-types";
5 | import RegisterButton from "./RegisterButton";
6 |
7 | // Sample array for testing
8 | const sampleArray = [0, 1, 2, 3];
9 |
10 | const styles = StyleSheet.create({
11 | buttonView: {
12 | flex: 7,
13 | justifyContent: "center",
14 | },
15 | container: {
16 | flexDirection: "row",
17 | },
18 | faceView: {
19 | flexDirection: "row",
20 | flex: 3,
21 | justifyContent: "center",
22 | },
23 | });
24 |
25 | function EventCardRegistration(props) {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 | {sampleArray.slice(0, 4).map((x) => {
33 | return (
34 |
41 | );
42 | })}
43 |
44 |
45 | );
46 | }
47 |
48 | EventCardRegistration.propTypes = {
49 | registerState: PropTypes.bool,
50 | };
51 |
52 | export default EventCardRegistration;
53 |
--------------------------------------------------------------------------------
/packages/app/components/EventOverview/EventOverviewRegister.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, View, Button, Share, Alert } from "react-native";
3 | import RegisterButton from "../EventCard/RegisterButton";
4 | import PropTypes from "prop-types";
5 |
6 | const styles = StyleSheet.create({
7 | container: {
8 | borderRadius: 0,
9 | flexDirection: "row",
10 | },
11 | goingButton: {
12 | flex: 7,
13 | marginRight: 15,
14 | marginTop: 10,
15 | },
16 | shareButton: {
17 | flex: 3,
18 | padding: 10,
19 | },
20 | });
21 |
22 | export default function EventRegister({ registerState }) {
23 | const handleOnShare = async () => {
24 | try {
25 | const result = await Share.share({
26 | message: "https://cppsea.com",
27 | });
28 | if (result.action === Share.sharedAction) {
29 | console.log("Content shared successfully");
30 | } else if (result.action === Share.dismissedAction) {
31 | console.log("Sharing cancelled");
32 | }
33 | } catch (error) {
34 | Alert.alert(error.message);
35 | }
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | EventRegister.propTypes = {
51 | registerState: PropTypes.bool,
52 | };
53 |
--------------------------------------------------------------------------------
/packages/app/app.config.js:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | module.exports = {
4 | name: "Icebreak",
5 | slug: "icebreak",
6 | owner: "icebreak",
7 | version: "1.0.0",
8 | orientation: "portrait",
9 | icon: "./assets/icon.png",
10 | userInterfaceStyle: "light",
11 | splash: {
12 | image: "./assets/splash.png",
13 | resizeMode: "contain",
14 | backgroundColor: "#ffffff",
15 | },
16 | scheme: "icebreak",
17 | updates: {
18 | fallbackToCacheTimeout: 0,
19 | },
20 | assetBundlePatterns: ["**/*"],
21 | ios: {
22 | supportsTablet: true,
23 | bundleIdentifier: "com.sea.icebreak",
24 | infoPlist: {
25 | CFBundleURLTypes: [
26 | {
27 | CFBundleURLSchemes: [process.env.IOS_GOOGLE_URL_SCHEME],
28 | },
29 | ],
30 | },
31 | },
32 | android: {
33 | adaptiveIcon: {
34 | foregroundImage: "./assets/adaptive-icon.png",
35 | backgroundColor: "#FFFFFF",
36 | },
37 | package: "com.sea.icebreak",
38 | },
39 | web: {
40 | favicon: "./assets/favicon.png",
41 | },
42 | extra: {
43 | eas: {
44 | projectId: "9753a4fe-f34e-4269-95b5-f6d0399ed1c8",
45 | },
46 | expoClientId: process.env.EXPO_CLIENT_ID,
47 | iosClientId: process.env.IOS_CLIENT_ID,
48 | anroidClientId: process.env.ANDROID_CLIENT_ID,
49 | webClientId: process.env.WEB_CLIENT_ID,
50 | },
51 | plugins: [
52 | [
53 | "@react-native-google-signin/google-signin",
54 | {
55 | iosUrlScheme: process.env.IOS_GOOGLE_URL_SCHEME,
56 | },
57 | ],
58 | ],
59 | };
60 |
--------------------------------------------------------------------------------
/packages/app/utils/useGoogleLogin.js:
--------------------------------------------------------------------------------
1 | import {
2 | GoogleSignin,
3 | statusCodes,
4 | } from "@react-native-google-signin/google-signin";
5 |
6 | import { ENDPOINT } from "@app/utils/constants";
7 | import axios from "axios";
8 | import * as SecureStore from "@app/utils/SecureStore";
9 |
10 | export async function useGoogleLogin(user, setUser) {
11 | try {
12 | await GoogleSignin.hasPlayServices();
13 | const userInfo = await GoogleSignin.signIn();
14 | const idToken = userInfo.idToken;
15 |
16 | const body = {
17 | token: idToken,
18 | };
19 |
20 | const { data: response } = await axios.post(
21 | `${ENDPOINT}/auth/google`,
22 | body
23 | );
24 |
25 | if (response?.status == "success") {
26 | await SecureStore.save("accessToken", response.data.accessToken);
27 | await SecureStore.save("refreshToken", response.data.refreshToken);
28 |
29 | setUser({
30 | ...user,
31 | isLoggedIn: true,
32 | data: response.data.user,
33 | });
34 | }
35 | } catch (error) {
36 | if (error.code === statusCodes.SIGN_IN_CANCELLED) {
37 | console.log("Google Error: User cancelled the login flow");
38 | } else if (error.code === statusCodes.IN_PROGRESS) {
39 | console.log(
40 | "Google Error: Operation (e.g. sign in) is in progress already"
41 | );
42 | } else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
43 | console.log("Google Error: Play services not available or outdated");
44 | } else {
45 | console.log(error);
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/app/utils/EventContext.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useState,
4 | useMemo,
5 | useContext,
6 | useEffect,
7 | } from "react";
8 | import axios from "axios";
9 | import * as SecureStore from "@app/utils/SecureStore";
10 | import { ENDPOINT } from "./constants";
11 | import PropTypes from "prop-types";
12 |
13 | const EventContext = createContext();
14 | const EVENTID = "6e22eb57-fce2-4db7-9279-5ab6c3acfec7";
15 |
16 | export function EventProvider({ eventID = EVENTID, children }) {
17 | const [event, setEvent] = useState([]);
18 |
19 | useEffect(() => {
20 | async function getEventData() {
21 | try {
22 | const token = await SecureStore.getValueFor("accessToken");
23 |
24 | const { data: eventResponse } = await axios.get(
25 | `${ENDPOINT}/events/${eventID}`,
26 | {
27 | headers: {
28 | Authorization: token,
29 | },
30 | }
31 | );
32 |
33 | setEvent(eventResponse.data.event);
34 | } catch (err) {
35 | console.error(err);
36 | }
37 | }
38 |
39 | getEventData();
40 | }, []);
41 |
42 | //retrieve data on re-render
43 | const eventCtx = useMemo(() => event);
44 |
45 | return (
46 | {children}
47 | );
48 | }
49 |
50 | export function useEventContext() {
51 | const eventInfo = useContext(EventContext);
52 | if (!eventInfo) {
53 | throw new Error(
54 | "You are using guild context outside of EventProvider. Context undefined"
55 | );
56 | }
57 | return eventInfo;
58 | }
59 |
60 | EventProvider.propTypes = {
61 | eventID: PropTypes.string,
62 | children: PropTypes.node,
63 | };
64 |
--------------------------------------------------------------------------------
/packages/server/utils/token.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 | require("bcrypt");
3 |
4 | function generateRefreshToken(user) {
5 | const { userId } = user;
6 | return jwt.sign(
7 | {
8 | userId,
9 | },
10 | process.env.TOKEN_SECRET,
11 | {
12 | expiresIn: "1d",
13 | }
14 | );
15 | }
16 |
17 | function generateAccessToken(user) {
18 | const { userId, firstName, lastName, avatar, email } = user;
19 | return jwt.sign(
20 | {
21 | userId,
22 | firstName,
23 | lastName,
24 | avatar,
25 | email,
26 | },
27 | process.env.WEB_CLIENT_SECRET,
28 | {
29 | expiresIn: "1h",
30 | }
31 | );
32 | }
33 |
34 | function generateResetPasswordToken(userId) {
35 | return jwt.sign(
36 | {
37 | userId,
38 | },
39 | process.env.TOKEN_SECRET,
40 | {
41 | expiresIn: "15m",
42 | }
43 | );
44 | }
45 |
46 | function verifyRefreshToken(refreshToken) {
47 | return jwt.verify(refreshToken, process.env.TOKEN_SECRET);
48 | }
49 |
50 | function verifyAccessToken(accessToken) {
51 | return jwt.verify(
52 | accessToken,
53 | process.env.WEB_CLIENT_SECRET,
54 | function (err, decoded) {
55 | if (err) {
56 | throw err;
57 | } else {
58 | // Token is valid
59 | // Return the payload of the access token
60 | return decoded;
61 | }
62 | }
63 | );
64 | }
65 |
66 | function verifyPasswordResetToken(passwordResetToken) {
67 | return jwt.verify(passwordResetToken, process.env.TOKEN_SECRET);
68 | }
69 |
70 | module.exports = {
71 | generateRefreshToken,
72 | generateAccessToken,
73 | generateResetPasswordToken,
74 | verifyRefreshToken,
75 | verifyAccessToken,
76 | verifyPasswordResetToken,
77 | };
78 |
--------------------------------------------------------------------------------
/packages/server/__tests__/controllers/auth.test.js:
--------------------------------------------------------------------------------
1 | const { prismaMock } = require("../prisma_mock");
2 | const { isUserEmail, isGoogleAccount } = require("../../controllers/auth");
3 |
4 | describe("Forgot Password User Verification Unit Tests", () => {
5 | test("should return true if user email exists", async () => {
6 | const testEmail = "userthatexists@gmail.com";
7 | const testUser = {
8 | email: testEmail,
9 | };
10 | prismaMock.users.findUnique.mockResolvedValue(testUser);
11 |
12 | const result = await isUserEmail(testEmail);
13 |
14 | expect(result).toEqual(true);
15 | });
16 |
17 | test("should return false if user email does not exist", async () => {
18 | const testEmail = "userthatisnonexistent@example.com";
19 | prismaMock.users.findUnique.mockResolvedValue(null);
20 |
21 | const result = await isUserEmail(testEmail);
22 |
23 | expect(result).toEqual(false);
24 | });
25 |
26 | test("should return true if user is a Google account", async () => {
27 | const testUserId = "googlegeneratedaccountid";
28 | const testUser = {
29 | userId: testUserId,
30 | password: null,
31 | };
32 | prismaMock.users.findUnique.mockResolvedValue(testUser);
33 |
34 | const result = await isGoogleAccount(testUserId);
35 |
36 | expect(result).toEqual(true);
37 | });
38 |
39 | test("should return false if user is not a Google account", async () => {
40 | const testUserId = "nongooglegeneratedaccountid";
41 | const testUser = {
42 | userId: testUserId,
43 | password: "hashedPassword",
44 | };
45 | prismaMock.users.findUnique.mockResolvedValue(testUser);
46 |
47 | const result = await isGoogleAccount(testUserId);
48 |
49 | expect(result).toEqual(false);
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/packages/server/validators/images.js:
--------------------------------------------------------------------------------
1 | const { body, param } = require("express-validator");
2 | const UserController = require("../controllers/users");
3 | const GuildController = require("../controllers/guilds");
4 | const EventController = require("../controllers/events");
5 |
6 | const imageTypeRegex =
7 | /(user_avatar|guild_avatar|guild_banner|event_thumbnail)/;
8 | const jpegPrefixRegex = /\/9j\/.*/;
9 |
10 | const imageEntityValidator = [
11 | param("imageType")
12 | .trim()
13 | .blacklist("<>")
14 | .matches(imageTypeRegex)
15 | .withMessage("Invalid image type"),
16 | param("entityUUID")
17 | .trim()
18 | .blacklist("<>")
19 | .isUUID()
20 | .withMessage("Invalid UUID")
21 | .bail()
22 | .custom(async (entityUUID, { req }) => {
23 | switch (req.params.imageType) {
24 | case "user_avatar":
25 | await UserController.getUser(entityUUID).catch(() => {
26 | throw new Error(`No user exists with an ID of ${entityUUID}`);
27 | });
28 | break;
29 | case "guild_avatar":
30 | case "guild_banner":
31 | await GuildController.getGuild(entityUUID).catch(() => {
32 | throw new Error(`No guild exists with an ID of ${entityUUID}`);
33 | });
34 | break;
35 | case "event_banner":
36 | await EventController.getEvent(entityUUID).catch(() => {
37 | throw new Error(`No event exists with an ID of ${entityUUID}`);
38 | });
39 | break;
40 | }
41 | }),
42 | ];
43 |
44 | const jpegBase64Validator = body("jpegBase64")
45 | .trim()
46 | .isBase64()
47 | .withMessage("Invalid Base64")
48 | .matches(jpegPrefixRegex)
49 | .withMessage("Input Base64 is not valid JPEG data");
50 |
51 | module.exports = { imageEntityValidator, jpegBase64Validator };
52 |
--------------------------------------------------------------------------------
/packages/server/prisma/migrations/20240428195602_update_enum_values/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The values [not interested,interested,attending,checked in] on the enum `event_attendee_status` will be removed. If these variants are still used in the database, this will fail.
5 | - The values [member,officer,owner] on the enum `guild_member_role` will be removed. If these variants are still used in the database, this will fail.
6 |
7 | */
8 | -- AlterEnum
9 | BEGIN;
10 | CREATE TYPE "event_attendee_status_new" AS ENUM ('Not Interested', 'Interested', 'Attending', 'Checked In');
11 | ALTER TABLE "event_attendees" ALTER COLUMN "status" DROP DEFAULT;
12 | ALTER TABLE "event_attendees" ALTER COLUMN "status" TYPE "event_attendee_status_new" USING ("status"::text::"event_attendee_status_new");
13 | ALTER TYPE "event_attendee_status" RENAME TO "event_attendee_status_old";
14 | ALTER TYPE "event_attendee_status_new" RENAME TO "event_attendee_status";
15 | DROP TYPE "event_attendee_status_old";
16 | ALTER TABLE "event_attendees" ALTER COLUMN "status" SET DEFAULT 'Not Interested';
17 | COMMIT;
18 |
19 | -- AlterEnum
20 | BEGIN;
21 | CREATE TYPE "guild_member_role_new" AS ENUM ('Member', 'Officer', 'Owner');
22 | ALTER TABLE "guild_members" ALTER COLUMN "role" DROP DEFAULT;
23 | ALTER TABLE "guild_members" ALTER COLUMN "role" TYPE "guild_member_role_new" USING ("role"::text::"guild_member_role_new");
24 | ALTER TYPE "guild_member_role" RENAME TO "guild_member_role_old";
25 | ALTER TYPE "guild_member_role_new" RENAME TO "guild_member_role";
26 | DROP TYPE "guild_member_role_old";
27 | ALTER TABLE "guild_members" ALTER COLUMN "role" SET DEFAULT 'Member';
28 | COMMIT;
29 |
30 | -- AlterTable
31 | ALTER TABLE "event_attendees" ALTER COLUMN "status" SET DEFAULT 'Not Interested';
32 |
33 | -- AlterTable
34 | ALTER TABLE "guild_members" ALTER COLUMN "role" SET DEFAULT 'Member';
35 |
--------------------------------------------------------------------------------
/packages/app/__tests__/FeedDrawer.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import FeedDrawer from "../screens/feed/FeedDrawer";
3 | import { render } from "@testing-library/react-native";
4 | import { NavigationContainer } from "@react-navigation/native";
5 | import jest, { test, expect } from "jest";
6 |
7 | // This section is used to mock the user data information within the FeedScreen.
8 | // Without this, Jest does not recognize the user data and throws an error.
9 | jest.mock("@app/utils/UserContext");
10 |
11 | const mockedValue = {
12 | user: {
13 | isLoggedIn: true,
14 | data: {
15 | firstName: "Bob",
16 | lastName: "Larry",
17 | // some other data here ( just trying to at least make user.data be not undefined )
18 | },
19 | },
20 | };
21 |
22 | jest.mock("@app/utils/UserContext", () => ({
23 | ...jest.requireActual("@app/utils/UserContext"),
24 | useUserContext: () => mockedValue,
25 | }));
26 |
27 | // This section is the actual test of the FeedDrawer component
28 |
29 | test("FeedDrawer is visible", () => {
30 | const { queryByText } = render(
31 | { }
32 | );
33 |
34 | // Test if the drawer is able to be visible by default
35 | expect(queryByText("Home")).toBeTruthy();
36 | });
37 |
38 | // This section is used to mock the FlatList component within the FeedScreen. Without this, the render() function will throw an error.
39 | jest.mock("react-native", () => {
40 | const React = jest.requireActual("react");
41 | const actual = jest.requireActual("react-native");
42 | const View = actual.View;
43 | const Text = actual.Text;
44 | function MockedFlatList() {
45 | return (
46 |
47 | Mocked FlatList
48 |
49 | );
50 | }
51 | Object.defineProperty(actual, "FlatList", {
52 | get: () => MockedFlatList,
53 | });
54 | return actual;
55 | });
56 |
--------------------------------------------------------------------------------
/packages/server/controllers/users.js:
--------------------------------------------------------------------------------
1 | const prisma = require("../prisma/prisma");
2 |
3 | async function getAllUsers() {
4 | const query = await prisma.user.findMany();
5 | return query;
6 | }
7 |
8 | async function getUser(userId) {
9 | const query = await prisma.user.findUnique({
10 | where: {
11 | userId: userId,
12 | },
13 | });
14 | return query;
15 | }
16 |
17 | async function getUserEmail(userId) {
18 | const query = await prisma.user.findUnique({
19 | where: {
20 | userId: userId,
21 | },
22 | });
23 | return query.email;
24 | }
25 |
26 | async function getUserByEmail(email) {
27 | const query = await prisma.user.findUnique({
28 | where: {
29 | email: email,
30 | },
31 | });
32 | return query;
33 | }
34 |
35 | async function getUserIdByEmail(email) {
36 | const query = await prisma.user.findUnique({
37 | where: {
38 | email: email,
39 | },
40 | });
41 | return query.userId;
42 | }
43 |
44 | async function getGuildsForUser(userId) {
45 | const userGuilds = await prisma.guildMember.findMany({
46 | where: {
47 | userId: userId,
48 | },
49 | include: {
50 | guilds: {
51 | select: {
52 | avatar: true,
53 | guildId: true,
54 | name: true,
55 | handler: true,
56 | },
57 | },
58 | },
59 | });
60 |
61 | const guilds = userGuilds.map((userGuild) => userGuild.guilds);
62 | return guilds;
63 | }
64 |
65 | async function updateNewUser(userId, userData) {
66 | const updatedUser = await prisma.user.update({
67 | where: {
68 | userId: userId,
69 | },
70 | data: {
71 | ...userData,
72 | isNew: false,
73 | },
74 | });
75 | return updatedUser;
76 | }
77 |
78 | module.exports = {
79 | getUser,
80 | getAllUsers,
81 | getUserByEmail,
82 | getGuildsForUser,
83 | getUserIdByEmail,
84 | getUserEmail,
85 | updateNewUser,
86 | };
87 |
--------------------------------------------------------------------------------
/packages/server/utils/redis.js:
--------------------------------------------------------------------------------
1 | const redis = require("ioredis");
2 | const crypto = require("node:crypto");
3 |
4 | // Create a Redis client instance
5 | const redisClient = new redis(process.env.REDIS_URL);
6 |
7 | // Function to check if a token is valid by verifying its absence in the Redis set
8 | async function checkInvalidToken(token) {
9 | const isMember = await redisClient.sismember("token_blacklist", token);
10 | if (isMember === 1) {
11 | return true;
12 | } else {
13 | return false;
14 | }
15 | }
16 |
17 | async function addToTokenBlacklist(token) {
18 | await redisClient.sadd("token_blacklist", token);
19 | }
20 |
21 | // Function to check if a password reset token is valid by verifying its absence in the Redis set
22 | async function checkInvalidPasswordResetToken(token) {
23 | // hash the token before checking the hashed tokens in the set
24 | const hash = crypto.createHash("sha256");
25 | hash.update(token);
26 | const hashedToken = hash.digest("hex");
27 |
28 | const isMember = await redisClient.sismember(
29 | "password_reset_token_blacklist",
30 | hashedToken
31 | );
32 | if (isMember === 1) {
33 | return true;
34 | } else {
35 | return false;
36 | }
37 | }
38 |
39 | async function addToPasswordResetTokenBlacklist(token) {
40 | // hash the token before tossing it into the set
41 | const hash = crypto.createHash("sha256");
42 | hash.update(token);
43 | const hashedToken = hash.digest("hex");
44 | await redisClient.sadd("password_reset_token_blacklist", hashedToken);
45 | }
46 |
47 | // Log any errors that occur during the Redis connection
48 | redisClient.on("error", (error) => {
49 | console.error("Redis connection error:", error);
50 | });
51 |
52 | // Export the Redis client
53 | module.exports = {
54 | redisClient,
55 | checkInvalidToken,
56 | addToTokenBlacklist,
57 | checkInvalidPasswordResetToken,
58 | addToPasswordResetTokenBlacklist,
59 | };
60 |
--------------------------------------------------------------------------------
/packages/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "app",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start --dev-client",
7 | "cold-start": "expo start -c --dev-client",
8 | "android": "expo start --android",
9 | "ios": "expo start --ios",
10 | "web": "expo start --web",
11 | "dev": "expo start --dev-client",
12 | "test": "jest"
13 | },
14 | "dependencies": {
15 | "@expo/vector-icons": "^14.0.0",
16 | "@react-native-async-storage/async-storage": "1.21.0",
17 | "@react-native-google-signin/google-signin": "^11.0.0",
18 | "@react-navigation/bottom-tabs": "^6.4.0",
19 | "@react-navigation/drawer": "^6.5.0",
20 | "@react-navigation/native": "^6.0.13",
21 | "@react-navigation/native-stack": "^6.9.0",
22 | "axios": "^1.6.8",
23 | "expo": "^50.0.14",
24 | "expo-application": "~5.8.3",
25 | "expo-auth-session": "~5.4.0",
26 | "expo-constants": "~15.4.5",
27 | "expo-dev-client": "~3.3.10",
28 | "expo-random": "~13.6.0",
29 | "expo-secure-store": "~12.8.1",
30 | "expo-status-bar": "~1.11.1",
31 | "expo-web-browser": "~12.8.2",
32 | "prop-types": "^15.8.1",
33 | "react": "18.2.0",
34 | "react-native": "0.73.6",
35 | "react-native-gesture-handler": "~2.14.0",
36 | "react-native-option-menu": "^1.1.3",
37 | "react-native-reanimated": "~3.6.2",
38 | "react-native-safe-area-context": "4.8.2",
39 | "react-native-screens": "~3.29.0",
40 | "react-native-svg": "14.1.0",
41 | "react-test-renderer": "^18.2.0"
42 | },
43 | "devDependencies": {
44 | "@babel/core": "^7.19.3",
45 | "@babel/plugin-proposal-private-methods": "^7.18.6",
46 | "@testing-library/react-native": "^12.4.3",
47 | "babel-plugin-module-resolver": "^5.0.0",
48 | "eslint-plugin-react": "7.34.1",
49 | "eslint-plugin-react-native": "4.1.0",
50 | "jest": "^29.3.1"
51 | },
52 | "private": true
53 | }
54 |
--------------------------------------------------------------------------------
/packages/server/__tests__/controllers/events.test.js:
--------------------------------------------------------------------------------
1 | const { prismaMock } = require("../prisma_mock");
2 | const { getPublicUpcomingEvents } = require("../../controllers/events");
3 |
4 | describe("getPublicUpcomingEvents Unit Tests", () => {
5 | const testEvents = [
6 | {
7 | eventId: "eventId1",
8 | name: "Event 1",
9 | startDate: new Date("2024-04-10"),
10 | guilds: {
11 | isInviteOnly: false,
12 | },
13 | },
14 | {
15 | eventId: "eventId2",
16 | name: "Event 2",
17 | startDate: new Date("2024-04-15"),
18 | guilds: {
19 | isInviteOnly: false,
20 | },
21 | },
22 | ];
23 |
24 | test("should fetch public upcoming events when action is 'next'", async () => {
25 | const limit = 10;
26 | const eventId = "mockEventID";
27 |
28 | prismaMock.events.findMany.mockResolvedValue(testEvents);
29 |
30 | const result = await getPublicUpcomingEvents(limit, eventId);
31 |
32 | expect(prismaMock.events.findMany).toHaveBeenCalledWith({
33 | take: limit,
34 | skip: 1,
35 | cursor: { eventId: eventId },
36 | orderBy: { startDate: "asc" },
37 | where: {
38 | guilds: { isInviteOnly: false },
39 | startDate: { gt: expect.any(Date) },
40 | },
41 | });
42 |
43 | expect(result).toEqual(testEvents);
44 | });
45 |
46 | test("should fetch public upcoming events when no action is provided", async () => {
47 | const limit = 10;
48 | const eventId = undefined;
49 |
50 | prismaMock.events.findMany.mockResolvedValue(testEvents);
51 |
52 | const result = await getPublicUpcomingEvents(limit, eventId);
53 |
54 | expect(prismaMock.events.findMany).toHaveBeenCalledWith({
55 | take: limit,
56 | orderBy: { startDate: "asc" },
57 | where: {
58 | guilds: { isInviteOnly: false },
59 | startDate: { gt: expect.any(Date) },
60 | },
61 | });
62 |
63 | expect(result).toEqual(testEvents);
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/packages/app/components/MemberCard/MemberCard.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import RoundedIcon from "@app/components/MemberCard/RoundedIcon";
3 | import PropTypes from "prop-types";
4 | import { Platform, StyleSheet, Text, View } from "react-native";
5 | import { ThreeDotsButton } from "./MemberFunctions";
6 | import ProfilePopup from "./ProfilePopup";
7 |
8 | const containerBG = "rgb(245, 245, 245)";
9 | const shadow = "rgba(0, 0, 0, 0.25)";
10 | const nameColor = "rgb(51,51,51)";
11 |
12 | const styles = StyleSheet.create({
13 | container: {
14 | alignItems: "center",
15 | backgroundColor: containerBG,
16 | borderRadius: 10,
17 | elevation: 1,
18 | flexDirection: "row",
19 | height: 60,
20 | justifyContent: "space-between",
21 | margin: 5,
22 | padding: 10,
23 | ...Platform.select({
24 | ios: {
25 | shadowColor: shadow,
26 | shadowOffset: { width: 0, height: 2 },
27 | shadowOpacity: 0.8,
28 | shadowRadius: 4,
29 | },
30 | android: {
31 | elevation: 4,
32 | },
33 | }),
34 | },
35 | name: {
36 | alignItems: "center",
37 | color: nameColor,
38 | flex: 1,
39 | fontSize: 24,
40 | fontWeight: "bold",
41 | textAlign: "center",
42 | },
43 | });
44 |
45 | const MemberCard = (props) => {
46 | const [showPopup, setShowPopup] = React.useState(false);
47 |
48 | const openPopup = () => {
49 | setShowPopup(true);
50 | };
51 |
52 | const closePopup = () => {
53 | setShowPopup(false);
54 | };
55 |
56 | return (
57 |
58 |
59 | {props.name}
60 |
61 |
67 |
68 | );
69 | };
70 |
71 | MemberCard.propTypes = {
72 | name: PropTypes.string,
73 | image: PropTypes.string,
74 | };
75 |
76 | export default MemberCard;
77 |
--------------------------------------------------------------------------------
/packages/app/utils/FeedContext.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useState,
4 | useMemo,
5 | useContext,
6 | useEffect,
7 | useCallback,
8 | } from "react";
9 | import axios from "axios";
10 | import * as SecureStore from "@app/utils/SecureStore";
11 | import { ENDPOINT } from "./constants";
12 | import PropTypes from "prop-types";
13 |
14 | const FeedContext = createContext();
15 |
16 | export function FeedProvider({ children }) {
17 | const [events, setEvents] = useState([]);
18 | const [refreshing, setRefreshing] = useState(false);
19 |
20 | async function getEventsData() {
21 | try {
22 | const token = await SecureStore.getValueFor("accessToken");
23 |
24 | const { data: eventsResponse } = await axios.get(
25 | `${ENDPOINT}/events/pages`,
26 | {
27 | headers: {
28 | Authorization: token,
29 | },
30 | }
31 | );
32 |
33 | const serializeEvents = eventsResponse.data.events.map((feedEvent) => {
34 | return {
35 | ...feedEvent,
36 | key: feedEvent.eventId,
37 | };
38 | });
39 |
40 | setEvents(serializeEvents);
41 | } catch (err) {
42 | console.error(err);
43 | }
44 | }
45 |
46 | useEffect(() => {
47 | getEventsData();
48 | }, [refreshing]);
49 |
50 | const onRefresh = useCallback(async () => {
51 | try {
52 | setRefreshing(true);
53 | await getEventsData();
54 | setRefreshing(false);
55 | } catch (err) {
56 | console.error(err);
57 | }
58 | }, []);
59 |
60 | const feedCtx = useMemo(() => ({
61 | events,
62 | refreshing,
63 | onRefresh,
64 | }));
65 |
66 | return (
67 | {children}
68 | );
69 | }
70 |
71 | export function useFeedContext() {
72 | const feedInfo = useContext(FeedContext);
73 | if (!feedInfo) {
74 | throw new Error(
75 | "You are using guild context outside of FeedProvider. Context undefined"
76 | );
77 | }
78 | return feedInfo;
79 | }
80 |
81 | FeedProvider.propTypes = {
82 | children: PropTypes.node,
83 | };
84 |
--------------------------------------------------------------------------------
/packages/app/components/EventOverview/EventOverviewText.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, Text, View } from "react-native";
3 | import GroupIcon from "../GroupScreen/GroupIcon";
4 | import PropTypes from "prop-types";
5 |
6 | const GRAY = "grey";
7 | const iconSize = 40;
8 |
9 | const styles = StyleSheet.create({
10 | description: {
11 | fontSize: 16,
12 | marginTop: 10,
13 | },
14 | eventTitle: {
15 | fontSize: 20,
16 | fontWeight: "bold",
17 | marginTop: 20,
18 | },
19 | groupIcon: {
20 | flex: 1.2,
21 | },
22 | groupName: {
23 | flex: 7,
24 | fontSize: 19,
25 | fontWeight: "bold",
26 | marginRight: 5,
27 | marginTop: 5,
28 | },
29 | overviewHeader: {
30 | flexDirection: "row",
31 | },
32 | smallText: {
33 | color: GRAY,
34 | fontSize: 16,
35 | marginTop: 10,
36 | },
37 | });
38 |
39 | const OverviewHeader = ({ groupName }) => {
40 | return (
41 |
42 |
43 |
49 |
50 | {groupName}
51 |
52 | );
53 | };
54 |
55 | export default function EventOverviewText({
56 | event,
57 | guild,
58 | timeBegin,
59 | timeEnd,
60 | }) {
61 | return (
62 |
63 |
64 | {event.title}
65 |
66 | {timeBegin} - {timeEnd}
67 |
68 | 📌 {event.location}
69 | {event.description}
70 |
71 | );
72 | }
73 |
74 | EventOverviewText.propTypes = {
75 | guild: PropTypes.any,
76 | event: PropTypes.any,
77 | timeBegin: PropTypes.string,
78 | timeEnd: PropTypes.string,
79 | };
80 |
81 | OverviewHeader.propTypes = {
82 | groupName: PropTypes.string,
83 | };
84 |
--------------------------------------------------------------------------------
/packages/app/components/GroupScreen/GroupIcon.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, Image, View, Text } from "react-native";
3 |
4 | import PropTypes from "prop-types";
5 |
6 | /**
7 | * A component for GroupHeader that displays an icon for an organization.
8 | *
9 | * @param {object} props - Object that contains properties of this component.
10 | * @param {number} props.size - Size of icon in pixels
11 | * @param {string} props.backgroundColor - Background color of icon.
12 | * @param {string} props.icon - Icon image source.
13 | */
14 |
15 | function GroupIcon(props) {
16 | return props.icon ? (
17 |
29 | ) : (
30 |
40 | {props.guildName.charAt(0)}
41 |
42 | );
43 | }
44 |
45 | const primary = "#33C7FF";
46 |
47 | const styles = StyleSheet.create({
48 | iconStyle: {
49 | borderRadius: 5,
50 | position: "absolute",
51 | resizeMode: "cover",
52 | },
53 | iconText: {
54 | color: primary,
55 | fontSize: 30,
56 | fontWeight: "bold",
57 | },
58 | iconTextStyle: {
59 | alignItems: "center",
60 | borderColor: primary,
61 | borderRadius: 5,
62 | borderWidth: 1.5,
63 | justifyContent: "center",
64 | overflow: "hidden",
65 | position: "absolute",
66 | resizeMode: "cover",
67 | },
68 | });
69 |
70 | GroupIcon.propTypes = {
71 | altImgStyle: PropTypes.object,
72 | backgroundColor: PropTypes.string,
73 | icon: PropTypes.number,
74 | size: PropTypes.number,
75 | guildName: PropTypes.string,
76 | iconContainer: PropTypes.object,
77 | };
78 |
79 | export default GroupIcon;
80 |
--------------------------------------------------------------------------------
/packages/app/__tests__/GroupScreen.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "@testing-library/react-native";
3 | import GroupScreen from "@app/screens/group/GroupScreen";
4 | import GroupHeader from "@app/components/GroupScreen/GroupHeader";
5 | import jest, { describe, it, expect } from "jest";
6 |
7 | jest.mock("@expo/vector-icons/Ionicons", () => ({
8 | Ionicons: () => null,
9 | }));
10 |
11 | jest.mock("@expo/vector-icons/FontAwesome5", () => ({
12 | FontAwesome5: () => null,
13 | }));
14 |
15 | describe("GroupScreen Tests", () => {
16 | describe(GroupScreen, () => {
17 | it("renders the GroupHeader component", () => {
18 | const { getByTestId } = render( );
19 | const groupHeader = getByTestId("groupHeader");
20 | expect(groupHeader).toBeTruthy();
21 | });
22 |
23 | it("renders the GroupTabs component", () => {
24 | const { getByTestId } = render( );
25 | const groupTabs = getByTestId("groupTabs");
26 | expect(groupTabs).toBeTruthy();
27 | });
28 |
29 | it("renders the selected tab", () => {
30 | const { getByTestId } = render( );
31 | const tab = getByTestId("tab");
32 | expect(tab).toBeTruthy();
33 | });
34 | });
35 |
36 | describe(GroupHeader, () => {
37 | it("renders group icon", () => {
38 | const { getByTestId } = render( );
39 | const groupIcon = getByTestId("groupIcon");
40 | expect(groupIcon).toBeTruthy();
41 | });
42 |
43 | it("renders club banner", () => {
44 | const { getByTestId } = render( );
45 | const clubBanner = getByTestId("clubBanner");
46 | expect(clubBanner).toBeTruthy();
47 | });
48 |
49 | it("renders group header info", () => {
50 | const { getByTestId } = render( );
51 | const groupHeaderInfo = getByTestId("groupHeaderInfo");
52 | expect(groupHeaderInfo).toBeTruthy();
53 | });
54 |
55 | it("renders group media icons", () => {
56 | const { getByTestId } = render( );
57 | const groupMediaIcon = getByTestId("groupMediaIcon");
58 | expect(groupMediaIcon).toBeTruthy();
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/packages/app/screens/group/GuildCard.js:
--------------------------------------------------------------------------------
1 | import GroupIcon from "@app/components/GroupScreen/GroupIcon";
2 | import React from "react";
3 | import { View, Text, StyleSheet } from "react-native";
4 | import PropTypes from "prop-types";
5 | import { TouchableOpacity } from "react-native";
6 |
7 | const GuildCard = (props) => {
8 | const iconSize = 50;
9 |
10 | const openGuild = (guildId) => {
11 | props.navigation.navigate("GroupScreen", { guildId: guildId });
12 | };
13 |
14 | return (
15 | openGuild(props.id)}>
16 |
25 |
26 | {props.name}
27 | {props.handle}
28 |
29 |
30 | );
31 | };
32 |
33 | GuildCard.propTypes = {
34 | navigation: PropTypes.object,
35 | id: PropTypes.string,
36 | avatar: PropTypes.string,
37 | name: PropTypes.string,
38 | handle: PropTypes.string,
39 | onCardClick: PropTypes.func,
40 | };
41 |
42 | const black = "#000000";
43 | const grey = "#808080";
44 | const white = "#fff";
45 |
46 | const styles = StyleSheet.create({
47 | avatar: {
48 | width: 60,
49 | },
50 | avatarImage: {
51 | position: "static",
52 | },
53 | card: {
54 | alignItems: "center",
55 | backgroundColor: white,
56 | borderRadius: 10,
57 | elevation: 15,
58 | flexDirection: "row",
59 | justifyItems: "center",
60 | margin: 10,
61 | paddingHorizontal: 15,
62 | paddingVertical: 15,
63 | shadowColor: black,
64 | shadowOffset: { width: 0, height: 2 },
65 | shadowOpacity: 0.2,
66 | shadowRadius: 5,
67 | zIndex: 99,
68 | },
69 | handle: {
70 | color: grey,
71 | fontSize: 16,
72 | },
73 | name: {
74 | fontSize: 18,
75 | fontWeight: "bold",
76 | },
77 | text: {
78 | flex: 1,
79 | justifyContent: "center",
80 | marginLeft: 15,
81 | },
82 | });
83 |
84 | export default GuildCard;
85 |
--------------------------------------------------------------------------------
/packages/app/components/GroupScreen/GroupHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet, Image } from "react-native";
3 |
4 | import GroupIcon from "./GroupIcon";
5 | import GroupHeaderInfo from "./GroupHeaderInfo";
6 | import GroupMediaIcon from "./GroupMediaIcon";
7 | import PropTypes from "prop-types";
8 | import { useGuildContext } from "@app/utils/GuildContext";
9 |
10 | const bannerHeight = 110;
11 | const iconSize = 62;
12 |
13 | const testGithubUrl = "https://github.com";
14 | const testDiscordUrl = "https://discord.com";
15 | const testLinkedInUrl = "https://linkedin.com";
16 | const testInstagramUrl = "https://instagram.com";
17 |
18 | function GroupHeader(props) {
19 | const { guild, guildMembers } = useGuildContext();
20 |
21 | return (
22 |
23 |
28 |
36 |
37 |
38 |
39 |
45 |
46 |
56 |
57 |
58 | );
59 | }
60 |
61 | const styles = StyleSheet.create({
62 | bannerStyle: {
63 | height: bannerHeight,
64 | resizeMode: "cover",
65 | width: "100%",
66 | },
67 | headerContainer: {
68 | height: "auto",
69 | },
70 | textContainer: {
71 | flexDirection: "row",
72 | marginHorizontal: 12,
73 | marginTop: -0.5 * iconSize,
74 | },
75 | });
76 |
77 | GroupHeader.propTypes = {
78 | testID: PropTypes.string,
79 | };
80 |
81 | export default GroupHeader;
82 |
--------------------------------------------------------------------------------
/packages/app/screens/group/tabs/EventsScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet, Text, Image } from "react-native";
3 | import PropTypes from "prop-types";
4 | import EventCardText from "@app/components/EventCard/EventCardText";
5 | import EventCardRegistration from "@app/components/EventCard/EventCardRegistration";
6 | import { useEventContext } from "@app/utils/EventContext";
7 |
8 | const DARK_GRAY = "#2C2C2C";
9 | const WHITE = "#FFFFFF";
10 |
11 | const mockData = [
12 | {
13 | title: "Wednesday, April 6",
14 | data: ["Pizza", "Burger", "Risotto"],
15 | },
16 | {
17 | title: "Friday, April 8",
18 | data: ["Pizza", "Burger"],
19 | },
20 | {
21 | title: "Sunday, April 10",
22 | data: ["Pizza"],
23 | },
24 | ];
25 |
26 | function EventsScreen(props) {
27 | const event = useEventContext();
28 |
29 | return (
30 |
31 | {mockData.map((section) => (
32 |
33 | {section.title}
34 | {section.data.map((item, index) => (
35 |
36 |
40 |
41 |
46 |
47 |
48 |
49 | ))}
50 |
51 | ))}
52 |
53 | );
54 | }
55 |
56 | EventsScreen.propTypes = {
57 | style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
58 | testID: PropTypes.string,
59 | navigation: PropTypes.object,
60 | };
61 |
62 | const styles = StyleSheet.create({
63 | banner: {
64 | borderTopLeftRadius: 15,
65 | borderTopRightRadius: 15,
66 | height: 144,
67 | width: "100%",
68 | },
69 | card: {
70 | backgroundColor: WHITE,
71 | marginBottom: 10,
72 | marginTop: 10,
73 | },
74 | container: {
75 | margin: 10,
76 | },
77 | header: {
78 | backgroundColor: WHITE,
79 | color: DARK_GRAY,
80 | fontSize: 20,
81 | fontWeight: "700",
82 | },
83 | });
84 |
85 | export default EventsScreen;
86 |
--------------------------------------------------------------------------------
/packages/app/screens/group/event_overview/EventOverviewScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet, Image } from "react-native";
3 | import PropTypes from "prop-types";
4 |
5 | import EventOverviewText from "@app/components/EventOverview/EventOverviewText";
6 | import EventOverviewRegister from "@app/components/EventOverview/EventOverviewRegister";
7 | import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
8 |
9 | const WHITE = "white";
10 | const transparentColor = "#00000077";
11 |
12 | const styles = StyleSheet.create({
13 | backArrow: {
14 | color: WHITE,
15 | paddingLeft: 11,
16 | paddingTop: 10,
17 | },
18 | backArrowView: {
19 | backgroundColor: transparentColor,
20 | borderRadius: 29 / 2,
21 | height: 40,
22 | left: 6,
23 | position: "absolute",
24 | resizeMode: "cover",
25 | top: 30,
26 | width: 40,
27 | },
28 | banner: {
29 | height: 160,
30 | resizeMode: "cover",
31 | width: "100%",
32 | },
33 | container: {
34 | backgroundColor: WHITE,
35 | flexDirection: "column",
36 | height: "100%",
37 | },
38 | overview: {
39 | flex: 8,
40 | padding: 10,
41 | },
42 | register: {
43 | flex: 1,
44 | },
45 | });
46 |
47 | export default function EventOverviewScreen({ navigation, route }) {
48 | const { previousScreen, event, timeBegin, timeEnd, guild } = route.params;
49 |
50 | const onBackPress = () => {
51 | navigation.navigate(previousScreen);
52 | };
53 |
54 | const BackArrow = () => {
55 | return (
56 |
57 |
63 |
64 | );
65 | };
66 |
67 | return (
68 |
69 |
73 |
74 |
75 |
81 |
82 |
83 |
84 |
85 |
86 | );
87 | }
88 |
89 | EventOverviewScreen.propTypes = {
90 | navigation: PropTypes.object,
91 | route: PropTypes.object,
92 | };
93 |
--------------------------------------------------------------------------------
/packages/server/validators/auth.js:
--------------------------------------------------------------------------------
1 | const { body } = require("express-validator");
2 | const AuthController = require("../controllers/auth");
3 | const UserController = require("../controllers/users");
4 | const { checkInvalidPasswordResetToken } = require("../utils/redis");
5 |
6 | const userEmailValidator = [
7 | body("email", "Invalid email.")
8 | .trim()
9 | .exists({ checkFalsy: true })
10 | .withMessage("Email can't be null or empty.")
11 | .isEmail()
12 | .withMessage("Invalid email entered!")
13 |
14 | // Check If User Exists in DB & If User is a Google OAuth Acc
15 | .bail()
16 | .custom(async (email) => {
17 | const userExists = await AuthController.isUserEmail(email);
18 | if (!userExists) {
19 | throw new Error("User with given email does not exist.");
20 | }
21 |
22 | const userId = await UserController.getUserIdByEmail(email);
23 | const isGoogleAccount = await AuthController.isGoogleAccount(userId);
24 | if (isGoogleAccount) {
25 | throw new Error("Please login using Google.");
26 | }
27 | }),
28 | ];
29 |
30 | const passwordResetValidator = [
31 | body("token", "Invalid token.")
32 | .trim()
33 | .exists({ checkFalsy: true })
34 | .withMessage("Token can't be null or empty.")
35 |
36 | // Check if the password reset token has been used before.
37 | .bail()
38 | .custom(async (token) => {
39 | const isUsedToken = await checkInvalidPasswordResetToken(token);
40 | if (isUsedToken) {
41 | throw new Error("This password reset token is expired/invalid!");
42 | }
43 | }),
44 |
45 | body("password", "Invalid password.")
46 | .trim()
47 | .exists({ checkFalsy: true })
48 | .withMessage("Password can't be null or empty.")
49 | .matches(
50 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,20}$/
51 | )
52 | .withMessage(
53 | "Password must be 12-20 characters long and contain at least one uppercase and lowercase letter, number, and special character!"
54 | ),
55 |
56 | body("passwordConfirmation", "Invalid confirmation password.").custom(
57 | (passwordConfirmation, { req }) => {
58 | if (passwordConfirmation !== req.body.password) {
59 | throw new Error(
60 | "Confirmation password does not match original password!"
61 | );
62 | }
63 | return true;
64 | }
65 | ),
66 | ];
67 |
68 | module.exports = {
69 | userEmailValidator,
70 | passwordResetValidator,
71 | };
72 |
--------------------------------------------------------------------------------
/packages/app/components/TextInput.js:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, useState } from "react";
2 | import {
3 | TextInput as RNTextInput,
4 | StyleSheet,
5 | Text,
6 | View,
7 | TouchableOpacity,
8 | } from "react-native";
9 |
10 | import EyeOff from "@app/assets/eye-line-off";
11 | import EyeOn from "@app/assets/eye-line-on";
12 |
13 | import PropTypes from "prop-types";
14 |
15 | const RED = "#f54242";
16 |
17 | const styles = StyleSheet.create({
18 | container: {
19 | alignItems: "flex-start",
20 | borderRadius: 10,
21 | height: "auto",
22 | width: "100%",
23 | },
24 | error: {
25 | color: RED,
26 | fontSize: 12,
27 | },
28 | input: {
29 | flex: 1,
30 | height: "100%",
31 | },
32 | textField: {
33 | alignItems: "flex-start",
34 | flexDirection: "row",
35 | },
36 | });
37 |
38 | const TextInput = forwardRef(function textInput(props, ref) {
39 | const [hidePassword, setHidePassword] = useState(props.password);
40 |
41 | return (
42 |
43 |
47 |
57 |
58 | {props.password && (
59 | setHidePassword(!hidePassword)}>
62 | {hidePassword ? : }
63 |
64 | )}
65 |
66 |
67 | {props.error && (
68 |
69 | {props.error}
70 |
71 | )}
72 |
73 | );
74 | });
75 |
76 | TextInput.propTypes = {
77 | testID: PropTypes.string,
78 | password: PropTypes.bool,
79 | container: PropTypes.object,
80 | style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
81 | error: PropTypes.string,
82 | borderColor: PropTypes.string,
83 | value: PropTypes.string,
84 | onChangeText: PropTypes.func,
85 | onSubmitEditing: PropTypes.func,
86 | placeholder: PropTypes.string,
87 | };
88 |
89 | export default TextInput;
90 |
--------------------------------------------------------------------------------
/packages/app/screens/group/GroupScreen.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import GroupHeader from "../../components/GroupScreen/GroupHeader.js";
5 | import GroupTabs from "../../components/GroupScreen/GroupTabs.js";
6 | import { GuildProvider } from "@app/utils/GuildContext.js";
7 | import Screen from "@app/components/Screen";
8 | import { StyleSheet, View } from "react-native";
9 | import { ScrollView } from "react-native-gesture-handler";
10 |
11 | import { EventProvider } from "@app/utils/EventContext.js";
12 | import EventsScreen from "../../screens/group/tabs/EventsScreen";
13 | import MembersScreen from "../../screens/group/tabs/MembersScreen";
14 | import LeaderboardScreen from "../../screens/group/tabs/LeaderboardScreen";
15 | import AboutScreen from "../../screens/group/tabs/AboutScreen";
16 | import NewsletterScreen from "../../screens/group/tabs/NewsletterScreen";
17 |
18 | const WHITE = "#F5F5F5";
19 |
20 | const tabs = [
21 | { name: "Events", screen: EventsScreen },
22 | { name: "Members", screen: MembersScreen },
23 | { name: "Leaderboard", screen: LeaderboardScreen },
24 | { name: "About", screen: AboutScreen },
25 | { name: "Newsletter", screen: NewsletterScreen },
26 | ];
27 |
28 | function GroupScreen({ navigation, route }) {
29 | const tabRef = useRef(null);
30 | const [activeTab, setActiveTab] = useState(tabs[0]);
31 |
32 | const styles = StyleSheet.create({
33 | container: {
34 | display: "flex",
35 | height: "100%",
36 | },
37 | groupTabs: {
38 | backgroundColor: WHITE,
39 | display: "flex",
40 | },
41 | screen: {
42 | flex: 1,
43 | },
44 | });
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 | setActiveTab(tab)}
58 | />
59 |
60 |
61 | {activeTab.screen && (
62 |
67 | )}
68 |
69 |
70 |
71 |
72 | );
73 | }
74 | GroupScreen.propTypes = {
75 | navigation: PropTypes.object,
76 | route: PropTypes.object,
77 | };
78 | export default GroupScreen;
79 |
--------------------------------------------------------------------------------
/packages/app/components/MemberCard/ProfilePopup.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | View,
4 | Text,
5 | StyleSheet,
6 | Modal,
7 | Dimensions,
8 | Image,
9 | TouchableOpacity,
10 | } from "react-native";
11 | import PropTypes from "prop-types";
12 | import { ThreeDotsButton } from "./MemberFunctions";
13 |
14 | const windowWidth = Dimensions.get("window").width;
15 | const windowHeight = Dimensions.get("window").height;
16 |
17 | const ProfilePopup = (props) => {
18 | const popupRef = React.useRef();
19 |
20 | const handleOverlayPress = (event) => {
21 | if (event.target !== popupRef.current) {
22 | props.onClose();
23 | }
24 | };
25 |
26 | return (
27 |
31 |
35 |
36 |
44 | {props.name}
45 |
46 | {/***/}
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | const obgc = "rgba(0, 0, 0, 0.5)";
54 | const pgbc = "white";
55 | const psc = "#000";
56 |
57 | const styles = StyleSheet.create({
58 | overlay: {
59 | alignItems: "center",
60 | backgroundColor: obgc,
61 | height: windowHeight,
62 | justifyContent: "center",
63 | position: "absolute",
64 | width: windowWidth,
65 | },
66 | popup: {
67 | alignItems: "center",
68 | backgroundColor: pgbc,
69 | borderRadius: 20,
70 | elevation: 5,
71 | flexDirection: "row",
72 | justifyContent: "space-between",
73 | minHeight: 200,
74 | padding: 20,
75 | shadowColor: psc,
76 | shadowOffset: {
77 | height: 2,
78 | width: 0,
79 | },
80 | shadowOpacity: 0.25,
81 | shadowRadius: 3.84,
82 | width: "80%",
83 | },
84 | profileImage: {
85 | borderRadius: 40,
86 | height: 80,
87 | marginBottom: 10,
88 | width: 80,
89 | },
90 | text: {
91 | fontSize: 24,
92 | fontWeight: "bold",
93 | marginBottom: 10,
94 | },
95 | });
96 |
97 | ProfilePopup.propTypes = {
98 | name: PropTypes.string.isRequired,
99 | image: PropTypes.string,
100 | onClose: PropTypes.func.isRequired,
101 | isVisible: PropTypes.bool.isRequired,
102 | };
103 |
104 | export default ProfilePopup;
105 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | A platform for organizations to interact with their members. A central hub for members to be always updated on the latest events while providing features to incentivize member growth.
9 |
10 | This is an ongoing project started by the Software Engineering Association at Cal Poly Pomona to help teach students software engineering skills and gain experience with the software development lifecycle.
11 |
12 | ## Our Tech Stack
13 |
14 |
34 |
35 | ## Getting Started
36 |
37 | This monorepo contains both the client and server codebase. In order to run the development enviroment, you must follow the setup based on your operating system.
38 |
39 | - [Wiki](https://github.com/cppsea/icebreak/wiki)
40 |
41 | ## Run
42 |
43 | Icebreak can be easily started by running:
44 |
45 | ```
46 | yarn dev
47 | ```
48 |
49 | This starts both the Expo client and Express.js backend for you to run Icebreak on an emulator or your physical device.
50 |
--------------------------------------------------------------------------------
/packages/server/scripts/database.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE users (
2 | user_id varchar(255) PRIMARY KEY NOT NULL,
3 | joined_date TIMESTAMP,
4 | last_login TIMESTAMP,
5 | first_name varchar(50) NOT NULL,
6 | last_name varchar(50) NOT NULL,
7 | email varchar(255) UNIQUE NOT NULL,
8 | avatar varchar(255) NOT NULL,
9 | password varchar(255)
10 | );
11 |
12 | CREATE TABLE Guild (
13 | guild_id VARCHAR(255),
14 | name VARCHAR(100),
15 | handler VARCHAR(50),
16 | description TEXT,
17 | media TEXT[],
18 | invite_only BOOLEAN,
19 | PRIMARY KEY(guild_id)
20 | );
21 |
22 | CREATE TABLE Event (
23 | event_id VARCHAR(255),
24 | guild_id VARCHAR(255),
25 | title VARCHAR(255),
26 | description VARCHAR(255),
27 | start_date TIMESTAMP,
28 | end_date TIMESTAMP,
29 | location VARCHAR(255),
30 | thumbnail VARCHAR(255),
31 | PRIMARY KEY(event_id),
32 | FOREIGN KEY(guild_id)
33 | REFERENCES Guild(guild_id)
34 | ON UPDATE CASCADE
35 | ON DELETE SET NULL
36 | );
37 |
38 | CREATE TABLE user_guild (
39 | user_id VARCHAR(255),
40 | guild_id VARCHAR(255),
41 | user_role VARCHAR(255),
42 | points SMALLINT,
43 | rank SMALLINT
44 | FOREIGN KEY(user_id)
45 | REFERENCES users(user_id)
46 | ON UPDATE CASCADE
47 | ON DELETE SET NULL,
48 | FOREIGN KEY(guild_id)
49 | REFERENCES Guild(guild_id)
50 | ON UPDATE CASCADE
51 | ON DELETE SET NULL,
52 | PRIMARY KEY(user_id, guild_id),
53 | );
54 |
55 | CREATE TABLE members_pending (
56 | user_id VARCHAR(255),
57 | event_id VARCHAR(255),
58 | FOREIGN KEY(user_id)
59 | REFERENCES users(user_id)
60 | ON UPDATE CASCADE
61 | ON DELETE SET NULL,
62 | FOREIGN KEY(event_id)
63 | REFERENCES Event(event_id)
64 | ON UPDATE CASCADE
65 | ON DELETE SET NULL
66 | );
67 |
68 | INSERT INTO Guild
69 | VALUES (
70 | 'nfb38fv30fb339fb',
71 | 'Software Engineering Association',
72 | 'cppsea',
73 | 'The Software Engineering Association (SEA) teaches and encourages the professional skills needed to be a Software Engineer, including code review, unit testing, communication, and software design. Our online and in-meeting exercises allow anyone, novice or professional, to sharpen and practice these skills.',
74 | '{https://www.instagram.com/cpp.sea, https://github.com/cppsea}',
75 | true
76 | );
77 |
78 | INSERT INTO Event
79 | VALUES (
80 | '384629bffb28f2',
81 | 'nfb38fv30fb339fb',
82 | 'SEA goes to Innovation Brew Works',
83 | 'Come join us for our first social hangout at the Innovation Brew Works. Meet other SEA members while playing board games and munching on food.',
84 | '2022-10-14 19:00:00',
85 | '2022-10-14 22:00:00',
86 | '3650 W Temple Ave, Pomona, CA 91768',
87 | 'https://thepolypost.com/wp-content/uploads/2018/02/t9u2ajcvo6bemz7qbahg.jpg'
88 | );
89 |
90 | INSERT INTO users (user_id, first_name, last_name, email, avatar)
91 | VALUES (
92 | 'sdfdf2f2bf2efgasdfssfsdff',
93 | 'bob',
94 | 'smith',
95 | 'bob.smith@cpp.com',
96 | 'https://www.memesmonkey.com/images/memesmonkey/24/2422d8b276a25c2ef0d1601b9332bc36.jpeg'
97 | );
--------------------------------------------------------------------------------
/packages/app/utils/GuildContext.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useState,
4 | useMemo,
5 | useContext,
6 | useEffect,
7 | } from "react";
8 | import axios from "axios";
9 | import * as SecureStore from "@app/utils/SecureStore";
10 | import { ENDPOINT } from "./constants";
11 | import PropTypes from "prop-types";
12 |
13 | const GuildContext = createContext();
14 |
15 | export function GuildProvider({
16 | // defaults to SEA guild for now
17 | guildId = "5f270196-ee82-4477-8277-8d4df5fcc864",
18 | children,
19 | }) {
20 | const [guild, setGuild] = useState({});
21 | const [guildMembers, setGuildMembers] = useState([]);
22 |
23 | useEffect(() => {
24 | async function fetchGuildData() {
25 | try {
26 | const accessToken = await SecureStore.getValueFor("accessToken");
27 |
28 | const { data: guildResponse } = await axios.get(
29 | `${ENDPOINT}/guilds/${guildId}`,
30 | {
31 | headers: {
32 | Authorization: accessToken,
33 | },
34 | }
35 | );
36 | const { data: guildMembersResponse } = await axios.get(
37 | `${ENDPOINT}/guilds/${guildId}/members`,
38 | {
39 | headers: {
40 | Authorization: accessToken,
41 | },
42 | }
43 | );
44 | const { data: guildAvatarResponse } = await axios.get(
45 | `${ENDPOINT}/media/images/guild_avatar/${guildId}`,
46 | {
47 | headers: {
48 | Authorization: accessToken,
49 | },
50 | }
51 | );
52 | const { data: guildBannerResponse } = await axios.get(
53 | `${ENDPOINT}/media/images/guild_banner/${guildId}`,
54 | {
55 | headers: {
56 | Authorization: accessToken,
57 | },
58 | }
59 | );
60 |
61 | const guild = guildResponse.data.guild;
62 | guild.avatar = guildAvatarResponse.data.imageURL.avatar;
63 | guild.banner = guildBannerResponse.data.imageURL.banner;
64 |
65 | setGuild(guildResponse.data.guild);
66 | setGuildMembers(guildMembersResponse.data.guildMembers);
67 | } catch (err) {
68 | console.error(err);
69 | }
70 | }
71 |
72 | fetchGuildData();
73 | }, []);
74 |
75 | const ctxValue = useMemo(() => ({
76 | guild,
77 | setGuild,
78 | guildMembers,
79 | setGuildMembers,
80 | }));
81 |
82 | return (
83 | {children}
84 | );
85 | }
86 |
87 | GuildProvider.propTypes = {
88 | guildId: PropTypes.string,
89 | children: PropTypes.node,
90 | };
91 |
92 | export function useGuildContext() {
93 | const guildCtxValue = useContext(GuildContext);
94 | if (!guildCtxValue) {
95 | throw new Error(
96 | "You are using guild context outside of GuildProvider. Context undefined"
97 | );
98 | }
99 |
100 | return guildCtxValue;
101 | }
102 |
--------------------------------------------------------------------------------
/packages/app/components/EventCard/EventCardText.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { StyleSheet, Text, View } from "react-native";
3 | import PropTypes from "prop-types";
4 | import { useGuildContext } from "@app/utils/GuildContext";
5 |
6 | const GRAY = "grey";
7 | const titleColor = "#002366";
8 |
9 | const styles = StyleSheet.create({
10 | description: {
11 | fontSize: 13,
12 | marginBottom: 10,
13 | },
14 | eventTitle: {
15 | color: titleColor,
16 | fontSize: 20,
17 | fontWeight: "bold",
18 | marginBottom: 7,
19 | },
20 | eventTitlePressed: {
21 | color: GRAY,
22 | fontSize: 20,
23 | fontWeight: "bold",
24 | marginBottom: 7,
25 | textDecorationLine: "underline",
26 | },
27 | smallText: {
28 | color: GRAY,
29 | fontSize: 12,
30 | },
31 | });
32 |
33 | function EventCardText({ event, navigation, previousScreen }) {
34 | const [isTitlePressed, setPressState] = useState(false);
35 | const { guild } = useGuildContext();
36 |
37 | function formatDate(rawDate) {
38 | const date = new Date(rawDate);
39 | let hours = date.getUTCHours();
40 | let minutes = date.getMinutes();
41 | let dayPeriod = "AM";
42 |
43 | if (hours > 12) {
44 | hours -= 12;
45 | dayPeriod = "PM";
46 | }
47 | if (minutes < 10) minutes = "0" + minutes;
48 |
49 | return `${date.getMonth() + 1}/${date.getDate()}/${
50 | date.getFullYear() - 2000
51 | } at ${hours}:${minutes} ${dayPeriod}`;
52 | }
53 |
54 | const onTitlePress = () => {
55 | setPressState(true);
56 | if (event.startDate) {
57 | navigation.navigate("EventOverviewScreen", {
58 | previousScreen: previousScreen,
59 | event: event,
60 | timeBegin: formatDate(event.startDate),
61 | timeEnd: formatDate(event.endDate),
62 | guild: guild,
63 | });
64 | } else {
65 | navigation.navigate("EventOverviewScreen", {
66 | previousScreen: previousScreen,
67 | event: event,
68 | guild: guild,
69 | });
70 | }
71 |
72 | setTimeout(() => {
73 | setPressState(false);
74 | }, 500);
75 | };
76 |
77 | return (
78 |
79 | {event.startDate && (
80 |
81 | {formatDate(event.startDate)} - {formatDate(event.endDate)}
82 |
83 | )}
84 |
89 | {event.title}
90 |
91 | {event.location && (
92 | 📌 {event.location}
93 | )}
94 | {event.description && (
95 |
96 | {event.description}
97 |
98 | )}
99 |
100 | );
101 | }
102 |
103 | EventCardText.propTypes = {
104 | event: PropTypes.any,
105 | navigation: PropTypes.object,
106 | previousScreen: PropTypes.string,
107 | };
108 |
109 | export default EventCardText;
110 |
--------------------------------------------------------------------------------
/packages/app/components/GroupScreen/GroupMediaIcon.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet, TouchableOpacity, Linking } from "react-native";
3 |
4 | import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
5 |
6 | import PropTypes from "prop-types";
7 |
8 | const GRAY = "#2C2C2C";
9 |
10 | const styles = StyleSheet.create({
11 | containerStyle: {
12 | flexDirection: "row",
13 | justifyContent: "flex-end",
14 | marginRight: 20,
15 | marginTop: 12,
16 | zIndex: 1,
17 | },
18 | mediaButtonStyle: {
19 | alignContent: "center",
20 | height: 40,
21 | justifyContent: "center",
22 | },
23 | mediaIconStyle: {
24 | color: GRAY,
25 | display: "flex",
26 | padding: 10,
27 | },
28 | });
29 |
30 | /**
31 | * A component for GroupHeader that displays the media icon(s) for an organization.
32 | *
33 | * @param {object} props - Object that contains properties of this component.
34 | * @param {number} props.size - Size of icon in pixels
35 | * @param {githubUrl} props.githubUrl - Url for GitHub
36 | * @param {discordUrl} props.discordUrl - Url for Discord
37 | * @param {linkedinUrl} props.linkedinUrl - Url for LinkedIn
38 | * @param {instagramUrl} props.instagramUrl - Url for Instagram
39 | */
40 | function GroupMediaIcon(props) {
41 | return (
42 |
43 | {props.githubUrl && (
44 | Linking.openURL(props.githubUrl)}>
47 |
52 |
53 | )}
54 |
55 | {props.discordUrl && (
56 | Linking.openURL(props.discordUrl)}>
59 |
64 |
65 | )}
66 |
67 | {props.linkedinUrl && (
68 | Linking.openURL(props.linkedinUrl)}>
71 |
76 |
77 | )}
78 |
79 | {props.instagramUrl && (
80 | Linking.openURL(props.instagramUrl)}>
83 |
88 |
89 | )}
90 |
91 | );
92 | }
93 |
94 | GroupMediaIcon.propTypes = {
95 | size: PropTypes.number,
96 | testID: PropTypes.string,
97 | githubUrl: PropTypes.string,
98 | discordUrl: PropTypes.string,
99 | linkedinUrl: PropTypes.string,
100 | instagramUrl: PropTypes.string,
101 | };
102 |
103 | export default GroupMediaIcon;
104 |
--------------------------------------------------------------------------------
/scripts/run.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const os = require("os");
3 | const path = require("path");
4 | const { exec, execSync, spawn } = require("child_process");
5 |
6 | const PORT = 5050;
7 | const PATH_LIMIT = 1024;
8 | const SPAWN_SHELL_DELAY = 1000;
9 | const REWRITE_NGROK_URL_DELAY = 2000;
10 | const NGROK_PATH = process.argv[2];
11 | const systemType = os.platform();
12 |
13 | const windowsShellOptions = {
14 | shell: process.env.ComSpec || "C:\\Windows\\system32\\cmd.exe",
15 | };
16 |
17 | function setNgrokPath() {
18 | if (!NGROK_PATH) return;
19 |
20 | const newPath = process.env.path + ";" + NGROK_PATH;
21 |
22 | if (newPath.length > PATH_LIMIT) {
23 | throw new Error(
24 | "Your path variable is over 1024 characters long. You will have to shorten it or add ngrok manually to your path."
25 | );
26 | } else {
27 | console.log("path changed");
28 | exec(`setx PATH "%PATH%;${NGROK_PATH}"`);
29 | }
30 | }
31 |
32 | function newTerminal(command) {
33 | const CWD = path.join(__dirname, "../");
34 |
35 | switch (systemType) {
36 | case "win32":
37 | // empty double quotes is intentional because of how the "start" Windows
38 | // command parameters work, setTimeout() used for Windows to prevent new
39 | // shells from opening too fast and skipping some commands
40 | setTimeout(() => {
41 | spawn(`start "" /d ${CWD} ${command}`, [], windowsShellOptions);
42 | }, SPAWN_SHELL_DELAY);
43 | break;
44 | case "darwin":
45 | execSync(`
46 | osascript -e 'tell application "Terminal" to activate' \
47 | -e 'tell application "System Events" to keystroke "t" using {command down}' \
48 | -e 'tell application "Terminal" to do script "cd ${CWD} && ${command}" in front window'
49 | `);
50 | break;
51 | default:
52 | console.log(
53 | "This dev script currently only supports Windows/unix at the moment."
54 | );
55 | }
56 | }
57 |
58 | // Make sure all instances of ngrok's process doesn't exist.
59 | if (systemType === "win32") {
60 | setNgrokPath();
61 | spawn("Taskkill /IM ngrok.exe /F", [], windowsShellOptions);
62 | } else if (systemType === "darwin") {
63 | exec("killall ngrok");
64 | }
65 | newTerminal(`ngrok http ${PORT}`);
66 |
67 | setTimeout(() => {
68 | exec("curl http://127.0.0.1:4040/api/tunnels", (error, stdout) => {
69 | const ngrok = JSON.parse(stdout);
70 | const { tunnels } = ngrok;
71 | const { public_url } = tunnels[0];
72 | console.log(public_url);
73 | fs.access("./packages/app/utils/", (error) => {
74 | if (error) {
75 | throw error;
76 | } else {
77 | const FILE_PATH = "./packages/app/utils/constants.js";
78 | fs.readFile(FILE_PATH, "utf-8", function (error, data) {
79 | if (error) throw error;
80 | const overwrite = data.replace(
81 | /\b(https:\/\/)\b.*\b(.ngrok.free.app)\b/,
82 | public_url
83 | );
84 |
85 | fs.writeFile(FILE_PATH, overwrite, "utf-8", function (error) {
86 | if (error) throw error;
87 | console.log("Successfully overwrite ngrok URL");
88 | });
89 | });
90 | }
91 | });
92 |
93 | newTerminal("yarn server:dev");
94 | newTerminal("yarn app:dev");
95 | });
96 | }, REWRITE_NGROK_URL_DELAY);
97 |
--------------------------------------------------------------------------------
/packages/server/prisma/migrations/0_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "event_attendee_status" AS ENUM ('not interested', 'interested', 'attending', 'checked in');
3 |
4 | -- CreateEnum
5 | CREATE TYPE "guild_member_role" AS ENUM ('member', 'officer', 'owner');
6 |
7 | -- CreateEnum
8 | CREATE TYPE "user_pronoun" AS ENUM ('He/Him', 'She/Her', 'They/Them/Their');
9 |
10 | -- CreateTable
11 | CREATE TABLE "users" (
12 | "user_id" UUID NOT NULL DEFAULT gen_random_uuid(),
13 | "joined_date" TIMESTAMP(6),
14 | "first_name" VARCHAR(50) NOT NULL,
15 | "last_name" VARCHAR(50) NOT NULL,
16 | "email" VARCHAR(255) NOT NULL,
17 | "avatar" VARCHAR(255),
18 | "password" VARCHAR(255),
19 | "is_new" BOOLEAN NOT NULL DEFAULT true,
20 | "handler" VARCHAR(50),
21 | "major" VARCHAR(100),
22 |
23 | CONSTRAINT "pk_user" PRIMARY KEY ("user_id")
24 | );
25 |
26 | -- CreateTable
27 | CREATE TABLE "event_attendees" (
28 | "user_id" UUID NOT NULL,
29 | "event_id" UUID NOT NULL,
30 | "status" "event_attendee_status" NOT NULL DEFAULT 'not interested',
31 |
32 | CONSTRAINT "pk_event_attendee" PRIMARY KEY ("user_id","event_id")
33 | );
34 |
35 | -- CreateTable
36 | CREATE TABLE "events" (
37 | "event_id" UUID NOT NULL DEFAULT gen_random_uuid(),
38 | "guild_id" UUID NOT NULL,
39 | "start_date" TIMESTAMP(6),
40 | "end_date" TIMESTAMP(6),
41 | "location" VARCHAR(255),
42 | "thumbnail" VARCHAR(255),
43 | "title" VARCHAR(255) NOT NULL,
44 | "description" TEXT,
45 |
46 | CONSTRAINT "pk_event" PRIMARY KEY ("event_id")
47 | );
48 |
49 | -- CreateTable
50 | CREATE TABLE "guild_members" (
51 | "user_id" UUID NOT NULL,
52 | "guild_id" UUID NOT NULL,
53 | "points" SMALLINT NOT NULL DEFAULT 0,
54 | "role" "guild_member_role" NOT NULL DEFAULT 'member',
55 |
56 | CONSTRAINT "pk_guild_member" PRIMARY KEY ("user_id","guild_id")
57 | );
58 |
59 | -- CreateTable
60 | CREATE TABLE "guilds" (
61 | "guild_id" UUID NOT NULL DEFAULT gen_random_uuid(),
62 | "name" VARCHAR(100) NOT NULL,
63 | "handler" VARCHAR(50) NOT NULL,
64 | "description" TEXT NOT NULL,
65 | "category" VARCHAR(255) NOT NULL,
66 | "location" VARCHAR(255),
67 | "website" VARCHAR(255),
68 | "tags" TEXT[],
69 | "banner" VARCHAR(255),
70 | "avatar" VARCHAR(255),
71 | "media" TEXT[],
72 | "invite_only" BOOLEAN NOT NULL DEFAULT true,
73 |
74 | CONSTRAINT "pk_guild" PRIMARY KEY ("guild_id")
75 | );
76 |
77 | -- AddForeignKey
78 | ALTER TABLE "event_attendees" ADD CONSTRAINT "fk_event" FOREIGN KEY ("event_id") REFERENCES "events"("event_id") ON DELETE CASCADE ON UPDATE NO ACTION;
79 |
80 | -- AddForeignKey
81 | ALTER TABLE "event_attendees" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "users"("user_id") ON DELETE CASCADE ON UPDATE NO ACTION;
82 |
83 | -- AddForeignKey
84 | ALTER TABLE "events" ADD CONSTRAINT "fk_guild" FOREIGN KEY ("guild_id") REFERENCES "guilds"("guild_id") ON DELETE CASCADE ON UPDATE NO ACTION;
85 |
86 | -- AddForeignKey
87 | ALTER TABLE "guild_members" ADD CONSTRAINT "fk_guild" FOREIGN KEY ("guild_id") REFERENCES "guilds"("guild_id") ON DELETE CASCADE ON UPDATE NO ACTION;
88 |
89 | -- AddForeignKey
90 | ALTER TABLE "guild_members" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "users"("user_id") ON DELETE CASCADE ON UPDATE NO ACTION;
91 |
92 |
--------------------------------------------------------------------------------
/packages/app/screens/feed/FeedDrawer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | createDrawerNavigator,
4 | DrawerContentScrollView,
5 | DrawerItemList,
6 | } from "@react-navigation/drawer";
7 | import { StyleSheet, View, Text, Image, TouchableOpacity } from "react-native";
8 | import FeedScreen from "./FeedScreen";
9 | import { useUserContext } from "@app/utils/UserContext";
10 |
11 | import Profile from "./feed_tabs/Profile";
12 | import Settings from "./feed_tabs/Settings";
13 |
14 | import PropTypes from "prop-types";
15 |
16 | import Ionicons from "@expo/vector-icons/Ionicons";
17 |
18 | const Feed = createDrawerNavigator();
19 |
20 | const DARK_BLUE = "darkblue";
21 | const GRAY = "grey";
22 |
23 | function FeedDrawer() {
24 | return (
25 | }>
28 | ({
33 | // to hide the feed in the drawer
34 | drawerLabelStyle: { fontSize: 100 },
35 | drawerLabel: "Home",
36 | drawerItemStyle: { display: "none" },
37 | headerLeft: () => (
38 | navigation.openDrawer()}>
41 |
46 |
47 | ),
48 | })}
49 | />
50 |
57 |
64 |
65 | );
66 | }
67 |
68 | function CustomDrawerContent(props) {
69 | // eslint-disable-next-line no-unused-vars
70 | const { user, setUser } = useUserContext();
71 | return (
72 |
73 |
74 |
75 |
76 | {user.data.firstName} {user.data.lastName}
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | const styles = StyleSheet.create({
85 | avatar: {
86 | borderRadius: 100,
87 | height: 40,
88 | width: 40,
89 | },
90 | drawerButton: {
91 | marginLeft: 18,
92 | },
93 | drawerDisplayName: {
94 | color: DARK_BLUE,
95 | fontSize: 20,
96 | marginTop: 10,
97 | },
98 | drawerHeader: {
99 | borderBottomColor: GRAY,
100 | borderBottomWidth: 2,
101 | justifyContent: "space-between",
102 | marginBottom: 15,
103 | marginLeft: 20,
104 | marginRight: 20,
105 | paddingBottom: 15,
106 | },
107 | });
108 |
109 | FeedDrawer.propTypes = {
110 | navigation: PropTypes.object,
111 | };
112 |
113 | export default FeedDrawer;
114 |
--------------------------------------------------------------------------------
/packages/server/prisma/seed.js:
--------------------------------------------------------------------------------
1 | const prisma = require("./prisma");
2 | const AuthController = require("../controllers/auth");
3 | const GuildController = require("../controllers/guilds");
4 | const EventController = require("../controllers/events");
5 | const { GuildMemberRole, EventAttendeeStatus } = require("@prisma/client");
6 |
7 | /**
8 | * Seed script for our PostgreSQL database through Prisma ORM.
9 | * Whenever we make new, breaking changes to our database schema,
10 | * data is lost and Prisma runs this seed script to re-populate the
11 | * database for convenience for development and testing.
12 | *
13 | * Read more here:
14 | * https://www.prisma.io/docs/orm/prisma-migrate/workflows/seeding
15 | */
16 | async function main() {
17 | const testUser = await AuthController.register({
18 | email: "test@gmail.com",
19 | password: "test",
20 | });
21 |
22 | const sea = {
23 | guildId: "5f270196-ee82-4477-8277-8d4df5fcc864",
24 | name: "Software Engineering Association",
25 | handler: "cppsea",
26 | description:
27 | "CPP CS club dedicated to teaching students software engineering concepts and principles.",
28 | category: "Education",
29 | isInviteOnly: true,
30 | };
31 |
32 | const swift = {
33 | guildId: "759c1d08-8210-48b0-ae51-9dd2e555f748",
34 | name: "Cal Poly Swift",
35 | handler: "cppswift",
36 | description: "CPP club that teaches cybersecurity to students.",
37 | category: "Education",
38 | isInviteOnly: true,
39 | };
40 |
41 | const css = {
42 | guildId: "5325b147-5524-4539-b652-0549e074a159",
43 | name: "CPP Computer Science Society",
44 | handler: "cppcss",
45 | description: "CPP club for all things computer science.",
46 | category: "Education",
47 | isInviteOnly: true,
48 | };
49 |
50 | const seaGuild = await GuildController.createGuild(sea);
51 | const swiftGuild = await GuildController.createGuild(swift);
52 | const cssGuild = await GuildController.createGuild(css);
53 |
54 | await GuildController.addGuildMember(seaGuild.guildId, testUser.userId);
55 | await GuildController.updateGuildMemberRole(
56 | seaGuild.guildId,
57 | testUser.userId,
58 | GuildMemberRole.Owner,
59 | );
60 |
61 | await GuildController.addGuildMember(cssGuild.guildId, testUser.userId);
62 | await GuildController.addGuildMember(swiftGuild.guildId, testUser.userId);
63 |
64 | const cybersecurityEvent = await EventController.createEvent(
65 | {
66 | title: "Intro to Cybersecurity",
67 | },
68 | swiftGuild.guildId,
69 | );
70 |
71 | const workshopEvent = await EventController.createEvent(
72 | {
73 | title: "Workshop: The Software Development Lifecycle",
74 | },
75 | seaGuild.guildId,
76 | );
77 |
78 | await EventController.updateAttendeeStatus(
79 | cybersecurityEvent.eventId,
80 | testUser.userId,
81 | EventAttendeeStatus.Attending,
82 | );
83 |
84 | await EventController.updateAttendeeStatus(
85 | workshopEvent.eventId,
86 | testUser.userId,
87 | EventAttendeeStatus.Interested,
88 | );
89 | }
90 |
91 | main()
92 | .then(async () => {
93 | await prisma.$disconnect();
94 | })
95 | .catch(async (e) => {
96 | console.error(e);
97 | await prisma.$disconnect();
98 | process.exit(1);
99 | });
100 |
--------------------------------------------------------------------------------
/packages/app/screens/group/GuildList.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { View, FlatList, Text, StyleSheet, Platform } from "react-native";
3 | import GuildCard from "./GuildCard";
4 | import { GuildProvider } from "@app/utils/GuildContext.js";
5 | import PropTypes from "prop-types";
6 |
7 | import axios from "axios";
8 | import { ENDPOINT } from "@app/utils/constants.js";
9 | import * as SecureStore from "@app/utils/SecureStore";
10 |
11 | const shadow = "rgba(0, 0, 0, 0.25)";
12 | const titleColor = "rgb(51,51,51)";
13 | const white = "#fff";
14 |
15 | const USER_ID = "80eb49ef-ce2a-46e2-b440-911192976ac1"; // temporary userId
16 |
17 | const styles = StyleSheet.create({
18 | card: {
19 | borderRadius: 15,
20 | marginBottom: 10,
21 | padding: 10,
22 | },
23 | container: {
24 | backgroundColor: white,
25 | flex: 1,
26 | paddingHorizontal: 5,
27 | paddingTop: 50,
28 | ...Platform.select({
29 | ios: {
30 | shadowColor: shadow,
31 | shadowOffset: { width: 0, height: 2 },
32 | shadowOpacity: 0.8,
33 | shadowRadius: 4,
34 | },
35 | android: {
36 | shadowColor: shadow,
37 | elevation: 20,
38 | },
39 | }),
40 | },
41 | title: {
42 | color: titleColor,
43 | fontSize: 30,
44 | fontWeight: "bold",
45 | margin: 15,
46 | marginBottom: 10,
47 | },
48 | });
49 |
50 | const useUserGuilds = () => {
51 | const [guilds, setGuilds] = useState([]);
52 |
53 | useEffect(() => {
54 | async function fetchUserGuilds() {
55 | try {
56 | const accessToken = await SecureStore.getValueFor("accessToken");
57 |
58 | const response = await axios.get(
59 | `${ENDPOINT}/users/${USER_ID}/guilds`,
60 | {
61 | headers: {
62 | Authorization: accessToken,
63 | },
64 | }
65 | );
66 |
67 | const userGuildResponse = response.data;
68 | setGuilds(userGuildResponse.data.userGuilds);
69 | } catch (err) {
70 | console.log(err);
71 | }
72 | }
73 |
74 | fetchUserGuilds();
75 | }, []);
76 |
77 | return guilds;
78 | };
79 |
80 | function GuildList(props) {
81 | const userGuilds = useUserGuilds(); // get data
82 |
83 | return (
84 |
85 |
86 | Your Guilds
87 | (
90 |
103 | )}
104 | keyExtractor={(item) => item.guildId.toString()}
105 | />
106 |
107 |
108 | );
109 | }
110 |
111 | GuildList.propTypes = {
112 | navigation: PropTypes.any,
113 | route: PropTypes.any,
114 | };
115 |
116 | export default GuildList;
117 |
--------------------------------------------------------------------------------
/packages/app/screens/group/tabs/AboutScreen.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, Text, View, Linking } from "react-native";
3 | import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
4 | import Screen from "@app/components/Screen";
5 | import Ionicons from "@expo/vector-icons/Ionicons";
6 | import { useGuildContext } from "@app/utils/GuildContext";
7 |
8 | const BLUE = "#3498DB";
9 | const GRAY = "#2C2C2C";
10 |
11 | function getIcon(url) {
12 | let icon = "link";
13 |
14 | if (url.includes("discord")) {
15 | icon = "discord";
16 | } else if (url.includes("facebook")) {
17 | icon = "facebook";
18 | } else if (url.includes("instagram")) {
19 | icon = "instagram";
20 | } else if (url.includes("linkedin")) {
21 | icon = "linkedin";
22 | } else if (url.includes("twitter") || url.includes("x.com")) {
23 | icon = "twitter";
24 | } else if (url.includes("github")) {
25 | icon = "github";
26 | }
27 |
28 | return ;
29 | }
30 |
31 | function AboutScreen() {
32 | const { guild, guildMembers } = useGuildContext();
33 | return (
34 |
35 |
36 | Description
37 | {guild.description}
38 |
39 |
40 |
41 | Links
42 | {guild.media.map((media) => (
43 |
44 | {getIcon(media)}
45 | Linking.openURL(media)}>
50 | {media}
51 |
52 |
53 | ))}
54 |
55 |
56 |
57 | More Info
58 |
59 |
65 | {guild.location}
66 |
67 |
68 |
74 | {guildMembers ? guildMembers.length : 0} Members
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | const styles = StyleSheet.create({
82 | container: {
83 | alignItems: "flex-start",
84 | flex: 1,
85 | marginTop: 10,
86 | },
87 | description: {
88 | fontSize: 15,
89 | },
90 | mediaContainer: {
91 | alignItems: "center",
92 | flexDirection: "row",
93 | marginBottom: 10,
94 | paddingLeft: 15,
95 | },
96 | mediaIconStyle: {
97 | color: GRAY,
98 | display: "flex",
99 | paddingRight: 10,
100 | },
101 | subContainer: {
102 | marginBottom: 20,
103 | paddingLeft: 15,
104 | paddingRight: 15,
105 | },
106 | title: {
107 | fontSize: 20,
108 | fontWeight: "bold",
109 | marginBottom: 10,
110 | },
111 | url: {
112 | color: BLUE,
113 | flexShrink: 1,
114 | },
115 | });
116 |
117 | export default AboutScreen;
118 |
--------------------------------------------------------------------------------
/packages/server/routes/api/users.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const { validationResult, matchedData } = require("express-validator");
4 | const {
5 | userIdValidator,
6 | onboardingValidator,
7 | } = require("../../validators/users");
8 |
9 | const UserController = require("../../controllers/users");
10 | const AuthController = require("../../controllers/auth");
11 |
12 | router.get("/", async (request, response) => {
13 | try {
14 | const users = await UserController.getAllUsers();
15 | response.status(200).json({
16 | status: "success",
17 | data: {
18 | users,
19 | },
20 | });
21 | } catch (error) {
22 | response.status(500).json({
23 | status: "error",
24 | message: error.message,
25 | });
26 | }
27 | });
28 |
29 | router.get(
30 | "/:userId",
31 | AuthController.authenticate,
32 | async (request, response) => {
33 | try {
34 | const { userId } = request.params;
35 |
36 | if (userId === undefined) {
37 | return response.status(400).json({
38 | status: "fail",
39 | data: {
40 | userId: "User ID not provided",
41 | },
42 | });
43 | }
44 |
45 | const user = await UserController.getUser(userId);
46 | response.status(200).json({
47 | status: "success",
48 | data: {
49 | user: user,
50 | },
51 | });
52 | } catch (error) {
53 | response.status(500).json({
54 | status: "error",
55 | message: error.message,
56 | });
57 | }
58 | },
59 | );
60 |
61 | // Get all guilds for a specific user
62 | router.get(
63 | "/:userId/guilds",
64 | AuthController.authenticate,
65 | userIdValidator,
66 | async (request, response) => {
67 | const result = validationResult(request);
68 |
69 | if (!result.isEmpty()) {
70 | return response.status(400).json({
71 | status: "fail",
72 | data: result.array(),
73 | });
74 | }
75 |
76 | const data = matchedData(request);
77 | const userId = data.userId;
78 |
79 | try {
80 | // Fetch all guilds for the user
81 | const userGuilds = await UserController.getGuildsForUser(userId);
82 |
83 | response.status(200).json({
84 | status: "success",
85 | data: {
86 | userGuilds,
87 | },
88 | });
89 | } catch (error) {
90 | response.status(500).json({
91 | status: "error",
92 | message: error.message,
93 | });
94 | }
95 | },
96 | );
97 |
98 | router.post(
99 | "/:userId/onboarding",
100 | AuthController.authenticate,
101 | onboardingValidator,
102 | async (request, response) => {
103 | const result = validationResult(request);
104 |
105 | if (!result.isEmpty()) {
106 | return response.status(400).json({
107 | status: "fail",
108 | data: result.array(),
109 | });
110 | }
111 | const validatedData = matchedData(request);
112 | const { userId } = matchedData(request);
113 |
114 | try {
115 | const updatedNewUser = await UserController.updateNewUser(
116 | userId,
117 | validatedData,
118 | );
119 | response.status(200).json({
120 | status: "success",
121 | data: {
122 | updatedNewUser,
123 | },
124 | });
125 | } catch (error) {
126 | response.status(500).json({
127 | status: "error",
128 | message: error.message,
129 | });
130 | }
131 | },
132 | );
133 |
134 | module.exports = router;
135 |
--------------------------------------------------------------------------------
/packages/app/screens/feed/FeedScreen.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { Text, Image, StyleSheet, FlatList, View } from "react-native";
3 | import axios from "axios";
4 | import PropTypes from "prop-types";
5 |
6 | import Screen from "@app/components/Screen";
7 | import Button from "@app/components/Button";
8 | import EventCardText from "@app/components/EventCard/EventCardText";
9 | import EventCardRegistration from "@app/components/EventCard/EventCardRegistration";
10 | import { useFeedContext } from "@app/utils/FeedContext";
11 | import { FeedProvider } from "@app/utils/FeedContext";
12 | import { GuildProvider } from "@app/utils/GuildContext";
13 |
14 | import { useUserContext } from "@app/utils/UserContext";
15 | import { logoutUser } from "@app/utils/datalayer";
16 | import { ENDPOINT } from "@app/utils/constants";
17 | import * as SecureStore from "@app/utils/SecureStore";
18 | import { GoogleSignin } from "@react-native-google-signin/google-signin";
19 |
20 | const WHITE = "#FFFFFF";
21 |
22 | function FeedScreen({ navigation }) {
23 | const { user, setUser } = useUserContext();
24 |
25 | const handleOnLogout = useCallback(async () => {
26 | console.log("logout");
27 |
28 | try {
29 | // Revoke the refresh token
30 | const refreshToken = await SecureStore.getValueFor("refreshToken");
31 | // eslint-disable-next-line no-unused-vars
32 | const response = await axios.post(`${ENDPOINT}/auth/token/revoke`, {
33 | refreshToken: refreshToken,
34 | });
35 |
36 | if (response.status === 200) {
37 | // Remove tokens from SecureStore and logout user
38 | await logoutUser();
39 | await GoogleSignin.signOut();
40 | setUser({
41 | isLoggedIn: false,
42 | });
43 | }
44 | } catch (error) {
45 | console.log(error);
46 | }
47 | }, [setUser]);
48 |
49 | const EventFlatList = () => {
50 | const { events, refreshing, onRefresh } = useFeedContext();
51 | const handleRenderItem = useCallback(({ item }) => {
52 | return (
53 |
54 |
55 |
56 |
61 |
62 |
63 |
64 |
65 | );
66 | }, []);
67 |
68 | return (
69 | item.key}
75 | />
76 | );
77 | };
78 |
79 | return (
80 | <>
81 |
82 |
83 | Hello, {user.firstName}
84 |
85 | {JSON.stringify(user)}
86 |
87 |
88 |
89 |
90 | >
91 | );
92 | }
93 |
94 | const styles = StyleSheet.create({
95 | avatar: {
96 | borderRadius: 100,
97 | height: 80,
98 | width: 80,
99 | },
100 | card: {
101 | backgroundColor: WHITE,
102 | marginBottom: 10,
103 | marginTop: 10,
104 | },
105 | container: {
106 | margin: 10,
107 | },
108 | });
109 |
110 | FeedScreen.propTypes = {
111 | navigation: PropTypes.object,
112 | };
113 |
114 | export default FeedScreen;
115 |
--------------------------------------------------------------------------------
/packages/server/controllers/images.js:
--------------------------------------------------------------------------------
1 | const prisma = require("../prisma/prisma");
2 | const { s3Client } = require("../utils/s3");
3 | const { PutObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
4 |
5 | const AWS_URL = "https://icebreak-assets.s3.us-west-1.amazonaws.com";
6 |
7 | async function uploadImageInAWS(imageType, entityUUID, imageData) {
8 | const key = imageType + "." + entityUUID + ".jpg";
9 | const body = Buffer.from(imageData, "base64");
10 |
11 | const putObjectCommand = new PutObjectCommand({
12 | Bucket: "icebreak-assets",
13 | Key: key,
14 | Body: body,
15 | });
16 |
17 | await s3Client.send(putObjectCommand);
18 | const imageUrl = `${AWS_URL}/${key}`;
19 | return imageUrl;
20 | }
21 |
22 | async function getImageInDb(imageType, entityUUID) {
23 | switch (imageType) {
24 | case "user_avatar":
25 | return prisma.user.findUniqueOrThrow({
26 | where: {
27 | userId: entityUUID,
28 | },
29 | select: {
30 | avatar: true,
31 | },
32 | });
33 | case "guild_avatar":
34 | return prisma.guild.findUniqueOrThrow({
35 | where: {
36 | guildId: entityUUID,
37 | },
38 | select: {
39 | avatar: true,
40 | },
41 | });
42 | case "guild_banner":
43 | return prisma.guild.findUniqueOrThrow({
44 | where: {
45 | guildId: entityUUID,
46 | },
47 | select: {
48 | banner: true,
49 | },
50 | });
51 | case "event_thumbnail":
52 | return prisma.event.findUniqueOrThrow({
53 | where: {
54 | eventId: entityUUID,
55 | },
56 | select: {
57 | thumbnail: true,
58 | },
59 | });
60 | }
61 | }
62 |
63 | async function deleteImageInAWS(imageType, entityUUID) {
64 | const key = imageType + "." + entityUUID + ".jpg";
65 | const command = new DeleteObjectCommand({
66 | Bucket: "icebreak-assets",
67 | Key: key,
68 | });
69 |
70 | await s3Client.send(command);
71 | }
72 |
73 | async function updateImageInAWS(imageType, entityUUID, imageData) {
74 | const key = imageType + "." + entityUUID + ".jpg";
75 | const body = Buffer.from(imageData, "base64");
76 | const putObjectCommand = new PutObjectCommand({
77 | Bucket: "icebreak-assets",
78 | Key: key,
79 | Body: body,
80 | });
81 |
82 | await s3Client.send(putObjectCommand);
83 | const imageUrl = `${AWS_URL}/${key}`;
84 | return imageUrl;
85 | }
86 |
87 | async function updateImageInDb(imageType, entityUUID, imageUrl) {
88 | switch (imageType) {
89 | case "user_avatar":
90 | await prisma.user.update({
91 | where: {
92 | userId: entityUUID,
93 | },
94 | data: {
95 | avatar: imageUrl,
96 | },
97 | });
98 | break;
99 | case "guild_avatar":
100 | await prisma.guild.update({
101 | where: {
102 | guildId: entityUUID,
103 | },
104 | data: {
105 | avatar: imageUrl,
106 | },
107 | });
108 | break;
109 | case "guild_banner":
110 | await prisma.guild.update({
111 | where: {
112 | guildId: entityUUID,
113 | },
114 | data: {
115 | banner: imageUrl,
116 | },
117 | });
118 | break;
119 | case "event_thumbnail":
120 | await prisma.event.update({
121 | where: {
122 | eventId: entityUUID,
123 | },
124 | data: {
125 | thumbnail: imageUrl,
126 | },
127 | });
128 | break;
129 | }
130 | }
131 |
132 | module.exports = {
133 | uploadImageInAWS,
134 | getImageInDb,
135 | deleteImageInAWS,
136 | updateImageInAWS,
137 | updateImageInDb,
138 | };
139 |
--------------------------------------------------------------------------------
/packages/app/screens/landing/ForgotPasswordScreen.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 |
3 | import {
4 | StyleSheet,
5 | Text,
6 | KeyboardAvoidingView,
7 | TouchableWithoutFeedback,
8 | Keyboard,
9 | Platform,
10 | Button,
11 | } from "react-native";
12 |
13 | import Screen from "@app/components/Screen";
14 | import TextInput from "@app/components/TextInput";
15 |
16 | import PropTypes from "prop-types";
17 |
18 | const LIGHT_GRAY = "#ebebeb";
19 |
20 | function ForgotPasswordScreen({ route }) {
21 | const [inputs, setInputs] = useState({
22 | email: route.params?.email ?? "",
23 | });
24 |
25 | const [errors, setErrors] = useState({});
26 |
27 | const handleOnChange = (inputKey, text) => {
28 | setInputs((prevState) => ({ ...prevState, [inputKey]: text }));
29 | };
30 | const handleError = (inputKey, error) => {
31 | setErrors((prevState) => ({ ...prevState, [inputKey]: error }));
32 | };
33 |
34 | const validateInput = () => {
35 | let isValid = true;
36 |
37 | // Reset the error message
38 | for (const inputKey in inputs) {
39 | handleError(inputKey, null);
40 | }
41 |
42 | if (!inputs.email) {
43 | handleError("email", "Please enter an email.");
44 | isValid = false;
45 | } else if (!isValidEmail(inputs.email)) {
46 | handleError("email", "Please enter a valid email.");
47 | isValid = false;
48 | }
49 |
50 | return isValid;
51 | };
52 |
53 | return (
54 |
55 |
56 |
59 | This is {route.params.name}
60 |
61 | {
68 | handleOnChange("email", text);
69 | handleError("email", null);
70 | }}
71 | error={errors.email}
72 | placeholder="Email">
73 |
74 | {
78 | validateInput();
79 | }}
80 | underlayColor="#0e81c4"
81 | fontColor="#ffffff"
82 | fontWeight="bold"
83 | style={[styles.loginButton, styles.component]}
84 | textStyle={styles.boldText}
85 | />
86 |
87 |
88 |
89 | );
90 | }
91 | const styles = StyleSheet.create({
92 | component: {
93 | alignItems: "center",
94 | borderRadius: 10,
95 | height: 50,
96 | justifyContent: "center",
97 | width: "100%",
98 | },
99 | container: {
100 | alignItems: "center",
101 | flex: 1,
102 | justifyContent: "center",
103 | paddingLeft: 20,
104 | paddingRight: 20,
105 | width: "100%",
106 | },
107 | textInput: {
108 | backgroundColor: LIGHT_GRAY,
109 | borderWidth: 1,
110 | justifyContent: "space-between",
111 | marginBottom: 7,
112 | paddingLeft: 10,
113 | paddingRight: 10,
114 | },
115 | });
116 |
117 | const isValidEmail = (email) => {
118 | const emailRE =
119 | /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i;
120 | return email.match(emailRE);
121 | };
122 |
123 | ForgotPasswordScreen.propTypes = {
124 | navigation: PropTypes.object,
125 | route: PropTypes.object,
126 | };
127 |
128 | export default ForgotPasswordScreen;
129 |
--------------------------------------------------------------------------------
/packages/app/Root.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { NavigationContainer } from "@react-navigation/native";
3 | import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
4 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
5 | import { GestureHandlerRootView } from "react-native-gesture-handler";
6 |
7 | import { UserProvider, useUserContext } from "@app/utils/UserContext";
8 | import { getUserInfo } from "@app/utils/datalayer";
9 | import * as SecureStore from "@app/utils/SecureStore";
10 | import { ENDPOINT } from "./utils/constants";
11 |
12 | import LandingStack from "@app/screens/landing/LandingStack";
13 | import FeedStack from "@app/screens/feed/FeedStack";
14 | import GroupStack from "@app/screens/group/GroupStack";
15 | import ExploreStack from "@app/screens/explore/ExploreStack";
16 | import axios from "axios";
17 |
18 | import { logoutUser } from "@app/utils/datalayer";
19 |
20 | import Constants from "expo-constants";
21 |
22 | const Tab = createBottomTabNavigator();
23 | const Stack = createNativeStackNavigator();
24 |
25 | import { GoogleSignin } from "@react-native-google-signin/google-signin";
26 |
27 | function TabNavigation() {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | function App() {
38 | const { user, setUser } = useUserContext();
39 |
40 | const currentSession = async () => {
41 | let accessToken = await SecureStore.getValueFor("accessToken");
42 |
43 | if (!accessToken) {
44 | return;
45 | }
46 |
47 | try {
48 | const userResponse = await getUserInfo(accessToken);
49 |
50 | if (userResponse.status === "success") {
51 | setUser({
52 | ...user,
53 | isLoggedIn: true,
54 | data: userResponse.data.user,
55 | });
56 | return;
57 | }
58 | } catch (err) {
59 | await logoutUser();
60 | console.log(
61 | "Something went wrong trying to auto log in with stored access token"
62 | );
63 | }
64 |
65 | if (!refreshToken) {
66 | return;
67 | }
68 |
69 | // If access token is invalid/expired, try to get a new one with the refresh token
70 | const refreshToken = await SecureStore.getValueFor("refreshToken");
71 | try {
72 | const { data: response } = await axios.post(`${ENDPOINT}/auth/token`, {
73 | refreshToken: refreshToken,
74 | });
75 |
76 | await SecureStore.save("accessToken", response.data.accessToken);
77 | await SecureStore.save("refreshToken", response.data.refreshToken);
78 |
79 | const userResponse = await getUserInfo(response.data.accessToken);
80 |
81 | if (userResponse.status === "success") {
82 | setUser({
83 | ...user,
84 | isLoggedIn: true,
85 | data: userResponse.data.user,
86 | });
87 | return;
88 | }
89 | } catch (err) {
90 | await logoutUser();
91 | console.log(
92 | "Something went wrong trying to auto log in with newly fetched access token from stored refresh token"
93 | );
94 | }
95 | };
96 |
97 | useEffect(() => {
98 | GoogleSignin.configure({
99 | webClientId: Constants.expoConfig.extra.webClientId,
100 | iosClientId: Constants.expoConfig.extra.iosClientId,
101 | });
102 |
103 | currentSession();
104 | }, []);
105 |
106 | return (
107 |
110 | {user?.isLoggedIn ? (
111 |
112 | ) : (
113 |
114 | )}
115 |
116 | );
117 | }
118 |
119 | function Root() {
120 | return (
121 | // eslint-disable-next-line react-native/no-inline-styles
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | );
130 | }
131 |
132 | export default Root;
133 |
--------------------------------------------------------------------------------
/packages/server/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DB_URL")
8 | }
9 |
10 | model User {
11 | userId String @id(map: "pk_user") @default(dbgenerated("gen_random_uuid()")) @map("user_id") @db.Uuid
12 | joinedDate DateTime? @map("joined_date") @db.Timestamp(6)
13 | firstName String @map("first_name") @db.VarChar(50)
14 | lastName String @map("last_name") @db.VarChar(50)
15 | email String @unique @db.VarChar(255)
16 | avatar String? @db.VarChar(255)
17 | password String? @db.VarChar(255)
18 | isNew Boolean @default(true) @map("is_new")
19 | handler String? @db.VarChar(50)
20 | major String? @db.VarChar(100)
21 | age Int?
22 | pronouns UserPronouns @default(TheyThemTheir)
23 | interests String[]
24 | eventAttendees EventAttendee[]
25 | guildMembers GuildMember[]
26 |
27 | @@map("users")
28 | }
29 |
30 | model EventAttendee {
31 | userId String @map("user_id") @db.Uuid
32 | eventId String @map("event_id") @db.Uuid
33 | status EventAttendeeStatus @default(NotInterested)
34 | events Event @relation(fields: [eventId], references: [eventId], onDelete: Cascade, onUpdate: NoAction, map: "fk_event")
35 | attendees User @relation(fields: [userId], references: [userId], onDelete: Cascade, onUpdate: NoAction, map: "fk_user")
36 |
37 | @@id([eventId, userId], map: "pk_event_attendee")
38 | @@map("event_attendees")
39 | }
40 |
41 | model Event {
42 | eventId String @id(map: "pk_event") @default(dbgenerated("gen_random_uuid()")) @map("event_id") @db.Uuid
43 | guildId String @map("guild_id") @db.Uuid
44 | startDate DateTime? @map("start_date") @db.Timestamp(6)
45 | endDate DateTime? @map("end_date") @db.Timestamp(6)
46 | location String? @db.VarChar(255)
47 | thumbnail String? @db.VarChar(255)
48 | title String @db.VarChar(255)
49 | description String?
50 | eventAttendees EventAttendee[]
51 | guilds Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade, onUpdate: NoAction, map: "fk_guild")
52 |
53 | @@map("events")
54 | }
55 |
56 | model GuildMember {
57 | userId String @map("user_id") @db.Uuid
58 | guildId String @map("guild_id") @db.Uuid
59 | points Int @default(0) @db.SmallInt
60 | role GuildMemberRole @default(Member)
61 | guilds Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade, onUpdate: NoAction, map: "fk_guild")
62 | members User @relation(fields: [userId], references: [userId], onDelete: Cascade, onUpdate: NoAction, map: "fk_user")
63 |
64 | @@id([guildId, userId], map: "pk_guild_member")
65 | @@map("guild_members")
66 | }
67 |
68 | model Guild {
69 | guildId String @id(map: "pk_guild") @default(dbgenerated("gen_random_uuid()")) @map("guild_id") @db.Uuid
70 | name String @db.VarChar(100)
71 | handler String @db.VarChar(50)
72 | description String
73 | category String @db.VarChar(255)
74 | location String? @db.VarChar(255)
75 | website String? @db.VarChar(255)
76 | tags String[]
77 | banner String? @db.VarChar(255)
78 | avatar String? @db.VarChar(255)
79 | media String[]
80 | isInviteOnly Boolean @default(true) @map("invite_only")
81 | events Event[]
82 | members GuildMember[]
83 |
84 | @@map("guilds")
85 | }
86 |
87 | enum EventAttendeeStatus {
88 | NotInterested @map("Not Interested")
89 | Interested @map("Interested")
90 | Attending @map("Attending")
91 | CheckedIn @map("Checked In")
92 |
93 | @@map("event_attendee_status")
94 | }
95 |
96 | enum GuildMemberRole {
97 | Member @map("Member")
98 | Officer @map("Officer")
99 | Owner @map("Owner")
100 |
101 | @@map("guild_member_role")
102 | }
103 |
104 | enum UserPronouns {
105 | HeHim @map("He/Him")
106 | SheHer @map("She/Her")
107 | TheyThemTheir @map("They/Them/Their")
108 |
109 | @@map("user_pronoun")
110 | }
111 |
--------------------------------------------------------------------------------
/packages/server/routes/api/images.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const { matchedData, validationResult } = require("express-validator");
4 |
5 | const AuthController = require("../../controllers/auth");
6 | const ImagesController = require("../../controllers/images");
7 | const {
8 | jpegBase64Validator,
9 | imageEntityValidator,
10 | } = require("../../validators/images");
11 |
12 | router.post(
13 | "/:imageType/:entityUUID",
14 | imageEntityValidator,
15 | jpegBase64Validator,
16 | AuthController.authenticate,
17 | async (request, response) => {
18 | const result = validationResult(request);
19 | if (!result.isEmpty()) {
20 | return response.status(400).json({
21 | status: "fail",
22 | data: result.array(),
23 | });
24 | }
25 |
26 | const { imageType, entityUUID, jpegBase64 } = matchedData(request);
27 |
28 | try {
29 | const url = await ImagesController.uploadImageInAWS(
30 | imageType,
31 | entityUUID,
32 | jpegBase64
33 | );
34 |
35 | await ImagesController.updateImageInDb(imageType, entityUUID, url);
36 |
37 | response.status(200).json({
38 | status: "success",
39 | data: {
40 | imageURL: url,
41 | },
42 | });
43 | } catch (err) {
44 | response.status(500).json({
45 | status: "error",
46 | message: err.message,
47 | });
48 | }
49 | }
50 | );
51 |
52 | router.get(
53 | "/:imageType/:entityUUID",
54 | AuthController.authenticate,
55 | imageEntityValidator,
56 | async (request, response) => {
57 | const result = validationResult(request);
58 | if (!result.isEmpty()) {
59 | return response.status(400).json({
60 | status: "fail",
61 | data: result.array(),
62 | });
63 | }
64 |
65 | const { imageType, entityUUID } = matchedData(request);
66 |
67 | try {
68 | const url = await ImagesController.getImageInDb(imageType, entityUUID);
69 |
70 | if (!url) {
71 | throw new Error("NoImageFound");
72 | }
73 |
74 | response.status(200).json({
75 | status: "success",
76 | data: {
77 | imageURL: url,
78 | },
79 | });
80 | } catch (err) {
81 | if (err.message == "NoImageFound") {
82 | return response.status(404).json({
83 | status: "fail",
84 | data: {
85 | entityUUID: `No image found for entity with UUID ${entityUUID}`,
86 | },
87 | });
88 | }
89 |
90 | response.status(500).json({
91 | status: "error",
92 | message: err.message,
93 | });
94 | }
95 | }
96 | );
97 |
98 | router.delete(
99 | "/:imageType/:entityUUID",
100 | AuthController.authenticate,
101 | imageEntityValidator,
102 | async (request, response) => {
103 | const result = validationResult(request);
104 | if (!result.isEmpty()) {
105 | return response.status(400).json({
106 | status: "fail",
107 | data: result.array(),
108 | });
109 | }
110 |
111 | const { imageType, entityUUID } = matchedData(request);
112 | try {
113 | await ImagesController.deleteImageInAWS(imageType, entityUUID);
114 | // set image column for entity in db to null = deleting image
115 | await ImagesController.updateImageInDb(imageType, entityUUID, null);
116 |
117 | response.status(200).json({
118 | status: "success",
119 | data: null,
120 | });
121 | } catch (err) {
122 | response.status(500).json({
123 | status: "error",
124 | message: err.message,
125 | });
126 | }
127 | }
128 | );
129 |
130 | router.put(
131 | "/:imageType/:entityUUID",
132 | AuthController.authenticate,
133 | imageEntityValidator,
134 | jpegBase64Validator,
135 | async (request, response) => {
136 | const result = validationResult(request);
137 | if (!result.isEmpty()) {
138 | return response.status(400).json({
139 | status: "fail",
140 | data: result.array(),
141 | });
142 | }
143 |
144 | const { imageType, entityUUID, jpegBase64 } = matchedData(request);
145 | try {
146 | const url = await ImagesController.updateImageInAWS(
147 | imageType,
148 | entityUUID,
149 | jpegBase64
150 | );
151 |
152 | await ImagesController.updateImageInDb(imageType, entityUUID, url);
153 |
154 | response.status(200).json({
155 | status: "success",
156 | data: {
157 | imageURL: url,
158 | },
159 | });
160 | } catch (err) {
161 | response.status(500).json({
162 | status: "error",
163 | message: err.message,
164 | });
165 | }
166 | }
167 | );
168 |
169 | module.exports = router;
170 |
--------------------------------------------------------------------------------
/packages/app/__tests__/Login.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, fireEvent } from "@testing-library/react-native";
3 | import LandingScreen from "@app/screens/landing/LandingScreen";
4 | import jest, { describe, it, expect } from "jest";
5 |
6 | // Need these to avoid "Cannot use import statement outside a module" error
7 | jest.mock("expo-web-browser", () => ({
8 | maybeCompleteAuthSession: jest.fn(),
9 | }));
10 | jest.mock("expo-auth-session/providers/google", () => jest.fn());
11 | jest.mock("@app/utils/datalayer", () => jest.fn());
12 | jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
13 | jest.mock("expo-constants", () => ({
14 | Constants: () => null,
15 | }));
16 |
17 | describe(LandingScreen, () => {
18 | it("renders logo text", () => {
19 | const { getByTestId } = render( );
20 | const logo = getByTestId("logo");
21 | expect(logo).toBeTruthy();
22 | });
23 |
24 | describe("Email/Password Text Inputs", () => {
25 | it("renders correctly", () => {
26 | const { getByTestId } = render( );
27 | const emailInput = getByTestId("emailInput.textInput");
28 | const passwordInput = getByTestId("passwordInput.textInput");
29 | expect(emailInput).toBeTruthy();
30 | expect(passwordInput).toBeTruthy();
31 | });
32 |
33 | it("displays the placeholder text", () => {
34 | const { getByTestId } = render( );
35 |
36 | const emailInput = getByTestId("emailInput.textInput");
37 | const passwordInput = getByTestId("passwordInput.textInput");
38 |
39 | expect(emailInput.props.placeholder).toBeTruthy();
40 | expect(passwordInput.props.placeholder).toBeTruthy();
41 | });
42 |
43 | it("accepts inputs", () => {
44 | const { getByTestId } = render( );
45 |
46 | const emailInput = getByTestId("emailInput.textInput");
47 | const passwordInput = getByTestId("passwordInput.textInput");
48 |
49 | fireEvent.changeText(emailInput, "123");
50 | fireEvent.changeText(passwordInput, "123");
51 |
52 | expect(emailInput.props.value).toBe("123");
53 | expect(passwordInput.props.value).toBe("123");
54 | });
55 |
56 | it("doesn't accept an invalid email/password", () => {
57 | const { queryByTestId, getByTestId } = render( );
58 | const loginButton = getByTestId("loginButton");
59 | const emailInput = getByTestId("emailInput.textInput");
60 | const passwordInput = getByTestId("passwordInput.textInput");
61 |
62 | expect(queryByTestId("emailInput.errorText")).toBeFalsy();
63 | expect(queryByTestId("passwordInput.errorText")).toBeFalsy();
64 | fireEvent.changeText(emailInput, "invalid-email");
65 | fireEvent.changeText(passwordInput, "");
66 | fireEvent.press(loginButton);
67 | expect(queryByTestId("emailInput.errorText")).toBeTruthy();
68 | expect(queryByTestId("passwordInput.errorText")).toBeTruthy();
69 | });
70 |
71 | it("visibility icon toggles password visibility", () => {
72 | const { getByTestId } = render( );
73 | const hidePasswordButton = getByTestId("passwordInput.visibility");
74 | const passwordInput = getByTestId("passwordInput.textInput");
75 |
76 | expect(passwordInput.props.secureTextEntry).toBeTruthy();
77 | fireEvent.press(hidePasswordButton);
78 | expect(passwordInput.props.secureTextEntry).toBeFalsy();
79 | });
80 | });
81 |
82 | describe("Buttons", () => {
83 | it("login button renders correctly", () => {
84 | const { getByTestId } = render( );
85 | const loginButton = getByTestId("loginButton");
86 | expect(loginButton).toBeTruthy();
87 | fireEvent.press(loginButton);
88 | });
89 |
90 | it("google button renders correctly", () => {
91 | const { getByTestId } = render( );
92 | const googleButton = getByTestId("googleButton");
93 | expect(googleButton).toBeTruthy();
94 | fireEvent.press(googleButton);
95 | });
96 |
97 | it("signup button renders correctly", () => {
98 | const mockNavigation = {
99 | navigate: jest.fn(),
100 | };
101 | const { getByTestId } = render(
102 |
103 | );
104 | const signupButton = getByTestId("signupButton");
105 | expect(signupButton).toBeTruthy();
106 | fireEvent.press(signupButton);
107 | });
108 |
109 | it("forgot password button renders correctly", () => {
110 | const { getByTestId } = render( );
111 | const forgotPassButton = getByTestId("forgotPassButton");
112 | expect(forgotPassButton).toBeTruthy();
113 | fireEvent.press(forgotPassButton);
114 | });
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/packages/app/components/GroupScreen/GroupHeaderInfo.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | View,
4 | StyleSheet,
5 | Text,
6 | Linking,
7 | TouchableWithoutFeedback,
8 | } from "react-native";
9 | import Ionicons from "@expo/vector-icons/Ionicons";
10 |
11 | import GroupTag from "./GroupTag";
12 |
13 | import PropTypes from "prop-types";
14 |
15 | const GRAY = "#2C2C2C";
16 | const LIGHT_GRAY = "#6C6C6C";
17 | const BLUE = "#3498DB";
18 |
19 | const styles = StyleSheet.create({
20 | clubDetails: {
21 | flexDirection: "row",
22 | flexWrap: "wrap",
23 | },
24 | containerStyle: {
25 | height: "100%",
26 | marginTop: 44,
27 | width: "100%",
28 | },
29 | dataContainer: {
30 | alignItems: "center",
31 | flexDirection: "row",
32 | marginRight: 10,
33 | marginTop: 6,
34 | },
35 | dataTextStyle: {
36 | color: LIGHT_GRAY,
37 | fontSize: 12,
38 | marginLeft: 5,
39 | },
40 | descriptionContainer: {
41 | marginTop: 6,
42 | },
43 | descriptionStyle: {
44 | fontSize: 13, // NOTE: Default font family; change later?
45 | },
46 | handlerContainer: {
47 | marginTop: -4,
48 | },
49 | handlerStyle: {
50 | color: LIGHT_GRAY,
51 | fontSize: 13,
52 | },
53 | tagContainer: {
54 | flexDirection: "row",
55 | flexWrap: "wrap",
56 | marginTop: 12,
57 | },
58 | titleContainer: {
59 | justifyContent: "center",
60 | },
61 | titleStyle: {
62 | color: GRAY,
63 | fontSize: 18,
64 | fontWeight: "700",
65 | },
66 | url: {
67 | color: BLUE,
68 | flexShrink: 1,
69 | },
70 | });
71 |
72 | /**
73 | * A component for GroupHeader that displays an organization's information.
74 | *
75 | * @param {object} props - Object that contains properties of this component.
76 | * @param {string} props.name - Name of org.
77 | * @param {string} props.handler - Handler of org, implementation already includes '@'.
78 | * @param {string} props.description - Description of org.
79 | *
80 | * @param {string} props.location - Location of org.
81 | * @param {number} props.members - Amount of members in org.
82 | * @param {string} props.url - Link to the website of org.
83 | * @param {string[]} props.tags - String array of tags related to org.
84 | */
85 | function GroupHeaderInfo(props) {
86 | const [isDescriptionTruncated, setIsDescriptionTruncated] = useState(true);
87 |
88 | const toggleDescriptionTruncation = () => {
89 | setIsDescriptionTruncated(!isDescriptionTruncated);
90 | };
91 |
92 | return (
93 |
94 |
95 | {props.name}
96 |
97 |
98 | @{props.handler}
99 |
100 |
101 |
102 |
103 |
104 |
108 | {props.description}
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | {props.location || "N/A"}
117 |
118 |
119 |
120 |
121 |
122 | {props.members > 0 ? props.members : 0} members
123 |
124 |
125 |
126 |
127 |
128 | Linking.openURL(props.url)}>
133 | {props.url}
134 |
135 |
136 |
137 |
138 | {props.tags && (
139 |
140 | {props.tags.map((tag, index) => (
141 |
142 | ))}
143 |
144 | )}
145 |
146 | );
147 | }
148 |
149 | GroupHeaderInfo.propTypes = {
150 | description: PropTypes.string,
151 | handler: PropTypes.string,
152 | location: PropTypes.string,
153 | members: PropTypes.number,
154 | name: PropTypes.string,
155 | orgTags: PropTypes.arrayOf(PropTypes.string),
156 | tags: PropTypes.arrayOf(PropTypes.string),
157 | testID: PropTypes.string,
158 | url: PropTypes.string,
159 | };
160 |
161 | export default GroupHeaderInfo;
162 |
--------------------------------------------------------------------------------
/packages/server/controllers/guilds.js:
--------------------------------------------------------------------------------
1 | const { GuildMemberRole } = require("@prisma/client");
2 | const prisma = require("../prisma/prisma");
3 | const { flatten } = require("../utils/flattener");
4 | const MINIMUM_SIMILARITY = 0.3;
5 |
6 | async function getAllGuilds() {
7 | return await prisma.guild.findMany();
8 | }
9 |
10 | async function getGuild(guildId) {
11 | return prisma.guild.findUniqueOrThrow({
12 | where: {
13 | guildId: guildId,
14 | },
15 | });
16 | }
17 |
18 | async function createGuild(guildData) {
19 | return await prisma.guild.create({
20 | data: guildData,
21 | });
22 | }
23 |
24 | async function updateGuild(guildId, guildData) {
25 | return await prisma.guild.update({
26 | where: {
27 | guildId: guildId,
28 | },
29 | data: guildData,
30 | });
31 | }
32 |
33 | async function deleteGuild(guildId) {
34 | return await prisma.guild.delete({
35 | where: {
36 | guildId: guildId,
37 | },
38 | });
39 | }
40 |
41 | async function searchGuildByName(pattern) {
42 | return prisma.$queryRaw`
43 | SELECT guild_id, name, handler, avatar FROM guilds
44 | WHERE WORD_SIMILARITY(${pattern}, name) > ${MINIMUM_SIMILARITY}
45 | ORDER BY WORD_SIMILARITY(${pattern}, name) DESC;`;
46 | }
47 |
48 | async function searchGuildByHandler(pattern) {
49 | return prisma.$queryRaw`
50 | SELECT guild_id, name, handler, avatar FROM guilds
51 | WHERE SIMILARITY(${pattern}, handler) > ${MINIMUM_SIMILARITY}
52 | ORDER BY SIMILARITY(${pattern}, handler) DESC;`;
53 | }
54 |
55 | async function guildExists(guildId) {
56 | const guild = await prisma.guild.findFirst({
57 | where: {
58 | guildId: guildId,
59 | },
60 | });
61 |
62 | return !!guild;
63 | }
64 |
65 | async function addGuildMember(guildId, userId) {
66 | return await prisma.guildMember.create({
67 | data: {
68 | userId: userId,
69 | guildId: guildId,
70 | points: 0,
71 | role: GuildMemberRole.Member,
72 | },
73 | });
74 | }
75 |
76 | async function getGuildMember(guildId, userId) {
77 | const getMember = await prisma.guildMember.findUnique({
78 | where: {
79 | guildId_userId: {
80 | guildId: guildId,
81 | userId: userId,
82 | },
83 | },
84 | include: {
85 | members: {
86 | select: {
87 | firstName: true,
88 | lastName: true,
89 | avatar: true,
90 | },
91 | },
92 | },
93 | });
94 |
95 | return getMember ? flatten(getMember) : getMember;
96 | }
97 |
98 | async function getAllGuildMembers(guildId) {
99 | const getMembers = await prisma.guildMember.findMany({
100 | where: {
101 | guildId: guildId,
102 | },
103 | select: {
104 | members: {
105 | select: {
106 | userId: true,
107 | firstName: true,
108 | lastName: true,
109 | avatar: true,
110 | },
111 | },
112 | },
113 | });
114 |
115 | const members = getMembers.flatMap((member) => member.members);
116 |
117 | return members;
118 | }
119 |
120 | async function updateGuildMemberRole(guildId, userId, role) {
121 | return await prisma.guildMember.update({
122 | where: {
123 | guildId_userId: {
124 | guildId: guildId,
125 | userId: userId,
126 | },
127 | },
128 | data: {
129 | role: role,
130 | },
131 | });
132 | }
133 |
134 | async function deleteGuildMember(guildId, userId) {
135 | return await prisma.guildMember.delete({
136 | where: {
137 | guildId_userId: {
138 | guildId: guildId,
139 | userId: userId,
140 | },
141 | },
142 | });
143 | }
144 |
145 | async function getLeaderboard(guildId) {
146 | const guildLeaderboard = await prisma.guildMember.findMany({
147 | where: {
148 | guildId: guildId,
149 | },
150 |
151 | include: {
152 | members: {
153 | select: {
154 | firstName: true,
155 | lastName: true,
156 | handler: true,
157 | avatar: true,
158 | },
159 | },
160 | },
161 |
162 | orderBy: [
163 | {
164 | points: "desc",
165 | },
166 | ],
167 | });
168 |
169 | const members = guildLeaderboard.map((member) => ({
170 | ...member.members,
171 | points: member.points,
172 | }));
173 |
174 | return members;
175 | }
176 |
177 | async function isGuildMember(guildId, userId) {
178 | const guildMember = await prisma.guildMember.findUnique({
179 | where: {
180 | guildId_userId: {
181 | guildId: guildId,
182 | userId: userId,
183 | },
184 | },
185 | });
186 | return !!guildMember;
187 | }
188 |
189 | module.exports = {
190 | getGuild,
191 | searchGuildByName,
192 | searchGuildByHandler,
193 | getAllGuilds,
194 | createGuild,
195 | updateGuild,
196 | deleteGuild,
197 | guildExists,
198 | addGuildMember,
199 | getGuildMember,
200 | getAllGuildMembers,
201 | updateGuildMemberRole,
202 | deleteGuildMember,
203 | getLeaderboard,
204 | isGuildMember,
205 | };
206 |
--------------------------------------------------------------------------------
/packages/server/__tests__/controllers/guilds.test.js:
--------------------------------------------------------------------------------
1 | const {
2 | PrismaClientKnownRequestError,
3 | } = require("@prisma/client/runtime/library");
4 | const GuildsController = require("../../controllers/guilds");
5 | const { prismaMock } = require("../prisma_mock");
6 |
7 | describe("Guilds Unit Tests", () => {
8 | test("should fetch guild by id", async () => {
9 | const testFetchGuild = {
10 | guildId: "1f050f81-fbef-485a-84b2-516dfbb3d0da",
11 | name: "Software Engineering Association",
12 | handler: "sea",
13 | description: "test description",
14 | category: "software engineering",
15 | };
16 | prismaMock.guilds.findUniqueOrThrow.mockResolvedValue(testFetchGuild);
17 |
18 | await expect(
19 | GuildsController.getGuild(testFetchGuild.guildId),
20 | ).resolves.toEqual({
21 | guildId: "1f050f81-fbef-485a-84b2-516dfbb3d0da",
22 | name: "Software Engineering Association",
23 | handler: "sea",
24 | description: "test description",
25 | category: "software engineering",
26 | });
27 | });
28 |
29 | test("should throw error if no guild has given id", async () => {
30 | prismaMock.guilds.findUniqueOrThrow.mockRejectedValue(
31 | new PrismaClientKnownRequestError("record not found", { code: "P2025" }),
32 | );
33 |
34 | await expect(
35 | GuildsController.getGuild("e2bdade9-4bf2-4220-8020-e09266363762"),
36 | ).rejects.toThrow(PrismaClientKnownRequestError);
37 | });
38 |
39 | test("should sucessfully create new guild", async () => {
40 | const testCreateGuild = {
41 | name: "Software Engineering Association",
42 | handler: "sea",
43 | description: "test description",
44 | category: "software engineering",
45 | };
46 |
47 | prismaMock.guilds.create.mockResolvedValue(testCreateGuild);
48 |
49 | await expect(
50 | GuildsController.createGuild(testCreateGuild),
51 | ).resolves.toEqual(expect.objectContaining(testCreateGuild));
52 | });
53 |
54 | test("should throw error/fail guild creation due to missing required fields in body.", async () => {
55 | const testinvalidCreateGuild = {
56 | location: "San Diego",
57 | website: "apple.com",
58 | banner: "apple.jpeg",
59 | avatar: "appleicon.jpeg",
60 | };
61 |
62 | prismaMock.guilds.create.mockRejectedValue(
63 | new PrismaClientKnownRequestError("Missing required field error", {
64 | code: "P2012",
65 | }),
66 | );
67 |
68 | await expect(
69 | GuildsController.createGuild(testinvalidCreateGuild),
70 | ).rejects.toThrow(PrismaClientKnownRequestError);
71 | });
72 |
73 | test("should update multiple guild properties successfully", async () => {
74 | const initial = {
75 | name: "Apple",
76 | handler: "Tim Cook",
77 | description: "Come buy our overpriced iphones.",
78 | category: "Guild Category",
79 | location: "Optional Location",
80 | website: "Optional Website",
81 | tags: ["Tag1", "Tag2", "apple 15"],
82 | banner: "URL to Banner Image",
83 | avatar: "URL to Icon Image",
84 | media: ["Media1", "Media2", "Media3"],
85 | isInviteOnly: true,
86 | };
87 |
88 | const expected = {
89 | guildId: "308b5bc4-7771-4514-99b7-9611cefb7e1d",
90 | name: "Apple",
91 | handler: "Tim Cook",
92 | description: "Come buy our overpriced iphones.",
93 | category: "Guild Category",
94 | location: "Optional Location",
95 | website: "Optional Website",
96 | tags: ["Tag1", "Tag2", "apple 15"],
97 | banner: "URL to Banner Image",
98 | avatar: "URL to Icon Image",
99 | media: ["Media1", "Media2", "Media3"],
100 | isInviteOnly: true,
101 | };
102 |
103 | prismaMock.guilds.update.mockResolvedValue(expected);
104 |
105 | const result = await GuildsController.updateGuild(
106 | "308b5bc4-7771-4514-99b7-6666cefb7e1d",
107 | initial,
108 | );
109 |
110 | expect(result).toEqual(expect.objectContaining(expected));
111 | });
112 |
113 | test("should update one guild property successfully", async () => {
114 | const initial = {
115 | name: "Facebook",
116 | };
117 |
118 | const expected = {
119 | guildId: "308b5bc4-7771-4514-99b7-9611cefb7e1d",
120 | name: "Facebook",
121 | };
122 |
123 | prismaMock.guilds.update.mockResolvedValue(expected);
124 |
125 | const result = await GuildsController.updateGuild(
126 | "308b5bc4-7771-4514-99b7-6666cefb7e1d",
127 | initial,
128 | );
129 |
130 | expect(result).toEqual(expect.objectContaining(expected));
131 | });
132 |
133 | test("should successfully delete a target guild", async () => {
134 | const guildIdToDelete = "308b5bc4-7771-4514-99b7-6666cefb7e1d";
135 |
136 | const mockGuild = {
137 | id: "308b5bc4-7771-4514-99b7-6666cefb7e1d",
138 | name: "Testing Delete Guild",
139 | };
140 | prismaMock.guilds.delete.mockResolvedValue(mockGuild);
141 |
142 | const result = await GuildsController.deleteGuild(guildIdToDelete);
143 |
144 | expect(result).toEqual(mockGuild);
145 | });
146 | });
147 |
--------------------------------------------------------------------------------
/packages/server/validators/users.js:
--------------------------------------------------------------------------------
1 | const { param, body } = require("express-validator");
2 |
3 | const UserController = require("../controllers/users");
4 | const EventController = require("../controllers/events");
5 | const GuildController = require("../controllers/guilds");
6 | const ALLOWED_PRONOUN = ["HeHim", "SheHer", "TheyThemTheir"];
7 |
8 | const userIdValidator = [
9 | param("userId")
10 | .notEmpty()
11 | .withMessage("User ID cannot be empty")
12 | .blacklist("<>")
13 | .isUUID()
14 | .withMessage("Invalid user ID provided")
15 | .bail()
16 | .custom(async (value) => {
17 | try {
18 | await UserController.getUser(value);
19 | } catch (error) {
20 | throw new Error("User with ID ${value} not found");
21 | }
22 | }),
23 | ];
24 |
25 | // validate userId in body instead of params
26 | const userIdBodyValidator = [
27 | body("userId")
28 | .notEmpty()
29 | .withMessage("User ID cannot be empty")
30 | .blacklist("<>")
31 | .isUUID()
32 | .withMessage("Invalid user ID provided")
33 | .bail()
34 | .custom(async (value) => {
35 | try {
36 | await UserController.getUser(value);
37 | } catch (error) {
38 | throw new Error("User with ID ${value} not found");
39 | }
40 | })
41 | .custom(async (userId, { req }) => {
42 | const eventId = req.params.eventId;
43 | const event = await EventController.getEvent(eventId);
44 | const isMember = await GuildController.isGuildMember(
45 | event.guildId,
46 | userId,
47 | );
48 | if (!isMember) {
49 | throw new Error("User is not a member of the guild.");
50 | }
51 | }),
52 | ];
53 |
54 | const onboardingValidator = [
55 | // User ID Check: Must Exist, and be new onboarding user.
56 | param("userId")
57 | .notEmpty()
58 | .withMessage("User ID cannot be empty")
59 | .blacklist("<>")
60 | .isUUID()
61 | .withMessage("Invalid user ID provided")
62 | .bail()
63 |
64 | // check if user exists and hasn't completed onboarding
65 | .custom(async (value) => {
66 | try {
67 | const user = await UserController.getUser(value);
68 | if (user.isNew === false) {
69 | throw new Error("User already onboarded!");
70 | }
71 | } catch (error) {
72 | if (error.message === "User already onboarded!") {
73 | throw new Error("User already onboarded!");
74 | } else {
75 | throw new Error("User not found!");
76 | }
77 | }
78 | }),
79 |
80 | // First Name Check: exist, be between 1 and 50 chars
81 | body("firstName", "Invalid firstName.")
82 | .exists({ checkFalsy: true })
83 | .withMessage("firstName can't be null or empty.")
84 | .blacklist("<>")
85 | .trim()
86 | .isLength({ min: 1, max: 50 })
87 | .withMessage("firstName length must be between 1 to 50 characters.")
88 | .isAlpha()
89 | .withMessage("firstName should be Alphabetic characters."),
90 |
91 | // Last Name Check: exist, be between 1 and 50 chars
92 | body("lastName", "Invalid lastName.")
93 | .exists({ checkFalsy: true })
94 | .withMessage("firstName can't be null or empty.")
95 | .blacklist("<>")
96 | .trim()
97 | .isLength({ min: 1, max: 50 })
98 | .withMessage("lastName length must be between 1 to 50 characters."),
99 |
100 | // Pronoun Check: exist, be between 1 and 25 chars, and inside the allowed array.
101 | body("pronouns", "Invalid pronoun.")
102 | .exists({ checkFalsy: true })
103 | .withMessage("pronoun can't be null or empty.")
104 | .blacklist("<>")
105 | .trim()
106 | .isLength({ min: 1, max: 25 })
107 | .withMessage("pronoun length must be between 1 to 25 characters.")
108 | .isIn(ALLOWED_PRONOUN)
109 | .withMessage(
110 | "pronouns must be one of the allowed values: " +
111 | ALLOWED_PRONOUN.join(", "),
112 | ),
113 |
114 | // Major Check: exist, be between 1 and 100 chars
115 | body("major", "Invalid major.")
116 | .exists({ checkFalsy: true })
117 | .withMessage("major can't be null or empty.")
118 | .blacklist("<>")
119 | .trim()
120 | .isLength({ min: 1, max: 100 })
121 | .withMessage("Major length must be between 1 to 100 characters."),
122 |
123 | // Avatar Check: exist, be between 1 and 255 chars, and is URL!
124 | body("avatar", "Invalid avatar.")
125 | .exists({ checkFalsy: true })
126 | .withMessage("avatar can't be null or empty.")
127 | .blacklist("<>")
128 | .trim()
129 | .isLength({ min: 1, max: 255 })
130 | .withMessage("Name length must be between 1 to 255 characters.")
131 | .isURL()
132 | .withMessage("Must be an URL!")
133 | .matches(/(?:\.jpg|\.png)$/)
134 | .withMessage("Must be an image URL in .jpg or .png form!"),
135 |
136 | // Age Check: be greater than or equal to 16.
137 | body("age", "Invalid age.")
138 | .exists({ checkFalsy: true })
139 | .blacklist("<>")
140 | .trim()
141 | .toInt()
142 | .custom((value) => {
143 | if (value < 16) {
144 | throw new Error("Age must be greater than 16.");
145 | }
146 | return true;
147 | }),
148 |
149 | // Interests Check: Must be a populated array
150 | body("interests", "Invalid interests.")
151 | .blacklist("<>")
152 | .trim()
153 | .isArray({ min: 1 })
154 | .withMessage("Array can't be empty!")
155 | .exists({ checkFalsy: true })
156 | .withMessage("Interests can't be null or empty."),
157 |
158 | // validate each element in the interests array
159 | body("interests.*", "Invalid interests.")
160 | .isAlpha("en-US", { ignore: " " })
161 | .withMessage("Must be alphabetical string!"),
162 | ];
163 |
164 | module.exports = {
165 | userIdValidator,
166 | userIdBodyValidator,
167 | onboardingValidator,
168 | };
169 |
--------------------------------------------------------------------------------
/packages/app/components/GroupScreen/GroupTabs.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect, createRef } from "react";
2 | import {
3 | View,
4 | Text,
5 | TouchableWithoutFeedback,
6 | Animated,
7 | StyleSheet,
8 | } from "react-native";
9 | import { ScrollView } from "react-native-gesture-handler";
10 | import PropTypes from "prop-types";
11 |
12 | const BLUE = "#3498DB";
13 | const LIGHT_GRAY = "#E4E4E4";
14 | const GRAY = "#717171";
15 | const DARK_GRAY = "#2C2C2C";
16 |
17 | const blueViewWidth = 60;
18 | const blueViewPosition = new Animated.ValueXY({
19 | x: 30,
20 | y: -1,
21 | });
22 |
23 | function GroupTabs(props) {
24 | const [isAnimationComplete, setIsAnimationComplete] = useState(true);
25 | const { selectTab, tabs, activeTab } = props;
26 |
27 | // viewRefs.current to access the list
28 | // viewRefs.current[index].current to access the view
29 | const viewRefs = useRef([]);
30 |
31 | useEffect(() => {
32 | // Initialize viewRefs list with a ref for each view
33 | viewRefs.current = tabs.map(() => createRef());
34 | }, [tabs]);
35 |
36 | // Move the blue view whenever activeTab
37 | useEffect(() => {
38 | setIsAnimationComplete(false);
39 | animateBlueSlider();
40 | }, [activeTab]);
41 |
42 | function animateBlueSlider() {
43 | getPosition()
44 | .then((position) => {
45 | Animated.spring(blueViewPosition, {
46 | toValue: { x: position, y: blueViewPosition.y },
47 | useNativeDriver: true,
48 | speed: 100,
49 | restSpeedThreshold: 100,
50 | restDisplacementThreshold: 40,
51 | }).start(({ finished }) => {
52 | if (finished) {
53 | setIsAnimationComplete(true);
54 | }
55 | });
56 | })
57 | .catch((error) => {
58 | console.log(error);
59 | });
60 | }
61 |
62 | // Set starting position for Blue View
63 | useEffect(() => {
64 | setIsAnimationComplete(true);
65 | }, []);
66 |
67 | function getPosition() {
68 | return new Promise((resolve, reject) => {
69 | for (let index = 0; index < tabs.length; index++) {
70 | if (tabs[index] == activeTab && viewRefs.current[index].current) {
71 | viewRefs.current[index].current.measure(
72 | (x, y, width, height, pageX) => {
73 | const tabCenter = pageX + width / 2;
74 | const position = tabCenter - blueViewWidth / 2;
75 | resolve(position);
76 | }
77 | );
78 | return;
79 | }
80 | }
81 | reject(new Error("Could not find active tab or view ref"));
82 | });
83 | }
84 |
85 | return (
86 |
87 |
88 |
94 |
95 | {tabs.map((tab, index) => (
96 | {
99 | selectTab(tab);
100 | }}>
101 |
102 |
103 |
110 | {tab.name}
111 |
112 |
113 |
114 | {activeTab.name === tab.name && (
115 |
122 | )}
123 |
124 |
125 | ))}
126 |
127 |
128 |
129 |
136 |
137 |
138 |
139 |
140 | );
141 | }
142 |
143 | const styles = StyleSheet.create({
144 | blueView: {
145 | backgroundColor: BLUE,
146 | borderRadius: 2,
147 | height: 3,
148 | left: 0,
149 | marginTop: -2,
150 | right: 0,
151 | transform: blueViewPosition.getTranslateTransform(),
152 | width: blueViewWidth,
153 | },
154 | bottomBorder: {
155 | backgroundColor: LIGHT_GRAY,
156 | height: 3,
157 | width: "100%",
158 | },
159 | innerTabView: {
160 | flexDirection: "row",
161 | },
162 | staticBlueView: {
163 | backgroundColor: BLUE,
164 | borderRadius: 2,
165 | height: 3,
166 | marginBottom: -10,
167 | transform: [{ translateY: 7 }],
168 | width: blueViewWidth,
169 | },
170 | tab: {
171 | alignItems: "center",
172 | paddingBottom: 10,
173 | paddingTop: 10,
174 | },
175 | tabScrollView: {
176 | marginLeft: 5,
177 | marginRight: 5,
178 | },
179 | tabText: {
180 | fontWeight: "600",
181 | paddingLeft: 10,
182 | paddingRight: 10,
183 | },
184 | tabTextContainer: {
185 | alignItems: "center",
186 | },
187 | });
188 |
189 | GroupTabs.propTypes = {
190 | activeTab: PropTypes.object,
191 | selectTab: PropTypes.func,
192 | size: PropTypes.number,
193 | style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
194 | tabs: PropTypes.arrayOf(PropTypes.object),
195 | testID: PropTypes.string,
196 | };
197 |
198 | export default GroupTabs;
199 |
--------------------------------------------------------------------------------
/packages/server/controllers/events.js:
--------------------------------------------------------------------------------
1 | const prisma = require("../prisma/prisma");
2 | var QRCode = require("qrcode");
3 |
4 | async function getAllEvents() {
5 | return prisma.event.findMany();
6 | }
7 |
8 | async function getEvents(limit, action, eventId) {
9 | let events;
10 |
11 | switch (action) {
12 | case "prev":
13 | events = await prisma.event.findMany({
14 | take: -1 * limit,
15 | skip: 1, // skip cursor (TODO: check if skip 1 is needed for previous page queries)
16 | cursor: {
17 | eventId: eventId,
18 | },
19 | orderBy: {
20 | eventId: "asc",
21 | },
22 | });
23 | break;
24 | case "next":
25 | events = await prisma.event.findMany({
26 | take: limit,
27 | skip: 1, // skip cursor
28 | cursor: {
29 | eventId: eventId,
30 | },
31 | orderBy: {
32 | eventId: "asc",
33 | },
34 | });
35 | break;
36 | // first request made for first page, no action in cursor present
37 | default:
38 | events = await prisma.event.findMany({
39 | take: limit,
40 | orderBy: {
41 | eventId: "asc",
42 | },
43 | });
44 | break;
45 | }
46 |
47 | return events;
48 | }
49 |
50 | async function getPublicUpcomingEvents(limit, eventId) {
51 | let events;
52 |
53 | if (eventId) {
54 | events = await prisma.event.findMany({
55 | take: limit,
56 | skip: 1,
57 | cursor: {
58 | eventId: eventId,
59 | },
60 | orderBy: {
61 | startDate: "asc",
62 | },
63 | where: {
64 | guilds: {
65 | isInviteOnly: false,
66 | },
67 | startDate: {
68 | gt: new Date(),
69 | },
70 | },
71 | });
72 | } else {
73 | events = await prisma.event.findMany({
74 | take: limit,
75 | skip: 1,
76 | orderBy: {
77 | startDate: "asc",
78 | },
79 | where: {
80 | guilds: {
81 | isInviteOnly: false,
82 | },
83 | startDate: {
84 | gt: new Date(),
85 | },
86 | },
87 | });
88 | }
89 | return events;
90 | }
91 |
92 | async function getPages(limit) {
93 | const totalEvents = await prisma.event.count();
94 | const totalPages = Math.ceil(totalEvents / limit);
95 | return totalPages;
96 | }
97 |
98 | async function createEvent(eventData, guildId) {
99 | const newEvent = await prisma.event.create({
100 | data: {
101 | guildId: guildId,
102 | title: eventData.title,
103 | description: eventData.description,
104 | startDate: eventData.startDate,
105 | endDate: eventData.endDate,
106 | location: eventData.location,
107 | thumbnail: eventData.thumbnail,
108 | },
109 | });
110 | return newEvent;
111 | }
112 |
113 | async function getEvent(eventId) {
114 | return prisma.event.findUniqueOrThrow({
115 | where: {
116 | eventId: eventId,
117 | },
118 | });
119 | }
120 |
121 | async function deleteEvent(eventId) {
122 | const deletedEvent = await prisma.event.delete({
123 | where: {
124 | eventId: eventId,
125 | },
126 | });
127 | return deletedEvent;
128 | }
129 |
130 | async function updateEvent(eventId, eventData) {
131 | const updateEvent = await prisma.event.update({
132 | where: {
133 | eventId: eventId,
134 | },
135 | //If a given data was not updated, it will be undefined and prisma will ignore the update of that field
136 | data: {
137 | title: eventData.title,
138 | description: eventData.description,
139 | startDate: eventData.startDate,
140 | endDate: eventData.endDate,
141 | location: eventData.location,
142 | thumbnail: eventData.thumbnail,
143 | },
144 | });
145 | return updateEvent;
146 | }
147 |
148 | async function getEventAttendees(eventId) {
149 | const query = await prisma.users.findMany({
150 | where: {
151 | eventAttendees: {
152 | some: {
153 | eventId: eventId,
154 | },
155 | },
156 | },
157 | select: {
158 | userId: true,
159 | firstName: true,
160 | lastName: true,
161 | avatar: true,
162 | },
163 | });
164 | return query;
165 | }
166 |
167 | async function getUpcomingEvents(currentDate, guildId) {
168 | const upcomingEvents = await prisma.event.findMany({
169 | where: {
170 | guildId: guildId,
171 | startDate: { gte: currentDate },
172 | },
173 | orderBy: {
174 | startDate: "asc",
175 | },
176 | });
177 |
178 | return upcomingEvents;
179 | }
180 |
181 | async function getArchivedEvents(currDate, pastDate, guildId) {
182 | const archivedEvents = await prisma.event.findMany({
183 | where: {
184 | guildId: guildId,
185 | AND: [{ startDate: { gte: pastDate } }, { endDate: { lte: currDate } }],
186 | },
187 | orderBy: {
188 | startDate: "desc",
189 | },
190 | });
191 |
192 | return archivedEvents;
193 | }
194 |
195 | async function updateAttendeeStatus(eventId, userId, attendeeStatus) {
196 | const query = await prisma.eventAttendee.upsert({
197 | where: {
198 | eventId_userId: {
199 | userId: userId,
200 | eventId: eventId,
201 | },
202 | },
203 | create: {
204 | userId: userId,
205 | eventId: eventId,
206 | status: attendeeStatus,
207 | },
208 | update: {
209 | status: attendeeStatus,
210 | },
211 | });
212 |
213 | if (attendeeStatus === "CheckedIn") {
214 | await addCheckInPoints(eventId, userId);
215 | }
216 |
217 | return query;
218 | }
219 |
220 | async function addCheckInPoints(eventId, userId) {
221 | const event = await prisma.event.findUnique({
222 | where: {
223 | eventId: eventId,
224 | },
225 | select: {
226 | startDate: true,
227 | guildId: true,
228 | },
229 | });
230 |
231 | if (!event) throw new Error("Event not found");
232 |
233 | const currentTime = new Date();
234 | const eventStartTime = event.startDate.getTime();
235 | const fiveMinutesInMilliseconds = 5 * 60 * 1000;
236 | const currentTimeInMilliseconds = currentTime.getTime();
237 | let pointsToAdd = 3;
238 |
239 | if (
240 | currentTimeInMilliseconds <= eventStartTime &&
241 | currentTimeInMilliseconds >= eventStartTime - fiveMinutesInMilliseconds
242 | ) {
243 | pointsToAdd = 5;
244 | } else if (
245 | currentTimeInMilliseconds > eventStartTime &&
246 | currentTimeInMilliseconds <= eventStartTime + fiveMinutesInMilliseconds
247 | ) {
248 | pointsToAdd = 4;
249 | }
250 |
251 | await prisma.guildMembers.update({
252 | where: {
253 | guildId_userId: {
254 | userId: userId,
255 | guildId: event.guildId,
256 | },
257 | },
258 | data: {
259 | points: {
260 | increment: pointsToAdd,
261 | },
262 | },
263 | });
264 | }
265 |
266 | async function generateCheckInQRCode(eventId) {
267 | const text = `icebreak://qr-code-check-in?eventId=${eventId}`;
268 | return QRCode.toDataURL(text);
269 | }
270 |
271 | module.exports = {
272 | getEvent,
273 | getEvents,
274 | getPages,
275 | getAllEvents,
276 | deleteEvent,
277 | updateEvent,
278 | createEvent,
279 | getUpcomingEvents,
280 | getArchivedEvents,
281 | getEventAttendees,
282 | updateAttendeeStatus,
283 | getPublicUpcomingEvents,
284 | generateCheckInQRCode,
285 | };
286 |
--------------------------------------------------------------------------------
/packages/server/controllers/auth.js:
--------------------------------------------------------------------------------
1 | const { OAuth2Client } = require("google-auth-library");
2 | const { v5: uuidv5, v4: uuidv4 } = require("uuid");
3 | const prisma = require("../prisma/prisma");
4 | const token = require("../utils/token");
5 | const bcrypt = require("bcrypt");
6 | const client = new OAuth2Client(process.env.WEB_CLIENT_ID);
7 | const nodemailer = require("nodemailer");
8 |
9 | const NAMESPACE = "7af17462-8078-4703-adda-be2143a4d93a";
10 |
11 | async function create(accessToken, refreshToken, profile, callback) {
12 | try {
13 | let { sub, given_name, family_name, picture, email } = profile._json;
14 | picture = picture.replace("=s96-c", "");
15 | const googleUUID = uuidv5(sub, NAMESPACE);
16 | const user = await prisma.user.findUniqueOrThrow({
17 | where: {
18 | userId: googleUUID,
19 | },
20 | });
21 | if (!user) {
22 | const newUser = await prisma.user.create({
23 | data: {
24 | userId: googleUUID,
25 | email: email,
26 | avatar: picture,
27 | firstName: given_name,
28 | lastName: family_name,
29 | },
30 | });
31 | console.log("user doesn't exist. create one");
32 | callback(null, newUser);
33 | } else {
34 | console.log("user exists");
35 | callback(null, user);
36 | }
37 | } catch (error) {
38 | callback(error);
39 | }
40 | }
41 |
42 | async function authenticateWithGoogle(token) {
43 | // Verify the token is valid; to ensure it's a valid google auth.
44 | const { payload } = await client.verifyIdToken({
45 | idToken: token,
46 | audience: process.env.WEB_CLIENT_ID,
47 | });
48 |
49 | // Check if this user already exist on our database.
50 | const { sub, email, given_name, family_name, picture } = payload;
51 |
52 | // generate Type 5 UUIDs with hashes of user's google account IDs instead of
53 | // completely random Type 4 UUIDs to keep UUIDs unique regardless of google or
54 | // local authentication
55 | const googleUUID = uuidv5(sub, NAMESPACE);
56 | const user = await prisma.user.findUnique({
57 | where: {
58 | userId: googleUUID,
59 | },
60 | });
61 |
62 | // if the query returns a row, there's a user with the existing userId.
63 | if (!user) {
64 | const newUser = await prisma.user.create({
65 | data: {
66 | userId: googleUUID,
67 | email: email,
68 | avatar: picture,
69 | firstName: given_name,
70 | lastName: family_name,
71 | },
72 | });
73 |
74 | return newUser;
75 | } else {
76 | return user;
77 | }
78 | }
79 |
80 | async function register(newUser) {
81 | const saltRounds = 10;
82 | const salt = await bcrypt.genSalt(saltRounds);
83 | const passwordHash = await bcrypt.hash(newUser.password, salt);
84 | const userId = uuidv4();
85 |
86 | // placeholder names until new user puts in their names in onboarding screen
87 | return await prisma.user.create({
88 | data: {
89 | userId,
90 | firstName: "New",
91 | lastName: "User",
92 | email: newUser.email,
93 | password: passwordHash,
94 | },
95 | });
96 | }
97 |
98 | async function login(request, response, next) {
99 | const provider = request.url.slice(1);
100 |
101 | switch (provider) {
102 | case "google":
103 | if (!request.body.token) {
104 | return response.status(400).json({
105 | status: "fail",
106 | data: {
107 | token: "Token not provided",
108 | },
109 | });
110 | }
111 |
112 | try {
113 | const user = await authenticateWithGoogle(request.body.token);
114 | request.user = user;
115 | next();
116 | } catch (err) {
117 | return response.status(500).json({
118 | status: "error",
119 | message: err.message,
120 | });
121 | }
122 | break;
123 | default:
124 | return response.status(500).json({
125 | status: "error",
126 | message: "Unsupported authentication provider",
127 | });
128 | }
129 | }
130 |
131 | async function serialize(payload, callback) {
132 | try {
133 | const { user_id } = payload;
134 | console.log("serializeUser", user_id);
135 | callback(null, user_id);
136 | } catch (error) {
137 | callback(error);
138 | }
139 | }
140 |
141 | async function deserialize(id, callback) {
142 | try {
143 | console.log("deserializeUser");
144 | console.log("id", id);
145 | const user = await prisma.user.findUniqueOrThrow({
146 | where: {
147 | userId: id,
148 | },
149 | });
150 | if (user) {
151 | console.log(user);
152 | callback(null, user);
153 | }
154 | } catch (error) {
155 | callback(error);
156 | }
157 | }
158 |
159 | async function authenticate(request, response, next) {
160 | const authToken = request.get("Authorization");
161 |
162 | if (!authToken) {
163 | return response.status(400).json({
164 | status: "fail",
165 | data: {
166 | Authorization: "Token not provided",
167 | },
168 | });
169 | }
170 |
171 | try {
172 | request.user = token.verifyAccessToken(authToken);
173 | next();
174 | } catch (error) {
175 | // Handle any errors that occur during token verification
176 | switch (error.name) {
177 | case "TokenExpiredError":
178 | return response.status(401).json({
179 | status: "fail",
180 | data: {
181 | accessToken: "Token expired",
182 | },
183 | });
184 | default:
185 | return response.status(500).json({
186 | status: "error",
187 | message: error.message,
188 | });
189 | }
190 | }
191 | }
192 |
193 | async function isUserEmail(email) {
194 | const result = await prisma.user.findUnique({
195 | where: {
196 | email: email,
197 | },
198 | });
199 |
200 | if (result === null) return false;
201 |
202 | return true;
203 | }
204 |
205 | async function isGoogleAccount(userId) {
206 | const result = await prisma.user.findUnique({
207 | where: {
208 | userId: userId,
209 | },
210 | });
211 |
212 | if (result.password == null) return true;
213 |
214 | return false;
215 | }
216 |
217 | async function sendEmail(address, subject, body) {
218 | const transporter = nodemailer.createTransport({
219 | host: "mail.privateemail.com",
220 | port: 465,
221 | secure: true,
222 | auth: {
223 | user: "icebreak@cppicebreak.com",
224 | pass: process.env.MAILBOX_PASSWORD,
225 | },
226 | });
227 |
228 | const info = await transporter.sendMail({
229 | from: "icebreak@cppicebreak.com",
230 | to: address,
231 | subject: subject,
232 | text: body,
233 | });
234 |
235 | return info;
236 | }
237 |
238 | async function sendPasswordResetEmail(address, passwordResetToken) {
239 | const subject = "Icebreak: Password Reset Request";
240 | const baseUrl = process.env.BASE_URL || "http://localhost:5050"; // Note: If the .env file does not contain BASE_URL, it will default to localhost:5050
241 | const link = `${baseUrl}/api/auth/reset-password?token=${passwordResetToken}`;
242 | const body = `Please click the following link to reset your password: ${link}`;
243 |
244 | return sendEmail(address, subject, body);
245 | }
246 |
247 | async function sendPasswordResetConfirmationEmail(address) {
248 | const subject = "Icebreak: Password Reset";
249 | const body =
250 | "Your Icebreak password has been changed. If you did not request a password change, please immediately contact us at icebreak@cppicebreak.com.";
251 |
252 | return sendEmail(address, subject, body);
253 | }
254 |
255 | async function resetPassword(userId, password) {
256 | // Encrypt the password with the method used for registering
257 | const saltRounds = 10;
258 | const salt = await bcrypt.genSalt(saltRounds);
259 | const hashedPass = await bcrypt.hash(password, salt);
260 |
261 | // Update the db with the new encryped password
262 | return await prisma.user.update({
263 | where: {
264 | userId: userId,
265 | },
266 | data: {
267 | password: hashedPass,
268 | },
269 | });
270 | }
271 |
272 | module.exports = {
273 | create,
274 | login,
275 | register,
276 | serialize,
277 | deserialize,
278 | authenticate,
279 | authenticateWithGoogle,
280 | isUserEmail,
281 | isGoogleAccount,
282 | sendPasswordResetEmail,
283 | sendPasswordResetConfirmationEmail,
284 | resetPassword,
285 | };
286 |
--------------------------------------------------------------------------------
/packages/app/screens/landing/LandingScreen.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react";
2 | import axios from "axios";
3 | import {
4 | StyleSheet,
5 | View,
6 | Text,
7 | KeyboardAvoidingView,
8 | TouchableWithoutFeedback,
9 | Keyboard,
10 | TouchableOpacity,
11 | Platform,
12 | } from "react-native";
13 |
14 | import * as WebBrowser from "expo-web-browser";
15 |
16 | import Button from "@app/components/Button";
17 | import Screen from "@app/components/Screen";
18 | import TextInput from "@app/components/TextInput";
19 | import GoogleIcon from "@app/assets/google-icon";
20 |
21 | import { useUserContext } from "@app/utils/UserContext";
22 | import { ENDPOINT } from "@app/utils/constants";
23 |
24 | import * as SecureStore from "@app/utils/SecureStore";
25 |
26 | import { useGoogleLogin } from "@app/utils/useGoogleLogin";
27 |
28 | import PropTypes from "prop-types";
29 |
30 | WebBrowser.maybeCompleteAuthSession();
31 |
32 | const BLUE = "#0b91e0";
33 | const DARK_GRAY = "#a3a3a3";
34 | const GRAY = "#c4c4c4";
35 | const LIGHT_GRAY = "#ebebeb";
36 |
37 | function LandingScreen({ navigation, route }) {
38 | const { user, setUser } = useUserContext();
39 |
40 | // State to change the variable with the TextInput
41 | const [inputs, setInputs] = useState({
42 | email: route.params?.email ?? "",
43 | password: "",
44 | });
45 | const [errors, setErrors] = useState({});
46 |
47 | const validateInput = () => {
48 | let isValid = true;
49 |
50 | // Reset the error message
51 | for (const inputKey in inputs) {
52 | handleError(inputKey, null);
53 | }
54 |
55 | if (!inputs.email) {
56 | handleError("email", "Please enter an email.");
57 | isValid = false;
58 | } else if (!isValidEmail(inputs.email)) {
59 | handleError("email", "Please enter a valid email.");
60 | isValid = false;
61 | }
62 |
63 | if (!inputs.password) {
64 | handleError("password", "Please enter a password.");
65 | isValid = false;
66 | }
67 |
68 | if (isValid) {
69 | login();
70 | }
71 | };
72 |
73 | const login = async () => {
74 | try {
75 | const response = await axios.post(`${ENDPOINT}/auth/local`, {
76 | email: inputs.email,
77 | password: inputs.password,
78 | });
79 |
80 | if (response?.data.status == "success") {
81 | await SecureStore.save("accessToken", response.data.data.accessToken);
82 | await SecureStore.save("refreshToken", response.data.data.refreshToken);
83 | setUser({
84 | ...user,
85 | isLoggedIn: true,
86 | data: response.data.data.user,
87 | });
88 | } else {
89 | console.log(response?.data.message);
90 | }
91 | } catch (error) {
92 | const responseData = error.response.data;
93 | if (
94 | responseData.data &&
95 | (responseData.data.email === "A user with that email does not exist." ||
96 | responseData.data.password === "Incorrect password")
97 | ) {
98 | handleError("email", "Invalid email or password.");
99 | } else {
100 | console.log(JSON.stringify(error));
101 | }
102 | }
103 | };
104 |
105 | const handleOnChange = (inputKey, text) => {
106 | setInputs((prevState) => ({ ...prevState, [inputKey]: text }));
107 | };
108 |
109 | const handleError = (inputKey, error) => {
110 | setErrors((prevState) => ({ ...prevState, [inputKey]: error }));
111 | };
112 |
113 | // Keeps a reference to help switch from Username input to Password input
114 | const refPasswordInput = useRef();
115 |
116 | return (
117 |
118 |
119 |
122 |
123 | icebreak
124 |
125 |
126 | {
133 | // whenever we type, we set email hook and clear errors
134 | handleOnChange("email", text);
135 | handleError("email", null);
136 | }}
137 | error={errors.email}
138 | placeholder="Email"
139 | onSubmitEditing={() => {
140 | refPasswordInput.current.focus();
141 | }}
142 | />
143 |
144 | {
151 | handleOnChange("password", text);
152 | handleError("password", null);
153 | }}
154 | error={errors.password}
155 | password
156 | placeholder="Password"
157 | onSubmitEditing={validateInput}
158 | />
159 |
160 | {
163 | navigation.navigate("ForgotPasswordScreen", {
164 | email: inputs.email,
165 | });
166 | }}
167 | style={styles.forgotPassContainer}>
168 | Forgot password?
169 |
170 |
171 | {
175 | validateInput();
176 | }}
177 | underlayColor="#0e81c4"
178 | fontColor="#ffffff"
179 | fontWeight="bold"
180 | style={[styles.loginButton, styles.component]}
181 | textStyle={styles.boldText}
182 | />
183 |
184 |
185 |
186 | useGoogleLogin(user, setUser)}
190 | underlayColor="#ebebeb"
191 | style={[styles.googleButton, styles.component]}
192 | fontWeight="bold"
193 | imageStyle={styles.imageStyle}
194 | icon={ }
195 | />
196 |
197 |
198 |
199 |
200 |
201 |
202 | Don't have an account?
203 |
204 | {
207 | navigation.navigate("SignUpScreen", { email: inputs.email });
208 | }}>
209 | Sign Up.
210 |
211 |
212 |
213 | );
214 | }
215 |
216 | // Style sheet to keep all the styles in one place
217 | const styles = StyleSheet.create({
218 | component: {
219 | alignItems: "center",
220 | borderRadius: 10,
221 | height: 50,
222 | justifyContent: "center",
223 | width: "100%",
224 | },
225 | container: {
226 | alignItems: "center",
227 | flex: 1,
228 | justifyContent: "center",
229 | paddingLeft: 20,
230 | paddingRight: 20,
231 | width: "100%",
232 | },
233 | forgotPassContainer: {
234 | alignSelf: "flex-end",
235 | },
236 | googleButton: {
237 | borderColor: DARK_GRAY,
238 | borderWidth: 1,
239 | },
240 | imageStyle: {
241 | height: 20,
242 | marginRight: 10,
243 | width: 20,
244 | },
245 | lineDivider: {
246 | backgroundColor: GRAY,
247 | height: 1,
248 | marginBottom: 20,
249 | marginTop: 20,
250 | width: "100%",
251 | },
252 | loginButton: {
253 | backgroundColor: BLUE,
254 | borderColor: BLUE,
255 | marginTop: 30,
256 | },
257 | logo: {
258 | fontSize: 40,
259 | fontWeight: "bold",
260 | margin: 20,
261 | },
262 | signupContainer: {
263 | flexDirection: "row",
264 | },
265 | textButton: {
266 | color: BLUE,
267 | fontWeight: "bold",
268 | },
269 | textInput: {
270 | backgroundColor: LIGHT_GRAY,
271 | borderWidth: 1,
272 | justifyContent: "space-between",
273 | marginBottom: 7,
274 | paddingLeft: 10,
275 | paddingRight: 10,
276 | },
277 | });
278 |
279 | const isValidEmail = (email) => {
280 | const emailRE =
281 | /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i;
282 | return email.match(emailRE);
283 | };
284 |
285 | LandingScreen.propTypes = {
286 | navigation: PropTypes.object,
287 | route: PropTypes.object,
288 | };
289 |
290 | export default LandingScreen;
291 |
--------------------------------------------------------------------------------