package main import ( "context" "encoding/json" "fmt" "math" "net/http" "strconv" "strings" "time" "github.com/golang-jwt/jwt/v5" "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" ) // ── Discussion document ────────────────────────────────────────────── type discussionDoc struct { ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` ProblemID string `bson:"problemId" json:"problemId"` ProblemTitle string `bson:"problemTitle" json:"problemTitle"` UserID string `bson:"userId" json:"userId"` Username string `bson:"username" json:"username"` Language string `bson:"language" json:"language"` Code string `bson:"code" json:"code"` Approach string `bson:"approach" json:"approach"` Upvotes int `bson:"upvotes" json:"upvotes"` UpvotedBy []string `bson:"upvotedBy" json:"upvotedBy"` Verdict string `bson:"verdict" json:"verdict"` ExecutionTime string `bson:"executionTime" json:"executionTime"` MemoryUsed string `bson:"memoryUsed" json:"memoryUsed"` CreatedAt time.Time `bson:"createdAt" json:"createdAt"` Tags []string `bson:"tags" json:"tags"` } // ── Helpers ────────────────────────────────────────────────────────── func extractUserIDFromJWT(r *http.Request) (string, error) { raw := readBearer(r) if raw == "" { return "", fmt.Errorf("not authenticated") } 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 { return "", fmt.Errorf("invalid or expired session") } claims, ok := tok.Claims.(*jwt.RegisteredClaims) if !ok || claims.Subject == "" { return "", fmt.Errorf("invalid or expired session") } return claims.Subject, nil } // ── Create indexes ────────────────────────────────────────────────── func ensureDiscussionIndexes(coll *mongo.Collection) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Fast lookup by problemId, sorted by newest first _, _ = coll.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "problemId", Value: 1}, {Key: "createdAt", Value: -1}}, }) // Fast duplicate check: same user + problem + language _, _ = coll.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "userId", Value: 1}, {Key: "problemId", Value: 1}, {Key: "language", Value: 1}}, }) } // ── GET /api/discussions?problemId=...&page=1&limit=10 ────────────── func handleGetDiscussions(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 } problemID := r.URL.Query().Get("problemId") if problemID == "" { jsonError(w, http.StatusBadRequest, "problemId is required") return } page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit < 1 || limit > 50 { limit = 10 } skip := int64((page - 1) * limit) ctx := r.Context() filter := bson.M{"problemId": problemID, "verdict": "Accepted"} total, err := coll.CountDocuments(ctx, filter) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to count discussions") return } totalPages := int(math.Ceil(float64(total) / float64(limit))) findOpts := options.Find(). SetSort(bson.D{{Key: "createdAt", Value: -1}}). SetSkip(skip). SetLimit(int64(limit)). SetProjection(bson.M{"__v": 0}) cursor, err := coll.Find(ctx, filter, findOpts) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to fetch discussions") return } defer cursor.Close(ctx) var discussions []discussionDoc if err := cursor.All(ctx, &discussions); err != nil { jsonError(w, http.StatusInternalServerError, "Failed to parse discussions") return } if discussions == nil { discussions = []discussionDoc{} } // Ensure upvotedBy is never null in JSON for i := range discussions { if discussions[i].UpvotedBy == nil { discussions[i].UpvotedBy = []string{} } if discussions[i].Tags == nil { discussions[i].Tags = []string{} } } w.Header().Set("Cache-Control", "public, max-age=30") jsonWrite(w, http.StatusOK, map[string]any{ "posts": discussions, "totalPages": totalPages, "currentPage": page, }) } } // ── POST /api/discussions ─────────────────────────────────────────── func handlePostDiscussion(coll *mongo.Collection, usersColl *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 } var body struct { ProblemID string `json:"problemId"` ProblemTitle string `json:"problemTitle"` UserID string `json:"userId"` Username string `json:"username"` Language string `json:"language"` Code string `json:"code"` Approach string `json:"approach"` ExecutionTime string `json:"executionTime"` MemoryUsed string `json:"memoryUsed"` Tags []string `json:"tags"` } if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON body") return } // Try JWT first, fall back to body userId for local-demo users userID, jwtErr := extractUserIDFromJWT(r) if jwtErr != nil { userID = strings.TrimSpace(body.UserID) } if userID == "" { jsonError(w, http.StatusUnauthorized, "Not authenticated") return } if strings.TrimSpace(body.ProblemID) == "" || strings.TrimSpace(body.Language) == "" || strings.TrimSpace(body.Code) == "" { jsonError(w, http.StatusBadRequest, "problemId, language, and code are required") return } // Determine username: try users collection first, then fall back to body username := strings.TrimSpace(body.Username) if jwtErr == nil { // If we got a real JWT, try to fetch display name from DB oid, err := primitive.ObjectIDFromHex(userID) if err == nil { var uDoc userDoc if e := usersColl.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&uDoc); e == nil { username = uDoc.DisplayName } } } if username == "" { username = "User" } if body.Tags == nil { body.Tags = []string{} } now := time.Now().UTC() // Upsert: same user + problem + language → update, otherwise insert filter := bson.M{ "userId": userID, "problemId": body.ProblemID, "language": body.Language, } update := bson.M{ "$set": bson.M{ "problemTitle": body.ProblemTitle, "username": username, "code": body.Code, "approach": body.Approach, "verdict": "Accepted", "executionTime": body.ExecutionTime, "memoryUsed": body.MemoryUsed, "tags": body.Tags, "createdAt": now, }, "$setOnInsert": bson.M{ "userId": userID, "problemId": body.ProblemID, "language": body.Language, "upvotes": 0, "upvotedBy": []string{}, }, } opts := options.Update().SetUpsert(true) result, err := coll.UpdateOne(r.Context(), filter, update, opts) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to save discussion") return } var postID string if result.UpsertedID != nil { postID = result.UpsertedID.(primitive.ObjectID).Hex() } else { // Fetch the existing doc ID var existing discussionDoc if e := coll.FindOne(r.Context(), filter).Decode(&existing); e == nil { postID = existing.ID.Hex() } } jsonWrite(w, http.StatusCreated, map[string]any{ "success": true, "postId": postID, }) } } // ── PATCH /api/discussions/upvote?id=... ──────────────────────────── func handleUpvoteDiscussion(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } // Try JWT first, fall back to query param for local-demo users userID, jwtErr := extractUserIDFromJWT(r) if jwtErr != nil { userID = strings.TrimSpace(r.URL.Query().Get("userId")) } if userID == "" { jsonError(w, http.StatusUnauthorized, "Not authenticated") return } postIDHex := r.URL.Query().Get("id") postOID, err := primitive.ObjectIDFromHex(postIDHex) if err != nil { jsonError(w, http.StatusBadRequest, "Invalid post ID") return } ctx := r.Context() // Check if user already upvoted var doc discussionDoc if err := coll.FindOne(ctx, bson.M{"_id": postOID}).Decode(&doc); err != nil { jsonError(w, http.StatusNotFound, "Discussion not found") return } alreadyUpvoted := false for _, uid := range doc.UpvotedBy { if uid == userID { alreadyUpvoted = true break } } var update bson.M if alreadyUpvoted { // Remove upvote update = bson.M{ "$pull": bson.M{"upvotedBy": userID}, "$inc": bson.M{"upvotes": -1}, } } else { // Add upvote update = bson.M{ "$addToSet": bson.M{"upvotedBy": userID}, "$inc": bson.M{"upvotes": 1}, } } _, err = coll.UpdateOne(ctx, bson.M{"_id": postOID}, update) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to toggle upvote") return } // Re-fetch to return updated state if err := coll.FindOne(ctx, bson.M{"_id": postOID}).Decode(&doc); err != nil { jsonError(w, http.StatusInternalServerError, "Failed to fetch updated discussion") return } if doc.UpvotedBy == nil { doc.UpvotedBy = []string{} } jsonWrite(w, http.StatusOK, map[string]any{ "upvotes": doc.Upvotes, "upvotedBy": doc.UpvotedBy, }) } } // ── DELETE /api/discussions?id=... ────────────────────────────────── func handleDeleteDiscussion(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } // Try JWT first, fall back to query param for local-demo users userID, jwtErr := extractUserIDFromJWT(r) if jwtErr != nil { userID = strings.TrimSpace(r.URL.Query().Get("userId")) } if userID == "" { jsonError(w, http.StatusUnauthorized, "Not authenticated") return } postIDHex := r.URL.Query().Get("id") postOID, err := primitive.ObjectIDFromHex(postIDHex) if err != nil { jsonError(w, http.StatusBadRequest, "Invalid post ID") return } ctx := r.Context() // Ownership check var doc discussionDoc if err := coll.FindOne(ctx, bson.M{"_id": postOID}).Decode(&doc); err != nil { jsonError(w, http.StatusNotFound, "Discussion not found") return } if doc.UserID != userID { jsonError(w, http.StatusForbidden, "You can only delete your own submissions") return } _, err = coll.DeleteOne(ctx, bson.M{"_id": postOID}) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to delete discussion") return } jsonWrite(w, http.StatusOK, map[string]any{ "success": true, }) } }