Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

omg maybe fix time #287

Merged
merged 16 commits into from
Feb 17, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 68 additions & 11 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
# Deployment

**Contact:** Please reach out to Jackson Romero (<jtromero@cmu.edu> or <jacksontromero@gmail.com>) for help with deployment or development on q'

## Set-by-step instructions for deploying q' onto a new system (tested on Ubuntu 22.04)

The following instructions are for general setup on an Ubuntu instance. To deploy on EC2 free tier, the instructions in [EC2 Guide](#aws-ec2-free-tier) first to setup your VM, then come back to the actual queue installation instructions.

### Postgres
1. Install postgresql, `sudo apt install postgresql`

1. Install postgresql, `sudo apt install postgresql`
2. Switch to postgres user, `sudo -i -u postgres`
3. Create a new user, `createuser --interactive` (I called mine `ohq` and will continue to use that as an example)
4. Create a database with the same name, `createdb ohq`
5. Logout of the postgres user, `^D`
6. Add new Linux user with the same name, `sudo adduser ohq`
7. Login to that user to connect to the database, `sudo -u ohq psql`
8. You can verify your connection by running `\conninfo`
8. Add a password for this user, you'll need this later in the server .env `ALTER USER ohq WITH PASSWORD '<db_password>'`
9. You can verify your connection by running `\conninfo`

---

### NGINX

1. Install NGINX, `sudo apt install nginx`
2. Start NGINX, `sudo systemctl start nginx`
3. Edit the file at `/etc/nginx/sites-enabled/default` to be the following
@@ -48,10 +54,11 @@ server {

include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot

# This is old and seemingly not needed, could cause ERR_TOO_MANY_REDIRECTS
# Redirect non-https traffic to https
if ($scheme != "https") {
return 301 https://$host$request_uri;
} # managed by Certbot
# if ($scheme != "https") {
# return 301 https://$host$request_uri;
# } # managed by Certbot

location /ohq/ {
rewrite /ohq/(.*) /$1 break;
@@ -68,17 +75,19 @@ server {
}
}
```

Be sure to replace <YOUR_DOMAINS>, e.g. "cs122.andrew.cmu.edu www.cs122.andrew.cmu.edu" and replace <YOUR_DOMAIN> with the folder path we'll create in a few steps, e.g. "cs122.andrew.cmu.edu"

4. Install certbot, `sudo snap install --classic certbot` and then `sudo ln -s /snap/bin/certbot /usr/bin/certbot`
5. Use certbot to generate SSL certificates. **THIS MAY REQUIRE DIFFERENT STEPS DEPENDING ON HOW AND WHERE YOUR DOMAIN IS HOSTED.** You will have to research this on your own. Ideally, your certificates will be created at `/etc/letsencrypt/live/<YOUR_DOMAIN>/fullchain.pem` and `/etc/letsencrypt/live/<YOUR_DOMAIN>/privkey.pem`, but if they're created in different places, you will have to modify these lines of the nginx config. **BE AWARE THAT YOU MAY HAVE TO SETUP CERTIFICATE ROTATION**
5. Use certbot to generate SSL certificates. **THIS MAY REQUIRE DIFFERENT STEPS DEPENDING ON HOW AND WHERE YOUR DOMAIN IS HOSTED.** You will have to research this on your own. Ideally, your certificates will be created at `/etc/letsencrypt/live/<YOUR_DOMAIN>/fullchain.pem` and `/etc/letsencrypt/live/<YOUR_DOMAIN>/privkey.pem`, but if they're created in different places, you will have to modify these lines of the nginx config. **BE AWARE THAT YOU MAY HAVE TO SETUP CERTIFICATE ROTATION**
6. You can validate your nginx config with `sudo nginx -t`
7. Start nginx with `sudo systemctl start nginx`
8. Depending on your domain hosting provider, there may be built-in ways to do this. However, if manually configuring your website, you should be able to add a .A record to your domain and going to it should show a 502 error page from nginx!
7. Restart nginx with `sudo systemctl restart nginx`
8. Depending on your domain hosting provider, there may be built-in ways to do this. However, if manually configuring your website, you should be able to add a .A record to your domain and going to it should show a 502 error page from nginx!

---

### OAuth

1. Using the Google Cloud console, create a project and search for OAuth
2. Under "OAuth Consent Screen", select "External" as the User Type and hit "Create".
3. Follow the instructions and add your primary domain under "Authorized Domains" (e.g. for cs122.andrew.cmu.edu this domain would be cmu.edu)
@@ -100,17 +109,19 @@ Be sure to replace <YOUR_DOMAINS>, e.g. "cs122.andrew.cmu.edu www.cs122.andrew.c
- Your primary URL, e.g. "https://cs122.andrew.cmu.edu"
- Your primary URL with port 443, e.g. "https://cs122.andrew.cmu.edu:443"

* Are all of these necessary? Quite frankly I don't know but 122 has all of these and our queue works so just to be safe I listed them all
- Are all of these necessary? Quite frankly I don't know but 122 has all of these and our queue works so just to be safe I listed them all

---

### q'

1. Clone this repo
2. Install Node.js 16+ for your system. This can be done via various package managers and their site (definitely works on Node 18.19 and 16.18)
2. Install Node.js for your system. This can be done via various package managers and their site
3. In both the /client and /server folders, run `npm install`
4. Setup the client and server .env files as follows:

#### Client .env

```
WDS_SOCKET_PORT=0

@@ -122,7 +133,9 @@ REACT_APP_SERVER_PATH=/api

PUBLIC_URL=/ohq
```

#### Server .env

```
PROTOCOL=https
DOMAIN=<YOUR_DOMAIN>
@@ -143,17 +156,61 @@ OWNER_EMAIL=<YOUR_ADMIN_EMAIL>
```

6. Deploy the server

```
% cd server
% npm run db:sync # Only on first time running or after database modification
% npm install -g nodemon
% npm start
```

7. Deploy the client

```
% cd client
% npm run build
% sudo npm install --global serve
% serve -s build -l 4000 -n
```
8. We have GitHub actions set up now to automatically push new queue updates. This isn't required to deploy the queue and is **optional**, but it's a nice to have. We use tmux to manage the client and server sessions. If you aren't doing this, I'd recommend **deleting the .github folder**.

8. We have GitHub actions set up now to automatically push new queue updates. This isn't required to deploy the queue and is **optional**, but it's a nice to have. We use tmux to manage the client and server sessions. If you aren't doing this, I'd recommend **deleting the .github folder**.

# AWS EC2 Free Tier

This section includes additional setup steps for deploying q' onto a free-tier EC2 instance.

## Create an EC2 Instance

1. From the EC2 page in the AWS console, click "Launch instances"
2. Set the OS Image to "Ubuntu"
3. The instance type should be "t2.micro" to be free-tier eligible
4. Under "Key pair (login)", click "Create new key pair" - we'll need this later to `scp` files to our instance. Download the RSA .pem file
5. Under "Configure Storage" feel free to increase your storage to 30GB as this is the max you get in free tier.
6. Click "Launch Instance"
7. You should now see your instance in a "Running" state

## Modify Instance Settings

1. Select your instance and go to its "Security" tab
2. Click on its Security Group
3. Click "Edit inbound rules" for this security group
4. Add two new inbound TCP rules for ports 80 and 443 - you can keep their Source Types as "Anywhere-IPv4" and select "0.0.0.0/0" for the Source

## Connect your domain to EC2

1. Under the "Details" tab, look at the "Public IPv4 address"
2. Add an A record to your domain's DNS records that points to this IP address

## Actually deploying the OHQ

**Before** going back to the [Deployment](#deployment) section, you **must** read the following. Not all of the steps in that section will work on EC2 free tier as it doesn't have enough RAM to actually build the OHQ.

So, when going through the deployment steps, install Postgres, NGINX, and the queue as instructed. However, **do the following instead of running `npm run build` for the client**.

1. Clone the OHQ locally and install all npm libraries
2. Fill in the client `.env` file with all of the values you will eventually use _on EC2_. There should be no difference between the client `.env` locally and the one on EC2
3. Run `npm run build` locally
4. This generates a folder, `/client/build`. We'll now copy this folder to EC2
5. Grab your EC2 instance's domain by copying its "Public IPv4 DNS"
6. Run `scp -r -i <path to your private key.pem> <local path to /client/build> ubuntu@<public ipv4 dns>:~/q-prime/client/` to copy the build folder to EC2
7. Now, on EC2, you should be able to see a folder `/client/build` and you can proceed with the original deployment steps to server the built version
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) [2025]

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@
```
3. You may need to download and install [PostgreSQL](https://www.postgresql.org/download/) and set up the database ([see below](#setting-up-the-database))
4. Set up environment files ([see below](#configuration))
5. Log in with the owner email, which is defined in `server/.env`, change "Current Semester" to a new value and click "Save." This creates the initial database entry for a semester.
5. **Log in with the owner email, which is defined in `server/.env`, change "Current Semester" to a new value and click "Save." This creates the initial database entry for a semester.**
6. Go to settings and create a new TA with an email that's **different from the owner email**. The owner email is special and can't interact normally on the queue, so you'll need a separate TA email to test the TA view.

## Running Server
3 changes: 2 additions & 1 deletion client/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -26,6 +26,7 @@
"require-jsdoc": "off",
"react/prop-types": "off",
"linebreak-style": "off",
"camelcase": "off"
"camelcase": "off",
"object-curly-spacing": "off"
}
}
20,910 changes: 11,121 additions & 9,789 deletions client/package-lock.json

Large diffs are not rendered by default.

17 changes: 9 additions & 8 deletions client/package.json
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
"@types/node": "^18.8.4",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"axios": "^0.27.2",
"axios": "^1.7.9",
"chart.js": "^4.4.1",
"chat.js": "^1.0.2",
"downloadjs": "^1.4.7",
@@ -28,24 +28,24 @@
"number-to-words": "^1.2.4",
"react": "^18.1.0",
"react-chartjs-2": "^5.2.0",
"react-cookie": "^4.1.1",
"react-cookie": "^7.2.2",
"react-dom": "^18.1.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-scripts": "^5.0.1",
"react-window": "^1.8.7",
"socket.io-client": "^4.5.1",
"socket.io-client": "^4.8.1",
"styled-components": "^5.3.5",
"typescript": "^4.8.4",
"underscore": "^1.13.4",
"universal-cookie": "^4.0.4",
"universal-cookie": "^7.2.2",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint src/**/*.ts src/**/*.tsx"
"lint": "eslint src/**/*.tsx"
},
"eslintConfig": {
"extends": [
@@ -68,8 +68,9 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.40.0",
"@typescript-eslint/parser": "^5.40.0",
"eslint": "^8.25.0",
"eslint": "^8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-plugin-react": "^7.31.10"
"eslint-plugin-react": "^7.31.10",
"prettier-eslint": "^16.3.0"
}
}
250 changes: 140 additions & 110 deletions client/src/components/settings/admin/ConfigSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@

import React, {useState, useEffect, useContext} from 'react';
import React, { useState, useEffect, useContext } from 'react';
import {
Button, CardContent, Typography, TextField, Grid, Checkbox,
Button,
CardContent,
Typography,
TextField,
Checkbox,
Stack,
Tooltip,
} from '@mui/material';

import BaseCard from '../../common/cards/BaseCard';

import SettingsService from '../../../services/SettingsService';
import {AdminSettingsContext} from '../../../contexts/AdminSettingsContext';
import {QueueDataContext} from '../../../contexts/QueueDataContext';
import { AdminSettingsContext } from '../../../contexts/AdminSettingsContext';
import { QueueDataContext } from '../../../contexts/QueueDataContext';
import { UserDataContext } from '../../../contexts/UserDataContext';

export default function ConfigSettings(props) {
const {adminSettings} = useContext(AdminSettingsContext);
const {queueData} = useContext(QueueDataContext);
const { adminSettings } = useContext(AdminSettingsContext);
const { queueData } = useContext(QueueDataContext);
const { userData } = useContext(UserDataContext);

const [currSem, setCurrSem] = useState('');
const [currSem, setCurrSem] = useState<string>(undefined);
const [slackURL, setSlackURL] = useState('');
const [questionsURL, setQuestionsURL] = useState('');
const [enforceCMUEmail, setEnforceCMUEmail] = useState(true);
@@ -32,7 +39,6 @@ export default function ConfigSettings(props) {
setQuestionsURL(queueData.questionsURL);
}, [queueData]);


const handleUpdateCourseName = (event) => {
event.preventDefault();
if (courseName === adminSettings.courseName) return;
@@ -100,129 +106,153 @@ export default function ConfigSettings(props) {
);
};


return (
<BaseCard>
<CardContent>
<Typography sx={{fontWeight: 'bold', ml: 1, mt: 1}} variant="body1" gutterBottom>
Config Settings
<Typography variant="h5" gutterBottom>
Configuration Settings
</Typography>
<form onSubmit={handleUpdateCourseName}>
<Grid container spacing={2} sx={{mb: 2}}>
<Grid className="d-flex" item sx={{mx: 1, ml: 1}}>
Course Name:

<Stack spacing={3}>
{/* Course Name */}
<form onSubmit={handleUpdateCourseName}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Course Name:</Typography>
<TextField
id="course-name"
placeholder="Course Name"
variant="standard"
sx={{ml: 1, mt: -1}}
style={{width: '160px'}}
size="small"
value={courseName ?? ''}
onChange={(e) => {
setCourseName(e.target.value);
}}
onChange={(e) => setCourseName(e.target.value)}
sx={{ width: 200 }}
/>
</Grid>
<Grid className="d-flex" item sx={{mr: 2}} xs={1.5}>
<Button type="submit" variant="contained">Save</Button>
</Grid>
</Grid>
</form>
<form onSubmit={handleUpdateSemester}>
<Grid container spacing={2} sx={{mb: 2}}>
<Grid className="d-flex" item sx={{mt: 1, ml: 1}}>
Current Semester:
<Button type="submit" variant="contained">
Save
</Button>
<Typography variant="caption" color="text.secondary">
Display name for the course
</Typography>
</Stack>
</form>

{/* Current Semester */}
<form onSubmit={handleUpdateSemester}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Current Semester:</Typography>
<TextField
id="current-sem"
variant="standard"
sx={{ml: 1, mt: -1}}
style={{width: '60px'}}
inputProps={{maxLength: 3}}
size="small"
value={currSem ?? ''}
onChange={(e) => {
setCurrSem(e.target.value);
}}
onChange={(e) => setCurrSem(e.target.value)}
disabled={!userData.isOwner}
inputProps={{ maxLength: 3 }}
sx={{ width: 80 }}
/>
</Grid>
<Grid className="d-flex" item sx={{mr: 2}}>
<Button type="submit" variant="contained">Save</Button>
</Grid>
</Grid>
</form>
<form onSubmit={handleUpdateCmuEmailEnabled}>
<Grid container spacing={2} sx={{mb: 2}}>
<Grid className="d-flex" item sx={{mt: 1, ml: 1}}>
Enforce CMU Email:
{!userData.isOwner ?
(
<Typography variant="caption" color="text.secondary">
Only {queueData.ownerEmail} can change semester
</Typography>
) :
(
<Tooltip
title={
<Typography>
Update Current Semester First, this initializes your semester!
</Typography>
}
placement="right"
arrow
open={currSem != undefined && adminSettings.currSem === ''}
enterDelay={1000}
>
<Button
type="submit"
variant="contained"
disabled={!userData.isOwner}
>
Save
</Button>
</Tooltip>
)
}
<Typography variant="caption" color="text.secondary">
Each semester has its own settings and stats
</Typography>
</Stack>
</form>

{/* Enforce CMU Email */}
<form onSubmit={handleUpdateCmuEmailEnabled}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Enforce CMU Email:</Typography>
<Checkbox
size="small"
sx={{ml: 1}}
checked={enforceCMUEmail}
onChange={(e) => {
setEnforceCMUEmail(e.target.checked);
}}
onChange={(e) => setEnforceCMUEmail(e.target.checked)}
/>
</Grid>
<Grid className="d-flex" item sx={{mt: 1, mr: 2}}>
<Button type="submit" variant="contained">Save</Button>
</Grid>
</Grid>
</form>
<form onSubmit={handleCooldownOverrideEnabled}>
<Grid container spacing={2} sx={{mb: 2}}>
<Grid className="d-flex" item sx={{mt: 1, ml: 1}}>
Allow Cooldown Override:
<Button type="submit" variant="contained">
Save
</Button>
<Typography variant="caption" color="text.secondary">
Require cmu.edu emails
</Typography>
</Stack>
</form>

{/* Allow Cooldown Override */}
<form onSubmit={handleCooldownOverrideEnabled}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Allow Cooldown Override:</Typography>
<Checkbox
size="small"
sx={{ml: 1}}
checked={allowCDOverride}
onChange={(e) => {
setAllowCDOverride(e.target.checked);
}}
onChange={(e) => setAllowCDOverride(e.target.checked)}
/>
</Grid>
<Grid className="d-flex" item sx={{mt: 1, mr: 2}}>
<Button type="submit" variant="contained">Save</Button>
</Grid>
</Grid>
</form>
<form onSubmit={handleUpdateSlackURL}>
<Grid container spacing={2} sx={{mt: 1, mb: 2}}>
<Grid className="d-flex" item sx={{mx: 1}} xs={10}>
<Button type="submit" variant="contained">
Save
</Button>
<Typography variant="caption" color="text.secondary">
Allow students to override cooldown
</Typography>
</Stack>
</form>

{/* Slack Webhook URL */}
<form onSubmit={handleUpdateSlackURL}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Slack Webhook URL:</Typography>
<TextField
id="slack-url"
placeholder="Slack Webhook URL"
variant="standard"
fullWidth
size="small"
value={slackURL ?? ''}
onChange={(e) => {
setSlackURL(e.target.value);
}}
onChange={(e) => setSlackURL(e.target.value)}
placeholder="https://hooks.slack.com/..."
sx={{ width: 250 }}
/>
</Grid>
<Grid className="d-flex" item sx={{mr: 2}} xs={1.5}>
<Button type="submit" variant="contained">Save</Button>
</Grid>
</Grid>
</form>
<form onSubmit={handleUpdateQuestionsURL}>
<Grid container spacing={2}>
<Grid className="d-flex" item sx={{mx: 1, mb: 1}} xs={10}>
<Button type="submit" variant="contained">
Save
</Button>
<Typography variant="caption" color="text.secondary">
URL for Slack notifications
</Typography>
</Stack>
</form>

{/* Questions Guide URL */}
<form onSubmit={handleUpdateQuestionsURL}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Questions Guide URL:</Typography>
<TextField
id="questions-url"
placeholder="Questions Guide URL"
variant="standard"
fullWidth
size="small"
value={questionsURL ?? ''}
onChange={(e) => {
setQuestionsURL(e.target.value);
}}
onChange={(e) => setQuestionsURL(e.target.value)}
placeholder="https://..."
sx={{ width: 250 }}
/>
</Grid>
<Grid className="d-flex" item sx={{mr: 2}} xs={1.5}>
<Button type="submit" variant="contained">Save</Button>
</Grid>
</Grid>
</form>
<Button type="submit" variant="contained">
Save
</Button>
<Typography variant="caption" color="text.secondary">
Link to question guidelines
</Typography>
</Stack>
</form>
</Stack>
</CardContent>
</BaseCard>
);
16 changes: 8 additions & 8 deletions client/src/contexts/AllStudentsContext.tsx
Original file line number Diff line number Diff line change
@@ -35,14 +35,14 @@ const AllStudentsContextProvider = ({children}: {children: React.ReactNode}) =>
setAllStudents(data.allStudents);
});

