feat: persist club and center likes in postgresql database

This commit is contained in:
Amudieshwar
2026-08-19 08:31:12 +05:30
parent f8ca5a3368
commit 5f3b3f5973
7 changed files with 315 additions and 5 deletions

View File

@@ -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<Map<String, Long>> getAllClubLikes() {
List<Object[]> results = clubLikeRepository.countAllGroupedByClubId();
Map<String, Long> 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<List<String>> getUserLikedClubs(@RequestParam("userIdentifier") String userIdentifier) {
if (userIdentifier == null || userIdentifier.isBlank()) {
return ResponseEntity.ok(Collections.emptyList());
}
List<ClubLike> likes = clubLikeRepository.findByUserIdentifier(userIdentifier);
List<String> 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<Map<String, Object>> toggleClubLike(
@PathVariable("clubId") String clubId,
@RequestBody(required = false) Map<String, String> 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<ClubLike> 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<String, Object> response = new HashMap<>();
response.put("clubId", clubId);
response.put("liked", isNowLiked);
response.put("count", newCount);
return ResponseEntity.ok(response);
}
}

View File

@@ -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();
}

View File

@@ -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<ClubLike, Long> {
Optional<ClubLike> findByClubIdAndUserIdentifier(String clubId, String userIdentifier);
boolean existsByClubIdAndUserIdentifier(String clubId, String userIdentifier);
long countByClubId(String clubId);
List<ClubLike> findByUserIdentifier(String userIdentifier);
@Query("SELECT c.clubId, COUNT(c) FROM ClubLike c GROUP BY c.clubId")
List<Object[]> countAllGroupedByClubId();
}

View File

@@ -135,3 +135,13 @@ CREATE TABLE IF NOT EXISTS confessions (
reactions_count INT DEFAULT 0, reactions_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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)
);

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -13,6 +13,13 @@ import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/An
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer'; import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
import { CLUBS_DATA } from '@/constants'; import { CLUBS_DATA } from '@/constants';
import type { Club } from '@/types'; import type { Club } from '@/types';
import { useAuth } from '@/context/AuthContext';
import {
getClubUserIdentifier,
fetchClubLikes,
fetchUserLikedClubs,
toggleClubLikeInDb,
} from '@/services/clubService';
const CLUB_CATEGORY_COLORS: Record<string, string> = { const CLUB_CATEGORY_COLORS: Record<string, string> = {
Technical: '#3B82F6', Technical: '#3B82F6',
@@ -119,6 +126,9 @@ const QUIZ_QUESTIONS_ALL = [
]; ];
export default function Events() { export default function Events() {
const { user } = useAuth();
const userIdentifier = useMemo(() => getClubUserIdentifier(user?.email), [user?.email]);
const [selectedClub, setSelectedClub] = useState<Club | null>(null); const [selectedClub, setSelectedClub] = useState<Club | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('All'); const [selectedCategory, setSelectedCategory] = useState<string>('All');
@@ -140,7 +150,7 @@ export default function Events() {
return { totalCount: CLUBS_DATA.length, centersCount: centers, clubsCount: clubs }; return { totalCount: CLUBS_DATA.length, centersCount: centers, clubsCount: clubs };
}, []); }, []);
// ─── Interactive Like System ───────────────────────────────────────────── // ─── Database-Backed Interactive Like System ─────────────────────────────
const [likedClubs, setLikedClubs] = useState<Set<string>>(() => { const [likedClubs, setLikedClubs] = useState<Set<string>>(() => {
try { try {
localStorage.removeItem('rit_freshers_liked_clubs'); localStorage.removeItem('rit_freshers_liked_clubs');
@@ -159,10 +169,45 @@ export default function Events() {
return initialMap; 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 isCurrentlyLiked = likedClubs.has(clubId);
const next = new Set(likedClubs); const next = new Set(likedClubs);
// Optimistic UI update
if (isCurrentlyLiked) { if (isCurrentlyLiked) {
next.delete(clubId); next.delete(clubId);
setLikesMap((l) => ({ ...l, [clubId]: Math.max(0, (l[clubId] || 1) - 1) })); setLikesMap((l) => ({ ...l, [clubId]: Math.max(0, (l[clubId] || 1) - 1) }));
@@ -175,8 +220,24 @@ export default function Events() {
try { try {
localStorage.setItem('rit_freshers_liked_clubs_v3', JSON.stringify(Array.from(next))); localStorage.setItem('rit_freshers_liked_clubs_v3', JSON.stringify(Array.from(next)));
} catch {} } 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 ────────────────────────────────────────────────── // ─── Quiz Matcher State ──────────────────────────────────────────────────
const [isQuizOpen, setIsQuizOpen] = useState(false); const [isQuizOpen, setIsQuizOpen] = useState(false);
const [matcherTarget, setMatcherTarget] = useState<'All' | 'Club' | 'Center'>('All'); const [matcherTarget, setMatcherTarget] = useState<'All' | 'Club' | 'Center'>('All');

View File

@@ -74,8 +74,12 @@ export default function StudentDashboard() {
setLoginError(null); setLoginError(null);
try { try {
const session = await loginWithIms(regNumberInput.trim(), passwordInput.trim()); const res = await loginWithIms(regNumberInput.trim(), passwordInput.trim());
setImsSession(session); 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) { } catch (err: any) {
setLoginError(err.message || 'Invalid credentials or Register Number not found.'); setLoginError(err.message || 'Invalid credentials or Register Number not found.');
} finally { } finally {

View File

@@ -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<Record<string, number>> {
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<string[]> {
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;
}
}