RYP / cmd /server /main.go
Soumya79's picture
Upload 1361 files
263c3f7 verified
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
}
// http://localhost:5173, http://127.0.0.13000:, etc.
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),
})
// Index aptitude_topics by category for fast filtering
_, _ = 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))
// ── Aptitude API ───────────────────────────────────────────────
mux.HandleFunc("/api/aptitude/topics", handleGetAptitudeTopics(client))
mux.HandleFunc("/api/aptitude/topics/", handleGetAptitudeTopicContent(client))
// ── DBMS API ───────────────────────────────────────────────────
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)
// ── Google OAuth ──────────────────────────────────────────────
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
}
// Upsert user in MongoDB
normEmail := strings.ToLower(strings.TrimSpace(gInfo.Email))
now := time.Now().UTC()
// Try to find existing user
var doc userDoc
err = coll.FindOne(r.Context(), bson.M{"email": normEmail}).Decode(&doc)
if errors.Is(err, mongo.ErrNoDocuments) {
// Create new user (no password – Google-only account)
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 duplicate key, someone registered with the same email between our check and insert; re-fetch
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 {
// Existing user – refresh photo / displayName if they are empty
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
}
// Method 1: regex-based
questionMatches := aiTeacherQuestionPattern.FindAllStringSubmatch(content, -1)
optionMatches := aiTeacherOptionPattern.FindAllStringSubmatch(content, -1)
if len(questionMatches) >= 2 && len(optionMatches) >= 4 {
return true
}
// Method 2: heuristic for inline-option formats the LLM often produces
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)
// Extract URLs from result-link anchors
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)
}
}
}
// Extract snippets
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}
// Use Wikipedia REST API for page summary
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 {
// Try search API as fallback
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
}
// Truncate if too long
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
// Priority 1: deterministic grading from structured test data (zero hallucination)
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 {
// Priority 2: LLM-based grading from test text in chat history
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"},
},
},
},
}
// Allow up to 5 tool iterations for deeper research chains
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"
}
// Build the map to append
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 {
// Execute tools
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,
})
}
// The loop continues, sending the tool outputs back to the model
} else {
// No tool calls, we have the final answer
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
}
// ── Google OAuth helpers ──────────────────────────────────────────────────────
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)
// Prune expired entries while we're here
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)
}
// ── Aptitude Topic Document ────────────────────────────────────────────────
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"`
}
// ── DBMS Topic Document ──────────────────────────────────────────────────
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"`
}
// ── System Design Topic Document ──────────────────────────────────────────
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
}
// handleGetDBMSTopics serves GET /api/dbms/topics
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})
}
}
// handleGetDBMSTopicContent serves GET /api/dbms/topics/:topic_no
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)
}
}
// handleGetAptitudeTopicsByCategory serves GET /api/aptitude/{category}
// Returns all topic boxes for the specified Aptitude category.
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})
}
}
// ── Aptitude Subtopic Document ────────────────────────────────────────────────
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)
}
}
// ── User Aptitude Content ────────────────────────────────────────────────
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
}
// The subTopicId in our frontend is 'percentages', 'profit-loss', etc.
// In the user's DB, chapter_name is "Percentages", "AVERAGES", "Speed, Time & Distance".
// We'll map them, or just use a regex search for the base word.
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
// Regex match for the search term to handle case differences
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)
}
}
// handleGetSystemDesignTopics serves GET /api/system-design/topics
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})
}
}
// handleGetSystemDesignTopicContent serves GET /api/system-design/topics/:chapter_no
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)
}
}
// ── Computer Networks Topic Document ──────────────────────────────────────
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"`
}
// handleGetCNTopics serves GET /api/cn/topics
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})
}
}
// handleGetCNTopicContent serves GET /api/cn/topics/:chapter_no
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)
}
}
// ── Python Programming Handlers ───────────────────────────────────────────
// handleGetPythonTopics serves GET /api/python/topics
// Reads raw BSON from the Python_Programming collection and maps fields to
// a stable JSON shape the frontend expects.
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()
// Exclude heavy fields from the listing – we only need module/title/level/subtitle/section
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
}
// Map raw docs to the JSON shape the frontend expects
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{}
// _id
if oid, ok := doc["_id"].(primitive.ObjectID); ok {
out.ID = oid.Hex()
}
// module β†’ chapter_no
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)
}
}
// title β†’ chapter_name
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})
}
}
// handleGetPythonTopicContent serves GET /api/python/topics/:module_no
// The DB field is "module" (int), not "chapter_no".
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
}
// Map to frontend-expected JSON keys
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
}
// Pass sections array as-is
if secs, ok := doc["sections"]; ok {
result["sections"] = secs
}
jsonWrite(w, http.StatusOK, result)
}
}
// C programming handlers use one Mongo document per section and group those
// section documents into chapter-level pages for the frontend.
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
}
}
// handleGetCProgrammingTopics serves GET /api/c/topics
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})
}
}
// handleGetCProgrammingTopicContent serves GET /api/c/topics/:chapter_no
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)
}
}
// handleGetJavaTopics serves GET /api/java/topics
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) // Reuse the same extraction logic
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})
}
}
// handleGetJavaTopicContent serves GET /api/java/topics/:chapter_no
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)
}
}
// handleGetAptitudeTopics serves GET /api/aptitude/topics?category={collection_name}
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})
}
}
// handleGetAptitudeTopicContent serves GET /api/aptitude/topics/{topicNo}?category={collection_name}
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)
}
}
// ── Operating System Handlers ──────────────────────────────────────────────
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"`
}
// handleGetOSSections serves GET /api/os/sections
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})
}
}
// handleGetOSSectionContent serves GET /api/os/sections/:section_no
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", &sectionNo)
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})
}
}