From 8cacd1f23ed3da6bfc7a9b55634edad23b29f6c4 Mon Sep 17 00:00:00 2001 From: Devesh S Date: Wed, 22 Jul 2026 12:30:21 +0530 Subject: [PATCH 01/24] feat: implement Q&A database persistence and Telegram bot integration --- .gitignore | 1 + .../CommunityQuestionController.java | 84 ++++ .../portal/controller/NotePyqController.java | 3 +- .../rit/portal/entity/CommunityAnswer.java | 40 ++ .../rit/portal/entity/CommunityQuestion.java | 46 ++ .../java/com/rit/portal/entity/NotePyq.java | 2 +- .../repository/CommunityAnswerRepository.java | 9 + .../CommunityQuestionRepository.java | 9 + .../portal/repository/NotePyqRepository.java | 2 +- .../src/main/resources/application.properties | 4 +- package-lock.json | 78 ---- src/pages/Community/Community.tsx | 394 ++++++++++++------ telegram-bot/README.md | 50 +++ telegram-bot/config.json | 5 + telegram-bot/requirements.txt | 4 + telegram-bot/telegram_bot.py | 238 +++++++++++ 16 files changed, 750 insertions(+), 219 deletions(-) create mode 100644 backend/src/main/java/com/rit/portal/controller/CommunityQuestionController.java create mode 100644 backend/src/main/java/com/rit/portal/entity/CommunityAnswer.java create mode 100644 backend/src/main/java/com/rit/portal/entity/CommunityQuestion.java create mode 100644 backend/src/main/java/com/rit/portal/repository/CommunityAnswerRepository.java create mode 100644 backend/src/main/java/com/rit/portal/repository/CommunityQuestionRepository.java create mode 100644 telegram-bot/README.md create mode 100644 telegram-bot/config.json create mode 100644 telegram-bot/requirements.txt create mode 100644 telegram-bot/telegram_bot.py diff --git a/.gitignore b/.gitignore index a547bf3..8e4bb48 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +*.db diff --git a/backend/src/main/java/com/rit/portal/controller/CommunityQuestionController.java b/backend/src/main/java/com/rit/portal/controller/CommunityQuestionController.java new file mode 100644 index 0000000..3167ef1 --- /dev/null +++ b/backend/src/main/java/com/rit/portal/controller/CommunityQuestionController.java @@ -0,0 +1,84 @@ +package com.rit.portal.controller; + +import com.rit.portal.entity.CommunityAnswer; +import com.rit.portal.entity.CommunityQuestion; +import com.rit.portal.repository.CommunityAnswerRepository; +import com.rit.portal.repository.CommunityQuestionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/questions") +public class CommunityQuestionController { + + @Autowired + private CommunityQuestionRepository questionRepository; + + @Autowired + private CommunityAnswerRepository answerRepository; + + private final RestTemplate restTemplate = new RestTemplate(); + private static final String TELEGRAM_BOT_URL = "http://localhost:8082/send_question"; + + @GetMapping + public List getAllQuestions() { + return questionRepository.findAll(); + } + + @PostMapping + public CommunityQuestion createQuestion(@RequestBody CommunityQuestion question) { + if (question.getUpvotes() == null) question.setUpvotes(0); + if (question.getIsAnswered() == null) question.setIsAnswered(false); + question.setCreatedAt(LocalDateTime.now()); + + CommunityQuestion saved = questionRepository.save(question); + + // Notify Telegram bot in a background thread to keep it robust and non-blocking + new Thread(() -> { + try { + Map payload = new HashMap<>(); + payload.put("question_id", saved.getId()); + payload.put("title", saved.getTitle()); + payload.put("body", saved.getBody()); + payload.put("author", saved.getAuthor()); + + restTemplate.postForEntity(TELEGRAM_BOT_URL, payload, String.class); + } catch (Exception e) { + System.err.println("Failed to notify Telegram Bot intermediary: " + e.getMessage()); + } + }).start(); + + return saved; + } + + @PostMapping("/{id}/answers") + public ResponseEntity addAnswer(@PathVariable Integer id, @RequestBody CommunityAnswer answer) { + return questionRepository.findById(id).map(question -> { + answer.setQuestion(question); + if (answer.getUpvotes() == null) answer.setUpvotes(0); + if (answer.getIsAccepted() == null) answer.setIsAccepted(false); + answer.setCreatedAt(LocalDateTime.now()); + + CommunityAnswer savedAnswer = answerRepository.save(answer); + + question.setIsAnswered(true); + questionRepository.save(question); + + return ResponseEntity.ok(savedAnswer); + }).orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/upvote") + public ResponseEntity upvoteQuestion(@PathVariable Integer id) { + return questionRepository.findById(id).map(question -> { + question.setUpvotes(question.getUpvotes() + 1); + return ResponseEntity.ok(questionRepository.save(question)); + }).orElse(ResponseEntity.notFound().build()); + } +} diff --git a/backend/src/main/java/com/rit/portal/controller/NotePyqController.java b/backend/src/main/java/com/rit/portal/controller/NotePyqController.java index ebdad65..54ff9c6 100644 --- a/backend/src/main/java/com/rit/portal/controller/NotePyqController.java +++ b/backend/src/main/java/com/rit/portal/controller/NotePyqController.java @@ -9,7 +9,6 @@ import java.util.List; @RestController @RequestMapping("/api/notes") -@CrossOrigin(origins = "*") // CrossOrigin configured globally, but added here for safety public class NotePyqController { @Autowired @@ -41,7 +40,7 @@ public class NotePyqController { // Increment downloads count @PostMapping("/{id}/download") - public ResponseEntity incrementDownloads(@PathVariable Long id) { + public ResponseEntity incrementDownloads(@PathVariable Integer id) { return noteRepository.findById(id).map(note -> { note.setDownloadsCount(note.getDownloadsCount() + 1); note.setFileType(note.getFileType()); // Keep dirty check diff --git a/backend/src/main/java/com/rit/portal/entity/CommunityAnswer.java b/backend/src/main/java/com/rit/portal/entity/CommunityAnswer.java new file mode 100644 index 0000000..2c49cad --- /dev/null +++ b/backend/src/main/java/com/rit/portal/entity/CommunityAnswer.java @@ -0,0 +1,40 @@ +package com.rit.portal.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "community_answers") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommunityAnswer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + @JsonIgnore + private CommunityQuestion question; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Column(nullable = false) + private String author; + + @Column(name = "upvotes") + private Integer upvotes = 0; + + @Column(name = "is_accepted") + private Boolean isAccepted = false; + + @Column(name = "created_at") + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/backend/src/main/java/com/rit/portal/entity/CommunityQuestion.java b/backend/src/main/java/com/rit/portal/entity/CommunityQuestion.java new file mode 100644 index 0000000..0bf8012 --- /dev/null +++ b/backend/src/main/java/com/rit/portal/entity/CommunityQuestion.java @@ -0,0 +1,46 @@ +package com.rit.portal.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "community_questions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommunityQuestion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Column(nullable = false) + private String author; + + @Column(name = "upvotes") + private Integer upvotes = 0; + + @Column(name = "tags", columnDefinition = "text[]") + private List tags = new ArrayList<>(); + + @Column(name = "is_answered") + private Boolean isAnswered = false; + + @Column(name = "created_at") + private LocalDateTime createdAt = LocalDateTime.now(); + + @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, fetch = FetchType.EAGER) + @Builder.Default + private List answers = new ArrayList<>(); +} diff --git a/backend/src/main/java/com/rit/portal/entity/NotePyq.java b/backend/src/main/java/com/rit/portal/entity/NotePyq.java index d71892b..9fb586a 100644 --- a/backend/src/main/java/com/rit/portal/entity/NotePyq.java +++ b/backend/src/main/java/com/rit/portal/entity/NotePyq.java @@ -15,7 +15,7 @@ public class NotePyq { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private Integer id; @Column(nullable = false) private String title; diff --git a/backend/src/main/java/com/rit/portal/repository/CommunityAnswerRepository.java b/backend/src/main/java/com/rit/portal/repository/CommunityAnswerRepository.java new file mode 100644 index 0000000..302685f --- /dev/null +++ b/backend/src/main/java/com/rit/portal/repository/CommunityAnswerRepository.java @@ -0,0 +1,9 @@ +package com.rit.portal.repository; + +import com.rit.portal.entity.CommunityAnswer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommunityAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/rit/portal/repository/CommunityQuestionRepository.java b/backend/src/main/java/com/rit/portal/repository/CommunityQuestionRepository.java new file mode 100644 index 0000000..72dd96f --- /dev/null +++ b/backend/src/main/java/com/rit/portal/repository/CommunityQuestionRepository.java @@ -0,0 +1,9 @@ +package com.rit.portal.repository; + +import com.rit.portal.entity.CommunityQuestion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommunityQuestionRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/rit/portal/repository/NotePyqRepository.java b/backend/src/main/java/com/rit/portal/repository/NotePyqRepository.java index 6a9fd1d..c14e7fd 100644 --- a/backend/src/main/java/com/rit/portal/repository/NotePyqRepository.java +++ b/backend/src/main/java/com/rit/portal/repository/NotePyqRepository.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Repository; import java.util.List; @Repository -public interface NotePyqRepository extends JpaRepository { +public interface NotePyqRepository extends JpaRepository { List findBySemester(Integer semester); List findByDepartment(String department); List findByFileType(String fileType); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c24917f..8dddc35 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,7 +1,7 @@ # ─── DATABASE CONNECTION CONFIGURATION ─── -spring.datasource.url=jdbc:postgresql://localhost:5432/rit_freshers_hub?sslmode=disable +spring.datasource.url=jdbc:postgresql://localhost:5433/rit_freshers_hub?sslmode=disable spring.datasource.username=postgres -spring.datasource.password=Amudiesh22@. +spring.datasource.password=Devesh@54 # ─── JPA / HIBERNATE SETTINGS ─── spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect diff --git a/package-lock.json b/package-lock.json index 817e569..c2aab0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -340,9 +340,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -360,9 +357,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -380,9 +374,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -400,9 +391,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -420,9 +408,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -440,9 +425,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -460,9 +442,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -480,9 +459,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1605,9 +1581,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1625,9 +1598,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1645,9 +1615,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1665,9 +1632,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1685,9 +1649,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1705,9 +1666,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1959,9 +1917,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1983,9 +1938,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2007,9 +1959,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2031,9 +1980,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2206,9 +2152,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2226,9 +2169,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2246,9 +2186,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2266,9 +2203,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3211,9 +3145,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3235,9 +3166,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3259,9 +3187,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3283,9 +3208,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/pages/Community/Community.tsx b/src/pages/Community/Community.tsx index bcfc482..4705b55 100644 --- a/src/pages/Community/Community.tsx +++ b/src/pages/Community/Community.tsx @@ -1,19 +1,48 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { MessageCircle, Heart, TrendingUp, ThumbsUp, Send, - Shield, Lock, Smile, ChevronRight, Search, Plus + Shield, Lock, Smile, ChevronRight, Search, Plus, User } from 'lucide-react'; import SectionTitle from '@/components/SectionTitle/SectionTitle'; import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer'; import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer'; import { QUESTIONS_DATA, CONFESSIONS_DATA } from '@/constants'; -import { formatDate } from '@/lib/utils'; type Tab = 'qa' | 'confession'; const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library']; +const AVATARS: Record = { + 'Priya S.': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80', + 'Ravi K.': 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80', + 'Arun M.': 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80', +}; + +const getAvatar = (author: string) => { + return AVATARS[author] || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`; +}; + +const getRelativeTime = (dateString: string) => { + const now = new Date('2026-07-22T11:56:24+05:30').getTime(); // Current local time from metadata + const past = new Date(dateString).getTime(); + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const elapsed = now - past; + + if (elapsed < msPerMinute) { + return 'just now'; + } else if (elapsed < msPerHour) { + return Math.round(elapsed / msPerMinute) + 'm ago'; + } else if (elapsed < msPerDay) { + return Math.round(elapsed / msPerHour) + 'h ago'; + } else { + const days = Math.round(elapsed / msPerDay); + return days === 1 ? 'yesterday' : `${days}d ago`; + } +}; + export default function Community() { const [activeTab, setActiveTab] = useState('qa'); const [confessionText, setConfessionText] = useState(''); @@ -21,10 +50,30 @@ export default function Community() { const [confessionPosted, setConfessionPosted] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [likedIds, setLikedIds] = useState>(new Set()); + const [questions, setQuestions] = useState(QUESTIONS_DATA); - const filteredQuestions = QUESTIONS_DATA.filter((q) => + const fetchQuestions = async () => { + try { + const response = await fetch('http://localhost:8080/api/questions'); + if (response.ok) { + const data = await response.json(); + if (data && data.length > 0) { + // Sort newest first + setQuestions(data.reverse()); + } + } + } catch (error) { + console.log('Backend not available. Falling back to local static questions data.', error); + } + }; + + useEffect(() => { + fetchQuestions(); + }, []); + + const filteredQuestions = questions.filter((q) => q.title.toLowerCase().includes(searchQuery.toLowerCase()) || - q.tags.some((t) => t.includes(searchQuery.toLowerCase())) + (q.tags && q.tags.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase()))) ); const handleConfess = () => { @@ -34,34 +83,81 @@ export default function Community() { setTimeout(() => setConfessionPosted(false), 3000); }; - const toggleLike = (id: string) => { + const toggleLike = async (id: string) => { setLikedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + + try { + await fetch(`http://localhost:8080/api/questions/${id}/upvote`, { + method: 'POST' + }); + fetchQuestions(); + } catch (error) { + console.log('Backend not available for upvote sync.', error); + } + }; + + const handlePostQuestion = async () => { + if (!questionText.trim()) return; + + const newQuestion = { + title: questionText.split('\n')[0].substring(0, 100) || "Q&A Question", + body: questionText, + author: 'Priya S.', + tags: ['fresher', 'general'], + upvotes: 0, + isAnswered: false, + createdAt: new Date().toISOString(), + answers: [] + }; + + try { + const response = await fetch('http://localhost:8080/api/questions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: newQuestion.title, + body: newQuestion.body, + author: newQuestion.author, + tags: newQuestion.tags + }) + }); + + if (response.ok) { + const saved = await response.json(); + setQuestions(prev => [saved, ...prev]); + setQuestionText(''); + } else { + setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]); + setQuestionText(''); + } + } catch (error) { + console.error("Error saving question:", error); + setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]); + setQuestionText(''); + } }; return ( -
+
{/* Header */} -
+
-

- RIT{' '} - - Community - +

+ RIT Community

-

+

Ask questions, share confessions, and connect with fellow RIT students.

-
+
{/* Tabs */} -
+
{[ { id: 'qa' as Tab, label: 'Freshers Q&A', icon: MessageCircle }, { id: 'confession' as Tab, label: 'Confessions', icon: Heart }, @@ -69,19 +165,18 @@ export default function Community() { @@ -93,33 +188,34 @@ export default function Community() { {activeTab === 'qa' && (
{/* Ask Question Box */} -
-

Ask a Question

+
+

Ask a Question