| package main |
|
|
| import ( |
| "bytes" |
| "context" |
| "crypto/rand" |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "log" |
| "net" |
| "net/http" |
| "net/url" |
| "os" |
| "path" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
|
|
| "RYP/backend/compiler/routes" |
|
|
| "github.com/golang-jwt/jwt/v5" |
| "github.com/joho/godotenv" |
| "go.mongodb.org/mongo-driver/bson" |
| "go.mongodb.org/mongo-driver/bson/primitive" |
| "go.mongodb.org/mongo-driver/mongo" |
| "go.mongodb.org/mongo-driver/mongo/options" |
| "golang.org/x/crypto/bcrypt" |
| "golang.org/x/oauth2" |
| "golang.org/x/oauth2/google" |
| ) |
|
|
| type userDoc struct { |
| ID primitive.ObjectID `bson:"_id"` |
| Email string `bson:"email"` |
| PasswordHash string `bson:"passwordHash"` |
| DisplayName string `bson:"displayName"` |
| PhotoURL string `bson:"photoURL,omitempty"` |
| CreatedAt time.Time `bson:"createdAt"` |
| CurrentStreak int `bson:"currentStreak"` |
| LongestStreak int `bson:"longestStreak"` |
| LastActiveAt time.Time `bson:"lastActiveAt,omitempty"` |
| StreakTimeZone string `bson:"streakTimeZone,omitempty"` |
| Coins int `bson:"coins"` |
| SolvedQuestionIDs []string `bson:"solvedQuestionIds,omitempty"` |
| DailyActivity map[string]int `bson:"dailyActivity,omitempty"` |
| DailyActivityBreakdown map[string]map[string]int `bson:"dailyActivityBreakdown,omitempty"` |
| LanguageStats map[string]int `bson:"languageStats,omitempty"` |
| } |
|
|
| type threadDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| UserID string `bson:"userId" json:"userId"` |
| Title string `bson:"title" json:"title"` |
| CreatedAt time.Time `bson:"createdAt" json:"createdAt"` |
| UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` |
| } |
|
|
| type messageDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| ThreadID primitive.ObjectID `bson:"threadId" json:"threadId"` |
| Role string `bson:"role" json:"role"` |
| Content string `bson:"content" json:"content"` |
| TestData *testDataDoc `bson:"testData,omitempty" json:"testData,omitempty"` |
| CreatedAt time.Time `bson:"createdAt" json:"createdAt"` |
| } |
|
|
| type testQuestionDoc struct { |
| Number int `bson:"number" json:"number"` |
| Text string `bson:"text" json:"text"` |
| Options map[string]string `bson:"options" json:"options"` |
| CorrectKey string `bson:"correctKey" json:"correctKey"` |
| } |
|
|
| type testDataDoc struct { |
| Topic string `bson:"topic" json:"topic"` |
| Questions []testQuestionDoc `bson:"questions" json:"questions"` |
| } |
|
|
| type publicUser struct { |
| ID string `json:"id"` |
| Email string `json:"email"` |
| DisplayName string `json:"displayName"` |
| PhotoURL string `json:"photoURL,omitempty"` |
| CreatedAt string `json:"createdAt"` |
| CurrentStreak int `json:"currentStreak"` |
| LongestStreak int `json:"longestStreak"` |
| Coins int `json:"coins"` |
| } |
|
|
| func trimEnv(s string) string { |
| s = strings.TrimSpace(s) |
| s = strings.TrimPrefix(s, `"`) |
| s = strings.TrimSuffix(s, `"`) |
| s = strings.TrimPrefix(s, `'`) |
| s = strings.TrimSuffix(s, `'`) |
| return strings.TrimSpace(s) |
| } |
|
|
| func loadDotEnv() { |
| candidates := []string{".env"} |
|
|
| if cwd, err := os.Getwd(); err == nil { |
| candidates = append(candidates, |
| filepath.Join(cwd, ".env"), |
| filepath.Join(cwd, "RYP", ".env"), |
| ) |
| } |
|
|
| if exePath, err := os.Executable(); err == nil { |
| exeDir := filepath.Dir(exePath) |
| candidates = append(candidates, |
| filepath.Join(exeDir, ".env"), |
| filepath.Join(exeDir, "..", ".env"), |
| ) |
| } |
|
|
| seen := make(map[string]struct{}, len(candidates)) |
| for _, candidate := range candidates { |
| absCandidate, err := filepath.Abs(candidate) |
| if err != nil { |
| continue |
| } |
| if _, ok := seen[absCandidate]; ok { |
| continue |
| } |
| seen[absCandidate] = struct{}{} |
|
|
| if _, err := os.Stat(absCandidate); err != nil { |
| continue |
| } |
|
|
| if err := godotenv.Load(absCandidate); err == nil { |
| return |
| } |
| } |
| } |
|
|
| func mongoURI() string { |
| u := trimEnv(os.Getenv("MONGODB_URI")) |
| if u == "" { |
| u = trimEnv(os.Getenv("MONGODB_URL")) |
| } |
| return u |
| } |
|
|
| func dbName() string { |
| n := trimEnv(os.Getenv("MONGODB_DB_NAME")) |
| if n == "" { |
| n = trimEnv(os.Getenv("MONGO_DB_NAME")) |
| } |
| if n == "" { |
| n = trimEnv(os.Getenv("DB_NAME")) |
| } |
| if n == "" { |
| return "ryp" |
| } |
| return n |
| } |
|
|
| func jwtSecret() string { |
| s := trimEnv(os.Getenv("JWT_SECRET")) |
| if s == "" { |
| log.Println("[auth] JWT_SECRET not set; using insecure development default") |
| return "dev-only-insecure-secret-change-in-production" |
| } |
| return s |
| } |
|
|
| func toPublicUser(d *userDoc) publicUser { |
| currentStreak := visibleCurrentStreak(d, "", time.Now().UTC()) |
| u := publicUser{ |
| ID: d.ID.Hex(), |
| Email: d.Email, |
| DisplayName: d.DisplayName, |
| CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339Nano), |
| CurrentStreak: currentStreak, |
| LongestStreak: maxInt(d.LongestStreak, maxInt(d.CurrentStreak, currentStreak)), |
| Coins: maxInt(d.Coins, 0), |
| } |
| if d.PhotoURL != "" { |
| u.PhotoURL = d.PhotoURL |
| } |
| return u |
| } |
|
|
| func signToken(userID string) (string, error) { |
| claims := jwt.RegisteredClaims{ |
| Subject: userID, |
| ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), |
| IssuedAt: jwt.NewNumericDate(time.Now()), |
| } |
| t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
| return t.SignedString([]byte(jwtSecret())) |
| } |
|
|
| func readBearer(r *http.Request) string { |
| h := r.Header.Get("Authorization") |
| if !strings.HasPrefix(strings.ToLower(h), "bearer ") { |
| return "" |
| } |
| return strings.TrimSpace(h[7:]) |
| } |
|
|
| func jsonWrite(w http.ResponseWriter, status int, v any) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| w.WriteHeader(status) |
| _ = json.NewEncoder(w).Encode(v) |
| } |
|
|
| func jsonError(w http.ResponseWriter, status int, msg string) { |
| jsonWrite(w, status, map[string]string{"error": msg}) |
| } |
|
|
| func withCORS(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| origin := r.Header.Get("Origin") |
| if matchedOrigin(origin) { |
| w.Header().Set("Access-Control-Allow-Origin", origin) |
| w.Header().Set("Vary", "Origin") |
| w.Header().Set("Access-Control-Allow-Credentials", "true") |
| w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Timezone") |
| w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") |
| } |
| if r.Method == http.MethodOptions { |
| w.WriteHeader(http.StatusNoContent) |
| return |
| } |
| next.ServeHTTP(w, r) |
| }) |
| } |
|
|
| func matchedOrigin(origin string) bool { |
| if origin == "" { |
| return false |
| } |
| |
| return strings.HasPrefix(origin, "http://localhost:") || |
| strings.HasPrefix(origin, "http://127.0.0.1:") |
| } |
|
|
| func maxBody(w http.ResponseWriter, r io.ReadCloser) io.ReadCloser { |
| return http.MaxBytesReader(w, r, 1<<20) |
| } |
|
|
| func StartServer() { |
| main() |
| } |
|
|
| func main() { |
| loadDotEnv() |
|
|
| uri := mongoURI() |
| if uri == "" { |
| log.Fatal("Set MONGODB_URI or MONGODB_URL in .env") |
| } |
|
|
| ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| defer cancel() |
|
|
| client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) |
| if err != nil { |
| log.Fatalf("MongoDB connect: %v", err) |
| } |
| defer func() { _ = client.Disconnect(context.Background()) }() |
|
|
| if err := client.Ping(ctx, nil); err != nil { |
| log.Fatalf("MongoDB ping: %v", err) |
| } |
|
|
| db := client.Database(dbName()) |
| coll := db.Collection("users") |
| threadsColl := db.Collection("ai_threads") |
| messagesColl := db.Collection("ai_messages") |
| aptitudeTopicsColl := db.Collection("aptitude_topics") |
| dbmsColl := client.Database("dbms").Collection("dbms") |
| systemDesignColl := client.Database("systemDesign").Collection("System_Design_Full_notes") |
| cnColl := client.Database("computer_networks").Collection("cn_full_notes") |
| osColl := client.Database("operating_system").Collection("OS") |
| pythonColl := client.Database("Programming_language").Collection("Python_Programming") |
| dsaQuestionsColl := client.Database("dsa_problems").Collection("all_problems") |
| discussionsColl := client.Database("All_Discussion").Collection("Discussion") |
| ensureDiscussionIndexes(discussionsColl) |
| chatColl := client.Database("All_Discussion").Collection("Chat") |
| ensureChatIndexes(chatColl) |
| cProgrammingColl := client.Database("Programming_language").Collection("C_programming_languages") |
| javaColl := client.Database("Programming_language").Collection("java_programming") |
|
|
| _, _ = coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ |
| Keys: bson.D{{Key: "email", Value: 1}}, |
| Options: options.Index().SetUnique(true), |
| }) |
|
|
| |
| _, _ = aptitudeTopicsColl.Indexes().CreateOne(context.Background(), mongo.IndexModel{ |
| Keys: bson.D{{Key: "category", Value: 1}, {Key: "order", Value: 1}}, |
| }) |
|
|
| mux := http.NewServeMux() |
|
|
| routes.RegisterRoutes(mux, db) |
|
|
| mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| w.Header().Set("Content-Type", "application/json") |
| _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) |
| }) |
|
|
| mux.HandleFunc("/api/auth/register", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| var body struct { |
| Email string `json:"email"` |
| Password string `json:"password"` |
| DisplayName string `json:"displayName"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if strings.TrimSpace(body.Email) == "" || body.Password == "" || strings.TrimSpace(body.DisplayName) == "" { |
| jsonError(w, http.StatusBadRequest, "Email, password, and display name are required.") |
| return |
| } |
| if len(body.Password) < 6 { |
| jsonError(w, http.StatusBadRequest, "Password must be at least 6 characters.") |
| return |
| } |
| normalized := strings.ToLower(strings.TrimSpace(body.Email)) |
| hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) |
| if err != nil { |
| log.Println("bcrypt:", err) |
| jsonError(w, http.StatusInternalServerError, "Registration failed.") |
| return |
| } |
| now := time.Now().UTC() |
| doc := userDoc{ |
| ID: primitive.NewObjectID(), |
| Email: normalized, |
| PasswordHash: string(hash), |
| DisplayName: strings.TrimSpace(body.DisplayName), |
| CreatedAt: now, |
| DailyActivity: map[string]int{}, |
| DailyActivityBreakdown: map[string]map[string]int{}, |
| LanguageStats: map[string]int{}, |
| } |
| doc.CurrentStreak = 1 |
| doc.LongestStreak = 1 |
| doc.LastActiveAt = now |
| doc.StreakTimeZone = resolveStreakTimeZone(readClientTimeZone(r), "") |
| _, err = coll.InsertOne(r.Context(), doc) |
| if err != nil { |
| if mongo.IsDuplicateKeyError(err) { |
| jsonError(w, http.StatusConflict, "An account with this email already exists.") |
| return |
| } |
| log.Println("register:", err) |
| jsonError(w, http.StatusInternalServerError, "Registration failed.") |
| return |
| } |
| token, err := signToken(doc.ID.Hex()) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Registration failed.") |
| return |
| } |
| jsonWrite(w, http.StatusCreated, map[string]any{ |
| "token": token, |
| "user": toPublicUser(&doc), |
| }) |
| }) |
|
|
| mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| var body struct { |
| Email string `json:"email"` |
| Password string `json:"password"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if strings.TrimSpace(body.Email) == "" || body.Password == "" { |
| jsonError(w, http.StatusBadRequest, "Email and password are required.") |
| return |
| } |
| normalized := strings.ToLower(strings.TrimSpace(body.Email)) |
| var doc userDoc |
| err := coll.FindOne(r.Context(), bson.M{"email": normalized}).Decode(&doc) |
| if err != nil { |
| if errors.Is(err, mongo.ErrNoDocuments) { |
| jsonError(w, http.StatusUnauthorized, "Invalid email or password.") |
| return |
| } |
| log.Println("login find:", err) |
| jsonError(w, http.StatusInternalServerError, "Login failed.") |
| return |
| } |
| if bcrypt.CompareHashAndPassword([]byte(doc.PasswordHash), []byte(body.Password)) != nil { |
| jsonError(w, http.StatusUnauthorized, "Invalid email or password.") |
| return |
| } |
| requestTZ := readClientTimeZone(r) |
| if err := touchUserStreak(r.Context(), coll, &doc, requestTZ, time.Now().UTC()); err != nil { |
| log.Println("login streak:", err) |
| jsonError(w, http.StatusInternalServerError, "Login failed.") |
| return |
| } |
| if err := syncUserTimeZone(r.Context(), coll, &doc, requestTZ); err != nil { |
| log.Println("login timezone:", err) |
| jsonError(w, http.StatusInternalServerError, "Login failed.") |
| return |
| } |
| token, err := signToken(doc.ID.Hex()) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Login failed.") |
| return |
| } |
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "token": token, |
| "user": toPublicUser(&doc), |
| }) |
| }) |
|
|
| mux.HandleFunc("/api/auth/me", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| raw := readBearer(r) |
| if raw == "" { |
| jsonError(w, http.StatusUnauthorized, "Not authenticated.") |
| return |
| } |
| tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { |
| if t.Method != jwt.SigningMethodHS256 { |
| return nil, fmt.Errorf("unexpected signing method") |
| } |
| return []byte(jwtSecret()), nil |
| }) |
| if err != nil || !tok.Valid { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| claims, ok := tok.Claims.(*jwt.RegisteredClaims) |
| if !ok || claims.Subject == "" { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| oid, err := primitive.ObjectIDFromHex(claims.Subject) |
| if err != nil { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| var doc userDoc |
| err = coll.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&doc) |
| if err != nil { |
| if errors.Is(err, mongo.ErrNoDocuments) { |
| jsonError(w, http.StatusUnauthorized, "User not found.") |
| return |
| } |
| jsonError(w, http.StatusInternalServerError, "Could not load session.") |
| return |
| } |
| requestTZ := readClientTimeZone(r) |
| if err := touchUserStreak(r.Context(), coll, &doc, requestTZ, time.Now().UTC()); err != nil { |
| log.Println("session streak:", err) |
| jsonError(w, http.StatusInternalServerError, "Could not load session.") |
| return |
| } |
| if err := syncUserTimeZone(r.Context(), coll, &doc, requestTZ); err != nil { |
| log.Println("session timezone:", err) |
| jsonError(w, http.StatusInternalServerError, "Could not load session.") |
| return |
| } |
| jsonWrite(w, http.StatusOK, map[string]any{"user": toPublicUser(&doc)}) |
| }) |
|
|
| mux.HandleFunc("/api/auth/update", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| raw := readBearer(r) |
| if raw == "" { |
| jsonError(w, http.StatusUnauthorized, "Not authenticated.") |
| return |
| } |
| tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { |
| if t.Method != jwt.SigningMethodHS256 { |
| return nil, fmt.Errorf("unexpected signing method") |
| } |
| return []byte(jwtSecret()), nil |
| }) |
| if err != nil || !tok.Valid { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| claims, ok := tok.Claims.(*jwt.RegisteredClaims) |
| if !ok || claims.Subject == "" { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| oid, err := primitive.ObjectIDFromHex(claims.Subject) |
| if err != nil { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
|
|
| var body struct { |
| DisplayName string `json:"displayName"` |
| OldPassword string `json:"oldPassword"` |
| NewPassword string `json:"newPassword"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
|
|
| var doc userDoc |
| err = coll.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&doc) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "User not found.") |
| return |
| } |
|
|
| updateFields := bson.M{} |
|
|
| if strings.TrimSpace(body.DisplayName) != "" { |
| updateFields["displayName"] = strings.TrimSpace(body.DisplayName) |
| } |
|
|
| if body.OldPassword != "" && body.NewPassword != "" { |
| if bcrypt.CompareHashAndPassword([]byte(doc.PasswordHash), []byte(body.OldPassword)) != nil { |
| jsonError(w, http.StatusUnauthorized, "Incorrect old password.") |
| return |
| } |
| if len(body.NewPassword) < 6 { |
| jsonError(w, http.StatusBadRequest, "New password must be at least 6 characters.") |
| return |
| } |
| hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to update password.") |
| return |
| } |
| updateFields["passwordHash"] = string(hash) |
| } else if body.NewPassword != "" && body.OldPassword == "" { |
| jsonError(w, http.StatusBadRequest, "Old password is required to set a new password.") |
| return |
| } |
|
|
| if len(updateFields) == 0 { |
| jsonError(w, http.StatusBadRequest, "Nothing to update.") |
| return |
| } |
|
|
| _, err = coll.UpdateOne(r.Context(), bson.M{"_id": oid}, bson.M{"$set": updateFields}) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to update profile.") |
| return |
| } |
|
|
| err = coll.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&doc) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to reload profile.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"user": toPublicUser(&doc)}) |
| }) |
|
|
| mux.HandleFunc("/api/user/add-coins", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| raw := readBearer(r) |
| if raw == "" { |
| jsonError(w, http.StatusUnauthorized, "Not authenticated.") |
| return |
| } |
| tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { |
| if t.Method != jwt.SigningMethodHS256 { |
| return nil, fmt.Errorf("unexpected signing method") |
| } |
| return []byte(jwtSecret()), nil |
| }) |
| if err != nil || !tok.Valid { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| claims, ok := tok.Claims.(*jwt.RegisteredClaims) |
| if !ok || claims.Subject == "" { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
| oid, err := primitive.ObjectIDFromHex(claims.Subject) |
| if err != nil { |
| jsonError(w, http.StatusUnauthorized, "Invalid or expired session.") |
| return |
| } |
|
|
| var body struct { |
| Amount int `json:"amount"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if body.Amount <= 0 { |
| jsonError(w, http.StatusBadRequest, "Amount must be greater than 0") |
| return |
| } |
|
|
| _, err = coll.UpdateOne(r.Context(), bson.M{"_id": oid}, bson.M{"$inc": bson.M{"coins": body.Amount}}) |
| _ = recordCoinTransaction(r.Context(), coll, oid, body.Amount, "Contest Reward", "contest") |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to add coins.") |
| return |
| } |
|
|
| var doc userDoc |
| err = coll.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&doc) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "User not found.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"user": toPublicUser(&doc)}) |
| }) |
| mux.HandleFunc("/api/user/stats", handleUserStats(coll)) |
| mux.HandleFunc("/api/user/solve-question", handleSolveQuestion(coll)) |
| mux.HandleFunc("/api/user/import-progress", handleImportProgress(coll)) |
| mux.HandleFunc("/api/user/spend-coins", handleSpendCoins(coll)) |
| mux.HandleFunc("/api/user/coin-history", handleGetCoinHistory(coll)) |
|
|
| |
| mux.HandleFunc("/api/aptitude/topics", handleGetAptitudeTopics(client)) |
| mux.HandleFunc("/api/aptitude/topics/", handleGetAptitudeTopicContent(client)) |
|
|
| |
| mux.HandleFunc("/api/dbms/topics", handleGetDBMSTopics(dbmsColl)) |
| mux.HandleFunc("/api/dbms/topics/", handleGetDBMSTopicContent(dbmsColl)) |
|
|
| mux.HandleFunc("/api/system-design/topics", handleGetSystemDesignTopics(systemDesignColl)) |
| mux.HandleFunc("/api/system-design/topics/", handleGetSystemDesignTopicContent(systemDesignColl)) |
| mux.HandleFunc("/api/cn/topics", handleGetCNTopics(cnColl)) |
| mux.HandleFunc("/api/cn/topics/", handleGetCNTopicContent(cnColl)) |
| mux.HandleFunc("/api/os/sections", handleGetOSSections(osColl)) |
| mux.HandleFunc("/api/os/sections/", handleGetOSSectionContent(osColl)) |
| mux.HandleFunc("/api/python/topics", handleGetPythonTopics(pythonColl)) |
| mux.HandleFunc("/api/python/topics/", handleGetPythonTopicContent(pythonColl)) |
| mux.HandleFunc("/api/dsa/questions", handleGetDSAQuestions(dsaQuestionsColl)) |
| mux.HandleFunc("/api/c/topics", handleGetCProgrammingTopics(cProgrammingColl)) |
| mux.HandleFunc("/api/c/topics/", handleGetCProgrammingTopicContent(cProgrammingColl)) |
| mux.HandleFunc("/api/java/topics", handleGetJavaTopics(javaColl)) |
| mux.HandleFunc("/api/java/topics/", handleGetJavaTopicContent(javaColl)) |
| mux.HandleFunc("/api/code/execute", handleCodeExecute) |
| mux.HandleFunc("/api/ai/execute", handleCodeExecute) |
| mux.HandleFunc("/api/discussions", func(w http.ResponseWriter, r *http.Request) { |
| switch r.Method { |
| case http.MethodGet: |
| handleGetDiscussions(discussionsColl)(w, r) |
| case http.MethodPost: |
| handlePostDiscussion(discussionsColl, coll)(w, r) |
| case http.MethodDelete: |
| handleDeleteDiscussion(discussionsColl)(w, r) |
| case http.MethodOptions: |
| w.WriteHeader(http.StatusNoContent) |
| default: |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| } |
| }) |
| mux.HandleFunc("/api/discussions/upvote", handleUpvoteDiscussion(discussionsColl)) |
| mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { |
| switch r.Method { |
| case http.MethodGet: |
| handleGetChatMessages(chatColl)(w, r) |
| case http.MethodPost: |
| handlePostChatMessage(chatColl, coll)(w, r) |
| case http.MethodDelete: |
| handleDeleteChatMessage(chatColl)(w, r) |
| case http.MethodOptions: |
| w.WriteHeader(http.StatusNoContent) |
| default: |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| } |
| }) |
| mux.HandleFunc("/api/ai/hint", handleGroqHint) |
| mux.HandleFunc("/api/ai/threads", handleGetThreads(threadsColl)) |
| mux.HandleFunc("/api/ai/threads/new", handleCreateThread(threadsColl)) |
| mux.HandleFunc("/api/ai/threads/messages", handleGetThreadMessages(threadsColl, messagesColl)) |
| mux.HandleFunc("/api/ai/threads/delete", handleDeleteThread(threadsColl, messagesColl)) |
| mux.HandleFunc("/api/ai/teacher", handleGroqTeacher(threadsColl, messagesColl)) |
| mux.HandleFunc("/api/ai/explain", handleGroqExplain) |
|
|
| |
| oauthCfg := googleOAuthConfig() |
| oauthStates := newOAuthStateStore() |
|
|
| mux.HandleFunc("/api/auth/google", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| state := oauthStates.New() |
| http.Redirect(w, r, oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOnline), http.StatusTemporaryRedirect) |
| }) |
|
|
| mux.HandleFunc("/api/auth/google/callback", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| state := r.URL.Query().Get("state") |
| if !oauthStates.Consume(state) { |
| http.Redirect(w, r, frontendErrorURL("Invalid or expired OAuth state"), http.StatusTemporaryRedirect) |
| return |
| } |
|
|
| code := r.URL.Query().Get("code") |
| if code == "" { |
| http.Redirect(w, r, frontendErrorURL("OAuth code missing"), http.StatusTemporaryRedirect) |
| return |
| } |
|
|
| googleToken, err := oauthCfg.Exchange(r.Context(), code) |
| if err != nil { |
| log.Println("[google oauth] token exchange:", err) |
| http.Redirect(w, r, frontendErrorURL("Google authentication failed"), http.StatusTemporaryRedirect) |
| return |
| } |
|
|
| gInfo, err := fetchGoogleUserInfo(r.Context(), oauthCfg, googleToken) |
| if err != nil { |
| log.Println("[google oauth] userinfo:", err) |
| http.Redirect(w, r, frontendErrorURL("Could not fetch Google profile"), http.StatusTemporaryRedirect) |
| return |
| } |
|
|
| |
| normEmail := strings.ToLower(strings.TrimSpace(gInfo.Email)) |
| now := time.Now().UTC() |
|
|
| |
| var doc userDoc |
| err = coll.FindOne(r.Context(), bson.M{"email": normEmail}).Decode(&doc) |
| if errors.Is(err, mongo.ErrNoDocuments) { |
| |
| doc = userDoc{ |
| ID: primitive.NewObjectID(), |
| Email: normEmail, |
| PasswordHash: "", |
| DisplayName: strings.TrimSpace(gInfo.Name), |
| PhotoURL: gInfo.Picture, |
| CreatedAt: now, |
| CurrentStreak: 0, |
| LongestStreak: 0, |
| Coins: 0, |
| DailyActivity: map[string]int{}, |
| DailyActivityBreakdown: map[string]map[string]int{}, |
| LanguageStats: map[string]int{}, |
| StreakTimeZone: "UTC", |
| } |
| var insertErr error |
| _, insertErr = coll.InsertOne(r.Context(), doc) |
| if insertErr != nil && !mongo.IsDuplicateKeyError(insertErr) { |
| log.Println("[google oauth] insert user:", insertErr) |
| http.Redirect(w, r, frontendErrorURL("Failed to create account"), http.StatusTemporaryRedirect) |
| return |
| } |
| |
| if mongo.IsDuplicateKeyError(insertErr) { |
| _ = coll.FindOne(r.Context(), bson.M{"email": normEmail}).Decode(&doc) |
| } |
| } else if err != nil { |
| log.Println("[google oauth] find user:", err) |
| http.Redirect(w, r, frontendErrorURL("Database error"), http.StatusTemporaryRedirect) |
| return |
| } else { |
| |
| update := bson.M{} |
| if doc.PhotoURL == "" && gInfo.Picture != "" { |
| update["photoURL"] = gInfo.Picture |
| } |
| if doc.DisplayName == "" && gInfo.Name != "" { |
| update["displayName"] = strings.TrimSpace(gInfo.Name) |
| } |
| if len(update) > 0 { |
| _, _ = coll.UpdateOne(r.Context(), bson.M{"_id": doc.ID}, bson.M{"$set": update}) |
| } |
| } |
|
|
| if err := touchUserStreak(r.Context(), coll, &doc, "UTC", time.Now().UTC()); err != nil { |
| log.Println("[google oauth] streak:", err) |
| } |
|
|
| token, err := signToken(doc.ID.Hex()) |
| if err != nil { |
| log.Println("[google oauth] sign token:", err) |
| http.Redirect(w, r, frontendErrorURL("Token signing failed"), http.StatusTemporaryRedirect) |
| return |
| } |
|
|
| redirectURL := trimEnv(os.Getenv("GOOGLE_REDIRECT_URL")) |
| if !strings.HasSuffix(redirectURL, "/") { |
| redirectURL += "/" |
| } |
| http.Redirect(w, r, redirectURL+"?token="+url.QueryEscape(token), http.StatusTemporaryRedirect) |
| }) |
|
|
| distIndex := filepath.Join("dist", "index.html") |
| if st, err := os.Stat(distIndex); err == nil && !st.IsDir() { |
| distDir := "dist" |
| mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) |
| return |
| } |
| rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") |
| if rel == "." || rel == "" { |
| http.ServeFile(w, r, distIndex) |
| return |
| } |
| if strings.Contains(rel, "..") { |
| http.ServeFile(w, r, distIndex) |
| return |
| } |
| full := filepath.Join(distDir, filepath.FromSlash(rel)) |
| if fi, err := os.Stat(full); err == nil && !fi.IsDir() { |
| http.ServeFile(w, r, full) |
| return |
| } |
| http.ServeFile(w, r, distIndex) |
| }) |
| } else { |
| mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/" { |
| http.NotFound(w, r) |
| return |
| } |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| _, _ = w.Write([]byte("RYP API on :3000 β run \"npm run dev\" and open http://localhost:5173\n")) |
| }) |
| } |
|
|
| port := trimEnv(os.Getenv("PORT")) |
| if port == "" { |
| port = "7860" |
| } |
| addr := ":" + port |
| listener, err := net.Listen("tcp", addr) |
| if err != nil { |
| log.Fatalf("Server listen: %v", err) |
| } |
| log.Printf("Server listening on http://localhost%s\n", addr) |
| log.Fatal(http.Serve(listener, withCORS(mux))) |
| } |
|
|
| func handleGroqHint(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| key := trimEnv(os.Getenv("GROQ_API_KEY")) |
| if key == "" { |
| jsonError(w, http.StatusInternalServerError, "GROQ_API_KEY is not configured.") |
| return |
| } |
| var body struct { |
| Messages []struct { |
| Role string `json:"role"` |
| Content string `json:"content"` |
| } `json:"messages"` |
| QuestionContext struct { |
| Title string `json:"title"` |
| Description string `json:"description"` |
| Language string `json:"language"` |
| Code string `json:"code"` |
| } `json:"questionContext"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| systemPrompt := fmt.Sprintf(`You are a helpful coding mentor. Your goal is to help the user solve a coding problem by providing HINTS ONLY. |
| |
| CRITICAL RULES: |
| 1. NEVER provide the full solution or actual code. |
| 2. Focus on logic, algorithms, and edge cases. |
| 3. Keep responses concise and encouraging. |
| 4. If the user asks for the code, politely decline and offer a hint instead. |
| |
| Problem Context: |
| Title: %s |
| Description: %s |
| Language: %s |
| Current Code: |
| %s`, |
| body.QuestionContext.Title, |
| body.QuestionContext.Description, |
| body.QuestionContext.Language, |
| body.QuestionContext.Code, |
| ) |
| msgs := []map[string]string{ |
| {"role": "system", "content": systemPrompt}, |
| } |
| for _, m := range body.Messages { |
| if m.Role == "" || m.Content == "" { |
| continue |
| } |
| msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content}) |
| } |
| payload := map[string]any{ |
| "model": "llama-3.3-70b-versatile", |
| "messages": msgs, |
| "temperature": 0.7, |
| "max_tokens": 1024, |
| } |
| b, err := json.Marshal(payload) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI hint.") |
| return |
| } |
| req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, |
| "https://api.groq.com/openai/v1/chat/completions", bytes.NewReader(b)) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI hint.") |
| return |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Authorization", "Bearer "+key) |
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| log.Println("Groq request:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI hint.") |
| return |
| } |
| defer resp.Body.Close() |
| respBody, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| log.Printf("Groq API status %d: %s", resp.StatusCode, string(respBody)) |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI hint.") |
| return |
| } |
| var groq struct { |
| Choices []struct { |
| Message struct { |
| Content string `json:"content"` |
| } `json:"message"` |
| } `json:"choices"` |
| } |
| if err := json.Unmarshal(respBody, &groq); err != nil || len(groq.Choices) == 0 { |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI hint.") |
| return |
| } |
| jsonWrite(w, http.StatusOK, map[string]string{"message": groq.Choices[0].Message.Content}) |
| } |
|
|
| func handleGroqExplain(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| key := trimEnv(os.Getenv("GROQ_API_KEY")) |
| if key == "" { |
| jsonError(w, http.StatusInternalServerError, "GROQ_API_KEY is not configured.") |
| return |
| } |
| var body struct { |
| Question string `json:"question"` |
| Options string `json:"options"` |
| Answer string `json:"answer"` |
| Topic string `json:"topic"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if strings.TrimSpace(body.Question) == "" { |
| jsonError(w, http.StatusBadRequest, "question is required") |
| return |
| } |
| systemPrompt := `You are an expert aptitude tutor for competitive exams (CAT, GATE, Placements). Explain this aptitude problem in a clear, structured way. |
| |
| Rules: |
| 1. Start with a brief "Key Concept" label (1 line) identifying the core skill needed. |
| 2. Show a clean "Step-by-Step Solution" with numbered steps. Use simple arithmetic, highlight each step clearly. |
| 3. End with a quick "Pro Tip" or "Shortcut" if one exists for this type of problem. |
| 4. Keep the explanation concise β max 150 words. |
| 5. Format: Use Markdown bold (**text**) for labels only. No bullet overuse.` |
|
|
| userContent := fmt.Sprintf("Topic: %s\n\nQuestion: %s\n\nOptions: %s\n\nCorrect Answer: %s\n\nExplain this solution step by step.", |
| body.Topic, body.Question, body.Options, body.Answer) |
|
|
| msgs := []map[string]string{ |
| {"role": "system", "content": systemPrompt}, |
| {"role": "user", "content": userContent}, |
| } |
| payload := map[string]any{ |
| "model": "llama-3.3-70b-versatile", |
| "messages": msgs, |
| "temperature": 0.4, |
| "max_tokens": 512, |
| } |
| b, err := json.Marshal(payload) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to encode explain request.") |
| return |
| } |
| req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, |
| "https://api.groq.com/openai/v1/chat/completions", bytes.NewReader(b)) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to create explain request.") |
| return |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Authorization", "Bearer "+key) |
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| log.Println("Groq explain request:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to generate explanation.") |
| return |
| } |
| defer resp.Body.Close() |
| respBody, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| log.Printf("Groq explain API status %d: %s", resp.StatusCode, string(respBody)) |
| jsonError(w, http.StatusInternalServerError, "Failed to generate explanation.") |
| return |
| } |
| var groq struct { |
| Choices []struct { |
| Message struct { |
| Content string `json:"content"` |
| } `json:"message"` |
| } `json:"choices"` |
| } |
| if err := json.Unmarshal(respBody, &groq); err != nil || len(groq.Choices) == 0 { |
| jsonError(w, http.StatusInternalServerError, "Failed to parse explanation.") |
| return |
| } |
| jsonWrite(w, http.StatusOK, map[string]string{"explanation": groq.Choices[0].Message.Content}) |
| } |
|
|
| func claimsSubject(r *http.Request) string { |
| raw := readBearer(r) |
| if raw == "" { |
| return "" |
| } |
| tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { |
| return []byte(jwtSecret()), nil |
| }) |
| if err != nil || !tok.Valid { |
| return "" |
| } |
| claims, ok := tok.Claims.(*jwt.RegisteredClaims) |
| if !ok || claims.Subject == "" { |
| return "" |
| } |
| return claims.Subject |
| } |
|
|
| func normalizeAITeacherMode(mode string) string { |
| switch strings.ToLower(strings.TrimSpace(mode)) { |
| case "placement": |
| return "placement" |
| case "files": |
| return "files" |
| default: |
| return "learning" |
| } |
| } |
|
|
| func aiTeacherModeInstruction(mode string) string { |
| switch mode { |
| case "placement": |
| return "Focus on resumes, projects, interview preparation, communication, job search strategy, and placement planning." |
| case "files": |
| return "Prioritize uploaded files. Summarize them clearly, point out gaps, and give concrete next steps based on the documents." |
| default: |
| return "Focus on teaching concepts clearly, breaking down problems step by step, and helping the student build understanding." |
| } |
| } |
|
|
| const aiTutorGreetingResponse = "Hi! I'm RYP AI Tutor. Ask me anything about coding, CS subjects, aptitude, placements, interviews, or career prep." |
| const aiTutorThanksResponse = "You're welcome. Send your next study, coding, placement, or interview question whenever you are ready." |
| const aiTutorClarificationResponse = "I could not understand that clearly. Please ask a clear study, coding, placement, or career question, and I will help step by step." |
|
|
| type aiTeacherAnswerSubmission struct { |
| Answers map[int]string |
| Canonical string |
| } |
|
|
| type aiTeacherGradeItem struct { |
| QuestionNumber int `json:"questionNumber"` |
| SelectedOption string `json:"selectedOption"` |
| CorrectOption string `json:"correctOption"` |
| CorrectAnswer string `json:"correctAnswer"` |
| Explanation string `json:"explanation"` |
| } |
|
|
| type aiTeacherGradeResult struct { |
| Items []aiTeacherGradeItem `json:"items"` |
| } |
|
|
| var aiTeacherAnswerPattern = regexp.MustCompile(`(?im)(?:^|[\s,;|])(?:q(?:uestion)?\s*)?([0-9]{1,2})\s*[\.\):\-]?\s*([a-e])(?:\b|$)`) |
| var aiTeacherQuestionPattern = regexp.MustCompile(`(?im)(?:^|\n)\s*\**(?:#{1,4}\s*)?(?:q(?:uestion)?\s*)?([0-9]{1,2})[\).:\-*]*\s+`) |
| var aiTeacherOptionPattern = regexp.MustCompile(`(?im)(?:(?:^|\n)\s*(?:[-*]\s*)?|\s{2,})([a-eA-E])[\).:\-]\s+\S+`) |
|
|
| func aiTeacherQuickResponse(message string, hasAttachments bool) (string, bool) { |
| if hasAttachments { |
| return "", false |
| } |
|
|
| normalized := normalizeAITeacherText(message) |
| if normalized == "" { |
| return "", false |
| } |
| if isAITeacherGreeting(normalized) { |
| return aiTutorGreetingResponse, true |
| } |
| if isAITeacherThanks(normalized) { |
| return aiTutorThanksResponse, true |
| } |
| if isLikelyUnclearAITeacherInput(normalized) { |
| return aiTutorClarificationResponse, true |
| } |
| return "", false |
| } |
|
|
| func parseAITeacherAnswerSubmission(message string) (aiTeacherAnswerSubmission, bool) { |
| normalized := strings.ToLower(strings.TrimSpace(message)) |
| if normalized == "" { |
| return aiTeacherAnswerSubmission{}, false |
| } |
|
|
| matches := aiTeacherAnswerPattern.FindAllStringSubmatch(normalized, -1) |
| if len(matches) < 2 { |
| return aiTeacherAnswerSubmission{}, false |
| } |
|
|
| answers := map[int]string{} |
| for _, match := range matches { |
| if len(match) < 3 { |
| continue |
| } |
| questionNumber, err := strconv.Atoi(match[1]) |
| if err != nil || questionNumber <= 0 || questionNumber > 100 { |
| continue |
| } |
| answers[questionNumber] = strings.ToLower(match[2]) |
| } |
| if len(answers) < 2 { |
| return aiTeacherAnswerSubmission{}, false |
| } |
|
|
| leftover := aiTeacherAnswerPattern.ReplaceAllString(normalized, " ") |
| leftover = strings.Trim(leftover, " \t\r\n,;|/-_") |
| if leftover != "" && !isAllowedAITeacherAnswerSubmissionPrefix(leftover) { |
| return aiTeacherAnswerSubmission{}, false |
| } |
|
|
| questionNumbers := make([]int, 0, len(answers)) |
| for questionNumber := range answers { |
| questionNumbers = append(questionNumbers, questionNumber) |
| } |
| sort.Ints(questionNumbers) |
|
|
| var canonical strings.Builder |
| for i, questionNumber := range questionNumbers { |
| if i > 0 { |
| canonical.WriteByte('\n') |
| } |
| canonical.WriteString(strconv.Itoa(questionNumber)) |
| canonical.WriteByte('.') |
| canonical.WriteString(answers[questionNumber]) |
| } |
|
|
| return aiTeacherAnswerSubmission{Answers: answers, Canonical: canonical.String()}, true |
| } |
|
|
| func isAllowedAITeacherAnswerSubmissionPrefix(value string) bool { |
| allowedWords := map[string]bool{ |
| "answer": true, "answers": true, "ans": true, "my": true, "here": true, |
| "are": true, "is": true, "the": true, "test": true, "quiz": true, |
| "submit": true, "submitting": true, "submitted": true, "choice": true, |
| "choices": true, "option": true, "options": true, |
| } |
| for _, field := range strings.Fields(value) { |
| field = strings.Trim(field, " \t\r\n.,!?;:\"'`()[]{}") |
| if field == "" { |
| continue |
| } |
| if !allowedWords[field] { |
| return false |
| } |
| } |
| return true |
| } |
|
|
| func findRecentAITeacherTestData(messages []messageDoc, currentUserMessageID primitive.ObjectID) (*testDataDoc, bool) { |
| for _, message := range messages { |
| if message.ID == currentUserMessageID || message.Role != "assistant" { |
| continue |
| } |
| if message.TestData != nil && len(message.TestData.Questions) > 0 { |
| return message.TestData, true |
| } |
| } |
| return nil, false |
| } |
|
|
| func findRecentAITeacherTest(messages []messageDoc, currentUserMessageID primitive.ObjectID) (string, bool) { |
| for _, message := range messages { |
| if message.ID == currentUserMessageID || message.Role != "assistant" { |
| continue |
| } |
| if looksLikeAITeacherTest(message.Content) { |
| return message.Content, true |
| } |
| } |
| return "", false |
| } |
|
|
| func looksLikeAITeacherTest(content string) bool { |
| if strings.TrimSpace(content) == "" { |
| return false |
| } |
| |
| questionMatches := aiTeacherQuestionPattern.FindAllStringSubmatch(content, -1) |
| optionMatches := aiTeacherOptionPattern.FindAllStringSubmatch(content, -1) |
| if len(questionMatches) >= 2 && len(optionMatches) >= 4 { |
| return true |
| } |
| |
| lower := strings.ToLower(content) |
| hasQuestions := strings.Count(lower, "question") >= 2 || len(questionMatches) >= 2 |
| optionCount := 0 |
| for _, marker := range []string{"a)", "b)", "c)", "d)", "a.", "b.", "c.", "d."} { |
| optionCount += strings.Count(lower, marker) |
| } |
| return hasQuestions && optionCount >= 6 |
| } |
|
|
| func normalizeAITeacherOption(value string) string { |
| value = strings.ToLower(strings.TrimSpace(value)) |
| for _, ch := range value { |
| if ch >= 'a' && ch <= 'e' { |
| return string(ch) |
| } |
| } |
| return "" |
| } |
|
|
| func buildAITeacherUngradableAnswerResponse() string { |
| return "I could not find the test questions in this conversation to grade your answers. Would you like me to generate a new test? Just say something like **\"Give me a test on Python basics\"** and I will create one for you." |
| } |
|
|
| func gradeFromStoredTestData(testData *testDataDoc, submission aiTeacherAnswerSubmission) string { |
| questionsByNumber := map[int]testQuestionDoc{} |
| for _, q := range testData.Questions { |
| questionsByNumber[q.Number] = q |
| } |
|
|
| questionNumbers := make([]int, 0, len(submission.Answers)) |
| for qn := range submission.Answers { |
| questionNumbers = append(questionNumbers, qn) |
| } |
| sort.Ints(questionNumbers) |
|
|
| var out strings.Builder |
| score := 0 |
| total := 0 |
|
|
| out.WriteString("### Test Results\n\n") |
| out.WriteString("| # | Your Answer | Correct Answer | Result |\n") |
| out.WriteString("|---|:---:|:---:|:---:|\n") |
|
|
| for _, qn := range questionNumbers { |
| selected := strings.ToLower(submission.Answers[qn]) |
| q, found := questionsByNumber[qn] |
| if !found { |
| out.WriteString(fmt.Sprintf("| %d | %s | β | Not found |\n", qn, strings.ToUpper(selected))) |
| continue |
| } |
| total++ |
| correct := strings.ToLower(q.CorrectKey) |
| if selected == correct { |
| score++ |
| out.WriteString(fmt.Sprintf("| %d | %s | %s | Correct |\n", qn, strings.ToUpper(selected), strings.ToUpper(correct))) |
| } else { |
| out.WriteString(fmt.Sprintf("| %d | %s | %s | Incorrect |\n", qn, strings.ToUpper(selected), strings.ToUpper(correct))) |
| } |
| } |
|
|
| out.WriteString("\n") |
| if total > 0 { |
| pct := float64(score) / float64(total) * 100 |
| out.WriteString(fmt.Sprintf("**Score: %d/%d (%.0f%%)**\n\n", score, total, pct)) |
| } |
|
|
| out.WriteString("### Detailed Review\n\n") |
| for _, qn := range questionNumbers { |
| selected := strings.ToLower(submission.Answers[qn]) |
| q, found := questionsByNumber[qn] |
| if !found { |
| out.WriteString(fmt.Sprintf("**Q%d:** Could not find this question in the test.\n\n", qn)) |
| continue |
| } |
| correct := strings.ToLower(q.CorrectKey) |
| correctText := q.Options[correct] |
| if selected == correct { |
| out.WriteString(fmt.Sprintf("**Q%d: Correct** β %s\n", qn, q.Text)) |
| out.WriteString(fmt.Sprintf("Your answer **%s** is correct", strings.ToUpper(selected))) |
| if correctText != "" { |
| out.WriteString(fmt.Sprintf(" (%s)", correctText)) |
| } |
| out.WriteString(".\n\n") |
| } else { |
| selectedText := q.Options[selected] |
| out.WriteString(fmt.Sprintf("**Q%d: Incorrect** β %s\n", qn, q.Text)) |
| out.WriteString(fmt.Sprintf("You answered **%s**", strings.ToUpper(selected))) |
| if selectedText != "" { |
| out.WriteString(fmt.Sprintf(" (%s)", selectedText)) |
| } |
| out.WriteString(fmt.Sprintf(", but the correct answer is **%s**", strings.ToUpper(correct))) |
| if correctText != "" { |
| out.WriteString(fmt.Sprintf(" (%s)", correctText)) |
| } |
| out.WriteString(".\n\n") |
| } |
| } |
|
|
| return strings.TrimSpace(out.String()) |
| } |
|
|
| func gradeAITeacherAnswerSubmission(ctx context.Context, apiKey string, testContent string, submission aiTeacherAnswerSubmission) (string, error) { |
| questionNumbers := make([]int, 0, len(submission.Answers)) |
| for questionNumber := range submission.Answers { |
| questionNumbers = append(questionNumbers, questionNumber) |
| } |
| sort.Ints(questionNumbers) |
|
|
| payload := map[string]any{ |
| "model": "llama-3.3-70b-versatile", |
| "messages": []map[string]string{ |
| { |
| "role": "system", |
| "content": `You are a strict quiz answer checker. Return JSON only. |
| Rules: |
| - Use only the provided test questions and options. |
| - Re-solve each question before selecting the correct option. |
| - Do not trust the student's claimed correctness. |
| - selectedOption and correctOption must be lowercase letters a-e. |
| - If a question cannot be verified from the test text, set correctOption to "" and explain what is missing. |
| - Return exactly: {"items":[{"questionNumber":1,"selectedOption":"a","correctOption":"b","correctAnswer":"option text","explanation":"short reason"}]}`, |
| }, |
| { |
| "role": "user", |
| "content": fmt.Sprintf("Test questions:\n%s\n\nStudent answers, already normalized to lowercase:\n%s\n\nGrade only these question numbers: %s", testContent, submission.Canonical, strings.Trim(strings.Join(strings.Fields(fmt.Sprint(questionNumbers)), ", "), "[]")), |
| }, |
| }, |
| "temperature": 0, |
| "max_tokens": 1800, |
| "response_format": map[string]any{ |
| "type": "json_object", |
| }, |
| } |
|
|
| b, err := json.Marshal(payload) |
| if err != nil { |
| return "", err |
| } |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.groq.com/openai/v1/chat/completions", bytes.NewReader(b)) |
| if err != nil { |
| return "", err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Authorization", "Bearer "+apiKey) |
|
|
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| return "", err |
| } |
| respBody, _ := io.ReadAll(resp.Body) |
| resp.Body.Close() |
|
|
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| return "", fmt.Errorf("groq grading status %d: %s", resp.StatusCode, string(respBody)) |
| } |
|
|
| var groq struct { |
| Choices []struct { |
| Message struct { |
| Content string `json:"content"` |
| } `json:"message"` |
| } `json:"choices"` |
| } |
| if err := json.Unmarshal(respBody, &groq); err != nil || len(groq.Choices) == 0 { |
| return "", fmt.Errorf("failed to parse grading response") |
| } |
|
|
| var result aiTeacherGradeResult |
| if err := json.Unmarshal([]byte(extractAITeacherJSON(groq.Choices[0].Message.Content)), &result); err != nil { |
| return "", err |
| } |
|
|
| return renderAITeacherGradeFeedback(submission, result), nil |
| } |
|
|
| func extractAITeacherJSON(value string) string { |
| value = strings.TrimSpace(value) |
| if strings.HasPrefix(value, "```") { |
| value = strings.TrimPrefix(value, "```json") |
| value = strings.TrimPrefix(value, "```") |
| value = strings.TrimSuffix(value, "```") |
| } |
| return strings.TrimSpace(value) |
| } |
|
|
| func renderAITeacherGradeFeedback(submission aiTeacherAnswerSubmission, result aiTeacherGradeResult) string { |
| itemsByQuestion := map[int]aiTeacherGradeItem{} |
| for _, item := range result.Items { |
| if item.QuestionNumber <= 0 { |
| continue |
| } |
| item.SelectedOption = normalizeAITeacherOption(item.SelectedOption) |
| item.CorrectOption = normalizeAITeacherOption(item.CorrectOption) |
| itemsByQuestion[item.QuestionNumber] = item |
| } |
|
|
| questionNumbers := make([]int, 0, len(submission.Answers)) |
| for questionNumber := range submission.Answers { |
| questionNumbers = append(questionNumbers, questionNumber) |
| } |
| sort.Ints(questionNumbers) |
|
|
| var out strings.Builder |
| score := 0 |
| gradable := 0 |
| out.WriteString("### Test Feedback\n\n") |
| out.WriteString("| Question | Your answer | Correct answer | Result |\n") |
| out.WriteString("|---|---:|---:|---|\n") |
|
|
| for _, questionNumber := range questionNumbers { |
| selected := normalizeAITeacherOption(submission.Answers[questionNumber]) |
| item, found := itemsByQuestion[questionNumber] |
| correct := "" |
| if found { |
| correct = normalizeAITeacherOption(item.CorrectOption) |
| } |
| resultText := "Needs review" |
| if correct != "" { |
| gradable++ |
| if selected == correct { |
| score++ |
| resultText = "Correct" |
| } else { |
| resultText = "Incorrect" |
| } |
| } |
| out.WriteString(fmt.Sprintf("| %d | %s | %s | %s |\n", questionNumber, strings.ToUpper(selected), strings.ToUpper(correct), resultText)) |
| } |
|
|
| out.WriteString("\n") |
| if gradable > 0 { |
| out.WriteString(fmt.Sprintf("**Score:** %d/%d\n\n", score, gradable)) |
| } |
| out.WriteString("### Review\n\n") |
|
|
| for _, questionNumber := range questionNumbers { |
| selected := normalizeAITeacherOption(submission.Answers[questionNumber]) |
| item, found := itemsByQuestion[questionNumber] |
| if !found || normalizeAITeacherOption(item.CorrectOption) == "" { |
| out.WriteString(fmt.Sprintf("%d. **Question %d:** I could not verify this one from the test text. Please resend the question if you want me to check it.\n", questionNumber, questionNumber)) |
| continue |
| } |
| correct := normalizeAITeacherOption(item.CorrectOption) |
| correctText := strings.TrimSpace(item.CorrectAnswer) |
| explanation := strings.TrimSpace(item.Explanation) |
| if explanation == "" { |
| explanation = "Checked by comparing your selected option with the verified correct option." |
| } |
| if selected == correct { |
| out.WriteString(fmt.Sprintf("%d. **Question %d:** Correct. Your answer **%s** matches the verified answer.", questionNumber, questionNumber, strings.ToUpper(selected))) |
| } else { |
| out.WriteString(fmt.Sprintf("%d. **Question %d:** Incorrect. You answered **%s**; the correct answer is **%s**", questionNumber, questionNumber, strings.ToUpper(selected), strings.ToUpper(correct))) |
| if correctText != "" { |
| out.WriteString(fmt.Sprintf(" (%s)", correctText)) |
| } |
| out.WriteString(".") |
| } |
| out.WriteString(" ") |
| out.WriteString(explanation) |
| out.WriteString("\n") |
| } |
|
|
| return strings.TrimSpace(out.String()) |
| } |
|
|
| func normalizeAITeacherText(message string) string { |
| normalized := strings.ToLower(strings.TrimSpace(message)) |
| normalized = strings.Trim(normalized, " \t\r\n.,!?;:\"'`()[]{}") |
| return strings.Join(strings.Fields(normalized), " ") |
| } |
|
|
| func isAITeacherGreeting(message string) bool { |
| switch message { |
| case "hi", "hii", "hiii", "hello", "hey", "heyy", "hey there", "hello there", "good morning", "good afternoon", "good evening": |
| return true |
| default: |
| return false |
| } |
| } |
|
|
| func isAITeacherThanks(message string) bool { |
| switch message { |
| case "thanks", "thank you", "thankyou", "ok", "okay", "cool", "got it": |
| return true |
| default: |
| return false |
| } |
| } |
|
|
| func isLikelyUnclearAITeacherInput(message string) bool { |
| fields := strings.Fields(message) |
| token := aiTeacherAlphaNumToken(message) |
| if token == "" { |
| return true |
| } |
| if len(fields) == 1 { |
| if isKnownAITeacherSingleWord(token) { |
| return false |
| } |
| if len(token) <= 5 { |
| return true |
| } |
| if countAITeacherVowels(token) == 0 && len(token) >= 4 { |
| return true |
| } |
| } |
| if len(fields) <= 2 && len(token) <= 4 { |
| return true |
| } |
| return false |
| } |
|
|
| func aiTeacherAlphaNumToken(message string) string { |
| var b strings.Builder |
| for _, ch := range strings.ToLower(message) { |
| if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') { |
| b.WriteRune(ch) |
| } |
| } |
| return b.String() |
| } |
|
|
| func countAITeacherVowels(value string) int { |
| count := 0 |
| for _, ch := range value { |
| if strings.ContainsRune("aeiou", ch) { |
| count++ |
| } |
| } |
| return count |
| } |
|
|
| func isKnownAITeacherSingleWord(token string) bool { |
| known := map[string]bool{ |
| "ai": true, "api": true, "aptitude": true, "array": true, "aws": true, |
| "bfs": true, "binary": true, "career": true, "class": true, "cloud": true, |
| "code": true, "coding": true, "css": true, "data": true, "dbms": true, |
| "dfs": true, "dsa": true, "git": true, "golang": true, "graph": true, |
| "html": true, "interview": true, "java": true, "javascript": true, |
| "leetcode": true, "linux": true, "loops": true, "mongodb": true, |
| "networking": true, "node": true, "oop": true, "oops": true, "os": true, |
| "placement": true, "python": true, "queue": true, "react": true, |
| "recursion": true, "resume": true, "roadmap": true, "sql": true, |
| "stack": true, "sorting": true, "system": true, "tree": true, |
| } |
| return known[token] |
| } |
|
|
| func handleGetThreads(threadsColl *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| userID := claimsSubject(r) |
| if userID == "" { |
| jsonError(w, http.StatusUnauthorized, "Unauthorized") |
| return |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "updatedAt", Value: -1}}) |
| cursor, err := threadsColl.Find(ctx, bson.M{"userId": userID}, opts) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Database error") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var threads []threadDoc |
| if err = cursor.All(ctx, &threads); err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to decode threads") |
| return |
| } |
|
|
| if threads == nil { |
| threads = []threadDoc{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"threads": threads}) |
| } |
| } |
|
|
| func handleCreateThread(threadsColl *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| userID := claimsSubject(r) |
| if userID == "" { |
| jsonError(w, http.StatusUnauthorized, "Unauthorized") |
| return |
| } |
|
|
| var body struct { |
| Title string `json:"title"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if strings.TrimSpace(body.Title) == "" { |
| body.Title = "New Chat" |
| } |
|
|
| now := time.Now().UTC() |
| doc := threadDoc{ |
| ID: primitive.NewObjectID(), |
| UserID: userID, |
| Title: body.Title, |
| CreatedAt: now, |
| UpdatedAt: now, |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) |
| defer cancel() |
|
|
| _, err := threadsColl.InsertOne(ctx, doc) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to create thread") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"thread": doc}) |
| } |
| } |
|
|
| func handleGetThreadMessages(threadsColl, messagesColl *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| userID := claimsSubject(r) |
| if userID == "" { |
| jsonError(w, http.StatusUnauthorized, "Unauthorized") |
| return |
| } |
|
|
| threadIDStr := r.URL.Query().Get("threadId") |
| threadID, err := primitive.ObjectIDFromHex(threadIDStr) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid thread ID") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) |
| defer cancel() |
|
|
| var thread threadDoc |
| err = threadsColl.FindOne(ctx, bson.M{"_id": threadID, "userId": userID}).Decode(&thread) |
| if err != nil { |
| jsonError(w, http.StatusForbidden, "Thread not found or access denied") |
| return |
| } |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "createdAt", Value: 1}}) |
| cursor, err := messagesColl.Find(ctx, bson.M{"threadId": threadID}, opts) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Database error") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var messages []messageDoc |
| if err = cursor.All(ctx, &messages); err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to decode messages") |
| return |
| } |
|
|
| if messages == nil { |
| messages = []messageDoc{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"messages": messages}) |
| } |
| } |
|
|
| func stripHTMLTags(s string) string { |
| var result strings.Builder |
| inTag := false |
| for _, r := range s { |
| if r == '<' { |
| inTag = true |
| } else if r == '>' { |
| inTag = false |
| } else if !inTag { |
| result.WriteRune(r) |
| } |
| } |
| return strings.TrimSpace(result.String()) |
| } |
|
|
| func searchDuckDuckGo(query string) string { |
| client := &http.Client{Timeout: 12 * time.Second} |
| data := url.Values{} |
| data.Set("q", query) |
| req, err := http.NewRequest("POST", "https://lite.duckduckgo.com/lite/", strings.NewReader(data.Encode())) |
| if err != nil { |
| return "Search failed." |
| } |
| req.Header.Add("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") |
|
|
| resp, err := client.Do(req) |
| if err != nil { |
| return "Search failed: " + err.Error() |
| } |
| defer resp.Body.Close() |
| body, _ := io.ReadAll(resp.Body) |
| html := string(body) |
|
|
| |
| var urls []string |
| linkParts := strings.Split(html, "class='result-link'>") |
| for i := 1; i < len(linkParts) && i <= 5; i++ { |
| endIdx := strings.Index(linkParts[i], "</a>") |
| if endIdx != -1 { |
| linkText := stripHTMLTags(linkParts[i][:endIdx]) |
| linkText = strings.TrimSpace(linkText) |
| if linkText != "" { |
| urls = append(urls, linkText) |
| } |
| } |
| } |
|
|
| |
| var results []string |
| snippetParts := strings.Split(html, "class='result-snippet'>") |
| for i := 1; i < len(snippetParts) && i <= 5; i++ { |
| endIdx := strings.Index(snippetParts[i], "</td>") |
| if endIdx != -1 { |
| snippet := snippetParts[i][:endIdx] |
| snippet = stripHTMLTags(snippet) |
| snippet = strings.TrimSpace(snippet) |
| if snippet != "" { |
| entry := fmt.Sprintf("Result %d: %s", i, snippet) |
| if i-1 < len(urls) { |
| entry += fmt.Sprintf("\nSource: %s", urls[i-1]) |
| } |
| results = append(results, entry) |
| } |
| } |
| } |
| if len(results) == 0 { |
| return "No relevant results found for: " + query |
| } |
| return "Web search results for \"" + query + "\":\n\n" + strings.Join(results, "\n\n") |
| } |
|
|
| func searchWikipedia(topic string) string { |
| client := &http.Client{Timeout: 10 * time.Second} |
| |
| encoded := url.PathEscape(strings.ReplaceAll(strings.TrimSpace(topic), " ", "_")) |
| apiURL := "https://en.wikipedia.org/api/rest_v1/page/summary/" + encoded |
| req, err := http.NewRequest("GET", apiURL, nil) |
| if err != nil { |
| return "Wikipedia lookup failed." |
| } |
| req.Header.Set("User-Agent", "RYPTutor/1.0 (educational project)") |
| req.Header.Set("Accept", "application/json") |
|
|
| resp, err := client.Do(req) |
| if err != nil { |
| return "Wikipedia lookup failed: " + err.Error() |
| } |
| defer resp.Body.Close() |
| body, _ := io.ReadAll(resp.Body) |
|
|
| if resp.StatusCode == 404 { |
| |
| return searchWikipediaFallback(client, topic) |
| } |
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| return "Wikipedia lookup returned no results for: " + topic |
| } |
|
|
| var wiki struct { |
| Title string `json:"title"` |
| Extract string `json:"extract"` |
| ContentURLs struct { |
| Desktop struct { |
| Page string `json:"page"` |
| } `json:"desktop"` |
| } `json:"content_urls"` |
| Description string `json:"description"` |
| } |
| if err := json.Unmarshal(body, &wiki); err != nil { |
| return "Wikipedia lookup failed to parse response." |
| } |
| if wiki.Extract == "" { |
| return "No Wikipedia article found for: " + topic |
| } |
|
|
| |
| extract := wiki.Extract |
| if len(extract) > 2000 { |
| extract = extract[:2000] + "..." |
| } |
|
|
| result := fmt.Sprintf("Wikipedia: %s\n\n%s", wiki.Title, extract) |
| if wiki.ContentURLs.Desktop.Page != "" { |
| result += fmt.Sprintf("\n\nSource: %s", wiki.ContentURLs.Desktop.Page) |
| } |
| return result |
| } |
|
|
| func searchWikipediaFallback(client *http.Client, topic string) string { |
| searchURL := "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + |
| url.QueryEscape(topic) + "&srlimit=3&format=json" |
| req, err := http.NewRequest("GET", searchURL, nil) |
| if err != nil { |
| return "Wikipedia search failed." |
| } |
| req.Header.Set("User-Agent", "RYPTutor/1.0 (educational project)") |
|
|
| resp, err := client.Do(req) |
| if err != nil { |
| return "Wikipedia search failed." |
| } |
| defer resp.Body.Close() |
| body, _ := io.ReadAll(resp.Body) |
|
|
| var searchResult struct { |
| Query struct { |
| Search []struct { |
| Title string `json:"title"` |
| Snippet string `json:"snippet"` |
| } `json:"search"` |
| } `json:"query"` |
| } |
| if err := json.Unmarshal(body, &searchResult); err != nil || len(searchResult.Query.Search) == 0 { |
| return "No Wikipedia results found for: " + topic |
| } |
|
|
| var results []string |
| for _, s := range searchResult.Query.Search { |
| snippet := stripHTMLTags(s.Snippet) |
| entry := fmt.Sprintf("**%s**: %s\nSource: https://en.wikipedia.org/wiki/%s", |
| s.Title, snippet, url.PathEscape(strings.ReplaceAll(s.Title, " ", "_"))) |
| results = append(results, entry) |
| } |
| return "Wikipedia search results:\n\n" + strings.Join(results, "\n\n") |
| } |
|
|
| func handleDeleteThread(threadsColl, messagesColl *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodDelete && r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| userID := claimsSubject(r) |
| if userID == "" { |
| jsonError(w, http.StatusUnauthorized, "Unauthorized") |
| return |
| } |
|
|
| threadIDStr := r.URL.Query().Get("threadId") |
| if threadIDStr == "" { |
| var body struct { |
| ThreadID string `json:"threadId"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err == nil { |
| threadIDStr = body.ThreadID |
| } |
| } |
|
|
| threadID, err := primitive.ObjectIDFromHex(threadIDStr) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid thread ID") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) |
| defer cancel() |
|
|
| var thread threadDoc |
| err = threadsColl.FindOne(ctx, bson.M{"_id": threadID, "userId": userID}).Decode(&thread) |
| if err != nil { |
| jsonError(w, http.StatusForbidden, "Thread not found or access denied") |
| return |
| } |
|
|
| _, _ = threadsColl.DeleteOne(ctx, bson.M{"_id": threadID}) |
| _, _ = messagesColl.DeleteMany(ctx, bson.M{"threadId": threadID}) |
|
|
| jsonWrite(w, http.StatusOK, map[string]string{"status": "deleted"}) |
| } |
| } |
|
|
| func handleGroqTeacher(threadsColl, messagesColl *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| userID := claimsSubject(r) |
| if userID == "" { |
| jsonError(w, http.StatusUnauthorized, "Unauthorized") |
| return |
| } |
| key := trimEnv(os.Getenv("GROQ_API_KEY")) |
| if key == "" { |
| jsonError(w, http.StatusInternalServerError, "GROQ_API_KEY is not configured.") |
| return |
| } |
|
|
| var body struct { |
| ThreadID string `json:"threadId"` |
| Message string `json:"message"` |
| Mode string `json:"mode"` |
| Attachments []struct { |
| Name string `json:"name"` |
| Type string `json:"type"` |
| Size int64 `json:"size"` |
| Kind string `json:"kind"` |
| TextExcerpt string `json:"textExcerpt"` |
| } `json:"attachments"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if body.ThreadID == "" { |
| jsonError(w, http.StatusBadRequest, "threadId is required") |
| return |
| } |
|
|
| rawMessage := strings.TrimSpace(body.Message) |
| if rawMessage == "" && len(body.Attachments) == 0 { |
| jsonError(w, http.StatusBadRequest, "A message or attachment is required") |
| return |
| } |
|
|
| var attachmentSummary strings.Builder |
| var attachmentContext strings.Builder |
| for _, file := range body.Attachments { |
| name := strings.TrimSpace(file.Name) |
| if name == "" { |
| continue |
| } |
| attachmentSummary.WriteString("\n- ") |
| attachmentSummary.WriteString(name) |
| if file.Type != "" { |
| attachmentSummary.WriteString(" (") |
| attachmentSummary.WriteString(file.Type) |
| attachmentSummary.WriteString(")") |
| } |
|
|
| attachmentContext.WriteString("\n- ") |
| attachmentContext.WriteString(name) |
| if file.Type != "" { |
| attachmentContext.WriteString(" (") |
| attachmentContext.WriteString(file.Type) |
| attachmentContext.WriteString(")") |
| } |
| if file.TextExcerpt != "" { |
| excerpt := file.TextExcerpt |
| if len(excerpt) > 3500 { |
| excerpt = excerpt[:3500] |
| } |
| attachmentContext.WriteString("\n Text excerpt:\n") |
| attachmentContext.WriteString(excerpt) |
| attachmentContext.WriteString("\n") |
| } else if strings.EqualFold(file.Kind, "image") { |
| attachmentContext.WriteString("\n Image uploaded. No OCR text was extracted, ask the student to describe if needed.\n") |
| } else { |
| attachmentContext.WriteString("\n File uploaded. No readable text excerpt was available.\n") |
| } |
| } |
|
|
| threadID, err := primitive.ObjectIDFromHex(body.ThreadID) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid thread ID") |
| return |
| } |
|
|
| ctxTimeout, cancel := context.WithTimeout(r.Context(), 150*time.Second) |
| defer cancel() |
|
|
| var thread threadDoc |
| err = threadsColl.FindOne(ctxTimeout, bson.M{"_id": threadID, "userId": userID}).Decode(&thread) |
| if err != nil { |
| jsonError(w, http.StatusForbidden, "Thread not found or access denied") |
| return |
| } |
|
|
| now := time.Now().UTC() |
| userContent := rawMessage |
| if userContent == "" { |
| if len(body.Attachments) > 0 { |
| userContent = "Please analyze the attached files." |
| } else { |
| userContent = "Help me with this." |
| } |
| } |
| if attachmentSummary.Len() > 0 { |
| userContent += "\n\nAttachments:" + attachmentSummary.String() |
| } |
| modelUserContent := userContent |
| if attachmentContext.Len() > 0 { |
| modelUserContent += "\n\nAttachment details for analysis:" + attachmentContext.String() |
| } |
|
|
| userMsg := messageDoc{ |
| ID: primitive.NewObjectID(), |
| ThreadID: threadID, |
| Role: "user", |
| Content: userContent, |
| CreatedAt: now, |
| } |
| _, err = messagesColl.InsertOne(ctxTimeout, userMsg) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to save message") |
| return |
| } |
|
|
| count, _ := messagesColl.CountDocuments(ctxTimeout, bson.M{"threadId": threadID}) |
| titleSource := rawMessage |
| if titleSource == "" && len(body.Attachments) > 0 { |
| titleSource = strings.TrimSpace(body.Attachments[0].Name) |
| } |
| if titleSource == "" { |
| titleSource = "New Conversation" |
| } |
|
|
| if count <= 1 { |
| title := titleSource |
| if len(title) > 30 { |
| title = title[:30] + "..." |
| } |
| _, _ = threadsColl.UpdateOne(ctxTimeout, bson.M{"_id": threadID}, bson.M{"$set": bson.M{"updatedAt": now, "title": title}}) |
| thread.Title = title |
| } else { |
| _, _ = threadsColl.UpdateOne(ctxTimeout, bson.M{"_id": threadID}, bson.M{"$set": bson.M{"updatedAt": now}}) |
| } |
| thread.UpdatedAt = now |
|
|
| answerSubmission, hasAnswerSubmission := parseAITeacherAnswerSubmission(rawMessage) |
| if !hasAnswerSubmission { |
| if quickResponse, ok := aiTeacherQuickResponse(rawMessage, len(body.Attachments) > 0); ok { |
| aiMsg := messageDoc{ |
| ID: primitive.NewObjectID(), |
| ThreadID: threadID, |
| Role: "assistant", |
| Content: quickResponse, |
| CreatedAt: time.Now().UTC(), |
| } |
| _, _ = messagesColl.InsertOne(ctxTimeout, aiMsg) |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "thread": thread, |
| "userMessage": userMsg, |
| "assistantMessage": aiMsg, |
| "message": aiMsg, |
| }) |
| return |
| } |
| } |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "createdAt", Value: -1}}).SetLimit(30) |
| cursor, err := messagesColl.Find(ctxTimeout, bson.M{"threadId": threadID}, opts) |
| var recentMessages []messageDoc |
| if err == nil { |
| _ = cursor.All(ctxTimeout, &recentMessages) |
| } |
|
|
| if hasAnswerSubmission { |
| var finalResponse string |
|
|
| |
| if testData, found := findRecentAITeacherTestData(recentMessages, userMsg.ID); found { |
| log.Printf("[AI Tutor] grading from stored test data (%d questions)", len(testData.Questions)) |
| finalResponse = gradeFromStoredTestData(testData, answerSubmission) |
| } else { |
| |
| testContent, found := findRecentAITeacherTest(recentMessages, userMsg.ID) |
| if found { |
| log.Println("[AI Tutor] grading via LLM from test text in chat") |
| gradedResponse, err := gradeAITeacherAnswerSubmission(ctxTimeout, key, testContent, answerSubmission) |
| if err != nil { |
| log.Println("Groq teacher grading:", err) |
| } else if strings.TrimSpace(gradedResponse) != "" { |
| finalResponse = gradedResponse |
| } |
| } |
| } |
|
|
| if finalResponse == "" { |
| finalResponse = buildAITeacherUngradableAnswerResponse() |
| } |
|
|
| aiMsg := messageDoc{ |
| ID: primitive.NewObjectID(), |
| ThreadID: threadID, |
| Role: "assistant", |
| Content: finalResponse, |
| CreatedAt: time.Now().UTC(), |
| } |
| _, _ = messagesColl.InsertOne(ctxTimeout, aiMsg) |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "thread": thread, |
| "userMessage": userMsg, |
| "assistantMessage": aiMsg, |
| "message": aiMsg, |
| }) |
| return |
| } |
|
|
| systemPrompt := fmt.Sprintf(`You are %s, a polished AI tutor and student assistant. |
| |
| ## CORE BEHAVIOR |
| - Continue the conversation using prior context. |
| - Be concise by default, then expand when the student asks for depth. |
| - Use Markdown only when it improves clarity (code blocks, tables, lists). |
| - If the request is unclear, ask one focused follow-up question instead of guessing. |
| - For coding help, explain the reasoning and tradeoffs clearly. |
| - Do not assist with cheating in live exams or live interviews. |
| |
| ## TEST & QUIZ GENERATION (CRITICAL) |
| - When the student asks for a test, quiz, practice questions, or MCQs: ALWAYS use the generate_test tool. |
| - NEVER write test questions as plain text. Always call generate_test so answers are stored and grading is automatic. |
| - Each question must have exactly 4 options (A, B, C, D) and one correct_key. |
| - Generate 5 questions by default unless the student specifies a different count. |
| - After calling generate_test, present the formatted questions to the student and ask them to submit answers like: 1.A 2.B 3.C 4.D 5.A |
| |
| ## GROUNDING & ACCURACY (CRITICAL) |
| - You have access to web_search and wikipedia_lookup tools. USE THEM PROACTIVELY. |
| - For any factual claim, technical definition, algorithm detail, or current information: call web_search or wikipedia_lookup BEFORE answering. |
| - Cite your sources inline using markdown links: [Source Title](URL). |
| - If search results conflict with each other, present both perspectives and note the uncertainty. |
| - NEVER fabricate citations, URLs, statistics, library names, or API endpoints. |
| - If you cannot find or verify something, explicitly say "I could not verify this" rather than guessing. |
| - Prefer Wikipedia for foundational CS concepts and definitions. |
| - Prefer web_search for current trends, latest versions, recent news, and real-time data. |
| |
| ## SCOPE |
| You answer questions related to education, study, coding, computer science, aptitude, placements, interviews, and career development. |
| - If the student greets you, greet them warmly and invite a study, coding, placement, or career question. |
| - If the student's text is unclear, random, or incomplete, ask one focused clarifying question instead of guessing. |
| - If the student asks a clearly off-topic question, briefly say you can help with study, coding, CS, aptitude, placements, interviews, and career prep, then ask them to send a related question. |
| - Never repeat an old refusal just because it appears in the chat history. Respond to the latest student message.`, "RYP AI Tutor") |
|
|
| msgs := []map[string]any{ |
| {"role": "system", "content": systemPrompt}, |
| } |
|
|
| for i := len(recentMessages) - 1; i >= 0; i-- { |
| m := recentMessages[i] |
| content := m.Content |
| if m.ID == userMsg.ID { |
| content = modelUserContent |
| } |
| msgs = append(msgs, map[string]any{"role": m.Role, "content": content}) |
| } |
|
|
| tools := []map[string]any{ |
| { |
| "type": "function", |
| "function": map[string]any{ |
| "name": "web_search", |
| "description": "Search the web using DuckDuckGo for up-to-date information, current trends, latest documentation, or recent news. Returns multiple search result snippets with source URLs. Use this for real-time or recent information.", |
| "parameters": map[string]any{ |
| "type": "object", |
| "properties": map[string]any{ |
| "query": map[string]any{ |
| "type": "string", |
| "description": "The search query string. Be specific and include relevant keywords.", |
| }, |
| }, |
| "required": []string{"query"}, |
| }, |
| }, |
| }, |
| { |
| "type": "function", |
| "function": map[string]any{ |
| "name": "wikipedia_lookup", |
| "description": "Look up a topic on Wikipedia to get an authoritative summary with source URL. Best for foundational concepts, definitions, algorithms, data structures, historical context, and well-established CS/math topics.", |
| "parameters": map[string]any{ |
| "type": "object", |
| "properties": map[string]any{ |
| "topic": map[string]any{ |
| "type": "string", |
| "description": "The topic to look up on Wikipedia. Use the canonical name (e.g. 'Binary search algorithm', 'Dijkstra algorithm', 'Object-oriented programming').", |
| }, |
| }, |
| "required": []string{"topic"}, |
| }, |
| }, |
| }, |
| { |
| "type": "function", |
| "function": map[string]any{ |
| "name": "generate_test", |
| "description": "Generate a multiple-choice test or quiz for the student. ALWAYS use this tool when the student asks for a test, quiz, practice questions, or MCQs. You MUST provide the correct answer for each question.", |
| "parameters": map[string]any{ |
| "type": "object", |
| "properties": map[string]any{ |
| "topic": map[string]any{ |
| "type": "string", |
| "description": "The topic of the test, e.g. 'Python basics', 'Data Structures', 'OOP concepts'", |
| }, |
| "questions": map[string]any{ |
| "type": "array", |
| "items": map[string]any{ |
| "type": "object", |
| "properties": map[string]any{ |
| "number": map[string]any{"type": "integer", "description": "Question number starting from 1"}, |
| "text": map[string]any{"type": "string", "description": "The question text"}, |
| "option_a": map[string]any{"type": "string", "description": "Option A text"}, |
| "option_b": map[string]any{"type": "string", "description": "Option B text"}, |
| "option_c": map[string]any{"type": "string", "description": "Option C text"}, |
| "option_d": map[string]any{"type": "string", "description": "Option D text"}, |
| "correct_key": map[string]any{"type": "string", "description": "The correct option letter: a, b, c, or d (lowercase)"}, |
| }, |
| "required": []string{"number", "text", "option_a", "option_b", "option_c", "option_d", "correct_key"}, |
| }, |
| }, |
| }, |
| "required": []string{"topic", "questions"}, |
| }, |
| }, |
| }, |
| } |
|
|
| |
| maxIterations := 5 |
| var finalResponse string |
| var pendingTestData *testDataDoc |
|
|
| for iter := 0; iter < maxIterations; iter++ { |
| payload := map[string]any{ |
| "model": "llama-3.3-70b-versatile", |
| "messages": msgs, |
| "temperature": 0.4, |
| "max_tokens": 3000, |
| "tools": tools, |
| "tool_choice": "auto", |
| } |
| b, err := json.Marshal(payload) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to encode AI payload.") |
| return |
| } |
| req, err := http.NewRequestWithContext(ctxTimeout, http.MethodPost, "https://api.groq.com/openai/v1/chat/completions", bytes.NewReader(b)) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to create AI request.") |
| return |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Authorization", "Bearer "+key) |
|
|
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| log.Println("Groq teacher:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI teacher response.") |
| return |
| } |
| respBody, _ := io.ReadAll(resp.Body) |
| resp.Body.Close() |
|
|
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| log.Printf("Groq teacher status %d: %s", resp.StatusCode, string(respBody)) |
| jsonError(w, http.StatusInternalServerError, "Failed to get AI teacher response.") |
| return |
| } |
|
|
| var groq struct { |
| Choices []struct { |
| Message struct { |
| Role string `json:"role"` |
| Content string `json:"content"` |
| ToolCalls []struct { |
| ID string `json:"id"` |
| Type string `json:"type"` |
| Function struct { |
| Name string `json:"name"` |
| Arguments string `json:"arguments"` |
| } `json:"function"` |
| } `json:"tool_calls"` |
| } `json:"message"` |
| } `json:"choices"` |
| } |
| if err := json.Unmarshal(respBody, &groq); err != nil || len(groq.Choices) == 0 { |
| jsonError(w, http.StatusInternalServerError, "Failed to parse AI teacher response.") |
| return |
| } |
|
|
| assistantMessage := groq.Choices[0].Message |
| if strings.TrimSpace(assistantMessage.Role) == "" { |
| assistantMessage.Role = "assistant" |
| } |
|
|
| |
| msgToAppend := map[string]any{ |
| "role": assistantMessage.Role, |
| "content": assistantMessage.Content, |
| } |
| if len(assistantMessage.ToolCalls) > 0 { |
| msgToAppend["tool_calls"] = assistantMessage.ToolCalls |
| } |
| msgs = append(msgs, msgToAppend) |
|
|
| if len(assistantMessage.ToolCalls) > 0 { |
| |
| for _, toolCall := range assistantMessage.ToolCalls { |
| var toolResult string |
| switch toolCall.Function.Name { |
| case "web_search": |
| var args struct { |
| Query string `json:"query"` |
| } |
| _ = json.Unmarshal([]byte(toolCall.Function.Arguments), &args) |
| log.Printf("[AI Tutor] web_search called: %q", args.Query) |
| toolResult = searchDuckDuckGo(args.Query) |
| case "wikipedia_lookup": |
| var args struct { |
| Topic string `json:"topic"` |
| } |
| _ = json.Unmarshal([]byte(toolCall.Function.Arguments), &args) |
| log.Printf("[AI Tutor] wikipedia_lookup called: %q", args.Topic) |
| toolResult = searchWikipedia(args.Topic) |
| case "generate_test": |
| var args struct { |
| Topic string `json:"topic"` |
| Questions []struct { |
| Number int `json:"number"` |
| Text string `json:"text"` |
| OptionA string `json:"option_a"` |
| OptionB string `json:"option_b"` |
| OptionC string `json:"option_c"` |
| OptionD string `json:"option_d"` |
| CorrectKey string `json:"correct_key"` |
| } `json:"questions"` |
| } |
| if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { |
| log.Printf("[AI Tutor] generate_test parse error: %v", err) |
| toolResult = "Error parsing test data. Please try again." |
| } else { |
| log.Printf("[AI Tutor] generate_test called: topic=%q, %d questions", args.Topic, len(args.Questions)) |
| pendingTestData = &testDataDoc{Topic: args.Topic} |
| var formattedTest strings.Builder |
| formattedTest.WriteString(fmt.Sprintf("Test generated: %s (%d questions).\n\n", args.Topic, len(args.Questions))) |
| for _, q := range args.Questions { |
| options := map[string]string{ |
| "a": q.OptionA, "b": q.OptionB, |
| "c": q.OptionC, "d": q.OptionD, |
| } |
| pendingTestData.Questions = append(pendingTestData.Questions, testQuestionDoc{ |
| Number: q.Number, |
| Text: q.Text, |
| Options: options, |
| CorrectKey: strings.ToLower(q.CorrectKey), |
| }) |
| formattedTest.WriteString(fmt.Sprintf("Question %d: %s\nA) %s\nB) %s\nC) %s\nD) %s\n\n", |
| q.Number, q.Text, q.OptionA, q.OptionB, q.OptionC, q.OptionD)) |
| } |
| formattedTest.WriteString("Answers are stored. Present these questions to the student and ask them to respond like: 1.A 2.B 3.C") |
| toolResult = formattedTest.String() |
| } |
| default: |
| toolResult = "Tool not supported: " + toolCall.Function.Name |
| } |
| msgs = append(msgs, map[string]any{ |
| "role": "tool", |
| "tool_call_id": toolCall.ID, |
| "name": toolCall.Function.Name, |
| "content": toolResult, |
| }) |
| } |
| |
| } else { |
| |
| finalResponse = assistantMessage.Content |
| break |
| } |
| } |
|
|
| if finalResponse == "" { |
| finalResponse = "I apologize, but I encountered an error formulating the response. Please try again." |
| } |
|
|
| aiMsg := messageDoc{ |
| ID: primitive.NewObjectID(), |
| ThreadID: threadID, |
| Role: "assistant", |
| Content: finalResponse, |
| TestData: pendingTestData, |
| CreatedAt: time.Now().UTC(), |
| } |
| _, _ = messagesColl.InsertOne(context.Background(), aiMsg) |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "thread": thread, |
| "userMessage": userMsg, |
| "assistantMessage": aiMsg, |
| "message": aiMsg, |
| }) |
| } |
| } |
|
|
| type executeCaseResult struct { |
| Passed bool `json:"passed"` |
| Input string `json:"input"` |
| Expected string `json:"expected"` |
| Actual string `json:"actual"` |
| Error string `json:"error,omitempty"` |
| } |
|
|
| type executeResponse struct { |
| Results []executeCaseResult `json:"results"` |
| Summary string `json:"summary"` |
| } |
|
|
| func handleGroqExecute(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
| key := trimEnv(os.Getenv("GROQ_API_KEY")) |
| if key == "" { |
| jsonError(w, http.StatusInternalServerError, "GROQ_API_KEY is not configured.") |
| return |
| } |
| var body struct { |
| Language string `json:"language"` |
| Code string `json:"code"` |
| QuestionTitle string `json:"questionTitle"` |
| TestCases json.RawMessage `json:"testCases"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if strings.TrimSpace(body.Language) == "" || strings.TrimSpace(body.Code) == "" { |
| jsonError(w, http.StatusBadRequest, "language and code are required.") |
| return |
| } |
| title := body.QuestionTitle |
| if title == "" { |
| title = "(untitled)" |
| } |
| userPrompt := fmt.Sprintf(`You are a high-performance code execution engine. Execute the following %s code against the provided test cases. |
| |
| Problem: %s |
| Code: |
| %s |
| |
| Test Cases: |
| %s |
| |
| Instructions: |
| 1. Analyze the code logic. |
| 2. For each test case, determine the output. |
| 3. Compare with expected output. |
| 4. Return a JSON object with the results. |
| |
| Format: |
| { |
| "results": [ |
| { |
| "passed": boolean, |
| "input": "string", |
| "expected": "string", |
| "actual": "string", |
| "error": "optional error message if failed" |
| } |
| ], |
| "summary": "Brief summary of execution" |
| } |
| |
| Only return the JSON object. No markdown fences or other text.`, |
| body.Language, title, body.Code, string(body.TestCases)) |
|
|
| msgs := []map[string]string{ |
| {"role": "system", "content": "You are a precise code evaluator. Output valid JSON only, no markdown."}, |
| {"role": "user", "content": userPrompt}, |
| } |
| payload := map[string]any{ |
| "model": "llama-3.3-70b-versatile", |
| "messages": msgs, |
| "temperature": 0.2, |
| "max_tokens": 4096, |
| } |
| b, err := json.Marshal(payload) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to run code evaluation.") |
| return |
| } |
| req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, |
| "https://api.groq.com/openai/v1/chat/completions", bytes.NewReader(b)) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to run code evaluation.") |
| return |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Authorization", "Bearer "+key) |
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| log.Println("Groq execute:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to run code evaluation.") |
| return |
| } |
| defer resp.Body.Close() |
| respBody, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| log.Printf("Groq execute status %d: %s", resp.StatusCode, string(respBody)) |
| jsonError(w, http.StatusInternalServerError, "Failed to run code evaluation.") |
| return |
| } |
| var groq struct { |
| Choices []struct { |
| Message struct { |
| Content string `json:"content"` |
| } `json:"message"` |
| } `json:"choices"` |
| } |
| if err := json.Unmarshal(respBody, &groq); err != nil || len(groq.Choices) == 0 { |
| jsonError(w, http.StatusInternalServerError, "Failed to run code evaluation.") |
| return |
| } |
| text := strings.TrimSpace(groq.Choices[0].Message.Content) |
| if strings.HasPrefix(text, "```") { |
| text = stripMarkdownFence(text) |
| } |
| start := strings.Index(text, "{") |
| end := strings.LastIndex(text, "}") |
| if start < 0 || end <= start { |
| jsonError(w, http.StatusInternalServerError, "Invalid response from execution engine.") |
| return |
| } |
| var parsed executeResponse |
| if err := json.Unmarshal([]byte(text[start:end+1]), &parsed); err != nil { |
| log.Println("execute JSON parse:", err) |
| jsonError(w, http.StatusInternalServerError, "Invalid response from execution engine.") |
| return |
| } |
| if len(parsed.Results) == 0 { |
| jsonError(w, http.StatusInternalServerError, "Invalid response from execution engine.") |
| return |
| } |
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "results": parsed.Results, |
| "summary": parsed.Summary, |
| }) |
| } |
|
|
| func stripMarkdownFence(s string) string { |
| s = strings.TrimSpace(s) |
| s = strings.TrimPrefix(s, "```json") |
| s = strings.TrimPrefix(s, "```") |
| s = strings.TrimSpace(s) |
| if i := strings.LastIndex(s, "```"); i >= 0 { |
| s = strings.TrimSpace(s[:i]) |
| } |
| return s |
| } |
|
|
| func handleLeetCodeSummary(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| var body struct { |
| ProfileLink string `json:"profileLink"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
|
|
| username, err := extractLeetCodeUsername(body.ProfileLink) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, err.Error()) |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) |
| defer cancel() |
|
|
| summary, err := fetchLeetCodeSummary(ctx, username) |
| if err != nil { |
| var statusErr *httpStatusError |
| if errors.As(err, &statusErr) { |
| jsonError(w, statusErr.StatusCode, statusErr.Message) |
| return |
| } |
| log.Println("leetcode summary:", err) |
| jsonError(w, http.StatusBadGateway, "Unable to fetch LeetCode profile right now.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, summary) |
| } |
|
|
| type httpStatusError struct { |
| StatusCode int |
| Message string |
| } |
|
|
| func (e *httpStatusError) Error() string { |
| if e == nil { |
| return "" |
| } |
| return e.Message |
| } |
|
|
| type leetCodeSummaryResponse struct { |
| Username string `json:"username"` |
| ProfileURL string `json:"profileURL"` |
| TotalSolved int `json:"totalSolved"` |
| } |
|
|
| type leetCodeSubmissionCount struct { |
| Difficulty string `json:"difficulty"` |
| Count int `json:"count"` |
| } |
|
|
| type leetCodeGraphQLResponse struct { |
| Data struct { |
| MatchedUser *struct { |
| Username string `json:"username"` |
| SubmitStats struct { |
| AcSubmissionNum []leetCodeSubmissionCount `json:"acSubmissionNum"` |
| } `json:"submitStats"` |
| SubmitStatsGlobal struct { |
| AcSubmissionNum []leetCodeSubmissionCount `json:"acSubmissionNum"` |
| } `json:"submitStatsGlobal"` |
| } `json:"matchedUser"` |
| } `json:"data"` |
| Errors []struct { |
| Message string `json:"message"` |
| } `json:"errors"` |
| } |
|
|
| func extractLeetCodeUsername(input string) (string, error) { |
| value := strings.TrimSpace(input) |
| if value == "" { |
| return "", errors.New("Enter your LeetCode profile link or username.") |
| } |
|
|
| if !strings.Contains(value, "://") && strings.Contains(strings.ToLower(value), "leetcode.com") { |
| value = "https://" + value |
| } |
|
|
| var username string |
| if strings.Contains(value, "://") { |
| parsed, err := url.Parse(value) |
| if err != nil { |
| return "", errors.New("Enter a valid LeetCode profile link or username.") |
| } |
|
|
| host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.") |
| if host != "" && host != "leetcode.com" { |
| return "", errors.New("Use a LeetCode profile link from leetcode.com.") |
| } |
|
|
| segments := strings.FieldsFunc(strings.Trim(parsed.Path, "/"), func(r rune) bool { |
| return r == '/' |
| }) |
| switch { |
| case len(segments) >= 2 && strings.EqualFold(segments[0], "u"): |
| username = segments[1] |
| case len(segments) >= 1: |
| username = segments[0] |
| } |
| } else { |
| username = value |
| } |
|
|
| username = strings.TrimSpace(strings.Trim(username, "/")) |
| username = strings.TrimPrefix(username, "@") |
| username = strings.TrimPrefix(username, "u/") |
| username = strings.TrimSpace(username) |
| username = strings.SplitN(strings.SplitN(username, "?", 2)[0], "#", 2)[0] |
|
|
| if username == "" { |
| return "", errors.New("Enter a valid LeetCode profile link or username.") |
| } |
|
|
| return username, nil |
| } |
|
|
| func fetchLeetCodeSummary(ctx context.Context, username string) (*leetCodeSummaryResponse, error) { |
| const query = ` |
| query userPublicProfile($username: String!) { |
| matchedUser(username: $username) { |
| username |
| submitStats { |
| acSubmissionNum { |
| difficulty |
| count |
| } |
| } |
| submitStatsGlobal { |
| acSubmissionNum { |
| difficulty |
| count |
| } |
| } |
| } |
| }` |
|
|
| payload, err := json.Marshal(map[string]any{ |
| "query": query, |
| "variables": map[string]string{ |
| "username": username, |
| }, |
| }) |
| if err != nil { |
| return nil, err |
| } |
|
|
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://leetcode.com/graphql/", bytes.NewReader(payload)) |
| if err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Origin", "https://leetcode.com") |
| req.Header.Set("Referer", "https://leetcode.com/") |
| req.Header.Set("User-Agent", "RYP/1.0") |
|
|
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| return nil, err |
| } |
| defer resp.Body.Close() |
|
|
| respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| if err != nil { |
| return nil, err |
| } |
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| log.Printf("leetcode status %d: %s", resp.StatusCode, string(respBody)) |
| return nil, &httpStatusError{ |
| StatusCode: http.StatusBadGateway, |
| Message: "LeetCode is not responding right now. Try again in a moment.", |
| } |
| } |
|
|
| var parsed leetCodeGraphQLResponse |
| if err := json.Unmarshal(respBody, &parsed); err != nil { |
| return nil, err |
| } |
|
|
| if parsed.Data.MatchedUser == nil { |
| return nil, &httpStatusError{ |
| StatusCode: http.StatusNotFound, |
| Message: "LeetCode profile not found.", |
| } |
| } |
|
|
| totalSolved, ok := findLeetCodeTotalSolved(parsed.Data.MatchedUser.SubmitStatsGlobal.AcSubmissionNum) |
| if !ok { |
| totalSolved, ok = findLeetCodeTotalSolved(parsed.Data.MatchedUser.SubmitStats.AcSubmissionNum) |
| } |
| if !ok { |
| return nil, &httpStatusError{ |
| StatusCode: http.StatusBadGateway, |
| Message: "Could not read the solved count from LeetCode.", |
| } |
| } |
|
|
| return &leetCodeSummaryResponse{ |
| Username: parsed.Data.MatchedUser.Username, |
| ProfileURL: fmt.Sprintf("https://leetcode.com/u/%s/", parsed.Data.MatchedUser.Username), |
| TotalSolved: totalSolved, |
| }, nil |
| } |
|
|
| func findLeetCodeTotalSolved(counts []leetCodeSubmissionCount) (int, bool) { |
| for _, item := range counts { |
| if strings.EqualFold(item.Difficulty, "All") { |
| return item.Count, true |
| } |
| } |
| return 0, false |
| } |
|
|
| |
|
|
| func googleOAuthConfig() *oauth2.Config { |
| backendUrl := trimEnv(os.Getenv("BACKEND_URL")) |
| if backendUrl == "" { |
| backendUrl = "http://localhost:3000" |
| } else { |
| backendUrl = strings.TrimSuffix(backendUrl, "/") |
| } |
|
|
| return &oauth2.Config{ |
| ClientID: trimEnv(os.Getenv("GOOGLE_CLIENT_ID")), |
| ClientSecret: trimEnv(os.Getenv("GOOGLE_CLIENT_SECRET")), |
| RedirectURL: backendUrl + "/api/auth/google/callback", |
| Scopes: []string{ |
| "https://www.googleapis.com/auth/userinfo.email", |
| "https://www.googleapis.com/auth/userinfo.profile", |
| }, |
| Endpoint: google.Endpoint, |
| } |
| } |
|
|
| type oauthStateStore struct { |
| states map[string]time.Time |
| } |
|
|
| func newOAuthStateStore() *oauthStateStore { |
| return &oauthStateStore{states: make(map[string]time.Time)} |
| } |
|
|
| func (s *oauthStateStore) New() string { |
| b := make([]byte, 16) |
| _, _ = rand.Read(b) |
| state := hex.EncodeToString(b) |
| |
| now := time.Now() |
| for k, v := range s.states { |
| if now.Sub(v) > 10*time.Minute { |
| delete(s.states, k) |
| } |
| } |
| s.states[state] = now |
| return state |
| } |
|
|
| func (s *oauthStateStore) Consume(state string) bool { |
| if state == "" { |
| return false |
| } |
| created, ok := s.states[state] |
| if !ok { |
| return false |
| } |
| delete(s.states, state) |
| return time.Since(created) < 10*time.Minute |
| } |
|
|
| type googleUserInfo struct { |
| Email string `json:"email"` |
| Name string `json:"name"` |
| Picture string `json:"picture"` |
| } |
|
|
| func fetchGoogleUserInfo(ctx context.Context, cfg *oauth2.Config, tok *oauth2.Token) (*googleUserInfo, error) { |
| client := cfg.Client(ctx, tok) |
| resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") |
| if err != nil { |
| return nil, err |
| } |
| defer resp.Body.Close() |
| var info googleUserInfo |
| if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { |
| return nil, err |
| } |
| if info.Email == "" { |
| return nil, fmt.Errorf("google returned empty email") |
| } |
| return &info, nil |
| } |
|
|
| func frontendErrorURL(msg string) string { |
| base := trimEnv(os.Getenv("GOOGLE_REDIRECT_URL")) |
| if base == "" { |
| base = "http://localhost:5173/auth/fallback/" |
| } |
| if !strings.HasSuffix(base, "/") { |
| base += "/" |
| } |
| return base + "?error=" + url.QueryEscape(msg) |
| } |
|
|
| |
|
|
| type aptitudeTopicDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| Category string `bson:"category" json:"category"` |
| TopicID string `bson:"topicId" json:"topicId"` |
| Title string `bson:"title" json:"title"` |
| Description string `bson:"description" json:"description"` |
| Icon string `bson:"icon" json:"icon"` |
| Tag string `bson:"tag" json:"tag"` |
| Chapters int `bson:"chapters" json:"chapters"` |
| Order int `bson:"order" json:"order"` |
| Topics []string `bson:"topics" json:"topics"` |
| Gradient string `bson:"gradient" json:"gradient"` |
| GlowColor string `bson:"glowColor" json:"glowColor"` |
| AccentColor string `bson:"accentColor" json:"accentColor"` |
| BarColor string `bson:"barColor" json:"barColor"` |
| } |
|
|
| |
|
|
| type dbmsDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| TopicNo int `bson:"topic_no" json:"topic_no"` |
| TopicName string `bson:"topic_name" json:"topic_name"` |
| Content string `bson:"content" json:"content"` |
| } |
|
|
| |
|
|
| type dsaExampleResponse struct { |
| Input string `json:"input"` |
| ExpectedOutput string `json:"expectedOutput"` |
| Explanation string `json:"explanation,omitempty"` |
| } |
|
|
| type dsaQuestionResponse struct { |
| ID string `json:"id"` |
| Source string `json:"source,omitempty"` |
| QuestionNumber int `json:"questionNumber"` |
| Title string `json:"title"` |
| Difficulty string `json:"difficulty"` |
| Category string `json:"category"` |
| Topics []string `json:"topics"` |
| Companies []string `json:"companies,omitempty"` |
| Hint string `json:"hint,omitempty"` |
| ProblemStatement string `json:"problemStatement"` |
| Examples []dsaExampleResponse `json:"examples"` |
| Constraints []string `json:"constraints,omitempty"` |
| } |
|
|
| func handleGetDSAQuestions(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find(). |
| SetSort(bson.D{{Key: "question_number", Value: 1}, {Key: "title", Value: 1}}). |
| SetProjection(bson.M{ |
| "source": 1, |
| "question_number": 1, |
| "title": 1, |
| "difficulty": 1, |
| "topics": 1, |
| "companies": 1, |
| "hint": 1, |
| "problem_statement": 1, |
| "examples": 1, |
| "constraints": 1, |
| }) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[dsa] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch DSA questions") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| questions := make([]dsaQuestionResponse, 0) |
| for cursor.Next(ctx) { |
| var doc bson.M |
| if err := cursor.Decode(&doc); err != nil { |
| log.Println("[dsa] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode DSA questions") |
| return |
| } |
|
|
| question := normalizeDSAQuestion(doc) |
| if question.Title == "" { |
| continue |
| } |
| questions = append(questions, question) |
| } |
| if err := cursor.Err(); err != nil { |
| log.Println("[dsa] cursor error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to read DSA questions") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"questions": questions}) |
| } |
| } |
|
|
| func normalizeDSAQuestion(doc bson.M) dsaQuestionResponse { |
| id := "" |
| if oid, ok := doc["_id"].(primitive.ObjectID); ok { |
| id = oid.Hex() |
| } else if value, ok := doc["_id"]; ok { |
| id = fmt.Sprint(value) |
| } |
|
|
| topics := stringSliceFromBSON(doc["topics"]) |
| category := "DSA" |
| if len(topics) > 0 { |
| category = topics[0] |
| } |
|
|
| return dsaQuestionResponse{ |
| ID: id, |
| Source: stringFromBSON(doc["source"]), |
| QuestionNumber: intFromBSON(doc["question_number"]), |
| Title: stringFromBSON(doc["title"]), |
| Difficulty: normalizeDSADifficulty(stringFromBSON(doc["difficulty"])), |
| Category: category, |
| Topics: topics, |
| Companies: stringSliceFromBSON(doc["companies"]), |
| Hint: stringFromBSON(doc["hint"]), |
| ProblemStatement: stringFromBSON(doc["problem_statement"]), |
| Examples: dsaExamplesFromBSON(doc["examples"]), |
| Constraints: stringSliceFromBSON(doc["constraints"]), |
| } |
| } |
|
|
| func normalizeDSADifficulty(value string) string { |
| switch strings.ToLower(strings.TrimSpace(value)) { |
| case "easy": |
| return "Easy" |
| case "medium", "med": |
| return "Medium" |
| case "hard": |
| return "Hard" |
| default: |
| return "Easy" |
| } |
| } |
|
|
| func stringFromBSON(value any) string { |
| if value == nil { |
| return "" |
| } |
| return strings.TrimSpace(fmt.Sprint(value)) |
| } |
|
|
| func intFromBSON(value any) int { |
| switch v := value.(type) { |
| case int: |
| return v |
| case int32: |
| return int(v) |
| case int64: |
| return int(v) |
| case float32: |
| return int(v) |
| case float64: |
| return int(v) |
| default: |
| return 0 |
| } |
| } |
|
|
| func stringSliceFromBSON(value any) []string { |
| switch values := value.(type) { |
| case []string: |
| return values |
| case []any: |
| result := make([]string, 0, len(values)) |
| for _, item := range values { |
| if text := stringFromBSON(item); text != "" { |
| result = append(result, text) |
| } |
| } |
| return result |
| case primitive.A: |
| result := make([]string, 0, len(values)) |
| for _, item := range values { |
| if text := stringFromBSON(item); text != "" { |
| result = append(result, text) |
| } |
| } |
| return result |
| case string: |
| if strings.TrimSpace(values) == "" { |
| return nil |
| } |
| return []string{strings.TrimSpace(values)} |
| default: |
| return nil |
| } |
| } |
|
|
| func dsaExamplesFromBSON(value any) []dsaExampleResponse { |
| var rawExamples []any |
| switch values := value.(type) { |
| case []any: |
| rawExamples = values |
| case primitive.A: |
| rawExamples = []any(values) |
| default: |
| return nil |
| } |
|
|
| examples := make([]dsaExampleResponse, 0, len(rawExamples)) |
| for _, raw := range rawExamples { |
| exampleMap, ok := raw.(bson.M) |
| if !ok { |
| if doc, ok := raw.(map[string]any); ok { |
| exampleMap = bson.M(doc) |
| } else { |
| continue |
| } |
| } |
|
|
| example := dsaExampleResponse{ |
| Input: stringFromBSON(exampleMap["input"]), |
| ExpectedOutput: stringFromBSON(exampleMap["expectedOutput"]), |
| Explanation: stringFromBSON(exampleMap["explanation"]), |
| } |
| if example.ExpectedOutput == "" { |
| example.ExpectedOutput = stringFromBSON(exampleMap["output"]) |
| } |
| if example.Input != "" || example.ExpectedOutput != "" || example.Explanation != "" { |
| examples = append(examples, example) |
| } |
| } |
|
|
| return examples |
| } |
|
|
| type systemDesignDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| ChapterNo int `bson:"chapter_no" json:"chapter_no"` |
| ChapterName string `bson:"chapter_name" json:"chapter_name"` |
| Subtitle string `bson:"subtitle" json:"subtitle"` |
| Level string `bson:"level" json:"level"` |
| Content bson.M `bson:"content" json:"content"` |
| } |
|
|
| func normalizeSystemDesignContent(doc bson.M) bson.M { |
| if content, ok := doc["content"].(bson.M); ok && len(content) > 0 { |
| return content |
| } |
|
|
| content := bson.M{} |
| for key, value := range doc { |
| switch key { |
| case "_id", "chapter_no", "chapter_name", "subtitle", "level", "content": |
| continue |
| default: |
| content[key] = value |
| } |
| } |
| return content |
| } |
|
|
| |
| func handleGetDBMSTopics(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetProjection(bson.M{"content": 0}).SetSort(bson.D{{Key: "topic_no", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[dbms] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch DBMS topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var topics []dbmsDoc |
| if err = cursor.All(ctx, &topics); err != nil { |
| log.Println("[dbms] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode DBMS topics") |
| return |
| } |
|
|
| if topics == nil { |
| topics = []dbmsDoc{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": topics}) |
| } |
| } |
|
|
| |
| func handleGetDBMSTopicContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing topic number") |
| return |
| } |
| topicNoStr := parts[4] |
| var topicNo int |
| _, err := fmt.Sscanf(topicNoStr, "%d", &topicNo) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid topic number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| var doc dbmsDoc |
| err = coll.FindOne(ctx, bson.M{"topic_no": topicNo}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Topic not found") |
| return |
| } |
| log.Println("[dbms] find one error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch topic content") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, doc) |
| } |
| } |
|
|
| |
| |
| func handleGetAptitudeTopicsByCategory(coll *mongo.Collection, category string) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "order", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{"category": category}, opts) |
| if err != nil { |
| log.Println("[aptitude] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch aptitude topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var topics []aptitudeTopicDoc |
| if err = cursor.All(ctx, &topics); err != nil { |
| log.Println("[aptitude] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode aptitude topics") |
| return |
| } |
|
|
| if topics == nil { |
| topics = []aptitudeTopicDoc{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": topics}) |
| } |
| } |
|
|
| |
|
|
| type subTopicExample struct { |
| Question string `bson:"question" json:"question"` |
| Solution string `bson:"solution" json:"solution"` |
| } |
|
|
| type subTopicHowToSolve struct { |
| Steps []string `bson:"steps" json:"steps"` |
| Formulas []string `bson:"formulas" json:"formulas"` |
| Example subTopicExample `bson:"example" json:"example"` |
| } |
|
|
| type aptitudeSubtopicDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| SubTopicID string `bson:"subTopicId" json:"subTopicId"` |
| Title string `bson:"title" json:"title"` |
| Description string `bson:"description" json:"description"` |
| HowToSolve subTopicHowToSolve `bson:"howToSolve" json:"howToSolve"` |
| } |
|
|
| func handleGetAptitudeSubtopic(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| subTopicId := parts[len(parts)-1] |
| if subTopicId == "" { |
| jsonError(w, http.StatusBadRequest, "Missing subTopicId") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| var doc aptitudeSubtopicDoc |
| err := coll.FindOne(ctx, bson.M{"subTopicId": subTopicId}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Subtopic not found") |
| return |
| } |
| log.Println("[aptitude] subtopic find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch subtopic") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, doc) |
| } |
| } |
|
|
| |
|
|
| type UserAptitudeDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| ChapterNo int `bson:"chapter_no" json:"chapterNo"` |
| ChapterName string `bson:"chapter_name" json:"chapterName"` |
| Content string `bson:"content" json:"content"` |
| } |
|
|
| func handleGetUserAptitudeContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| subTopicId := parts[len(parts)-1] |
| if subTopicId == "" { |
| jsonError(w, http.StatusBadRequest, "Missing subTopicId") |
| return |
| } |
|
|
| |
| |
| |
| searchTerm := subTopicId |
| if subTopicId == "percentages" { |
| searchTerm = "Percentages" |
| } else if subTopicId == "averages" { |
| searchTerm = "AVERAGES" |
| } else if subTopicId == "speed-distance" { |
| searchTerm = "Speed, Time & Distance" |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| var doc UserAptitudeDoc |
| |
| err := coll.FindOne(ctx, bson.M{"chapter_name": primitive.Regex{Pattern: "(?i)" + searchTerm}}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Content not found in user database") |
| return |
| } |
| log.Println("[aptitude] user content find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch user content") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, doc) |
| } |
| } |
|
|
| |
| func handleGetSystemDesignTopics(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetProjection(bson.M{"content": 0}).SetSort(bson.D{{Key: "chapter_no", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[system_design] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch System Design topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var topics []systemDesignDoc |
| if err = cursor.All(ctx, &topics); err != nil { |
| log.Println("[system_design] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode System Design topics") |
| return |
| } |
|
|
| if topics == nil { |
| topics = []systemDesignDoc{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": topics}) |
| } |
| } |
|
|
| |
| func handleGetSystemDesignTopicContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing chapter number") |
| return |
| } |
| chapterNoStr := parts[4] |
| var chapterNo int |
| _, err := fmt.Sscanf(chapterNoStr, "%d", &chapterNo) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid chapter number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| var doc bson.M |
| err = coll.FindOne(ctx, bson.M{"chapter_no": chapterNo}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Topic not found") |
| return |
| } |
| log.Println("[system_design] find one error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch topic content") |
| return |
| } |
|
|
| doc["content"] = normalizeSystemDesignContent(doc) |
| jsonWrite(w, http.StatusOK, doc) |
| } |
| } |
|
|
| |
|
|
| type cnDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| ChapterNo int `bson:"chapter_no" json:"chapter_no"` |
| ChapterName string `bson:"chapter_name" json:"chapter_name"` |
| Subtitle string `bson:"subtitle" json:"subtitle"` |
| Level string `bson:"level" json:"level"` |
| Topics []string `bson:"topics" json:"topics"` |
| Content bson.M `bson:"content" json:"content"` |
| } |
|
|
| |
| func handleGetCNTopics(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetProjection(bson.M{"content": 0}).SetSort(bson.D{{Key: "chapter_no", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[cn] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch CN topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var topics []cnDoc |
| if err = cursor.All(ctx, &topics); err != nil { |
| log.Println("[cn] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode CN topics") |
| return |
| } |
|
|
| if topics == nil { |
| topics = []cnDoc{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": topics}) |
| } |
| } |
|
|
| |
| func handleGetCNTopicContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing chapter number") |
| return |
| } |
| chapterNoStr := parts[4] |
| var chapterNo int |
| _, err := fmt.Sscanf(chapterNoStr, "%d", &chapterNo) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid chapter number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| var doc cnDoc |
| err = coll.FindOne(ctx, bson.M{"chapter_no": chapterNo}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Topic not found") |
| return |
| } |
| log.Println("[cn] find one error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch topic content") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, doc) |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| func handleGetPythonTopics(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| |
| opts := options.Find(). |
| SetProjection(bson.M{"sections": 0, "builtin_functions": 0, "next_steps": 0}). |
| SetSort(bson.D{{Key: "module", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[python] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch Python topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var rawDocs []bson.M |
| if err = cursor.All(ctx, &rawDocs); err != nil { |
| log.Println("[python] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode Python topics") |
| return |
| } |
|
|
| |
| type pythonTopicOut struct { |
| ID string `json:"id"` |
| ChapterNo int `json:"chapter_no"` |
| ChapterName string `json:"chapter_name"` |
| Subtitle string `json:"subtitle"` |
| Level string `json:"level"` |
| Section string `json:"section"` |
| } |
|
|
| topics := make([]pythonTopicOut, 0, len(rawDocs)) |
| for _, doc := range rawDocs { |
| out := pythonTopicOut{} |
| |
| if oid, ok := doc["_id"].(primitive.ObjectID); ok { |
| out.ID = oid.Hex() |
| } |
| |
| if m, ok := doc["module"]; ok { |
| switch v := m.(type) { |
| case int32: |
| out.ChapterNo = int(v) |
| case int64: |
| out.ChapterNo = int(v) |
| case float64: |
| out.ChapterNo = int(v) |
| } |
| } |
| |
| if t, ok := doc["title"].(string); ok { |
| out.ChapterName = t |
| } |
| if s, ok := doc["subtitle"].(string); ok { |
| out.Subtitle = s |
| } |
| if l, ok := doc["level"].(string); ok { |
| out.Level = l |
| } |
| if sec, ok := doc["section"].(string); ok { |
| out.Section = sec |
| } |
| topics = append(topics, out) |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": topics}) |
| } |
| } |
|
|
| |
| |
| func handleGetPythonTopicContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing module number") |
| return |
| } |
| moduleNoStr := parts[4] |
| var moduleNo int |
| _, err := fmt.Sscanf(moduleNoStr, "%d", &moduleNo) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid module number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| var doc bson.M |
| err = coll.FindOne(ctx, bson.M{"module": moduleNo}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Topic not found") |
| return |
| } |
| log.Println("[python] find one error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch topic content") |
| return |
| } |
|
|
| |
| result := map[string]any{} |
|
|
| if oid, ok := doc["_id"].(primitive.ObjectID); ok { |
| result["id"] = oid.Hex() |
| } |
| if m, ok := doc["module"]; ok { |
| switch v := m.(type) { |
| case int32: |
| result["chapter_no"] = int(v) |
| case int64: |
| result["chapter_no"] = int(v) |
| case float64: |
| result["chapter_no"] = int(v) |
| } |
| } |
| if t, ok := doc["title"].(string); ok { |
| result["chapter_name"] = t |
| } |
| if s, ok := doc["subtitle"].(string); ok { |
| result["subtitle"] = s |
| } |
| if l, ok := doc["level"].(string); ok { |
| result["level"] = l |
| } |
| |
| if secs, ok := doc["sections"]; ok { |
| result["sections"] = secs |
| } |
|
|
| jsonWrite(w, http.StatusOK, result) |
| } |
| } |
|
|
| |
| |
| func cProgrammingValueToInt(value any) int { |
| switch v := value.(type) { |
| case int: |
| return v |
| case int32: |
| return int(v) |
| case int64: |
| return int(v) |
| case float64: |
| return int(v) |
| case string: |
| n, _ := strconv.Atoi(strings.TrimSpace(v)) |
| return n |
| default: |
| return 0 |
| } |
| } |
|
|
| func cProgrammingValueToString(value any) string { |
| switch v := value.(type) { |
| case string: |
| return v |
| case fmt.Stringer: |
| return v.String() |
| case nil: |
| return "" |
| default: |
| return strings.TrimSpace(fmt.Sprint(v)) |
| } |
| } |
|
|
| func cProgrammingSectionOut(doc bson.M) bson.M { |
| out := bson.M{} |
| for key, value := range doc { |
| if key == "_id" { |
| continue |
| } |
| out[key] = value |
| } |
|
|
| if oid, ok := doc["_id"].(primitive.ObjectID); ok { |
| out["id"] = oid.Hex() |
| } |
|
|
| chapterNo := cProgrammingValueToInt(doc["chapter"]) |
| if chapterNo > 0 { |
| out["chapter_no"] = chapterNo |
| } |
|
|
| chapterName := cProgrammingValueToString(doc["title"]) |
| if chapterName != "" { |
| out["chapter_name"] = chapterName |
| } |
|
|
| if _, ok := out["content"]; !ok { |
| out["content"] = "" |
| } |
| content := cProgrammingValueToString(out["content"]) |
| out["has_content"] = strings.TrimSpace(content) != "" || cProgrammingHasExtraDetails(doc) |
|
|
| return out |
| } |
|
|
| func cProgrammingHasExtraDetails(doc bson.M) bool { |
| for key, value := range doc { |
| switch key { |
| case "_id", "chapter", "title", "section", "section_title", "content": |
| continue |
| } |
| if cProgrammingDetailHasValue(value) { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| func cProgrammingDetailHasValue(value any) bool { |
| switch v := value.(type) { |
| case nil: |
| return false |
| case string: |
| return strings.TrimSpace(v) != "" |
| case primitive.A: |
| return len(v) > 0 |
| case []any: |
| return len(v) > 0 |
| case bson.M: |
| return len(v) > 0 |
| case map[string]any: |
| return len(v) > 0 |
| default: |
| return true |
| } |
| } |
|
|
| |
| func handleGetCProgrammingTopics(coll *mongo.Collection) http.HandlerFunc { |
| type cSectionPreview struct { |
| ID string `json:"id"` |
| Section string `json:"section"` |
| SectionTitle string `json:"section_title"` |
| HasContent bool `json:"has_content"` |
| } |
|
|
| type cChapterOut struct { |
| ID string `json:"id"` |
| ChapterNo int `json:"chapter_no"` |
| ChapterName string `json:"chapter_name"` |
| Subtitle string `json:"subtitle"` |
| Level string `json:"level"` |
| SectionCount int `json:"section_count"` |
| ContentCount int `json:"content_count"` |
| Sections []cSectionPreview `json:"sections"` |
| } |
|
|
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "chapter", Value: 1}, {Key: "section", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[c_programming] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch C programming topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var rawDocs []bson.M |
| if err = cursor.All(ctx, &rawDocs); err != nil { |
| log.Println("[c_programming] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode C programming topics") |
| return |
| } |
|
|
| chapterMap := map[int]*cChapterOut{} |
| chapterOrder := []int{} |
| for _, doc := range rawDocs { |
| chapterNo := cProgrammingValueToInt(doc["chapter"]) |
| if chapterNo == 0 { |
| continue |
| } |
|
|
| chapter, ok := chapterMap[chapterNo] |
| if !ok { |
| chapter = &cChapterOut{ |
| ID: fmt.Sprintf("c-chapter-%d", chapterNo), |
| ChapterNo: chapterNo, |
| ChapterName: cProgrammingValueToString(doc["title"]), |
| Level: "Core", |
| Sections: []cSectionPreview{}, |
| } |
| chapterMap[chapterNo] = chapter |
| chapterOrder = append(chapterOrder, chapterNo) |
| } |
|
|
| section := cProgrammingSectionOut(doc) |
| hasContent, _ := section["has_content"].(bool) |
| if hasContent { |
| chapter.ContentCount++ |
| } |
|
|
| chapter.SectionCount++ |
| chapter.Sections = append(chapter.Sections, cSectionPreview{ |
| ID: cProgrammingValueToString(section["id"]), |
| Section: cProgrammingValueToString(doc["section"]), |
| SectionTitle: cProgrammingValueToString(doc["section_title"]), |
| HasContent: hasContent, |
| }) |
| } |
|
|
| sort.Ints(chapterOrder) |
| chapters := make([]cChapterOut, 0, len(chapterOrder)) |
| for _, chapterNo := range chapterOrder { |
| chapter := chapterMap[chapterNo] |
| if chapter.SectionCount == 1 { |
| chapter.Subtitle = "1 topic" |
| } else { |
| chapter.Subtitle = fmt.Sprintf("%d topics", chapter.SectionCount) |
| } |
| chapters = append(chapters, *chapter) |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": chapters}) |
| } |
| } |
|
|
| |
| func handleGetCProgrammingTopicContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing chapter number") |
| return |
| } |
|
|
| chapterNo, err := strconv.Atoi(parts[4]) |
| if err != nil || chapterNo <= 0 { |
| jsonError(w, http.StatusBadRequest, "Invalid chapter number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "section", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{"chapter": chapterNo}, opts) |
| if err != nil { |
| log.Println("[c_programming] find one error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch C programming content") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var rawDocs []bson.M |
| if err = cursor.All(ctx, &rawDocs); err != nil { |
| log.Println("[c_programming] decode content error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode C programming content") |
| return |
| } |
|
|
| if len(rawDocs) == 0 { |
| jsonError(w, http.StatusNotFound, "Topic not found") |
| return |
| } |
|
|
| sections := make([]bson.M, 0, len(rawDocs)) |
| contentCount := 0 |
| for _, doc := range rawDocs { |
| section := cProgrammingSectionOut(doc) |
| if hasContent, _ := section["has_content"].(bool); hasContent { |
| contentCount++ |
| } |
| sections = append(sections, section) |
| } |
|
|
| chapterName := cProgrammingValueToString(rawDocs[0]["title"]) |
| result := bson.M{ |
| "id": fmt.Sprintf("c-chapter-%d", chapterNo), |
| "chapter_no": chapterNo, |
| "chapter_name": chapterName, |
| "subtitle": fmt.Sprintf("%d topics", len(sections)), |
| "level": "Core", |
| "section_count": len(sections), |
| "content_count": contentCount, |
| "sections": sections, |
| } |
|
|
| jsonWrite(w, http.StatusOK, result) |
| } |
| } |
|
|
| |
| func handleGetJavaTopics(coll *mongo.Collection) http.HandlerFunc { |
| type javaSectionPreview struct { |
| ID string `json:"id"` |
| Section string `json:"section"` |
| SectionTitle string `json:"section_title"` |
| HasContent bool `json:"has_content"` |
| } |
|
|
| type javaChapterOut struct { |
| ID string `json:"id"` |
| ChapterNo int `json:"chapter_no"` |
| ChapterName string `json:"chapter_name"` |
| Subtitle string `json:"subtitle"` |
| Level string `json:"level"` |
| SectionCount int `json:"section_count"` |
| ContentCount int `json:"content_count"` |
| Sections []javaSectionPreview `json:"sections"` |
| } |
|
|
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "chapter", Value: 1}, {Key: "section", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Println("[java_programming] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch Java programming topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var rawDocs []bson.M |
| if err = cursor.All(ctx, &rawDocs); err != nil { |
| log.Println("[java_programming] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode Java programming topics") |
| return |
| } |
|
|
| chapterMap := map[int]*javaChapterOut{} |
| chapterOrder := []int{} |
| for _, doc := range rawDocs { |
| chapterNo := cProgrammingValueToInt(doc["chapter"]) |
| if chapterNo == 0 { |
| continue |
| } |
|
|
| chapter, ok := chapterMap[chapterNo] |
| if !ok { |
| chapter = &javaChapterOut{ |
| ID: fmt.Sprintf("java-chapter-%d", chapterNo), |
| ChapterNo: chapterNo, |
| ChapterName: cProgrammingValueToString(doc["title"]), |
| Level: "Core", |
| Sections: []javaSectionPreview{}, |
| } |
| chapterMap[chapterNo] = chapter |
| chapterOrder = append(chapterOrder, chapterNo) |
| } |
|
|
| section := cProgrammingSectionOut(doc) |
| hasContent, _ := section["has_content"].(bool) |
| if hasContent { |
| chapter.ContentCount++ |
| } |
|
|
| chapter.SectionCount++ |
| chapter.Sections = append(chapter.Sections, javaSectionPreview{ |
| ID: cProgrammingValueToString(section["id"]), |
| Section: cProgrammingValueToString(doc["section"]), |
| SectionTitle: cProgrammingValueToString(doc["section_title"]), |
| HasContent: hasContent, |
| }) |
| } |
|
|
| sort.Ints(chapterOrder) |
| chapters := make([]javaChapterOut, 0, len(chapterOrder)) |
| for _, chapterNo := range chapterOrder { |
| chapter := chapterMap[chapterNo] |
| if chapter.SectionCount == 1 { |
| chapter.Subtitle = "1 topic" |
| } else { |
| chapter.Subtitle = fmt.Sprintf("%d topics", chapter.SectionCount) |
| } |
| chapters = append(chapters, *chapter) |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": chapters}) |
| } |
| } |
|
|
| |
| func handleGetJavaTopicContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing chapter number") |
| return |
| } |
|
|
| chapterNo, err := strconv.Atoi(parts[4]) |
| if err != nil || chapterNo <= 0 { |
| jsonError(w, http.StatusBadRequest, "Invalid chapter number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| opts := options.Find().SetSort(bson.D{{Key: "section", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{"chapter": chapterNo}, opts) |
| if err != nil { |
| log.Println("[java_programming] find one error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch Java programming content") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var rawDocs []bson.M |
| if err = cursor.All(ctx, &rawDocs); err != nil { |
| log.Println("[java_programming] decode content error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode Java programming content") |
| return |
| } |
|
|
| if len(rawDocs) == 0 { |
| jsonError(w, http.StatusNotFound, "Topic not found") |
| return |
| } |
|
|
| sections := make([]bson.M, 0, len(rawDocs)) |
| contentCount := 0 |
| for _, doc := range rawDocs { |
| section := cProgrammingSectionOut(doc) |
| if hasContent, _ := section["has_content"].(bool); hasContent { |
| contentCount++ |
| } |
| sections = append(sections, section) |
| } |
|
|
| chapterName := cProgrammingValueToString(rawDocs[0]["title"]) |
| result := bson.M{ |
| "id": fmt.Sprintf("java-chapter-%d", chapterNo), |
| "chapter_no": chapterNo, |
| "chapter_name": chapterName, |
| "subtitle": fmt.Sprintf("%d topics", len(sections)), |
| "level": "Core", |
| "section_count": len(sections), |
| "content_count": contentCount, |
| "sections": sections, |
| } |
|
|
| jsonWrite(w, http.StatusOK, result) |
| } |
| } |
|
|
| |
| func handleGetAptitudeTopics(client *mongo.Client) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| category := r.URL.Query().Get("category") |
| if category == "" { |
| jsonError(w, http.StatusBadRequest, "Missing category") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| coll := client.Database("aptitude").Collection(category) |
| opts := options.Find().SetProjection(bson.M{"content": 0}).SetSort(bson.D{{Key: "chapter_no", Value: 1}}) |
| cursor, err := coll.Find(ctx, bson.M{}, opts) |
| if err != nil { |
| log.Printf("[aptitude] find error for %s: %v", category, err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch topics") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var topics []bson.M |
| if err = cursor.All(ctx, &topics); err != nil { |
| log.Printf("[aptitude] decode error for %s: %v", category, err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode topics") |
| return |
| } |
|
|
| if topics == nil { |
| topics = []bson.M{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"topics": topics}) |
| } |
| } |
|
|
| |
| func handleGetAptitudeTopicContent(client *mongo.Client) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| category := r.URL.Query().Get("category") |
| if category == "" { |
| jsonError(w, http.StatusBadRequest, "Missing category") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| topicNoStr := parts[len(parts)-1] |
| if topicNoStr == "" { |
| jsonError(w, http.StatusBadRequest, "Missing topic number") |
| return |
| } |
|
|
| topicNo, err := strconv.Atoi(topicNoStr) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid topic number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| coll := client.Database("aptitude").Collection(category) |
| var doc bson.M |
| err = coll.FindOne(ctx, bson.M{"chapter_no": topicNo}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| jsonError(w, http.StatusNotFound, "Topic content not found") |
| return |
| } |
| log.Printf("[aptitude] content find error for %s: %v", category, err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch topic content") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, doc) |
| } |
| } |
|
|
| |
|
|
| type osDocRaw struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| Section string `bson:"section" json:"section"` |
| Topic string `bson:"topic" json:"topic"` |
| Type string `bson:"type" json:"type"` |
| Subtopic string `bson:"subtopic" json:"subtopic"` |
| Content bson.M `bson:"content" json:"content"` |
| } |
|
|
| |
| func handleGetOSSections(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| sectionVals, err := coll.Distinct(ctx, "section", bson.M{}) |
| if err != nil { |
| log.Println("[os] distinct error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch OS sections") |
| return |
| } |
|
|
| type osSection struct { |
| SectionNo int `json:"section_no"` |
| Section string `json:"section"` |
| TopicCount int `json:"topic_count"` |
| Level string `json:"level"` |
| } |
|
|
| levels := []string{"Beginner", "Beginner", "Intermediate", "Intermediate", "Intermediate", "Advanced", "Advanced", "Advanced"} |
| sections := make([]osSection, 0, len(sectionVals)) |
| for i, v := range sectionVals { |
| sec, ok := v.(string) |
| if !ok { |
| continue |
| } |
| count, _ := coll.CountDocuments(ctx, bson.M{"section": sec}) |
| level := "Intermediate" |
| if i < len(levels) { |
| level = levels[i] |
| } |
| sections = append(sections, osSection{ |
| SectionNo: i + 1, |
| Section: sec, |
| TopicCount: int(count), |
| Level: level, |
| }) |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"sections": sections}) |
| } |
| } |
|
|
| |
| func handleGetOSSectionContent(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| parts := strings.Split(r.URL.Path, "/") |
| if len(parts) < 5 { |
| jsonError(w, http.StatusBadRequest, "Missing section number") |
| return |
| } |
| sectionNoStr := parts[4] |
| var sectionNo int |
| _, err := fmt.Sscanf(sectionNoStr, "%d", §ionNo) |
| if err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid section number") |
| return |
| } |
|
|
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
|
|
| sectionVals, err := coll.Distinct(ctx, "section", bson.M{}) |
| if err != nil || sectionNo < 1 || sectionNo > len(sectionVals) { |
| jsonError(w, http.StatusNotFound, "Section not found") |
| return |
| } |
| sectionName, ok := sectionVals[sectionNo-1].(string) |
| if !ok { |
| jsonError(w, http.StatusNotFound, "Section not found") |
| return |
| } |
|
|
| cursor, err := coll.Find(ctx, bson.M{"section": sectionName}) |
| if err != nil { |
| log.Println("[os] find error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch section content") |
| return |
| } |
| defer cursor.Close(ctx) |
|
|
| var topics []osDocRaw |
| if err = cursor.All(ctx, &topics); err != nil { |
| log.Println("[os] decode error:", err) |
| jsonError(w, http.StatusInternalServerError, "Failed to decode section content") |
| return |
| } |
|
|
| if topics == nil { |
| topics = []osDocRaw{} |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{"section": sectionName, "topics": topics}) |
| } |
| } |
|
|