From e78450b18fef493f25f19c0b94a5a29effe99e19 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Thu, 2 Nov 2023 00:53:58 +0800 Subject: [PATCH] Link user profile settings to backend (firebase and supabase) (#210) Fixes #189 and fixes #124 --- frontend/src/components/profile/columns.tsx | 2 +- .../src/firebase-client/useUpdateProfile.ts | 15 +++ frontend/src/hooks/useHistory.tsx | 11 +- frontend/src/hooks/useUser.ts | 10 +- frontend/src/pages/api/historyHandler.ts | 42 +++++- frontend/src/pages/api/userHandler.ts | 27 ++++ frontend/src/pages/attempt/[id]/index.tsx | 60 ++++++++- frontend/src/pages/profile/[id]/index.tsx | 43 ++++-- frontend/src/pages/profile/_profile.tsx | 122 +++++++++-------- frontend/src/pages/profile/index.tsx | 125 +++--------------- frontend/src/pages/questions/new.tsx | 1 + frontend/src/pages/settings/_account.tsx | 28 +++- frontend/src/pages/settings/_match.tsx | 68 ++++++++-- frontend/src/types/UserTypes.ts | 2 +- .../migration.sql | 54 ++++++++ prisma/schema.prisma | 31 ++--- services/admin-service/.gitignore | 64 +++++++++ services/gateway/.gitignore | 64 +++++++++ .../matching-service/src/swagger-output.json | 66 ++++++++- services/user-service/src/db/functions.ts | 35 +++++ services/user-service/src/routes/index.ts | 20 +++ services/user-service/src/swagger-output.json | 23 ++++ services/user-service/systemtest/app.test.ts | 10 +- .../user-service/test/db/functions.test.ts | 4 +- .../user-service/test/routes/index.test.ts | 4 +- 25 files changed, 709 insertions(+), 222 deletions(-) create mode 100644 frontend/src/firebase-client/useUpdateProfile.ts create mode 100644 prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql create mode 100644 services/admin-service/.gitignore create mode 100644 services/gateway/.gitignore diff --git a/frontend/src/components/profile/columns.tsx b/frontend/src/components/profile/columns.tsx index 38cfb5ef..2f0cea8f 100644 --- a/frontend/src/components/profile/columns.tsx +++ b/frontend/src/components/profile/columns.tsx @@ -31,7 +31,7 @@ export const columns: ColumnDef[] = [ id: "actions", header: "Actions", cell: ({ row }) => { - const attemptId = row.id; + const attemptId = row.original.id; return ( +
+ + {showSuccess && ( + Successfully updated user profile! + )} +
+
Danger Zone diff --git a/frontend/src/pages/settings/_match.tsx b/frontend/src/pages/settings/_match.tsx index 00b7f2a0..a48fb2a8 100644 --- a/frontend/src/pages/settings/_match.tsx +++ b/frontend/src/pages/settings/_match.tsx @@ -2,56 +2,102 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Label } from "@radix-ui/react-dropdown-menu"; import { useUser } from "@/hooks/useUser"; import { AuthContext } from "@/contexts/AuthContext"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { EditableUser } from "@/types/UserTypes"; import DifficultySelector from "@/components/common/difficulty-selector"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Difficulty } from "@/types/QuestionTypes"; +import { DotWave } from "@uiball/loaders"; export default function MatchSettingsCard() { const { user: currentUser } = useContext(AuthContext); - const { updateUser } = useUser(); + const { updateUser, getAppUser } = useUser(); + const [isLoading, setIsLoading] = useState(true); + const [showSuccess, setShowSuccess] = useState(false); + const submitButtonRef = useRef(null); const [updatedUser, setUpdatedUser] = useState({ uid: currentUser?.uid ?? '' } as EditableUser); const [selectedDifficulty, setSelectedDifficulty] = useState('medium'); + const [selectedLanguage, setSelectedLanguage] = useState('c++'); useEffect(() => { - console.log(updatedUser); - }, [updatedUser]); + if (currentUser) { + getAppUser().then((user) => { + if (user) { + setSelectedDifficulty(user.matchDifficulty); + setSelectedLanguage(user.matchProgrammingLanguage); + } + setIsLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]) useEffect(() => { - console.log(selectedDifficulty); + setUpdatedUser((prev) => ({ ...prev, matchDifficulty: selectedDifficulty })); }, [selectedDifficulty]); + useEffect(() => { + setUpdatedUser((prev) => ({ ...prev, matchProgrammingLanguage: selectedLanguage })); + }, [selectedLanguage]); + return ( Match Preferences + {isLoading ? ( +
+ +
+ ) : (
- { + setShowSuccess(false); + setSelectedLanguage(lang); + }}> Python - Javascript + {/* Javascript */} Java - c++ + C++
- - setSelectedDifficulty(value)} /> + + { + setShowSuccess(false); + setSelectedDifficulty(value); + }} /> +
+
+ + {showSuccess && ( + Successfully updated match preferences! + )}
-
+ )} +
) diff --git a/frontend/src/types/UserTypes.ts b/frontend/src/types/UserTypes.ts index 2eb25533..bf980e18 100644 --- a/frontend/src/types/UserTypes.ts +++ b/frontend/src/types/UserTypes.ts @@ -2,7 +2,7 @@ export type EditableUser = { uid: string; displayName?: string | null; photoUrl?: string | null; - matchDifficulty?: number | null; + matchDifficulty?: string | null; matchProgrammingLanguage?: string | null; }; diff --git a/prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql b/prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql new file mode 100644 index 00000000..6fc0d38d --- /dev/null +++ b/prisma/migrations/20231031082108_change_match_difficulty_to_string/migration.sql @@ -0,0 +1,54 @@ +/* + Warnings: + + - A unique constraint covering the columns `[attempt_id]` on the table `Room` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "AppUser" ALTER COLUMN "matchDifficulty" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Match" ADD COLUMN "questionId" TEXT; + +-- AlterTable +ALTER TABLE "Room" ADD COLUMN "active_users" TEXT[], +ADD COLUMN "attempt_id" TEXT, +ADD COLUMN "question_id" TEXT; + +-- CreateTable +CREATE TABLE "Attempt" ( + "id" TEXT NOT NULL, + "question_id" TEXT NOT NULL, + "answer" TEXT, + "solved" BOOLEAN NOT NULL DEFAULT false, + "time_saved_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "room_id" TEXT, + "time_updated" TIMESTAMP(3) NOT NULL, + "time_created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Attempt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_AppUserToAttempt" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_AppUserToAttempt_AB_unique" ON "_AppUserToAttempt"("A", "B"); + +-- CreateIndex +CREATE INDEX "_AppUserToAttempt_B_index" ON "_AppUserToAttempt"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "Room_attempt_id_key" ON "Room"("attempt_id"); + +-- AddForeignKey +ALTER TABLE "Room" ADD CONSTRAINT "Room_attempt_id_fkey" FOREIGN KEY ("attempt_id") REFERENCES "Attempt"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_AppUserToAttempt" ADD CONSTRAINT "_AppUserToAttempt_A_fkey" FOREIGN KEY ("A") REFERENCES "AppUser"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_AppUserToAttempt" ADD CONSTRAINT "_AppUserToAttempt_B_fkey" FOREIGN KEY ("B") REFERENCES "Attempt"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9de8f6cb..6c082575 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -31,37 +28,37 @@ model AppUser { uid String @id displayName String? photoUrl String? - matchDifficulty Int? + matchDifficulty String? matchProgrammingLanguage String? - attempts Attempt[] + attempts Attempt[] @relation("AppUserToAttempt") } model Room { room_id String @id - active_users String[] // Array of user_id strings still active - users String[] // Array of user_id strings + users String[] status EnumRoomStatus text String saved_text String? question_id String? - attempt Attempt? @relation(fields: [attempt_id], references: [id]) attempt_id String? @unique -} - -enum EnumRoomStatus { - active - inactive + active_users String[] + attempt Attempt? @relation(fields: [attempt_id], references: [id]) } model Attempt { id String @id @default(uuid()) - users AppUser[] question_id String answer String? solved Boolean @default(false) + time_saved_at DateTime @default(now()) + room_id String? + time_updated DateTime @updatedAt time_created DateTime @default(now()) - time_saved_at DateTime @default(now()) // when answers are updated - time_updated DateTime @updatedAt // any field change - room_id String? // may be inactive room Room? + users AppUser[] @relation("AppUserToAttempt") +} + +enum EnumRoomStatus { + active + inactive } diff --git a/services/admin-service/.gitignore b/services/admin-service/.gitignore new file mode 100644 index 00000000..ce597ce5 --- /dev/null +++ b/services/admin-service/.gitignore @@ -0,0 +1,64 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Ignore built ts files +dist/**/* + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/services/gateway/.gitignore b/services/gateway/.gitignore new file mode 100644 index 00000000..ce597ce5 --- /dev/null +++ b/services/gateway/.gitignore @@ -0,0 +1,64 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Ignore built ts files +dist/**/* + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/services/matching-service/src/swagger-output.json b/services/matching-service/src/swagger-output.json index 6e329ba1..b5d54982 100644 --- a/services/matching-service/src/swagger-output.json +++ b/services/matching-service/src/swagger-output.json @@ -66,7 +66,71 @@ } } }, - "/api/matching-service/": { + "/api/matching-service/match/{room_id}": { + "get": { + "description": "", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "description": "", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "questionId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/matching-service/demo": { "get": { "description": "", "responses": { diff --git a/services/user-service/src/db/functions.ts b/services/user-service/src/db/functions.ts index 7ebedf81..7eaf01fe 100644 --- a/services/user-service/src/db/functions.ts +++ b/services/user-service/src/db/functions.ts @@ -75,6 +75,20 @@ const userDatabaseFunctions = { } }, + async getAttemptById(attemptId: string) { + try { + const attempt = await prismaClient.attempt.findUnique({ + where: { + id: attemptId, + }, + }); + return attempt; + } catch (error: any) { + console.error(`Error retrieving attempt: ${error.message}`); + throw error; + } + }, + async createAttemptOfUser(data: { uid: string; question_id: string; @@ -111,6 +125,27 @@ const userDatabaseFunctions = { throw error; } }, + + async setMatchPreferenceOfUser(uid: string, data: { + matchDifficulty: string; + matchProgrammingLanguage: string; + }) { + try { + const updatedResult = await prismaClient.appUser.update({ + where: { + uid: uid, + }, + data: { + matchDifficulty: data["matchDifficulty"], + matchProgrammingLanguage: data["matchProgrammingLanguage"], + }, + }); + return updatedResult; + } catch (error: any) { + console.error(`Error setting match preference: ${error.message}`); + throw error; + } + } }; export default userDatabaseFunctions; diff --git a/services/user-service/src/routes/index.ts b/services/user-service/src/routes/index.ts index e540ef92..16eb45b1 100644 --- a/services/user-service/src/routes/index.ts +++ b/services/user-service/src/routes/index.ts @@ -115,6 +115,26 @@ indexRouter.get( function (req: express.Request, res: express.Response) { userDatabaseFunctions .getAttemptsOfUser(req.params.uid) + .then((result) => { + if (result === null) { + // res.status(404).end(); + res.send(200).json([]); + } else { + res.status(200).json(result); + } + }) + .catch(() => { + // Server side error such as database not being available + res.status(500).end(); + }); + } +); + +indexRouter.get( + "/attempt/:attempt_id", + function (req: express.Request, res: express.Response) { + userDatabaseFunctions + .getAttemptById(req.params.attempt_id) .then((result) => { if (result === null) { res.status(404).end(); diff --git a/services/user-service/src/swagger-output.json b/services/user-service/src/swagger-output.json index 3dce4a77..afcf18a9 100644 --- a/services/user-service/src/swagger-output.json +++ b/services/user-service/src/swagger-output.json @@ -120,6 +120,29 @@ } } ], + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/user-service/attempt/{attempt_id}": { + "get": { + "description": "", + "parameters": [ + { + "name": "attempt_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" diff --git a/services/user-service/systemtest/app.test.ts b/services/user-service/systemtest/app.test.ts index eaf746c0..cb249dd7 100644 --- a/services/user-service/systemtest/app.test.ts +++ b/services/user-service/systemtest/app.test.ts @@ -4,13 +4,13 @@ import app from "../src/app" import request from 'supertest'; -const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, - matchProgrammingLanguage: "Python" }; +const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "easy", + matchProgrammingLanguage: "python" }; -const updatedNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 1, - matchProgrammingLanguage: "Python"}; +const updatedNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "medium", + matchProgrammingLanguage: "python"}; -const updatePayload = { matchDifficulty: 1 }; +const updatePayload = { matchDifficulty: "medium" }; const userIdHeader = "User-Id"; diff --git a/services/user-service/test/db/functions.test.ts b/services/user-service/test/db/functions.test.ts index 0726d726..4e7d02c2 100644 --- a/services/user-service/test/db/functions.test.ts +++ b/services/user-service/test/db/functions.test.ts @@ -4,8 +4,8 @@ import prismaMock from '../../src/db/__mocks__/prismaClient' vi.mock('../../src/db/prismaClient') -const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, - matchProgrammingLanguage: "Python" }; +const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "easy", + matchProgrammingLanguage: "python" }; const partialNewUser = { uid: '1'}; diff --git a/services/user-service/test/routes/index.test.ts b/services/user-service/test/routes/index.test.ts index 6d2b5abd..0720302d 100644 --- a/services/user-service/test/routes/index.test.ts +++ b/services/user-service/test/routes/index.test.ts @@ -12,8 +12,8 @@ const app = express(); const userIdHeader = "User-Id"; app.use(indexRouter); -const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: 0, - matchProgrammingLanguage: "Python" }; +const fullNewUser = { uid: '1', displayName: 'Test User', photoUrl: "fakeUrl", matchDifficulty: "easy", + matchProgrammingLanguage: "python" }; describe('/index', () => { /**