From 5f3b3f5973730601c8c950b9196dfb871cae8c5c Mon Sep 17 00:00:00 2001 From: Amudieshwar Date: Wed, 19 Aug 2026 08:31:12 +0530 Subject: [PATCH] feat: persist club and center likes in postgresql database --- .../rit/portal/controller/ClubController.java | 98 +++++++++++++++++++ .../java/com/rit/portal/entity/ClubLike.java | 31 ++++++ .../portal/repository/ClubLikeRepository.java | 23 +++++ schema.sql | 10 ++ src/pages/Events/Events.tsx | 67 ++++++++++++- .../StudentDashboard/StudentDashboard.tsx | 8 +- src/services/clubService.ts | 83 ++++++++++++++++ 7 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/rit/portal/controller/ClubController.java create mode 100644 backend/src/main/java/com/rit/portal/entity/ClubLike.java create mode 100644 backend/src/main/java/com/rit/portal/repository/ClubLikeRepository.java create mode 100644 src/services/clubService.ts diff --git a/backend/src/main/java/com/rit/portal/controller/ClubController.java b/backend/src/main/java/com/rit/portal/controller/ClubController.java new file mode 100644 index 0000000..571b9a8 --- /dev/null +++ b/backend/src/main/java/com/rit/portal/controller/ClubController.java @@ -0,0 +1,98 @@ +package com.rit.portal.controller; + +import com.rit.portal.entity.ClubLike; +import com.rit.portal.repository.ClubLikeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/clubs") +@CrossOrigin(origins = "*") +public class ClubController { + + @Autowired + private ClubLikeRepository clubLikeRepository; + + /** + * Get total count of likes for all clubs & centers + */ + @GetMapping("/likes") + public ResponseEntity> getAllClubLikes() { + List results = clubLikeRepository.countAllGroupedByClubId(); + Map likesMap = new HashMap<>(); + for (Object[] result : results) { + String clubId = (String) result[0]; + Long count = (Long) result[1]; + likesMap.put(clubId, count); + } + return ResponseEntity.ok(likesMap); + } + + /** + * Get list of club IDs liked by a specific user or device + */ + @GetMapping("/user-likes") + public ResponseEntity> getUserLikedClubs(@RequestParam("userIdentifier") String userIdentifier) { + if (userIdentifier == null || userIdentifier.isBlank()) { + return ResponseEntity.ok(Collections.emptyList()); + } + List likes = clubLikeRepository.findByUserIdentifier(userIdentifier); + List likedClubIds = likes.stream() + .map(ClubLike::getClubId) + .collect(Collectors.toList()); + return ResponseEntity.ok(likedClubIds); + } + + /** + * Toggle like state for a club/center by a user or device + */ + @PostMapping("/{clubId}/like") + @Transactional + public ResponseEntity> toggleClubLike( + @PathVariable("clubId") String clubId, + @RequestBody(required = false) Map payload, + @RequestParam(value = "userIdentifier", required = false) String paramUserIdentifier) { + + String userIdentifier = null; + if (payload != null && payload.containsKey("userIdentifier")) { + userIdentifier = payload.get("userIdentifier"); + } + if ((userIdentifier == null || userIdentifier.isBlank()) && paramUserIdentifier != null) { + userIdentifier = paramUserIdentifier; + } + if (userIdentifier == null || userIdentifier.isBlank()) { + userIdentifier = "anonymous_guest"; + } + + Optional existingLike = clubLikeRepository.findByClubIdAndUserIdentifier(clubId, userIdentifier); + boolean isNowLiked; + + if (existingLike.isPresent()) { + clubLikeRepository.delete(existingLike.get()); + isNowLiked = false; + } else { + ClubLike newLike = ClubLike.builder() + .clubId(clubId) + .userIdentifier(userIdentifier) + .createdAt(LocalDateTime.now()) + .build(); + clubLikeRepository.save(newLike); + isNowLiked = true; + } + + long newCount = clubLikeRepository.countByClubId(clubId); + + Map response = new HashMap<>(); + response.put("clubId", clubId); + response.put("liked", isNowLiked); + response.put("count", newCount); + + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/rit/portal/entity/ClubLike.java b/backend/src/main/java/com/rit/portal/entity/ClubLike.java new file mode 100644 index 0000000..c4d2f3b --- /dev/null +++ b/backend/src/main/java/com/rit/portal/entity/ClubLike.java @@ -0,0 +1,31 @@ +package com.rit.portal.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "club_likes", uniqueConstraints = { + @UniqueConstraint(name = "unique_club_user_like", columnNames = {"club_id", "user_identifier"}) +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ClubLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "club_id", nullable = false, length = 100) + private String clubId; + + @Column(name = "user_identifier", nullable = false, length = 255) + private String userIdentifier; + + @Column(name = "created_at") + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/backend/src/main/java/com/rit/portal/repository/ClubLikeRepository.java b/backend/src/main/java/com/rit/portal/repository/ClubLikeRepository.java new file mode 100644 index 0000000..0fed755 --- /dev/null +++ b/backend/src/main/java/com/rit/portal/repository/ClubLikeRepository.java @@ -0,0 +1,23 @@ +package com.rit.portal.repository; + +import com.rit.portal.entity.ClubLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ClubLikeRepository extends JpaRepository { + + Optional findByClubIdAndUserIdentifier(String clubId, String userIdentifier); + + boolean existsByClubIdAndUserIdentifier(String clubId, String userIdentifier); + + long countByClubId(String clubId); + + List findByUserIdentifier(String userIdentifier); + + @Query("SELECT c.clubId, COUNT(c) FROM ClubLike c GROUP BY c.clubId") + List countAllGroupedByClubId(); +} diff --git a/schema.sql b/schema.sql index 9bbd096..5c01514 100644 --- a/schema.sql +++ b/schema.sql @@ -135,3 +135,13 @@ CREATE TABLE IF NOT EXISTS confessions ( reactions_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + +-- 10. Club & Center Likes Table +CREATE TABLE IF NOT EXISTS club_likes ( + id SERIAL PRIMARY KEY, + club_id VARCHAR(100) NOT NULL, + user_identifier VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_club_user_like UNIQUE (club_id, user_identifier) +); + diff --git a/src/pages/Events/Events.tsx b/src/pages/Events/Events.tsx index 88785e4..78259fd 100644 --- a/src/pages/Events/Events.tsx +++ b/src/pages/Events/Events.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -13,6 +13,13 @@ import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/An import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer'; import { CLUBS_DATA } from '@/constants'; import type { Club } from '@/types'; +import { useAuth } from '@/context/AuthContext'; +import { + getClubUserIdentifier, + fetchClubLikes, + fetchUserLikedClubs, + toggleClubLikeInDb, +} from '@/services/clubService'; const CLUB_CATEGORY_COLORS: Record = { Technical: '#3B82F6', @@ -119,6 +126,9 @@ const QUIZ_QUESTIONS_ALL = [ ]; export default function Events() { + const { user } = useAuth(); + const userIdentifier = useMemo(() => getClubUserIdentifier(user?.email), [user?.email]); + const [selectedClub, setSelectedClub] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('All'); @@ -140,7 +150,7 @@ export default function Events() { return { totalCount: CLUBS_DATA.length, centersCount: centers, clubsCount: clubs }; }, []); - // ─── Interactive Like System ───────────────────────────────────────────── + // ─── Database-Backed Interactive Like System ───────────────────────────── const [likedClubs, setLikedClubs] = useState>(() => { try { localStorage.removeItem('rit_freshers_liked_clubs'); @@ -159,10 +169,45 @@ export default function Events() { return initialMap; }); - const toggleLike = (clubId: string) => { + // Sync likes with database backend on mount and when user identity changes + useEffect(() => { + let isMounted = true; + + async function syncWithDatabase() { + // Fetch total like counts for all clubs from database + const dbLikesMap = await fetchClubLikes(); + if (isMounted && Object.keys(dbLikesMap).length > 0) { + setLikesMap((prev) => { + const merged = { ...prev }; + Object.entries(dbLikesMap).forEach(([id, count]) => { + merged[id] = count; + }); + return merged; + }); + } + + // Fetch user's liked clubs from database + const userLikedList = await fetchUserLikedClubs(userIdentifier); + if (isMounted && userLikedList.length > 0) { + setLikedClubs(new Set(userLikedList)); + try { + localStorage.setItem('rit_freshers_liked_clubs_v3', JSON.stringify(userLikedList)); + } catch {} + } + } + + syncWithDatabase(); + + return () => { + isMounted = false; + }; + }, [userIdentifier]); + + const toggleLike = async (clubId: string) => { const isCurrentlyLiked = likedClubs.has(clubId); const next = new Set(likedClubs); + // Optimistic UI update if (isCurrentlyLiked) { next.delete(clubId); setLikesMap((l) => ({ ...l, [clubId]: Math.max(0, (l[clubId] || 1) - 1) })); @@ -175,8 +220,24 @@ export default function Events() { try { localStorage.setItem('rit_freshers_liked_clubs_v3', JSON.stringify(Array.from(next))); } catch {} + + // Persist like directly in PostgreSQL database backend + const dbResult = await toggleClubLikeInDb(clubId, userIdentifier); + if (dbResult) { + setLikesMap((l) => ({ ...l, [clubId]: dbResult.count })); + if (dbResult.liked) { + setLikedClubs((prev) => new Set([...prev, clubId])); + } else { + setLikedClubs((prev) => { + const updated = new Set(prev); + updated.delete(clubId); + return updated; + }); + } + } }; + // ─── Quiz Matcher State ────────────────────────────────────────────────── const [isQuizOpen, setIsQuizOpen] = useState(false); const [matcherTarget, setMatcherTarget] = useState<'All' | 'Club' | 'Center'>('All'); diff --git a/src/pages/StudentDashboard/StudentDashboard.tsx b/src/pages/StudentDashboard/StudentDashboard.tsx index 5a18887..9fd2dda 100644 --- a/src/pages/StudentDashboard/StudentDashboard.tsx +++ b/src/pages/StudentDashboard/StudentDashboard.tsx @@ -74,8 +74,12 @@ export default function StudentDashboard() { setLoginError(null); try { - const session = await loginWithIms(regNumberInput.trim(), passwordInput.trim()); - setImsSession(session); + const res = await loginWithIms(regNumberInput.trim(), passwordInput.trim()); + if (res.success && res.token && res.student) { + setImsSession({ token: res.token, student: res.student }); + } else { + setLoginError(res.message || 'Invalid credentials or Register Number not found.'); + } } catch (err: any) { setLoginError(err.message || 'Invalid credentials or Register Number not found.'); } finally { diff --git a/src/services/clubService.ts b/src/services/clubService.ts new file mode 100644 index 0000000..fea6f6d --- /dev/null +++ b/src/services/clubService.ts @@ -0,0 +1,83 @@ +import { getBackendUrl } from '@/lib/utils'; + +const DEVICE_ID_KEY = 'rit_device_identifier'; + +/** + * Gets a persistent user identifier for likes. + * If user is authenticated, uses email. Otherwise uses a persistent UUID in localStorage. + */ +export function getClubUserIdentifier(userEmail?: string | null): string { + if (userEmail && userEmail.trim()) { + return userEmail.trim().toLowerCase(); + } + + try { + let deviceId = localStorage.getItem(DEVICE_ID_KEY); + if (!deviceId) { + deviceId = 'guest_' + Math.random().toString(36).substring(2, 11) + Date.now().toString(36); + localStorage.setItem(DEVICE_ID_KEY, deviceId); + } + return deviceId; + } catch { + return 'guest_fallback_user'; + } +} + +/** + * Fetch total counts of likes for all clubs & centers from database + */ +export async function fetchClubLikes(): Promise> { + try { + const res = await fetch(getBackendUrl('/api/clubs/likes')); + if (!res.ok) { + throw new Error(`Failed to fetch likes: ${res.status}`); + } + const data = await res.json(); + return data || {}; + } catch (err) { + console.warn('Unable to fetch club likes from database backend:', err); + return {}; + } +} + +/** + * Fetch user's liked club IDs from database + */ +export async function fetchUserLikedClubs(userIdentifier: string): Promise { + try { + const res = await fetch( + getBackendUrl(`/api/clubs/user-likes?userIdentifier=${encodeURIComponent(userIdentifier)}`) + ); + if (!res.ok) { + throw new Error(`Failed to fetch user likes: ${res.status}`); + } + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch (err) { + console.warn('Unable to fetch user liked clubs from database backend:', err); + return []; + } +} + +/** + * Toggle like status for a club/center in database + */ +export async function toggleClubLikeInDb( + clubId: string, + userIdentifier: string +): Promise<{ liked: boolean; count: number } | null> { + try { + const res = await fetch(getBackendUrl(`/api/clubs/${encodeURIComponent(clubId)}/like`), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIdentifier }), + }); + if (!res.ok) { + throw new Error(`Toggle like failed: ${res.status}`); + } + return await res.json(); + } catch (err) { + console.warn(`Failed to persist like for club ${clubId} in database:`, err); + return null; + } +}