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,
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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