From 3502e42e495849819e9c2d6646e034744179fb28 Mon Sep 17 00:00:00 2001 From: solomonng2001 Date: Tue, 29 Oct 2024 01:43:06 +0800 Subject: [PATCH] Sync submission across webrtc and update history-service routing --- .../src/app/collaboration/[id]/page.tsx | 43 +++--- apps/frontend/src/app/services/history.ts | 61 ++++++--- .../CollaborativeEditor.tsx | 35 ++++- apps/history-service/handlers/create.go | 21 +-- .../handlers/createOrUpdate.go | 125 ++++++++++++++++++ apps/history-service/handlers/delete.go | 4 +- apps/history-service/handlers/list.go | 69 ++++++++++ apps/history-service/handlers/read.go | 6 +- apps/history-service/handlers/update.go | 12 +- apps/history-service/main.go | 21 ++- apps/history-service/models/models.go | 1 - 11 files changed, 314 insertions(+), 84 deletions(-) create mode 100644 apps/history-service/handlers/createOrUpdate.go create mode 100644 apps/history-service/handlers/list.go diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index 9c9538e614..1e9c5548c8 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -16,7 +16,7 @@ import { import { Content } from "antd/es/layout/layout"; import "./styles.scss"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { GetSingleQuestion, Question } from "@/app/services/question"; import { ClockCircleOutlined, @@ -28,13 +28,15 @@ import { } from "@ant-design/icons"; import { ProgrammingLanguageOptions } from "@/utils/SelectOptions"; import CollaborativeEditor from "@/components/CollaborativeEditor/CollaborativeEditor"; -import { CreateHistory, UpdateHistory } from "@/app/services/history"; +import { CreateOrUpdateHistory } from "@/app/services/history"; import { Language } from "@codemirror/language"; +import { WebrtcProvider } from "y-webrtc"; interface CollaborationProps {} export default function CollaborationPage(props: CollaborationProps) { const router = useRouter(); + const providerRef = useRef(null); const [isLoading, setIsLoading] = useState(false); @@ -80,26 +82,18 @@ export default function CollaborationPage(props: CollaborationProps) { }); }; - const handleSubmitCode = async () => { - if (!historyDocRefId) { - const data = await CreateHistory({ - title: questionTitle ?? "", - code: code, - language: selectedLanguage, - user: currentUser ?? "", - matchedUser: matchedUser ?? "", - matchId: collaborationId ?? "", - matchedTopics: matchedTopics ?? [], - questionDocRefId: questionDocRefId ?? "", - questionDifficulty: complexity ?? "", - questionTopics: categories, - }); - setHistoryDocRefId(data.docRefId); - successMessage("Code submitted successfully!"); - return; + const sendCodeSavedStatusToMatchedUser = () => { + if (!providerRef.current) { + throw new Error("Provider not initialized"); } + providerRef.current.awareness.setLocalStateField("codeSavedStatus", true); + } - UpdateHistory({ + const handleSubmitCode = async () => { + if (!collaborationId) { + throw new Error("Collaboration ID not found"); + } + const data = await CreateOrUpdateHistory({ title: questionTitle ?? "", code: code, language: selectedLanguage, @@ -110,8 +104,9 @@ export default function CollaborationPage(props: CollaborationProps) { questionDocRefId: questionDocRefId ?? "", questionDifficulty: complexity ?? "", questionTopics: categories, - }, historyDocRefId!); - successMessage("Code updated successfully!"); + }, collaborationId); + successMessage("Code saved successfully!"); + sendCodeSavedStatusToMatchedUser(); } const handleCodeChange = (code: string) => { @@ -273,7 +268,9 @@ export default function CollaborationPage(props: CollaborationProps) { user={currentUser} collaborationId={collaborationId} language={selectedLanguage} - onCodeChange={handleCodeChange} + providerRef={providerRef} + matchedUser={matchedUser} + onCodeChange={handleCodeChange} /> )} diff --git a/apps/frontend/src/app/services/history.ts b/apps/frontend/src/app/services/history.ts index ddce38c46e..fc01f913ec 100644 --- a/apps/frontend/src/app/services/history.ts +++ b/apps/frontend/src/app/services/history.ts @@ -13,41 +13,64 @@ export interface History { questionTopics: string[]; createdAt?: string; updatedAt?: string; - docRefId?: string; } -export const CreateHistory = async ( - history: History +export const CreateOrUpdateHistory = async ( + history: History, + matchId: string, ): Promise => { - const response = await fetch(`${HISTORY_SERVICE_URL}histories`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(history), - }); + const response = await fetch( + `${HISTORY_SERVICE_URL}histories/${matchId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(history), + } + ); if (response.status === 200) { return response.json(); } else { throw new Error( - `Error creating history: ${response.status} ${response.statusText}` + `Error saving history: ${response.status} ${response.statusText}` ); } -}; +} -export const UpdateHistory = async ( - history: History, - historyDocRefId: string +export const GetHistory = async ( + matchId: string, ): Promise => { const response = await fetch( - `${HISTORY_SERVICE_URL}histories/${historyDocRefId}`, + `${HISTORY_SERVICE_URL}histories/${matchId}`, { - method: "PUT", + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (response.status === 200) { + return response.json(); + } else { + throw new Error( + `Error reading history: ${response.status} ${response.statusText}` + ); + } +} + +export const GetUserHistories = async ( + username: string, +): Promise => { + const response = await fetch( + `${HISTORY_SERVICE_URL}histories/${username}`, + { + method: "GET", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(history), } ); @@ -55,7 +78,7 @@ export const UpdateHistory = async ( return response.json(); } else { throw new Error( - `Error updating history: ${response.status} ${response.statusText}` + `Error reading user histories: ${response.status} ${response.statusText}` ); } } diff --git a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx index 1174a24601..5b5c01c06f 100644 --- a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx +++ b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx @@ -1,5 +1,5 @@ // Referenced from example in https://www.npmjs.com/package/y-codemirror.next -import React, { useEffect, useRef, useState } from "react"; +import React, { MutableRefObject, useEffect, useRef, useState } from "react"; import * as Y from "yjs"; import { yCollab } from "y-codemirror.next"; import { WebrtcProvider } from "y-webrtc"; @@ -19,9 +19,26 @@ interface CollaborativeEditorProps { user: string; collaborationId: string; language: string; + providerRef: MutableRefObject; + matchedUser: string | undefined; onCodeChange: (code: string) => void; } +interface AwarenessUpdate { + added: number[]; + updated: number[]; + removed: number[]; +} + +interface Awareness { + user: { + name: string; + color: string; + colorLight: string; + }; + codeSavedStatus: boolean; +} + export const usercolors = [ { color: "#30bced", light: "#30bced33" }, { color: "#6eeb83", light: "#6eeb8333" }, @@ -154,8 +171,8 @@ const CollaborativeEditor = (props: CollaborativeEditorProps) => { const provider = new WebrtcProvider(props.collaborationId, ydoc, { signaling: [process.env.NEXT_PUBLIC_SIGNALLING_SERVICE_URL], }); + props.providerRef.current = provider; const ytext = ydoc.getText("codemirror"); - console.log("testing y text", ytext); // TODO: remove const undoManager = new Y.UndoManager(ytext); provider.awareness.setLocalStateField("user", { @@ -164,6 +181,20 @@ const CollaborativeEditor = (props: CollaborativeEditorProps) => { colorLight: userColor.light, }); + // Listener for awareness updates to receive status changes from peers + provider.awareness.on("update", ({ added, updated } : AwarenessUpdate) => { + added.concat(updated).filter(clientId => clientId !== provider.awareness.clientID).forEach((clientID) => { + const state = provider.awareness.getStates().get(clientID) as Awareness; + if (state && state.codeSavedStatus) { + // Display the received status message + messageApi.open({ + type: "success", + content: `${props.matchedUser ?? "Peer"} saved code successfully!`, + }); + } + }); + }); + const state = EditorState.create({ doc: ytext.toString(), extensions: [ diff --git a/apps/history-service/handlers/create.go b/apps/history-service/handlers/create.go index c2098ced19..d981fb9190 100644 --- a/apps/history-service/handlers/create.go +++ b/apps/history-service/handlers/create.go @@ -1,6 +1,7 @@ package handlers import ( + "cloud.google.com/go/firestore" "encoding/json" "google.golang.org/api/iterator" "history-service/models" @@ -10,40 +11,36 @@ import ( // Create a new code snippet func (s *Service) CreateHistory(w http.ResponseWriter, r *http.Request) { - println("test1") ctx := r.Context() // Parse request var collaborationHistory models.CollaborationHistory if err := utils.DecodeJSONBody(w, r, &collaborationHistory); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) - println(err.Error()) return } - println("test2") + // Document reference ID in firestore mapped to the match ID in model + docRef := s.Client.Collection("collaboration-history").Doc(collaborationHistory.MatchID) - docRef, _, err := s.Client.Collection("collaboration-history").Add(ctx, map[string]interface{}{ + _, err := docRef.Set(ctx, map[string]interface{}{ "title": collaborationHistory.Title, "code": collaborationHistory.Code, "language": collaborationHistory.Language, "user": collaborationHistory.User, "matchedUser": collaborationHistory.MatchedUser, - "matchId": collaborationHistory.MatchID, "matchedTopics": collaborationHistory.MatchedTopics, "questionDocRefId": collaborationHistory.QuestionDocRefID, "questionDifficulty": collaborationHistory.QuestionDifficulty, "questionTopics": collaborationHistory.QuestionTopics, - "createdAt": collaborationHistory.CreatedAt, - "updatedAt": collaborationHistory.UpdatedAt, + "createdAt": firestore.ServerTimestamp, + "updatedAt": firestore.ServerTimestamp, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - println("test3") - // Get data doc, err := docRef.Get(ctx) if err != nil { @@ -55,16 +52,12 @@ func (s *Service) CreateHistory(w http.ResponseWriter, r *http.Request) { return } - println("test4") - // Map data if err := doc.DataTo(&collaborationHistory); err != nil { http.Error(w, "Failed to map history data", http.StatusInternalServerError) return } - collaborationHistory.DocRefID = doc.Ref.ID - - println(collaborationHistory.Title, "test") + collaborationHistory.MatchID = doc.Ref.ID w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/apps/history-service/handlers/createOrUpdate.go b/apps/history-service/handlers/createOrUpdate.go new file mode 100644 index 0000000000..f9df4bcc33 --- /dev/null +++ b/apps/history-service/handlers/createOrUpdate.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "cloud.google.com/go/firestore" + "encoding/json" + "github.com/go-chi/chi/v5" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "history-service/models" + "history-service/utils" + "net/http" +) + +func (s *Service) CreateOrUpdateHistory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse request + matchId := chi.URLParam(r, "matchId") + var collaborationHistory models.CollaborationHistory + if err := utils.DecodeJSONBody(w, r, &collaborationHistory); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Reference document + docRef := s.Client.Collection("collaboration-history").Doc(matchId) + + // Check if exists + _, err := docRef.Get(ctx) + if err != nil { + if status.Code(err) == codes.NotFound { + // Create collaboration history + _, err := docRef.Set(ctx, map[string]interface{}{ + "title": collaborationHistory.Title, + "code": collaborationHistory.Code, + "language": collaborationHistory.Language, + "user": collaborationHistory.User, + "matchedUser": collaborationHistory.MatchedUser, + "matchedTopics": collaborationHistory.MatchedTopics, + "questionDocRefId": collaborationHistory.QuestionDocRefID, + "questionDifficulty": collaborationHistory.QuestionDifficulty, + "questionTopics": collaborationHistory.QuestionTopics, + "createdAt": firestore.ServerTimestamp, + "updatedAt": firestore.ServerTimestamp, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Get data + doc, err := docRef.Get(ctx) + if err != nil { + if err != iterator.Done { + http.Error(w, "History not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to get history", http.StatusInternalServerError) + return + } + + // Map data + if err := doc.DataTo(&collaborationHistory); err != nil { + http.Error(w, "Failed to map history data", http.StatusInternalServerError) + return + } + collaborationHistory.MatchID = doc.Ref.ID + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(collaborationHistory) + return + } + } + + // Update collaboration history + + // Validation + // Check if exists + _, err = docRef.Get(ctx) + if err != nil { + if status.Code(err) == codes.NotFound { + http.Error(w, "History not found", http.StatusNotFound) + return + } + http.Error(w, "Error fetching history", http.StatusInternalServerError) + return + } + + // Prepare the update data. + updates := []firestore.Update{ + {Path: "code", Value: collaborationHistory.Code}, + {Path: "updatedAt", Value: firestore.ServerTimestamp}, + } + + // Update database + _, err = docRef.Update(ctx, updates) + if err != nil { + http.Error(w, "Error updating history", http.StatusInternalServerError) + return + } + + // Get data + doc, err := docRef.Get(ctx) + if err != nil { + if err != iterator.Done { + http.Error(w, "History not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to get history", http.StatusInternalServerError) + return + } + + // Map data + if err := doc.DataTo(&collaborationHistory); err != nil { + http.Error(w, "Failed to map history data", http.StatusInternalServerError) + return + } + collaborationHistory.MatchID = doc.Ref.ID + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(collaborationHistory) +} diff --git a/apps/history-service/handlers/delete.go b/apps/history-service/handlers/delete.go index 5d73206174..a450b58ea4 100644 --- a/apps/history-service/handlers/delete.go +++ b/apps/history-service/handlers/delete.go @@ -12,10 +12,10 @@ func (s *Service) DeleteHistory(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse request - docRefId := chi.URLParam(r, "docRefId") + matchId := chi.URLParam(r, "matchId") // Reference document - docRef := s.Client.Collection("collaboration-history").Doc(docRefId) + docRef := s.Client.Collection("collaboration-history").Doc(matchId) // Validation // Check if exists diff --git a/apps/history-service/handlers/list.go b/apps/history-service/handlers/list.go new file mode 100644 index 0000000000..554dc159ca --- /dev/null +++ b/apps/history-service/handlers/list.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "encoding/json" + "github.com/go-chi/chi/v5" + "google.golang.org/api/iterator" + "history-service/models" + "net/http" +) + +func (s *Service) ListUserHistories(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse request + username := chi.URLParam(r, "username") + + // Reference collection + collRef := s.Client.Collection("collaboration-history") + + // Query data + iterUser := collRef.Where("user", "==", username).Documents(ctx) + iterMatchedUser := collRef.Where("matchedUser", "==", username).Documents(ctx) + + // Map data + var histories []models.CollaborationHistory + for { + doc, err := iterUser.Next() + if err == iterator.Done { + break + } + if err != nil { + http.Error(w, "Failed to get histories for user", http.StatusInternalServerError) + return + } + + var history models.CollaborationHistory + if err := doc.DataTo(&history); err != nil { + http.Error(w, "Failed to map history data for user", http.StatusInternalServerError) + return + } + history.MatchID = doc.Ref.ID + + histories = append(histories, history) + } + + for { + doc, err := iterMatchedUser.Next() + if err == iterator.Done { + break + } + if err != nil { + http.Error(w, "Failed to get histories for matched user", http.StatusInternalServerError) + return + } + + var history models.CollaborationHistory + if err := doc.DataTo(&history); err != nil { + http.Error(w, "Failed to map history data for matched user", http.StatusInternalServerError) + return + } + history.MatchID = doc.Ref.ID + + histories = append(histories, history) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(histories) +} diff --git a/apps/history-service/handlers/read.go b/apps/history-service/handlers/read.go index cc594e70af..fd97c5d031 100644 --- a/apps/history-service/handlers/read.go +++ b/apps/history-service/handlers/read.go @@ -12,10 +12,10 @@ import ( func (s *Service) ReadHistory(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - docRefID := chi.URLParam(r, "docRefId") + matchId := chi.URLParam(r, "matchId") // Reference document - docRef := s.Client.Collection("collaboration-history").Doc(docRefID) + docRef := s.Client.Collection("collaboration-history").Doc(matchId) // Get data doc, err := docRef.Get(ctx) @@ -34,7 +34,7 @@ func (s *Service) ReadHistory(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to map history data", http.StatusInternalServerError) return } - collaborationHistory.DocRefID = doc.Ref.ID + collaborationHistory.MatchID = doc.Ref.ID w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/apps/history-service/handlers/update.go b/apps/history-service/handlers/update.go index 7eca693357..b6cb953709 100644 --- a/apps/history-service/handlers/update.go +++ b/apps/history-service/handlers/update.go @@ -1,6 +1,7 @@ package handlers import ( + "cloud.google.com/go/firestore" "encoding/json" "github.com/go-chi/chi/v5" "google.golang.org/api/iterator" @@ -9,9 +10,6 @@ import ( "history-service/models" "history-service/utils" "net/http" - "time" - - "cloud.google.com/go/firestore" ) // Update an existing code snippet @@ -19,7 +17,7 @@ func (s *Service) UpdateHistory(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse request - docRefId := chi.URLParam(r, "docRefId") + matchId := chi.URLParam(r, "matchId") var updatedHistory models.CollaborationHistory if err := utils.DecodeJSONBody(w, r, &updatedHistory); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -27,7 +25,7 @@ func (s *Service) UpdateHistory(w http.ResponseWriter, r *http.Request) { } // Reference document - docRef := s.Client.Collection("collaboration-history").Doc(docRefId) + docRef := s.Client.Collection("collaboration-history").Doc(matchId) // Validation // Check if exists @@ -44,7 +42,7 @@ func (s *Service) UpdateHistory(w http.ResponseWriter, r *http.Request) { // Prepare the update data. updates := []firestore.Update{ {Path: "code", Value: updatedHistory.Code}, - {Path: "updatedAt", Value: time.Now()}, + {Path: "updatedAt", Value: firestore.ServerTimestamp}, } // Update database @@ -70,7 +68,7 @@ func (s *Service) UpdateHistory(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to map history data", http.StatusInternalServerError) return } - updatedHistory.DocRefID = doc.Ref.ID + updatedHistory.MatchID = doc.Ref.ID w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/apps/history-service/main.go b/apps/history-service/main.go index 6d4f745af8..cf69c934d2 100644 --- a/apps/history-service/main.go +++ b/apps/history-service/main.go @@ -23,13 +23,6 @@ func main() { log.Fatal("Error loading .env file") } - //ctx := context.Background() - //client, err := initFirestore(ctx) - //if err != nil { - // log.Fatalf("Failed to initialize Firestore client: %v", err) - //} - //defer client.Close() - // Initialize Firestore client ctx := context.Background() client, err := initFirestore(ctx) @@ -82,21 +75,23 @@ func initChiRouter(service *handlers.Service) *chi.Mux { func registerRoutes(r *chi.Mux, service *handlers.Service) { r.Route("/histories", func(r chi.Router) { - r.Post("/", service.CreateHistory) + r.Get("/{username}", service.ListUserHistories) + //r.Post("/", service.CreateHistory) - r.Route("/{docRefId}", func(r chi.Router) { + r.Route("/{matchId}", func(r chi.Router) { + r.Put("/", service.CreateOrUpdateHistory) r.Get("/", service.ReadHistory) - r.Put("/", service.UpdateHistory) - r.Delete("/", service.DeleteHistory) + //r.Put("/", service.UpdateHistory) + //r.Delete("/", service.DeleteHistory) }) }) } func initRestServer(r *chi.Mux) { - // Serve on port 8080 + // Serve on port 8082 if no port found port := os.Getenv("PORT") if port == "" { - port = "8080" + port = "8082" } // Start the server diff --git a/apps/history-service/models/models.go b/apps/history-service/models/models.go index 36102ee34b..9928b8809b 100644 --- a/apps/history-service/models/models.go +++ b/apps/history-service/models/models.go @@ -17,5 +17,4 @@ type CollaborationHistory struct { // Special DB fields CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` - DocRefID string `json:"docRefId" firestore:"docRefId"` }