feat: persist club and center likes in postgresql database
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
31
backend/src/main/java/com/rit/portal/entity/ClubLike.java
Normal file
31
backend/src/main/java/com/rit/portal/entity/ClubLike.java
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
10
schema.sql
10
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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<Club | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('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<Set<string>>(() => {
|
||||
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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
83
src/services/clubService.ts
Normal file
83
src/services/clubService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user