diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml new file mode 100644 index 00000000..41137e7b --- /dev/null +++ b/.github/actions/setup-pnpm/action.yml @@ -0,0 +1,27 @@ +name: Setup pnpm +runs: + using: composite + steps: + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8c8259d5..524add78 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,36 +6,14 @@ ## Screenshots - - + - - +## Test Plan -## Steps to verify/test this change: - -- [ ] Verify changes work as expected on staging instance - - -## Final Checks: - -- [ ] Verify successful deployment - -(optional) - -- [ ] Write tests -- [ ] Write documentation + ## Issues - + Closes # diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml deleted file mode 100644 index b8a11908..00000000 --- a/.github/workflows/build-and-deploy.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Build and deploy - -on: - push: - branches: - - main - pull_request: - types: - - opened - - reopened - - synchronize - -# do not cancel in progress, SST will be stuck in a "locked" state if cancelled mid-deployment -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - -jobs: - build_and_deploy: - name: Build and deploy PeterPortal - runs-on: ubuntu-latest - if: (github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'no deploy')) - environment: - name: ${{ (github.event_name == 'pull_request' && format('staging-{0}', github.event.pull_request.number)) || 'production' }} - url: https://${{ (github.event_name == 'pull_request' && format('staging-{0}.', github.event.pull_request.number)) || '' }}peterportal.org - - steps: - - name: Check Out Repo - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - uses: pnpm/action-setup@v3 - name: Install pnpm - with: - version: 9 - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install Dependencies - run: pnpm install - env: - HUSKY: 0 - - - name: Build and deploy - run: pnpm sst deploy --stage ${{ (github.event_name == 'pull_request' && format('staging-{0}', github.event.pull_request.number)) || 'prod' }} - env: - CI: false - PUBLIC_API_URL: ${{secrets.PUBLIC_API_URL}} - DATABASE_URL: ${{ github.event_name == 'pull_request' && secrets.DEV_DATABASE_URL || secrets.PROD_DATABASE_URL }} - SESSION_SECRET: ${{secrets.SESSION_SECRET}} - GOOGLE_CLIENT: ${{secrets.GOOGLE_CLIENT}} - GOOGLE_SECRET: ${{secrets.GOOGLE_SECRET}} - GRECAPTCHA_SECRET: ${{secrets.GRECAPTCHA_SECRET}} - ADMIN_EMAILS: ${{secrets.ADMIN_EMAILS}} - PRODUCTION_DOMAIN: ${{secrets.PRODUCTION_DOMAIN}} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - NODE_ENV: ${{ github.event_name == 'pull_request' && 'staging' || 'production' }} - ANTEATER_API_KEY: ${{ secrets.ANTEATER_API_KEY }} diff --git a/.github/workflows/clean-up-pr.yml b/.github/workflows/clean-up-pr.yml deleted file mode 100644 index d25d107d..00000000 --- a/.github/workflows/clean-up-pr.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Clean up PR - -on: - pull_request: - types: [closed] - -# use pr number for group instead of github.ref because ref will be main branch when the PR closes which is not a unique group for the PR -concurrency: - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - clean-up-pr: - runs-on: ubuntu-latest - - steps: - - name: Check Out Repo - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - uses: pnpm/action-setup@v3 - name: Install pnpm - with: - version: 9 - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install Dependencies - run: pnpm install - env: - HUSKY: 0 - - - name: Remove staging stack - run: pnpm sst remove --stage staging-${{ github.event.pull_request.number }} - env: - CI: false - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - - name: Deactivate deployment - uses: strumwolf/delete-deployment-environment@v3.0.0 - with: - environment: staging-${{ github.event.pull_request.number }} - token: ${{ secrets.GITHUB_TOKEN }} - onlyDeactivateDeployments: true diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..a74d9617 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,46 @@ +name: Deploy production + +on: + push: + branches: + - main + +# do not cancel in progress, SST will be stuck in a "locked" state if cancelled mid-deployment +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + build_and_deploy: + name: Build and deploy + runs-on: ubuntu-latest + environment: + name: production + url: https://peterportal.org + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Install dependencies + run: pnpm install + env: + HUSKY: 0 + + - name: Build and deploy + run: pnpm sst deploy --stage prod + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + NODE_ENV: production + + PUBLIC_API_URL: ${{ secrets.PUBLIC_API_URL }} + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + GOOGLE_CLIENT: ${{ secrets.GOOGLE_CLIENT }} + GOOGLE_SECRET: ${{ secrets.GOOGLE_SECRET }} + GRECAPTCHA_SECRET: ${{ secrets.GRECAPTCHA_SECRET }} + ADMIN_EMAILS: ${{ secrets.ADMIN_EMAILS }} + PRODUCTION_DOMAIN: ${{ secrets.PRODUCTION_DOMAIN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + ANTEATER_API_KEY: ${{ secrets.ANTEATER_API_KEY }} diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..0edc4a30 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,50 @@ +name: Deploy staging + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +# do not cancel in progress, SST will be stuck in a "locked" state if cancelled mid-deployment +concurrency: + group: staging-${{ github.event.pull_request.number }} + +jobs: + build_and_deploy: + name: Build and deploy + runs-on: ubuntu-latest + # don't run if labeled "no deploy" && don't run on PRs from forks + if: (!contains(github.event.pull_request.labels.*.name, 'no deploy')) && github.event.pull_request.head.repo.full_name == github.repository + environment: + name: staging-${{ github.event.pull_request.number }} + url: https://staging-${{ github.event.pull_request.number }}.peterportal.org + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Install dependencies + run: pnpm install + env: + HUSKY: 0 + + - name: Build and deploy + run: pnpm sst deploy --stage staging-${{ github.event.pull_request.number }} + env: + DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }} + NODE_ENV: staging + + PUBLIC_API_URL: ${{ secrets.PUBLIC_API_URL }} + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + GOOGLE_CLIENT: ${{ secrets.GOOGLE_CLIENT }} + GOOGLE_SECRET: ${{ secrets.GOOGLE_SECRET }} + GRECAPTCHA_SECRET: ${{ secrets.GRECAPTCHA_SECRET }} + ADMIN_EMAILS: ${{ secrets.ADMIN_EMAILS }} + PRODUCTION_DOMAIN: ${{ secrets.PRODUCTION_DOMAIN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + ANTEATER_API_KEY: ${{ secrets.ANTEATER_API_KEY }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ea0aec03..b35febac 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,34 +15,12 @@ jobs: lint: name: Lint and check formatting runs-on: ubuntu-latest - steps: - - name: Check Out Repo + - name: Checkout repo uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - uses: pnpm/action-setup@v3 - name: Install pnpm - with: - version: 9 - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm - name: Install Dependencies run: pnpm install diff --git a/.github/workflows/remove-staging.yml b/.github/workflows/remove-staging.yml new file mode 100644 index 00000000..0165c1cd --- /dev/null +++ b/.github/workflows/remove-staging.yml @@ -0,0 +1,41 @@ +name: Remove staging + +on: + pull_request: + types: + - closed + +# use pr number for group instead of github.ref because ref will be main branch when the PR closes which is not a unique group for the PR +# group should match with deploy-staging workflow so those don't run concurrently (if someone closes/reopens a PR) +concurrency: + group: staging-${{ github.event.pull_request.number }} + +jobs: + clean-up-pr: + runs-on: ubuntu-latest + # don't run on PRs from forks + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Install Dependencies + run: pnpm install + env: + HUSKY: 0 + + - name: Remove staging + run: pnpm sst remove --stage staging-${{ github.event.pull_request.number }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Deactivate deployment + uses: strumwolf/delete-deployment-environment@v3.0.0 + with: + environment: staging-${{ github.event.pull_request.number }} + token: ${{ secrets.GITHUB_TOKEN }} + onlyDeactivateDeployments: true diff --git a/README.md b/README.md index aa17b36a..04bb2fab 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ git clone https://github.com//peterportal-client 4. Run `pnpm install` to install all node dependencies for the site and API. This may take a few minutes. -5. Rename the `.env.example` file in the api directory to `.env`. This includes the minimum environment variables needed for running the backend. +5. Make a copy of the `.env.example` file in the api directory and name it `.env`. This includes the minimum environment variables needed for running the backend. 6. (Optional) Set up your own PostgreSQL database and Google OAuth to be able to test features that require signing in such as leaving reviews or saving roadmaps to your account. Add additional variables/secrets to the .env file from the previous step. diff --git a/api/drizzle/0001_heavy_xavin.sql b/api/drizzle/0001_heavy_xavin.sql new file mode 100644 index 00000000..b08b743a --- /dev/null +++ b/api/drizzle/0001_heavy_xavin.sql @@ -0,0 +1 @@ +ALTER TABLE "review" ALTER COLUMN "updated_at" DROP DEFAULT; \ No newline at end of file diff --git a/api/drizzle/meta/0001_snapshot.json b/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..305c2084 --- /dev/null +++ b/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,634 @@ +{ + "id": "5cf771c6-fe23-4061-932b-81c6c0c7a8ae", + "prevId": "150643a6-771c-4cba-b1bc-b3cb060d15ba", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.planner": { + "name": "planner", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "planner_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "years": { + "name": "years", + "type": "jsonb[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planners_user_id_idx": { + "name": "planners_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "planner_user_id_user_id_fk": { + "name": "planner_user_id_user_id_fk", + "tableFrom": "planner", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report": { + "name": "report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "report_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "review_id": { + "name": "review_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_review_id_idx": { + "name": "reports_review_id_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_review_id_review_id_fk": { + "name": "report_review_id_review_id_fk", + "tableFrom": "report", + "tableTo": "review", + "columnsFrom": ["review_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review": { + "name": "review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "review_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "professor_id": { + "name": "professor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grade_received": { + "name": "grade_received", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "for_credit": { + "name": "for_credit", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "quarter": { + "name": "quarter", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "take_again": { + "name": "take_again", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "textbook": { + "name": "textbook", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "attendance": { + "name": "attendance", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "reviews_professor_id_idx": { + "name": "reviews_professor_id_idx", + "columns": [ + { + "expression": "professor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_course_id_idx": { + "name": "reviews_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_user_id_user_id_fk": { + "name": "review_user_id_user_id_fk", + "tableFrom": "review", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_review": { + "name": "unique_review", + "nullsNotDistinct": false, + "columns": ["user_id", "professor_id", "course_id"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_check": { + "name": "rating_check", + "value": "\"review\".\"rating\" >= 1 AND \"review\".\"rating\" <= 5" + }, + "difficulty_check": { + "name": "difficulty_check", + "value": "\"review\".\"difficulty\" >= 1 AND \"review\".\"difficulty\" <= 5" + } + }, + "isRLSEnabled": false + }, + "public.saved_course": { + "name": "saved_course", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "saved_course_user_id_user_id_fk": { + "name": "saved_course_user_id_user_id_fk", + "tableFrom": "saved_course", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "saved_course_user_id_course_id_pk": { + "name": "saved_course_user_id_course_id_pk", + "columns": ["user_id", "course_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sid": { + "name": "sid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sess": { + "name": "sess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expire": { + "name": "expire", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transferred_course": { + "name": "transferred_course", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "course_name": { + "name": "course_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "units": { + "name": "units", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "transferred_courses_user_id_idx": { + "name": "transferred_courses_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transferred_course_user_id_user_id_fk": { + "name": "transferred_course_user_id_user_id_fk", + "tableFrom": "transferred_course", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "user_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "picture": { + "name": "picture", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_roadmap_edit_at": { + "name": "last_roadmap_edit_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_google_id": { + "name": "unique_google_id", + "nullsNotDistinct": false, + "columns": ["google_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vote": { + "name": "vote", + "schema": "", + "columns": { + "review_id": { + "name": "review_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "votes_user_id_idx": { + "name": "votes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vote_review_id_review_id_fk": { + "name": "vote_review_id_review_id_fk", + "tableFrom": "vote", + "tableTo": "review", + "columnsFrom": ["review_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vote_user_id_user_id_fk": { + "name": "vote_user_id_user_id_fk", + "tableFrom": "vote", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vote_review_id_user_id_pk": { + "name": "vote_review_id_user_id_pk", + "columns": ["review_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "votes_vote_check": { + "name": "votes_vote_check", + "value": "\"vote\".\"vote\" = 1 OR \"vote\".\"vote\" = -1" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 28471cbc..a8247973 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1731572839686, "tag": "0000_third_nekra", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1733697058204, + "tag": "0001_heavy_xavin", + "breakpoints": true } ] } diff --git a/api/package.json b/api/package.json index 4b4aa4cd..12f71f1c 100644 --- a/api/package.json +++ b/api/package.json @@ -14,7 +14,6 @@ "dependencies": { "@trpc/server": "^10.45.2", "@vendia/serverless-express": "^4.12.6", - "axios": "^1.7.8", "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", "dotenv-flow": "^4.1.0", diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index f9fb1cd0..7166150d 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -56,7 +56,7 @@ export const review = pgTable( difficulty: integer('difficulty').notNull(), gradeReceived: text('grade_received').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow(), + updatedAt: timestamp('updated_at'), forCredit: boolean('for_credit').notNull(), quarter: text('quarter').notNull(), takeAgain: boolean('take_again').notNull(), diff --git a/api/src/helpers/recaptcha.ts b/api/src/helpers/recaptcha.ts index 61d40268..a2fcf4a8 100644 --- a/api/src/helpers/recaptcha.ts +++ b/api/src/helpers/recaptcha.ts @@ -1,5 +1,4 @@ import { ReviewSubmission } from '@peterportal/types'; -import axios from 'axios'; export async function verifyCaptcha(review: ReviewSubmission) { const reqBody = { @@ -7,9 +6,14 @@ export async function verifyCaptcha(review: ReviewSubmission) { response: review.captchaToken ?? '', }; const query = new URLSearchParams(reqBody); - const response = await axios - .post('https://www.google.com/recaptcha/api/siteverify?' + query) - .then((x) => x.data) + const response = await fetch('https://www.google.com/recaptcha/api/siteverify?' + query, { method: 'POST' }) + .then((res) => { + if (res.ok) { + return res.json(); + } else { + throw new Error(res.statusText); + } + }) .catch((e) => { console.error('Error validating captcha response', e); return { success: false }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2299ae40..3bddceb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,9 +57,6 @@ importers: '@vendia/serverless-express': specifier: ^4.12.6 version: 4.12.6 - axios: - specifier: ^1.7.8 - version: 1.7.9 connect-pg-simple: specifier: ^10.0.0 version: 10.0.0 @@ -166,9 +163,6 @@ importers: '@trpc/client': specifier: ^10.45.2 version: 10.45.2(@trpc/server@10.45.2) - axios: - specifier: ^1.7.8 - version: 1.7.9 bootstrap: specifier: ^4.6.2 version: 4.6.2(jquery@3.7.1)(popper.js@1.16.1) @@ -3968,9 +3962,6 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.3.1: - resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -6650,7 +6641,8 @@ snapshots: ast-types-flow@0.0.8: {} - asynckit@0.4.0: {} + asynckit@0.4.0: + optional: true available-typed-arrays@1.0.7: dependencies: @@ -6683,6 +6675,7 @@ snapshots: proxy-from-env: 1.1.0 transitivePeerDependencies: - debug + optional: true axobject-query@4.1.0: {} @@ -6854,6 +6847,7 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + optional: true commander@12.1.0: {} @@ -7043,7 +7037,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - delayed-stream@1.0.0: {} + delayed-stream@1.0.0: + optional: true depd@2.0.0: {} @@ -7667,7 +7662,8 @@ snapshots: flatted@3.3.1: {} - follow-redirects@1.15.6: {} + follow-redirects@1.15.6: + optional: true for-each@0.3.3: dependencies: @@ -7678,6 +7674,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.35 + optional: true forwarded@0.2.0: {} @@ -8203,7 +8200,7 @@ snapshots: mqtt-packet@6.10.0: dependencies: bl: 4.1.0 - debug: 4.3.4 + debug: 4.3.7 process-nextick-args: 2.0.1 transitivePeerDependencies: - supports-color @@ -8213,7 +8210,7 @@ snapshots: dependencies: commist: 1.1.0 concat-stream: 2.0.0 - debug: 4.3.4 + debug: 4.3.7 duplexify: 4.1.3 help-me: 3.0.0 inherits: 2.0.4 @@ -8224,7 +8221,7 @@ snapshots: pump: 3.0.0 readable-stream: 3.6.2 reinterval: 1.1.0 - rfdc: 1.3.1 + rfdc: 1.4.1 split2: 3.2.2 ws: 7.5.9 xtend: 4.0.2 @@ -8287,7 +8284,7 @@ snapshots: number-allocator@1.0.14: dependencies: - debug: 4.3.4 + debug: 4.3.7 js-sdsl: 4.3.0 transitivePeerDependencies: - supports-color @@ -8574,7 +8571,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} + proxy-from-env@1.1.0: + optional: true pstree.remy@1.1.8: {} @@ -8858,9 +8856,6 @@ snapshots: reusify@1.0.4: {} - rfdc@1.3.1: - optional: true - rfdc@1.4.1: {} rimraf@3.0.2: diff --git a/site/package.json b/site/package.json index ad60ac9b..b2125fcf 100644 --- a/site/package.json +++ b/site/package.json @@ -9,7 +9,6 @@ "@nivo/pie": "^0.88.0", "@reduxjs/toolkit": "^2.4.0", "@trpc/client": "^10.45.2", - "axios": "^1.7.8", "bootstrap": "^4.6.2", "node-html-parser": "^6.1.13", "react": "^18.3.1", diff --git a/site/src/component/AppHeader/AppHeader.tsx b/site/src/component/AppHeader/AppHeader.tsx index 7ea472a0..82203bef 100644 --- a/site/src/component/AppHeader/AppHeader.tsx +++ b/site/src/component/AppHeader/AppHeader.tsx @@ -85,7 +85,7 @@ const AppHeader: FC = () => { diff --git a/site/src/component/Footer/Footer.tsx b/site/src/component/Footer/Footer.tsx index bd13eda6..1519e242 100644 --- a/site/src/component/Footer/Footer.tsx +++ b/site/src/component/Footer/Footer.tsx @@ -12,7 +12,11 @@ const Footer: FC = () => { API - + Feedback diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index 70fcc82a..1d517f61 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -29,14 +29,14 @@ const GradeDist: FC = (props) => { * @param props attributes received from the parent element */ - const [gradeDistData, setGradeDistData] = useState(null!); + const [gradeDistData, setGradeDistData] = useState(); const [chartType, setChartType] = useState('bar'); const [currentQuarter, setCurrentQuarter] = useState(''); const [currentProf, setCurrentProf] = useState(''); - const [profEntries, setProfEntries] = useState(null!); + const [profEntries, setProfEntries] = useState(); const [currentCourse, setCurrentCourse] = useState(''); - const [courseEntries, setCourseEntries] = useState(null!); - const [quarterEntries, setQuarterEntries] = useState(null!); + const [courseEntries, setCourseEntries] = useState(); + const [quarterEntries, setQuarterEntries] = useState(); const fetchGradeDistData = useCallback(() => { let requests: Promise[]; @@ -74,7 +74,7 @@ const GradeDist: FC = (props) => { const professors: Set = new Set(); const result: Entry[] = []; - gradeDistData.forEach((match) => match.instructors.forEach((prof) => professors.add(prof))); + gradeDistData!.forEach((match) => match.instructors.forEach((prof) => professors.add(prof))); Array.from(professors) .sort((a, b) => a.localeCompare(b)) @@ -92,7 +92,7 @@ const GradeDist: FC = (props) => { const courses: Set = new Set(); const result: Entry[] = []; - gradeDistData.forEach((match) => courses.add(match.department + ' ' + match.courseNumber)); + gradeDistData!.forEach((match) => courses.add(match.department + ' ' + match.courseNumber)); Array.from(courses) .sort((a, b) => a.localeCompare(b)) @@ -104,7 +104,7 @@ const GradeDist: FC = (props) => { // update list of professors/courses when new course/professor is detected useEffect(() => { - if (gradeDistData && gradeDistData.length !== 0) { + if (gradeDistData?.length) { if (props.course) { createProfEntries(); } else if (props.professor) { @@ -121,7 +121,7 @@ const GradeDist: FC = (props) => { const quarters: Set = new Set(); const result: Entry[] = [{ value: 'ALL', text: 'All Quarters' }]; - gradeDistData + gradeDistData! .filter((entry) => { if (props.course && entry.instructors.includes(currentProf)) { return true; @@ -156,7 +156,7 @@ const GradeDist: FC = (props) => { // update list of quarters when new professor/course is chosen useEffect(() => { - if ((currentProf || currentCourse) && gradeDistData.length !== 0) { + if ((currentProf || currentCourse) && gradeDistData?.length) { createQuarterEntries(); } }, [currentProf, currentCourse, createQuarterEntries, gradeDistData]); @@ -230,7 +230,7 @@ const GradeDist: FC = (props) => { ); - if (gradeDistData !== null && gradeDistData.length !== 0) { + if (gradeDistData?.length) { const graphProps = { gradeData: gradeDistData, quarter: currentQuarter, diff --git a/site/src/component/ReviewForm/ReviewForm.tsx b/site/src/component/ReviewForm/ReviewForm.tsx index f28fc98d..dd7ea156 100644 --- a/site/src/component/ReviewForm/ReviewForm.tsx +++ b/site/src/component/ReviewForm/ReviewForm.tsx @@ -197,11 +197,6 @@ const ReviewForm: FC = ({ ); })} - - - Can't find your professor? - - Missing instructor ); diff --git a/site/src/component/SearchHitContainer/SearchHitContainer.tsx b/site/src/component/SearchHitContainer/SearchHitContainer.tsx index 4e705722..31c51a48 100644 --- a/site/src/component/SearchHitContainer/SearchHitContainer.tsx +++ b/site/src/component/SearchHitContainer/SearchHitContainer.tsx @@ -29,10 +29,16 @@ const SearchResults = ({ quarter.courses.map((course) => course.department + ' ' + course.courseNumber), ), ); + const transfers = roadmap?.transfers.map((transfer) => transfer.name); if (index === 'courses') { return (results as CourseGQLData[]).map((course, i) => { const requiredCourses = Array.from( - validateCourse(new Set(allExistingCourses), course.prerequisiteTree, new Set(), course.corequisites), + validateCourse( + new Set([...allExistingCourses, ...transfers]), + course.prerequisiteTree, + new Set(), + course.corequisites, + ), ); return ( 0 && { requiredCourses })} /> diff --git a/site/src/component/SearchPopup/SearchPopup.scss b/site/src/component/SearchPopup/SearchPopup.scss index 148a8033..82027a52 100644 --- a/site/src/component/SearchPopup/SearchPopup.scss +++ b/site/src/component/SearchPopup/SearchPopup.scss @@ -110,6 +110,22 @@ color: var(--peterportal-mid-gray); } } +.search-popup-professor-name { + display: flex; + flex-wrap: wrap; +} + +.search-popup-professor-name > span { + display: inline-block; + margin-right: 0.3em; +} + +.search-popup-professor-name > span.ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} @media only screen and (max-width: 1300px) { .search-popup-infos { diff --git a/site/src/component/SearchPopup/SearchPopup.tsx b/site/src/component/SearchPopup/SearchPopup.tsx index c6e241b0..9bda698d 100644 --- a/site/src/component/SearchPopup/SearchPopup.tsx +++ b/site/src/component/SearchPopup/SearchPopup.tsx @@ -117,8 +117,17 @@ const SearchPopupContent: FC = (props) => { / 5.0 - - {score.name} + + + {score.name.split(' ').map((part, idx) => { + const isTooLong = part.length > 13; + return ( + + {part} + + ); + })} + ))} diff --git a/site/src/component/Verify/Verify.tsx b/site/src/component/Verify/Verify.tsx index 7a25ba46..ae6ed455 100644 --- a/site/src/component/Verify/Verify.tsx +++ b/site/src/component/Verify/Verify.tsx @@ -1,34 +1,36 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import SubReview from '../../component/Review/SubReview'; import Button from 'react-bootstrap/Button'; import { Divider } from 'semantic-ui-react'; import './Verify.scss'; import trpc from '../../trpc'; -import { ReviewData } from '@peterportal/types'; +import { selectReviews, setReviews } from '../../store/slices/reviewSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; const Verify: FC = () => { - const [reviews, setReviews] = useState([]); + const reviews = useAppSelector(selectReviews); const [loaded, setLoaded] = useState(false); + const dispatch = useAppDispatch(); - const getUnverifiedReviews = async () => { + const getUnverifiedReviews = useCallback(async () => { const res = await trpc.reviews.get.query({ verified: false }); - setReviews(res); + dispatch(setReviews(res)); setLoaded(true); - }; + }, [dispatch]); useEffect(() => { getUnverifiedReviews(); document.title = 'Verify Reviews | PeterPortal'; - }, []); + }, [getUnverifiedReviews]); const verifyReview = async (reviewId: number) => { await trpc.reviews.verify.mutate({ id: reviewId }); - setReviews(reviews.filter((review) => review.id !== reviewId)); + dispatch(setReviews(reviews.filter((review) => review.id !== reviewId))); }; const deleteReview = async (reviewId: number) => { await trpc.reviews.delete.mutate({ id: reviewId }); - setReviews(reviews.filter((review) => review.id !== reviewId)); + dispatch(setReviews(reviews.filter((review) => review.id !== reviewId))); }; if (!loaded) { diff --git a/site/src/pages/RoadmapPage/RoadmapMultiplan.tsx b/site/src/pages/RoadmapPage/RoadmapMultiplan.tsx index 362c1431..026a09b2 100644 --- a/site/src/pages/RoadmapPage/RoadmapMultiplan.tsx +++ b/site/src/pages/RoadmapPage/RoadmapMultiplan.tsx @@ -20,6 +20,7 @@ interface RoadmapSelectableItemProps { index: number; clickHandler: () => void; editHandler: () => void; + duplicateHandler: () => void; deleteHandler: () => void; } @@ -28,6 +29,7 @@ const RoadmapSelectableItem: FC = ({ index, clickHandler, editHandler, + duplicateHandler, deleteHandler, }) => { return ( @@ -38,6 +40,9 @@ const RoadmapSelectableItem: FC = ({ + @@ -89,6 +94,24 @@ const RoadmapMultiplan: FC = () => { setEditIdx(-1); }; + const duplicatePlan = (plan: RoadmapPlan) => { + let newName = `${plan.name} (Copy)`; + let counter = 1; + while (allPlans.plans.find((p) => p.name === newName)) { + counter++; + newName = `${plan.name} (Copy ${counter})`; + } + dispatch( + addRoadmapPlan({ + name: newName, + content: JSON.parse(JSON.stringify(plan.content)), + }), + ); + const newIndex = allPlans.plans.length; + setCurrentPlanIndex(newIndex); + dispatch(setPlanIndex(newIndex)); + }; + useEffect(() => { document.title = `${name} | PeterPortal`; }, [name]); @@ -121,6 +144,7 @@ const RoadmapMultiplan: FC = () => { setCurrentPlanIndex(index); }} editHandler={() => setEditIdx(index)} + duplicateHandler={() => duplicatePlan(plan)} deleteHandler={() => setDelIdx(index)} /> ))}