const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
HomeService.getAllStudents().then((res) => {
setAllStudents(res.data.allStudents);
});
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// const handleVisibilityChange = () => {
// if (document.visibilityState === 'visible') {
// HomeService.getAllStudents().then((res) => {
// setAllStudents(res.data.allStudents);
// });
// }
// };
// document.addEventListener('visibilitychange', handleVisibilityChange);
}
}, [userData.isTA]);

37 changes: 22 additions & 15 deletions client/src/contexts/QueueDataContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {QueueData} from '../../../types/QueueData';
import React, {createContext, useEffect, useState} from 'react';
import { QueueData } from '../../../types/QueueData';
import React, { createContext, useEffect, useState } from 'react';
import HomeService from '../services/HomeService';
import {socketSubscribeTo} from '../services/SocketsService';
import { socketSubscribeTo } from '../services/SocketsService';

/**
* Context object for queue data
@@ -12,16 +12,23 @@ import {socketSubscribeTo} from '../services/SocketsService';
*/
const QueueDataContext = createContext({
queueData: {} as QueueData,
setQueueData: ((queueData: QueueData) => {}) as React.Dispatch<React.SetStateAction<QueueData>>,
setQueueData: ((queueData: QueueData) => {}) as React.Dispatch<
React.SetStateAction<QueueData>
>,
});

/**
* Context provider for queue data
* @return {React.Provider} Context provider for queue data
*/
const QueueDataContextProvider = ({children}: {children: React.ReactNode}) => {
const QueueDataContextProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [queueData, setQueueData] = useState<QueueData>({
title: 'Office Hours Queue',
ownerEmail: '',
uninitializedSem: false,
queueFrozen: true,
allowCDOverride: true,
@@ -54,21 +61,21 @@ const QueueDataContextProvider = ({children}: {children: React.ReactNode}) => {
setQueueData(data);
});

const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
HomeService.getAll().then((res) => {
setQueueData(res.data);
});
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// const handleVisibilityChange = () => {
// if (document.visibilityState === 'visible') {
// HomeService.getAll().then((res) => {
// setQueueData(res.data);
// });
// }
// };
// document.addEventListener('visibilitychange', handleVisibilityChange);
}, []);

return (
<QueueDataContext.Provider value={{queueData, setQueueData}}>
<QueueDataContext.Provider value={{ queueData, setQueueData }}>
{children}
</QueueDataContext.Provider>
);
};

export {QueueDataContext, QueueDataContextProvider};
export { QueueDataContext, QueueDataContextProvider };
20 changes: 10 additions & 10 deletions client/src/contexts/StudentDataContext.tsx
Original file line number Diff line number Diff line change
@@ -68,16 +68,16 @@ const StudentDataContextProvider = ({children}: {children: React.ReactNode}) =>
}
});

const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
HomeService.getStudentData().then((res) => {
if (res.status === 200 && res.data.andrewID === userData.andrewID) {
setStudentData(res.data);
}
});
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// const handleVisibilityChange = () => {
// if (document.visibilityState === 'visible') {
// HomeService.getStudentData().then((res) => {
// if (res.status === 200 && res.data.andrewID === userData.andrewID) {
// setStudentData(res.data);
// }
// });
// }
// };
// document.addEventListener('visibilitychange', handleVisibilityChange);
}
}, [userData.isAuthenticated]);

7 changes: 4 additions & 3 deletions client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -3,11 +3,12 @@
"target": "es5",
"esModuleInterop": true,
"downlevelIteration": true,
"suppressImplicitAnyIndexErrors": true,
"strictNullChecks": false,
"jsx": "react-jsx",
"lib": [
"es5", "es2015.core", "dom"
"es5",
"es2015.core",
"dom"
],
"module": "commonjs",
"moduleResolution": "node",
@@ -19,4 +20,4 @@
"exclude": [
"node_modules"
]
}
}
28 changes: 14 additions & 14 deletions client/webpack.config.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
const path = require('path');

module.exports = {
entry: './src/App.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{test: /\.ts$/, use: 'ts-loader'}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js']
}
};
entry: './src/App.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{test: /\.ts$/, use: 'ts-loader'},
],
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
};
2 changes: 2 additions & 0 deletions server/controllers/home.js
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ const sockets = require('./sockets');
const waittime = require('./waittimes');
const settings = require('./settings');
const notify = require('./notify');
const config = require('../config/config.js');

const StudentStatus = queue.StudentStatus;

@@ -78,6 +79,7 @@ function buildQueueData() {
uninitializedSem: adminSettings.currSem == null,
queueFrozen: queueFrozen,
allowCDOverride: adminSettings.allowCDOverride,
ownerEmail: config.OWNER_EMAIL,

// global stats
numStudents: ohq.size(),
87 changes: 52 additions & 35 deletions server/controllers/settings.js
Original file line number Diff line number Diff line change
@@ -7,64 +7,86 @@ const models = require('../models');
const sockets = require('./sockets');
const slack = require('./slack');
const home = require('./home');
const path = require('path');

// Global admin settings
// FIXME: some default values are set to simplify testing;
// In production, these should be cleared
var fs = require('fs');
const defaultAdminSettings = {
courseName: '',
currSem: 'S23',
currSem: '',
slackURL: null,
questionsURL: '',
rejoinTime: 15,
enforceCMUEmail: true,
allowCDOverride: true,
allowCDOverride: false,
dayDictionary: {},
};

const adminSettingsPath = path.join(
__dirname,
'..',
'..',
'adminSettings.json'
);

let adminSettings = defaultAdminSettings;

// whenever starting server, check if adminSettings.json is up to date
updateAdminSettingsJSON();

function haveSameKeys(obj1, obj2) {
const obj1Keys = Object.keys(obj1).sort();
const obj2Keys = Object.keys(obj2).sort();
return JSON.stringify(obj1Keys) === JSON.stringify(obj2Keys);
}

// If no admin setting have been generated, use the above default values
if (!fs.existsSync('../adminSettings.json')) {
var json = JSON.stringify(adminSettings);
fs.writeFile('../adminSettings.json', json, 'utf8', function () {
console.log('Created admin settings JSON');
});
function _createAdminSettingsJSON() {
if (!fs.existsSync(adminSettingsPath)) {
var json = JSON.stringify(adminSettings);
fs.writeFileSync(adminSettingsPath, json, 'utf8');
}
}

// If admin settings have been generated, but the keys don't match, update the missing keys
else if (
fs.existsSync('../adminSettings.json') &&
!haveSameKeys(
adminSettings,
JSON.parse(fs.readFileSync('../adminSettings.json', 'utf8'))
)
) {
let currAdminSettings = fs.readFileSync(
'../adminSettings.json',
'utf8',
(flag = 'r+')
);
let newAdminSettings = JSON.parse(currAdminSettings);
for (let key in adminSettings) {
if (!newAdminSettings.hasOwnProperty(key)) {
newAdminSettings[key] = adminSettings[key];
function updateAdminSettingsJSON() {
if (!fs.existsSync(adminSettingsPath)) {
_createAdminSettingsJSON();
}

if (
!haveSameKeys(
adminSettings,
JSON.parse(fs.readFileSync(adminSettingsPath, 'utf8'))
)
) {
let currAdminSettings = fs.readFileSync(
adminSettingsPath,
'utf8',
(flag = 'r+')
);
let newAdminSettings = JSON.parse(currAdminSettings);
for (let key in adminSettings) {
if (!newAdminSettings.hasOwnProperty(key)) {
newAdminSettings[key] = adminSettings[key];
}
}
var json = JSON.stringify(newAdminSettings);
fs.writeFileSync(adminSettingsPath, json, 'utf8', function () {
console.log('Updated admin settings JSON');
});
}
var json = JSON.stringify(newAdminSettings);
fs.writeFileSync('../adminSettings.json', json, 'utf8', function () {
console.log('Updated admin settings JSON');
});
}

exports.get_admin_settings = function () {
let data = fs.readFileSync('../adminSettings.json', 'utf8', (flag = 'r+'));
// if file doesn't exist, create it
if (!fs.existsSync(adminSettingsPath)) {
updateAdminSettingsJSON();
}

let data = fs.readFileSync(adminSettingsPath, 'utf8', (flag = 'r+'));
if (data) {
adminSettings = JSON.parse(data);
} else {
@@ -279,13 +301,8 @@ exports.post_update_course_name = function (req, res) {
};

exports.post_update_semester = function (req, res) {
if (!req.user || !req.user.isAdmin) {
respond_error(
req,
res,
"You don't have permissions to perform this operation",
403
);
if (!req.user || !req.user.isOwner) {
respond_error(req, res, 'Only the course owner can change semesters', 403);
return;
}

6 changes: 5 additions & 1 deletion server/controllers/sockets.js
Original file line number Diff line number Diff line change
@@ -17,6 +17,10 @@ exports.init = function (server) {
cors: {
origin: config.PROTOCOL + "://" + config.DOMAIN + ":" + config.CLIENT_PORT,
methods: ["GET", "POST"]
},
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true
}
});

@@ -63,7 +67,7 @@ exports.queueData = function (queueData) {
console.log("ERROR: Socket.io is not initialized yet");
return;
}

sio.emit("queueData", {
...queueData
});
3,253 changes: 1,333 additions & 1,920 deletions server/package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions server/package.json
Original file line number Diff line number Diff line change
@@ -30,17 +30,17 @@
"express": "^4.18.1",
"google-auth-library": "^8.5.2",
"jest": "^28.1.0",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.2",
"moment": "^2.29.3",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^2.3.3",
"mysql2": "^3.12.0",
"nodemon": "^2.0.16",
"package.json": "^2.0.1",
"package.json": "^0.0.0",
"pg": "^8.7.3",
"sequelize": "^6.20.1",
"slack-webhook": "^1.0.0",
"socket.io": "^4.5.1"
"socket.io": "^4.8.1"
},
"devDependencies": {
"@babel/core": "^7.18.2",
59 changes: 30 additions & 29 deletions types/QueueData.ts
Original file line number Diff line number Diff line change
@@ -3,43 +3,44 @@
*/
export type QueueData = {
// most important global data
title: string,
uninitializedSem: boolean,
queueFrozen: boolean,
allowCDOverride: boolean,
title: string;
uninitializedSem: boolean;
queueFrozen: boolean;
allowCDOverride: boolean;
ownerEmail: string;

// global stats
numStudents: number
rejoinTime: number,
numUnhelped: number,
minsPerStudent: number,
numTAs: number,
numStudents: number;
rejoinTime: number;
numUnhelped: number;
minsPerStudent: number;
numTAs: number;

// queue data
announcements: {
id: number,
content: string
}[],
id: number;
content: string;
}[];

questionsURL: string,
questionsURL: string;

topics: {
assignment_id: number,
name: string,
category: string,
start_date: string,
end_date: string,
}[],
assignment_id: number;
name: string;
category: string;
start_date: string;
end_date: string;
}[];
locations: {
dayDictionary: any,
roomDictionary: any
},
dayDictionary: any;
roomDictionary: any;
};

tas: {
ta_id: number,
name: string,
preferred_name: string,
email: string,
isAdmin: boolean,
}[],
}
ta_id: number;
name: string;
preferred_name: string;
email: string;
isAdmin: boolean;
}[];
};