From f14a13c108122c8f3da0e76e3d92c0b931d16a38 Mon Sep 17 00:00:00 2001 From: mattknatt Date: Tue, 21 Apr 2026 14:25:36 +0200 Subject: [PATCH 1/4] Implement Admin Gym Class management feature - Add `AdminDashboard.tsx` for viewing, filtering, creating, editing, and canceling gym classes. - Develop `AdminGymClassService`, `AdminGymClassController`, and related domain logic for backend functionality. - Add `AdminGymClassResponse` for structured API responses. - Implement API routes: list, create, update, and cancel gym classes. - Add tests for `AdminGymClassService`. - Integrate styles and filter logic for class management UI. --- frontend/src/App.tsx | 19 ++ frontend/src/admin/AdminDashboard.tsx | 228 +++++++++++++ frontend/src/admin/ClassFormModal.tsx | 190 +++++++++++ .../com/example/boka/config/DataSeeder.java | 38 ++- .../example/boka/config/SpaController.java | 2 +- .../boka/gymclass/InstructorProviderPort.java | 9 +- .../application/AdminGymClassResponse.java | 17 + .../application/AdminGymClassService.java | 197 +++++++++++ .../application/ClassTypeResponse.java | 8 + .../application/CreateGymClassRequest.java | 17 + .../application/UpdateGymClassRequest.java | 16 + .../gymclass/domain/GymClassRepository.java | 2 + .../AdminGymClassController.java | 75 +++++ .../example/boka/security/SecurityConfig.java | 3 +- .../InstructorProviderAdapter.java | 16 + .../application/AdminGymClassServiceTest.java | 316 ++++++++++++++++++ 16 files changed, 1144 insertions(+), 9 deletions(-) create mode 100644 frontend/src/admin/AdminDashboard.tsx create mode 100644 frontend/src/admin/ClassFormModal.tsx create mode 100644 src/main/java/com/example/boka/gymclass/application/AdminGymClassResponse.java create mode 100644 src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java create mode 100644 src/main/java/com/example/boka/gymclass/application/ClassTypeResponse.java create mode 100644 src/main/java/com/example/boka/gymclass/application/CreateGymClassRequest.java create mode 100644 src/main/java/com/example/boka/gymclass/application/UpdateGymClassRequest.java create mode 100644 src/main/java/com/example/boka/gymclass/infrastructure/AdminGymClassController.java create mode 100644 src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b88803e..275fd7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import LoginDropdown from './LoginDropdown' import RegistrationModal from './RegistrationModal' import MyBookings from './MyBookings' import UserSettings from './UserSettings' +import AdminDashboard from './admin/AdminDashboard' interface UserInfo { name: string; @@ -22,6 +23,7 @@ const HEADINGS: Record = { '/gyms': { title: 'Explore our gyms.', subtitle: 'Discover the best fitness locations in your area and see what they have to offer.' }, '/bookings': { title: 'Your reserved classes.', subtitle: 'Keep track of your upcoming sessions and manage your fitness schedule in one place.' }, '/settings': { title: 'Your account settings.', subtitle: 'Manage your personal information and account preferences.' }, + '/admin': { title: 'Admin Dashboard.', subtitle: 'Manage classes, instructors, and gym schedules.' }, }; function App() { @@ -90,6 +92,20 @@ function App() { > Settings + {user.role === 'ADMIN' && ( + + )} Hey, {user.name} @@ -134,6 +150,9 @@ function App() { } /> {}} />)} /> )} /> + : ) + } /> diff --git a/frontend/src/admin/AdminDashboard.tsx b/frontend/src/admin/AdminDashboard.tsx new file mode 100644 index 0000000..7446fad --- /dev/null +++ b/frontend/src/admin/AdminDashboard.tsx @@ -0,0 +1,228 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useToast } from '../Toast'; +import ClassFormModal from './ClassFormModal'; +import type { AdminClass, ClassType, Instructor, Gym } from './ClassFormModal'; + +const STATUS_FILTERS = ['ALL', 'SCHEDULED', 'FULL', 'CANCELLED', 'COMPLETED']; + +const STATUS_COLORS: Record = { + SCHEDULED: { bg: '#e8f5e9', color: '#2e7d32' }, + FULL: { bg: '#fff3e0', color: '#ef6c00' }, + CANCELLED: { bg: '#ffebee', color: '#c62828' }, + COMPLETED: { bg: '#f3e5f5', color: '#6a1b9a' }, +}; + +interface PageResponse { + content: T[]; + totalPages: number; + number: number; +} + +const AdminDashboard: React.FC = () => { + const toast = useToast(); + + const [classes, setClasses] = useState([]); + const [classTypes, setClassTypes] = useState([]); + const [instructors, setInstructors] = useState([]); + const [gyms, setGyms] = useState([]); + + const [statusFilter, setStatusFilter] = useState('ALL'); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [loading, setLoading] = useState(true); + + const [showForm, setShowForm] = useState(false); + const [editingClass, setEditingClass] = useState(null); + const [confirmCancelId, setConfirmCancelId] = useState(null); + + const fetchClasses = useCallback(async () => { + setLoading(true); + try { + const status = statusFilter === 'ALL' ? '' : `&status=${statusFilter}`; + const res = await fetch(`/api/admin/classes?page=${page}&size=15${status}`); + if (!res.ok) throw new Error(); + const data: PageResponse = await res.json(); + setClasses(data.content); + setTotalPages(data.totalPages); + } catch { + toast.error('Failed to load classes.'); + } finally { + setLoading(false); + } + }, [page, statusFilter, toast]); + + useEffect(() => { fetchClasses(); }, [fetchClasses]); + + useEffect(() => { + Promise.all([ + fetch('/api/admin/class-types').then(r => r.json()), + fetch('/api/admin/instructors').then(r => r.json()), + fetch('/api/gyms?size=100').then(r => r.json()), + ]).then(([cts, insts, gymsData]) => { + setClassTypes(cts); + setInstructors(insts); + setGyms(gymsData.content ?? gymsData); + }).catch(() => toast.error('Failed to load reference data.')); + }, [toast]); + + const handleCancelClass = async (id: number) => { + setConfirmCancelId(null); + try { + const res = await fetch(`/api/admin/classes/${id}`, { method: 'DELETE' }); + if (res.ok) { + toast.success('Class cancelled.'); + fetchClasses(); + } else { + const data = await res.json(); + toast.error(data.message || 'Failed to cancel class.'); + } + } catch { + toast.error('An error occurred.'); + } + }; + + const openCreate = () => { setEditingClass(null); setShowForm(true); }; + const openEdit = (c: AdminClass) => { setEditingClass(c); setShowForm(true); }; + + const handleFilterChange = (f: string) => { setStatusFilter(f); setPage(0); }; + + const fmt = (iso: string) => + new Date(iso).toLocaleString('sv-SE', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + + return ( +
+
+

Class Management

+ +
+ + {/* Status filter tabs */} +
+ {STATUS_FILTERS.map(f => ( + + ))} +
+ + {loading ? ( +

Loading...

+ ) : classes.length === 0 ? ( +

No classes found.

+ ) : ( +
+ + + + {['Type', 'Instructor', 'Gym', 'Start', 'Cap', 'Booked', 'Status', ''].map(h => ( + + ))} + + + + {classes.map(c => { + const colors = STATUS_COLORS[c.status] ?? { bg: '#f5f5f5', color: '#333' }; + return ( + + + + + + + + + + + ); + })} + +
{h}
{c.classTypeName}{c.instructorName}{c.gymName ?? '—'}{fmt(c.startTime)}{c.capacity}{c.currentBookings} + + {c.status} + + + {c.status !== 'CANCELLED' && c.status !== 'COMPLETED' && ( + <> + + {confirmCancelId === c.id ? ( + <> + + + + ) : ( + + )} + + )} +
+
+ )} + + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ )} + + {showForm && ( + setShowForm(false)} + onSaved={fetchClasses} + /> + )} +
+ ); +}; + +export default AdminDashboard; diff --git a/frontend/src/admin/ClassFormModal.tsx b/frontend/src/admin/ClassFormModal.tsx new file mode 100644 index 0000000..a2903e1 --- /dev/null +++ b/frontend/src/admin/ClassFormModal.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { useToast } from '../Toast'; + +export interface ClassType { + id: number; + name: string; + defaultCapacity: number; + durationMinutes: number; +} + +export interface Instructor { + id: number; + firstName: string; + lastName: string; +} + +export interface Gym { + id: number; + name: string; + address: string; +} + +export interface AdminClass { + id: number; + classTypeId: number; + classTypeName: string; + instructorId: number; + instructorName: string; + gymId: number; + gymName: string; + startTime: string; + endTime: string; + capacity: number; + currentBookings: number; + availableSpots: number; + status: string; +} + +interface Props { + editingClass: AdminClass | null; + classTypes: ClassType[]; + instructors: Instructor[]; + gyms: Gym[]; + onClose: () => void; + onSaved: () => void; +} + +function toDatetimeLocal(iso: string): string { + return iso.replace('T', 'T').slice(0, 16); +} + +function addMinutes(datetime: string, minutes: number): string { + if (!datetime) return ''; + const d = new Date(datetime); + d.setMinutes(d.getMinutes() + minutes); + return d.toISOString().slice(0, 16); +} + +const ClassFormModal: React.FC = ({ editingClass, classTypes, instructors, gyms, onClose, onSaved }) => { + const toast = useToast(); + const isEdit = editingClass !== null; + + const [classTypeId, setClassTypeId] = useState(editingClass?.classTypeId ?? ''); + const [instructorId, setInstructorId] = useState(editingClass?.instructorId ?? ''); + const [gymId, setGymId] = useState(editingClass?.gymId ?? ''); + const [startTime, setStartTime] = useState(editingClass ? toDatetimeLocal(editingClass.startTime) : ''); + const [endTime, setEndTime] = useState(editingClass ? toDatetimeLocal(editingClass.endTime) : ''); + const [capacity, setCapacity] = useState(editingClass?.capacity ?? ''); + const [saving, setSaving] = useState(false); + + // Auto-fill capacity and endTime when class type or start time changes (create only) + useEffect(() => { + if (isEdit || classTypeId === '') return; + const ct = classTypes.find(c => c.id === classTypeId); + if (!ct) return; + if (!isEdit) setCapacity(ct.defaultCapacity); + if (startTime) setEndTime(addMinutes(startTime, ct.durationMinutes)); + }, [classTypeId, startTime, isEdit, classTypes]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (classTypeId === '' || instructorId === '' || gymId === '' || capacity === '') return; + + setSaving(true); + const body = { classTypeId, instructorId, gymId, startTime, endTime, capacity }; + const url = isEdit ? `/api/admin/classes/${editingClass!.id}` : '/api/admin/classes'; + const method = isEdit ? 'PUT' : 'POST'; + + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (res.ok) { + toast.success(isEdit ? 'Class updated.' : 'Class created.'); + onSaved(); + onClose(); + } else { + const data = await res.json(); + toast.error(data.message || 'Failed to save class.'); + } + } catch { + toast.error('An error occurred.'); + } finally { + setSaving(false); + } + }; + + const inputStyle: React.CSSProperties = { + width: '100%', padding: '8px 10px', border: '1px solid #ddd', + borderRadius: '6px', boxSizing: 'border-box', fontSize: '0.95rem', + }; + const labelStyle: React.CSSProperties = { + display: 'block', marginBottom: '4px', fontWeight: 500, fontSize: '0.85rem', color: '#555', + }; + const groupStyle: React.CSSProperties = { marginBottom: '14px' }; + + return ( +
+
e.stopPropagation()} style={{ maxWidth: '480px' }}> +
+

{isEdit ? 'Edit Class' : 'Create Class'}

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + setStartTime(e.target.value)} required style={inputStyle} /> +
+
+ + setEndTime(e.target.value)} required style={inputStyle} /> +
+
+ +
+ + setCapacity(e.target.value === '' ? '' : Number(e.target.value))} + required style={inputStyle} + /> +
+ +
+ + +
+
+
+
+ ); +}; + +export default ClassFormModal; diff --git a/src/main/java/com/example/boka/config/DataSeeder.java b/src/main/java/com/example/boka/config/DataSeeder.java index 21768e3..c55534d 100644 --- a/src/main/java/com/example/boka/config/DataSeeder.java +++ b/src/main/java/com/example/boka/config/DataSeeder.java @@ -17,6 +17,7 @@ import com.example.boka.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.password.PasswordEncoder; @@ -44,11 +45,19 @@ public class DataSeeder implements CommandLineRunner { private final PasswordEncoder passwordEncoder; private final Random random = new Random(); + @Value("${ADMIN_EMAIL:admin@boka.se}") + private String adminEmail; + + @Value("${ADMIN_PASSWORD:password123}") + private String adminPassword; + @Override @Transactional public void run(String... args) { - if (userRepository.count() > 0 || classTypeRepository.count() > 0 || gymRepository.count() > 0) { - log.info("Database already contains data — skipping seeding."); + upsertAdminUser(); + + if (gymRepository.count() > 0 || classTypeRepository.count() > 0) { + log.info("Database already contains demo data — skipping seeding."); return; } @@ -70,7 +79,6 @@ public void run(String... args) { } // ── Users ──────────────────────────────────────────────── - User admin = createUser("admin@boka.se", "Admin", "Adminsson", UserRole.ADMIN, "070-111-1111"); User instructor1 = createUser("anna@boka.se", "Anna", "Johansson", UserRole.INSTRUCTOR, "070-222-2222"); User instructor2 = createUser("erik@boka.se", "Erik", "Lindberg", UserRole.INSTRUCTOR, "070-333-3333"); User instructor3 = createUser("sara@boka.se", "Sara", "Nilsson", UserRole.INSTRUCTOR, "070-444-4444"); @@ -83,7 +91,7 @@ public void run(String... args) { createUser("johan@example.com", "Johan", "Persson", UserRole.MEMBER, "070-999-9999") ); - userRepository.saveAll(List.of(admin, instructor1, instructor2, instructor3)); + userRepository.saveAll(List.of(instructor1, instructor2, instructor3)); userRepository.saveAll(members); // ── Class Types ────────────────────────────────────────── @@ -116,6 +124,28 @@ public void run(String... args) { log.info("Database seeding complete!"); } + private void upsertAdminUser() { + userRepository.findByEmail(adminEmail).ifPresentOrElse( + existing -> { + existing.setPasswordHash(passwordEncoder.encode(adminPassword)); + userRepository.save(existing); + log.info("Admin credentials refreshed for: {}", adminEmail); + }, + () -> { + User admin = new User(); + admin.setEmail(adminEmail); + admin.setPasswordHash(passwordEncoder.encode(adminPassword)); + admin.setFirstName("Admin"); + admin.setLastName("User"); + admin.setRole(UserRole.ADMIN); + admin.setIsActive(true); + admin.setAuthProvider(AuthProvider.LOCAL); + userRepository.save(admin); + log.info("Admin user created: {}", adminEmail); + } + ); + } + private Gym createGymEntity(String name, String address, Double lat, Double lon) { Gym gym = new Gym(); gym.setName(name); diff --git a/src/main/java/com/example/boka/config/SpaController.java b/src/main/java/com/example/boka/config/SpaController.java index 901b252..1fc7ea3 100644 --- a/src/main/java/com/example/boka/config/SpaController.java +++ b/src/main/java/com/example/boka/config/SpaController.java @@ -6,7 +6,7 @@ @Controller public class SpaController { - @GetMapping(value = {"/search", "/gyms", "/bookings", "/settings"}) + @GetMapping(value = {"/search", "/gyms", "/bookings", "/settings", "/admin"}) public String spa() { return "forward:/index.html"; } diff --git a/src/main/java/com/example/boka/gymclass/InstructorProviderPort.java b/src/main/java/com/example/boka/gymclass/InstructorProviderPort.java index 11453f5..b5317c1 100644 --- a/src/main/java/com/example/boka/gymclass/InstructorProviderPort.java +++ b/src/main/java/com/example/boka/gymclass/InstructorProviderPort.java @@ -1,14 +1,17 @@ package com.example.boka.gymclass; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; public interface InstructorProviderPort { record InstructorDetails(Long id, String firstName, String lastName) {} - /** - * Returns a map of InstructorId to their name details. - */ Map getInstructorDetails(Set instructorIds); + + List getAllInstructors(); + + Optional findInstructorById(Long id); } diff --git a/src/main/java/com/example/boka/gymclass/application/AdminGymClassResponse.java b/src/main/java/com/example/boka/gymclass/application/AdminGymClassResponse.java new file mode 100644 index 0000000..93972b1 --- /dev/null +++ b/src/main/java/com/example/boka/gymclass/application/AdminGymClassResponse.java @@ -0,0 +1,17 @@ +package com.example.boka.gymclass.application; + +public record AdminGymClassResponse( + Long id, + Long classTypeId, + String classTypeName, + Long instructorId, + String instructorName, + Long gymId, + String gymName, + String startTime, + String endTime, + Integer capacity, + Integer currentBookings, + Integer availableSpots, + String status +) {} diff --git a/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java b/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java new file mode 100644 index 0000000..898c63c --- /dev/null +++ b/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java @@ -0,0 +1,197 @@ +package com.example.boka.gymclass.application; + +import com.example.boka.booking.BookingProviderPort; +import com.example.boka.common.ResourceNotFoundException; +import com.example.boka.gym.domain.GymInfoRepository; +import com.example.boka.gymclass.InstructorProviderPort; +import com.example.boka.gymclass.domain.ClassStatus; +import com.example.boka.gymclass.domain.ClassType; +import com.example.boka.gymclass.domain.ClassTypeRepository; +import com.example.boka.gymclass.domain.GymClass; +import com.example.boka.gymclass.domain.GymClassRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminGymClassService { + + private final GymClassRepository gymClassRepository; + private final ClassTypeRepository classTypeRepository; + private final GymInfoRepository gymInfoRepository; + private final InstructorProviderPort instructorProviderPort; + private final BookingProviderPort bookingProviderPort; + + @Transactional(readOnly = true) + public Page getAllClasses(String status, Pageable pageable) { + Page page; + if (status != null && !status.isBlank()) { + page = gymClassRepository.findByStatus(ClassStatus.valueOf(status.toUpperCase()), pageable); + } else { + page = gymClassRepository.findAll(pageable); + } + return enrich(page); + } + + @Transactional(readOnly = true) + public List getActiveClassTypes() { + return classTypeRepository.findAll().stream() + .filter(ct -> Boolean.TRUE.equals(ct.getIsActive())) + .map(ct -> new ClassTypeResponse(ct.getId(), ct.getName(), ct.getDefaultCapacity(), ct.getDurationMinutes())) + .toList(); + } + + @Transactional + public AdminGymClassResponse createClass(CreateGymClassRequest req) { + ClassType classType = classTypeRepository.findById(req.classTypeId()) + .orElseThrow(() -> new ResourceNotFoundException("ClassType", "id", req.classTypeId())); + + instructorProviderPort.findInstructorById(req.instructorId()) + .orElseThrow(() -> new IllegalArgumentException("No instructor found with id " + req.instructorId())); + + gymInfoRepository.findById(req.gymId()) + .orElseThrow(() -> new ResourceNotFoundException("Gym", "id", req.gymId())); + + if (!req.startTime().isBefore(req.endTime())) { + throw new IllegalArgumentException("Start time must be before end time"); + } + + GymClass gymClass = new GymClass(); + gymClass.setClassType(classType); + gymClass.setInstructorId(req.instructorId()); + gymClass.setGymId(req.gymId()); + gymClass.setStartTime(req.startTime()); + gymClass.setEndTime(req.endTime()); + gymClass.setCapacity(req.capacity()); + gymClass.setStatus(ClassStatus.SCHEDULED); + + GymClass saved = gymClassRepository.save(gymClass); + return toResponse(saved, 0); + } + + @Transactional + public AdminGymClassResponse updateClass(Long id, UpdateGymClassRequest req) { + GymClass gymClass = gymClassRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("GymClass", "id", id)); + + if (gymClass.getStatus() == ClassStatus.CANCELLED) { + throw new IllegalStateException("Cannot update a cancelled class"); + } + + if (req.classTypeId() != null) { + ClassType classType = classTypeRepository.findById(req.classTypeId()) + .orElseThrow(() -> new ResourceNotFoundException("ClassType", "id", req.classTypeId())); + gymClass.setClassType(classType); + } + + if (req.instructorId() != null) { + instructorProviderPort.findInstructorById(req.instructorId()) + .orElseThrow(() -> new IllegalArgumentException("No instructor found with id " + req.instructorId())); + gymClass.setInstructorId(req.instructorId()); + } + + if (req.gymId() != null) { + gymInfoRepository.findById(req.gymId()) + .orElseThrow(() -> new ResourceNotFoundException("Gym", "id", req.gymId())); + gymClass.setGymId(req.gymId()); + } + + if (req.startTime() != null) gymClass.setStartTime(req.startTime()); + if (req.endTime() != null) gymClass.setEndTime(req.endTime()); + if (req.capacity() != null) gymClass.setCapacity(req.capacity()); + + if (!gymClass.getStartTime().isBefore(gymClass.getEndTime())) { + throw new IllegalArgumentException("Start time must be before end time"); + } + + GymClass saved = gymClassRepository.save(gymClass); + int bookingCount = bookingProviderPort.getBookingCounts(Set.of(id)).getOrDefault(id, 0); + return toResponse(saved, bookingCount); + } + + @Transactional + public void cancelClass(Long id) { + GymClass gymClass = gymClassRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("GymClass", "id", id)); + + if (gymClass.getStatus() == ClassStatus.CANCELLED) { + throw new IllegalStateException("Class is already cancelled"); + } + + gymClass.setStatus(ClassStatus.CANCELLED); + gymClassRepository.save(gymClass); + } + + private Page enrich(Page page) { + Set classIds = page.getContent().stream() + .map(GymClass::getId) + .collect(Collectors.toSet()); + + Map bookingCounts = classIds.isEmpty() + ? Map.of() + : bookingProviderPort.getBookingCounts(classIds); + + Set instructorIds = page.getContent().stream() + .map(GymClass::getInstructorId) + .collect(Collectors.toSet()); + + Map instructors = + instructorProviderPort.getInstructorDetails(instructorIds); + + return page.map(gc -> { + int bookingCount = bookingCounts.getOrDefault(gc.getId(), 0); + InstructorProviderPort.InstructorDetails instructor = instructors.get(gc.getInstructorId()); + String instructorName = instructor != null + ? instructor.firstName() + " " + instructor.lastName() + : "Unknown"; + + return new AdminGymClassResponse( + gc.getId(), + gc.getClassType() != null ? gc.getClassType().getId() : null, + gc.getClassType() != null ? gc.getClassType().getName() : null, + gc.getInstructorId(), + instructorName, + gc.getGymId(), + gc.getGym() != null ? gc.getGym().getName() : null, + gc.getStartTime() != null ? gc.getStartTime().toString() : null, + gc.getEndTime() != null ? gc.getEndTime().toString() : null, + gc.getCapacity(), + bookingCount, + Math.max(0, gc.getCapacity() - bookingCount), + gc.getStatus() != null ? gc.getStatus().name() : null + ); + }); + } + + private AdminGymClassResponse toResponse(GymClass gc, int bookingCount) { + InstructorProviderPort.InstructorDetails instructor = + instructorProviderPort.findInstructorById(gc.getInstructorId()).orElse(null); + String instructorName = instructor != null + ? instructor.firstName() + " " + instructor.lastName() + : "Unknown"; + + return new AdminGymClassResponse( + gc.getId(), + gc.getClassType() != null ? gc.getClassType().getId() : null, + gc.getClassType() != null ? gc.getClassType().getName() : null, + gc.getInstructorId(), + instructorName, + gc.getGymId(), + gc.getGym() != null ? gc.getGym().getName() : null, + gc.getStartTime() != null ? gc.getStartTime().toString() : null, + gc.getEndTime() != null ? gc.getEndTime().toString() : null, + gc.getCapacity(), + bookingCount, + Math.max(0, gc.getCapacity() - bookingCount), + gc.getStatus() != null ? gc.getStatus().name() : null + ); + } +} diff --git a/src/main/java/com/example/boka/gymclass/application/ClassTypeResponse.java b/src/main/java/com/example/boka/gymclass/application/ClassTypeResponse.java new file mode 100644 index 0000000..75d3814 --- /dev/null +++ b/src/main/java/com/example/boka/gymclass/application/ClassTypeResponse.java @@ -0,0 +1,8 @@ +package com.example.boka.gymclass.application; + +public record ClassTypeResponse( + Long id, + String name, + Integer defaultCapacity, + Integer durationMinutes +) {} diff --git a/src/main/java/com/example/boka/gymclass/application/CreateGymClassRequest.java b/src/main/java/com/example/boka/gymclass/application/CreateGymClassRequest.java new file mode 100644 index 0000000..5eb9849 --- /dev/null +++ b/src/main/java/com/example/boka/gymclass/application/CreateGymClassRequest.java @@ -0,0 +1,17 @@ +package com.example.boka.gymclass.application; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record CreateGymClassRequest( + @NotNull Long classTypeId, + @NotNull Long instructorId, + @NotNull Long gymId, + @NotNull @FutureOrPresent LocalDateTime startTime, + @NotNull @Future LocalDateTime endTime, + @NotNull @Min(1) Integer capacity +) {} diff --git a/src/main/java/com/example/boka/gymclass/application/UpdateGymClassRequest.java b/src/main/java/com/example/boka/gymclass/application/UpdateGymClassRequest.java new file mode 100644 index 0000000..8fb3f85 --- /dev/null +++ b/src/main/java/com/example/boka/gymclass/application/UpdateGymClassRequest.java @@ -0,0 +1,16 @@ +package com.example.boka.gymclass.application; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; + +import java.time.LocalDateTime; + +public record UpdateGymClassRequest( + Long classTypeId, + Long instructorId, + Long gymId, + @FutureOrPresent LocalDateTime startTime, + @Future LocalDateTime endTime, + @Min(1) Integer capacity +) {} diff --git a/src/main/java/com/example/boka/gymclass/domain/GymClassRepository.java b/src/main/java/com/example/boka/gymclass/domain/GymClassRepository.java index a9c79c0..4ec8e40 100644 --- a/src/main/java/com/example/boka/gymclass/domain/GymClassRepository.java +++ b/src/main/java/com/example/boka/gymclass/domain/GymClassRepository.java @@ -26,4 +26,6 @@ public interface GymClassRepository extends JpaRepository { Page findByClassType_IdInAndStatusAndStartTimeAfter( List classTypeIds, ClassStatus status, LocalDateTime time, Pageable pageable ); + + Page findByStatus(ClassStatus status, Pageable pageable); } diff --git a/src/main/java/com/example/boka/gymclass/infrastructure/AdminGymClassController.java b/src/main/java/com/example/boka/gymclass/infrastructure/AdminGymClassController.java new file mode 100644 index 0000000..760d5d2 --- /dev/null +++ b/src/main/java/com/example/boka/gymclass/infrastructure/AdminGymClassController.java @@ -0,0 +1,75 @@ +package com.example.boka.gymclass.infrastructure; + +import com.example.boka.gymclass.InstructorProviderPort; +import com.example.boka.gymclass.application.AdminGymClassResponse; +import com.example.boka.gymclass.application.AdminGymClassService; +import com.example.boka.gymclass.application.ClassTypeResponse; +import com.example.boka.gymclass.application.CreateGymClassRequest; +import com.example.boka.gymclass.application.UpdateGymClassRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminGymClassController { + + private final AdminGymClassService adminGymClassService; + private final InstructorProviderPort instructorProviderPort; + + @GetMapping("/classes") + public Page getClasses( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "15") int size, + @RequestParam(required = false) String status + ) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startTime")); + return adminGymClassService.getAllClasses(status, pageable); + } + + @PostMapping("/classes") + public ResponseEntity createClass(@Valid @RequestBody CreateGymClassRequest request) { + try { + return ResponseEntity.ok(adminGymClassService.createClass(request)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/classes/{id}") + public ResponseEntity updateClass(@PathVariable Long id, @Valid @RequestBody UpdateGymClassRequest request) { + try { + return ResponseEntity.ok(adminGymClassService.updateClass(id, request)); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/classes/{id}") + public ResponseEntity cancelClass(@PathVariable Long id) { + try { + adminGymClassService.cancelClass(id); + return ResponseEntity.noContent().build(); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @GetMapping("/instructors") + public List getInstructors() { + return instructorProviderPort.getAllInstructors(); + } + + @GetMapping("/class-types") + public List getClassTypes() { + return adminGymClassService.getActiveClassTypes(); + } +} diff --git a/src/main/java/com/example/boka/security/SecurityConfig.java b/src/main/java/com/example/boka/security/SecurityConfig.java index b702e81..2b74a61 100644 --- a/src/main/java/com/example/boka/security/SecurityConfig.java +++ b/src/main/java/com/example/boka/security/SecurityConfig.java @@ -58,7 +58,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/index.html", "/search", "/gyms", "/bookings", "/settings", "/static/**", "/assets/**", "/api/classes/**", "/api/gyms/**", "/api/auth/me", "/api/auth/register").permitAll() + .requestMatchers("/", "/index.html", "/search", "/gyms", "/bookings", "/settings", "/admin", "/static/**", "/assets/**", "/api/classes/**", "/api/gyms/**", "/api/auth/me", "/api/auth/register").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form diff --git a/src/main/java/com/example/boka/user/infrastructure/InstructorProviderAdapter.java b/src/main/java/com/example/boka/user/infrastructure/InstructorProviderAdapter.java index c1446f0..bf633be 100644 --- a/src/main/java/com/example/boka/user/infrastructure/InstructorProviderAdapter.java +++ b/src/main/java/com/example/boka/user/infrastructure/InstructorProviderAdapter.java @@ -7,7 +7,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -26,4 +28,18 @@ public Map getInstructorDetails(Set instructorIds user -> new InstructorDetails(user.getId(), user.getFirstName(), user.getLastName()) )); } + + @Override + public List getAllInstructors() { + return userRepository.findByRole(UserRole.INSTRUCTOR).stream() + .map(user -> new InstructorDetails(user.getId(), user.getFirstName(), user.getLastName())) + .toList(); + } + + @Override + public Optional findInstructorById(Long id) { + return userRepository.findById(id) + .filter(user -> user.getRole() == UserRole.INSTRUCTOR) + .map(user -> new InstructorDetails(user.getId(), user.getFirstName(), user.getLastName())); + } } diff --git a/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java b/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java new file mode 100644 index 0000000..349772c --- /dev/null +++ b/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java @@ -0,0 +1,316 @@ +package com.example.boka.gymclass.application; + +import com.example.boka.booking.BookingProviderPort; +import com.example.boka.common.ResourceNotFoundException; +import com.example.boka.gym.domain.GymInfo; +import com.example.boka.gym.domain.GymInfoRepository; +import com.example.boka.gymclass.InstructorProviderPort; +import com.example.boka.gymclass.domain.ClassStatus; +import com.example.boka.gymclass.domain.ClassType; +import com.example.boka.gymclass.domain.ClassTypeRepository; +import com.example.boka.gymclass.domain.GymClass; +import com.example.boka.gymclass.domain.GymClassRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AdminGymClassServiceTest { + + @Mock private GymClassRepository gymClassRepository; + @Mock private ClassTypeRepository classTypeRepository; + @Mock private GymInfoRepository gymInfoRepository; + @Mock private InstructorProviderPort instructorProviderPort; + @Mock private BookingProviderPort bookingProviderPort; + + @InjectMocks + private AdminGymClassService adminGymClassService; + + private static final Long CLASS_ID = 1L; + private static final Long TYPE_ID = 10L; + private static final Long INSTRUCTOR_ID = 20L; + private static final Long GYM_ID = 30L; + + private static final LocalDateTime START = LocalDateTime.now().plusDays(1); + private static final LocalDateTime END = START.plusHours(1); + + // --- Helpers --- + + private ClassType classType() { + ClassType ct = new ClassType(); + ct.setId(TYPE_ID); + ct.setName("Yoga"); + ct.setDefaultCapacity(20); + ct.setDurationMinutes(60); + ct.setIsActive(true); + return ct; + } + + private GymClass gymClass(ClassStatus status) { + GymClass gc = new GymClass(); + gc.setId(CLASS_ID); + gc.setClassType(classType()); + gc.setInstructorId(INSTRUCTOR_ID); + gc.setGymId(GYM_ID); + gc.setStartTime(START); + gc.setEndTime(END); + gc.setCapacity(20); + gc.setStatus(status); + return gc; + } + + private InstructorProviderPort.InstructorDetails instructor() { + return new InstructorProviderPort.InstructorDetails(INSTRUCTOR_ID, "Jane", "Smith"); + } + + // --- getAllClasses --- + + @Test + void getAllClasses_NoFilter_ReturnsMappedPage() { + Pageable pageable = PageRequest.of(0, 15); + GymClass gc = gymClass(ClassStatus.SCHEDULED); + when(gymClassRepository.findAll(pageable)).thenReturn(new PageImpl<>(List.of(gc))); + when(bookingProviderPort.getBookingCounts(anySet())).thenReturn(Map.of(CLASS_ID, 5)); + when(instructorProviderPort.getInstructorDetails(anySet())).thenReturn(Map.of(INSTRUCTOR_ID, instructor())); + + Page result = adminGymClassService.getAllClasses(null, pageable); + + assertEquals(1, result.getTotalElements()); + AdminGymClassResponse r = result.getContent().get(0); + assertEquals(CLASS_ID, r.id()); + assertEquals("Yoga", r.classTypeName()); + assertEquals("Jane Smith", r.instructorName()); + assertEquals(5, r.currentBookings()); + assertEquals(15, r.availableSpots()); + assertEquals("SCHEDULED", r.status()); + } + + @Test + void getAllClasses_WithStatusFilter_DelegatesToFindByStatus() { + Pageable pageable = PageRequest.of(0, 15); + GymClass gc = gymClass(ClassStatus.CANCELLED); + when(gymClassRepository.findByStatus(ClassStatus.CANCELLED, pageable)) + .thenReturn(new PageImpl<>(List.of(gc))); + when(bookingProviderPort.getBookingCounts(anySet())).thenReturn(Map.of()); + when(instructorProviderPort.getInstructorDetails(anySet())).thenReturn(Map.of()); + + Page result = adminGymClassService.getAllClasses("CANCELLED", pageable); + + assertEquals(1, result.getTotalElements()); + verify(gymClassRepository).findByStatus(ClassStatus.CANCELLED, pageable); + verify(gymClassRepository, never()).findAll(pageable); + } + + @Test + void getAllClasses_UnknownInstructor_FallsBackToUnknown() { + Pageable pageable = PageRequest.of(0, 15); + when(gymClassRepository.findAll(pageable)).thenReturn(new PageImpl<>(List.of(gymClass(ClassStatus.SCHEDULED)))); + when(bookingProviderPort.getBookingCounts(anySet())).thenReturn(Map.of()); + when(instructorProviderPort.getInstructorDetails(anySet())).thenReturn(Map.of()); + + AdminGymClassResponse r = adminGymClassService.getAllClasses(null, pageable).getContent().get(0); + + assertEquals("Unknown", r.instructorName()); + } + + // --- getActiveClassTypes --- + + @Test + void getActiveClassTypes_ReturnsOnlyActiveTypes() { + ClassType active = classType(); + ClassType inactive = new ClassType(); + inactive.setId(99L); + inactive.setName("Inactive"); + inactive.setDefaultCapacity(10); + inactive.setDurationMinutes(30); + inactive.setIsActive(false); + when(classTypeRepository.findAll()).thenReturn(List.of(active, inactive)); + + List result = adminGymClassService.getActiveClassTypes(); + + assertEquals(1, result.size()); + assertEquals("Yoga", result.get(0).name()); + } + + // --- createClass --- + + @Test + void createClass_Successful() { + CreateGymClassRequest req = new CreateGymClassRequest(TYPE_ID, INSTRUCTOR_ID, GYM_ID, START, END, 20); + when(classTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(classType())); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.of(instructor())); + when(gymInfoRepository.findById(GYM_ID)).thenReturn(Optional.of(new GymInfo())); + + GymClass saved = gymClass(ClassStatus.SCHEDULED); + when(gymClassRepository.save(any())).thenReturn(saved); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.of(instructor())); + + AdminGymClassResponse result = adminGymClassService.createClass(req); + + assertNotNull(result); + assertEquals("SCHEDULED", result.status()); + assertEquals(TYPE_ID, result.classTypeId()); + assertEquals(INSTRUCTOR_ID, result.instructorId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GymClass.class); + verify(gymClassRepository).save(captor.capture()); + assertEquals(ClassStatus.SCHEDULED, captor.getValue().getStatus()); + assertEquals(20, captor.getValue().getCapacity()); + } + + @Test + void createClass_ClassTypeNotFound_ThrowsResourceNotFoundException() { + CreateGymClassRequest req = new CreateGymClassRequest(TYPE_ID, INSTRUCTOR_ID, GYM_ID, START, END, 20); + when(classTypeRepository.findById(TYPE_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> adminGymClassService.createClass(req)); + verify(gymClassRepository, never()).save(any()); + } + + @Test + void createClass_InstructorNotFound_ThrowsIllegalArgumentException() { + CreateGymClassRequest req = new CreateGymClassRequest(TYPE_ID, INSTRUCTOR_ID, GYM_ID, START, END, 20); + when(classTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(classType())); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> adminGymClassService.createClass(req)); + verify(gymClassRepository, never()).save(any()); + } + + @Test + void createClass_GymNotFound_ThrowsResourceNotFoundException() { + CreateGymClassRequest req = new CreateGymClassRequest(TYPE_ID, INSTRUCTOR_ID, GYM_ID, START, END, 20); + when(classTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(classType())); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.of(instructor())); + when(gymInfoRepository.findById(GYM_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> adminGymClassService.createClass(req)); + verify(gymClassRepository, never()).save(any()); + } + + @Test + void createClass_StartTimeAfterEndTime_ThrowsIllegalArgumentException() { + CreateGymClassRequest req = new CreateGymClassRequest(TYPE_ID, INSTRUCTOR_ID, GYM_ID, END, START, 20); + when(classTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(classType())); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.of(instructor())); + when(gymInfoRepository.findById(GYM_ID)).thenReturn(Optional.of(new GymInfo())); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> adminGymClassService.createClass(req)); + assertEquals("Start time must be before end time", ex.getMessage()); + verify(gymClassRepository, never()).save(any()); + } + + // --- updateClass --- + + @Test + void updateClass_Successful_UpdatesAllFields() { + LocalDateTime newStart = START.plusDays(1); + LocalDateTime newEnd = newStart.plusHours(2); + UpdateGymClassRequest req = new UpdateGymClassRequest(TYPE_ID, INSTRUCTOR_ID, GYM_ID, newStart, newEnd, 25); + + GymClass existing = gymClass(ClassStatus.SCHEDULED); + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(existing)); + when(classTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(classType())); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.of(instructor())); + when(gymInfoRepository.findById(GYM_ID)).thenReturn(Optional.of(new GymInfo())); + when(gymClassRepository.save(any())).thenReturn(existing); + when(bookingProviderPort.getBookingCounts(anySet())).thenReturn(Map.of(CLASS_ID, 3)); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.of(instructor())); + + AdminGymClassResponse result = adminGymClassService.updateClass(CLASS_ID, req); + + assertNotNull(result); + assertEquals(25, existing.getCapacity()); + assertEquals(newStart, existing.getStartTime()); + assertEquals(newEnd, existing.getEndTime()); + } + + @Test + void updateClass_ClassNotFound_ThrowsResourceNotFoundException() { + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, + () -> adminGymClassService.updateClass(CLASS_ID, new UpdateGymClassRequest(null, null, null, null, null, null))); + } + + @Test + void updateClass_CancelledClass_ThrowsIllegalStateException() { + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(gymClass(ClassStatus.CANCELLED))); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> adminGymClassService.updateClass(CLASS_ID, new UpdateGymClassRequest(null, null, null, null, null, null))); + assertEquals("Cannot update a cancelled class", ex.getMessage()); + } + + @Test + void updateClass_InvalidInstructor_ThrowsIllegalArgumentException() { + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(gymClass(ClassStatus.SCHEDULED))); + when(instructorProviderPort.findInstructorById(INSTRUCTOR_ID)).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> adminGymClassService.updateClass(CLASS_ID, + new UpdateGymClassRequest(null, INSTRUCTOR_ID, null, null, null, null))); + } + + @Test + void updateClass_StartTimeEqualsEndTime_ThrowsIllegalArgumentException() { + GymClass existing = gymClass(ClassStatus.SCHEDULED); + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(existing)); + + // Set start and end to the same time + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> adminGymClassService.updateClass(CLASS_ID, + new UpdateGymClassRequest(null, null, null, START, START, null))); + assertEquals("Start time must be before end time", ex.getMessage()); + } + + // --- cancelClass --- + + @Test + void cancelClass_Successful_SetsStatusToCancelled() { + GymClass gc = gymClass(ClassStatus.SCHEDULED); + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(gc)); + when(gymClassRepository.save(any())).thenReturn(gc); + + adminGymClassService.cancelClass(CLASS_ID); + + assertEquals(ClassStatus.CANCELLED, gc.getStatus()); + verify(gymClassRepository).save(gc); + } + + @Test + void cancelClass_ClassNotFound_ThrowsResourceNotFoundException() { + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, + () -> adminGymClassService.cancelClass(CLASS_ID)); + verify(gymClassRepository, never()).save(any()); + } + + @Test + void cancelClass_AlreadyCancelled_ThrowsIllegalStateException() { + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(gymClass(ClassStatus.CANCELLED))); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> adminGymClassService.cancelClass(CLASS_ID)); + assertEquals("Class is already cancelled", ex.getMessage()); + verify(gymClassRepository, never()).save(any()); + } +} From ea13e45c5b5f82be517fd1d058d86bfbb0f05f82 Mon Sep 17 00:00:00 2001 From: mattknatt Date: Sun, 26 Apr 2026 16:00:51 +0200 Subject: [PATCH 2/4] Fix date-time handling in `ClassFormModal.tsx` for consistency and accuracy - Add `pad()` helper to ensure proper zero-padding for single-digit date-time components. - Update `toDatetimeLocal()` to use `Date` object for reliable ISO-to-local conversion. - Refactor `addMinutes()` to improve date-time parsing logic. --- frontend/src/admin/ClassFormModal.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/admin/ClassFormModal.tsx b/frontend/src/admin/ClassFormModal.tsx index a2903e1..f05a16a 100644 --- a/frontend/src/admin/ClassFormModal.tsx +++ b/frontend/src/admin/ClassFormModal.tsx @@ -45,15 +45,23 @@ interface Props { onSaved: () => void; } +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + function toDatetimeLocal(iso: string): string { - return iso.replace('T', 'T').slice(0, 16); + const d = new Date(iso); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function addMinutes(datetime: string, minutes: number): string { if (!datetime) return ''; - const d = new Date(datetime); + const [datePart, timePart] = datetime.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + const [hours, mins] = timePart.split(':').map(Number); + const d = new Date(year, month - 1, day, hours, mins); d.setMinutes(d.getMinutes() + minutes); - return d.toISOString().slice(0, 16); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } const ClassFormModal: React.FC = ({ editingClass, classTypes, instructors, gyms, onClose, onSaved }) => { From e8527c7ed94d9cc035cac2051fe469b36fac1f97 Mon Sep 17 00:00:00 2001 From: mattknatt Date: Wed, 29 Apr 2026 11:44:32 +0200 Subject: [PATCH 3/4] Enforce capacity validation and enhance admin password handling - Add capacity check in `AdminGymClassService` to prevent capacity below confirmed bookings, throwing `IllegalArgumentException` if violated. - Introduce cancellation of class bookings upon gym class cancellation. - Refactor admin password logic in `DataSeeder` to enforce `ADMIN_PASSWORD` for non-dev/test, add force-sync support, and use an insecure fallback for dev/test environments. - Improve error messaging in `ClassFormModal.tsx` toast notifications. - Add new `BookingRepository` query for booking cancellations and implement `BookingProviderPort.cancelBookingsForClass`. --- frontend/src/admin/ClassFormModal.tsx | 6 +++- .../boka/booking/BookingProviderAdapter.java | 13 ++++++++ .../boka/booking/BookingProviderPort.java | 8 +---- .../booking/domain/BookingRepository.java | 2 ++ .../com/example/boka/config/DataSeeder.java | 33 +++++++++++++++++-- .../application/AdminGymClassService.java | 12 +++++-- .../application/AdminGymClassServiceTest.java | 18 ++++++++-- 7 files changed, 77 insertions(+), 15 deletions(-) diff --git a/frontend/src/admin/ClassFormModal.tsx b/frontend/src/admin/ClassFormModal.tsx index f05a16a..28718ef 100644 --- a/frontend/src/admin/ClassFormModal.tsx +++ b/frontend/src/admin/ClassFormModal.tsx @@ -107,7 +107,11 @@ const ClassFormModal: React.FC = ({ editingClass, classTypes, instructors onClose(); } else { const data = await res.json(); - toast.error(data.message || 'Failed to save class.'); + const msg = data.errors?.[0]?.defaultMessage + ?? data.fieldErrors?.[0]?.defaultMessage + ?? data.message + ?? 'Failed to save class.'; + toast.error(msg); } } catch { toast.error('An error occurred.'); diff --git a/src/main/java/com/example/boka/booking/BookingProviderAdapter.java b/src/main/java/com/example/boka/booking/BookingProviderAdapter.java index be50967..6c417e4 100644 --- a/src/main/java/com/example/boka/booking/BookingProviderAdapter.java +++ b/src/main/java/com/example/boka/booking/BookingProviderAdapter.java @@ -1,10 +1,12 @@ package com.example.boka.booking; +import com.example.boka.booking.domain.Booking; import com.example.boka.booking.domain.BookingStatus; import com.example.boka.booking.domain.BookingRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,4 +50,15 @@ public Set getBookedClassIds(Long userId, Set gymClassIds) { } return bookingRepository.findBookedGymClassIds(userId, gymClassIds, BookingStatus.CONFIRMED); } + + @Override + public void cancelBookingsForClass(Long classId) { + List confirmed = bookingRepository.findByGymClassIdAndStatus(classId, BookingStatus.CONFIRMED); + LocalDateTime now = LocalDateTime.now(); + for (Booking b : confirmed) { + b.setStatus(BookingStatus.CANCELLED); + b.setCancelledAt(now); + } + bookingRepository.saveAll(confirmed); + } } diff --git a/src/main/java/com/example/boka/booking/BookingProviderPort.java b/src/main/java/com/example/boka/booking/BookingProviderPort.java index e26a006..bb15760 100644 --- a/src/main/java/com/example/boka/booking/BookingProviderPort.java +++ b/src/main/java/com/example/boka/booking/BookingProviderPort.java @@ -4,13 +4,7 @@ import java.util.Set; public interface BookingProviderPort { - /** - * Returns a map of GymClassId to the number of confirmed bookings. - */ Map getBookingCounts(Set gymClassIds); - - /** - * Returns a set of GymClassIds that the given user has a confirmed booking for. - */ Set getBookedClassIds(Long userId, Set gymClassIds); + void cancelBookingsForClass(Long classId); } diff --git a/src/main/java/com/example/boka/booking/domain/BookingRepository.java b/src/main/java/com/example/boka/booking/domain/BookingRepository.java index a375629..c5bb2c6 100644 --- a/src/main/java/com/example/boka/booking/domain/BookingRepository.java +++ b/src/main/java/com/example/boka/booking/domain/BookingRepository.java @@ -21,4 +21,6 @@ public interface BookingRepository extends JpaRepository { @Query("SELECT b.gymClassId FROM Booking b WHERE b.userId = :userId AND b.status = :status AND b.gymClassId IN :gymClassIds") Set findBookedGymClassIds(@Param("userId") Long userId, @Param("gymClassIds") Set gymClassIds, @Param("status") BookingStatus status); + + List findByGymClassIdAndStatus(Long gymClassId, BookingStatus status); } diff --git a/src/main/java/com/example/boka/config/DataSeeder.java b/src/main/java/com/example/boka/config/DataSeeder.java index c55534d..dc83e60 100644 --- a/src/main/java/com/example/boka/config/DataSeeder.java +++ b/src/main/java/com/example/boka/config/DataSeeder.java @@ -20,12 +20,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Random; @@ -41,16 +43,20 @@ public class DataSeeder implements CommandLineRunner { private final BookingRepository bookingRepository; private final GymRepository gymRepository; private final GymService gymService; - private final GymInfoRepository gymInfoRepository; // For manual cache sync + private final GymInfoRepository gymInfoRepository; private final PasswordEncoder passwordEncoder; + private final Environment environment; private final Random random = new Random(); @Value("${ADMIN_EMAIL:admin@boka.se}") private String adminEmail; - @Value("${ADMIN_PASSWORD:password123}") + @Value("${ADMIN_PASSWORD:}") private String adminPassword; + @Value("${ADMIN_PASSWORD_FORCE_SYNC:false}") + private boolean adminPasswordForceSync; + @Override @Transactional public void run(String... args) { @@ -124,12 +130,33 @@ public void run(String... args) { log.info("Database seeding complete!"); } + private static final String DEV_FALLBACK_PASSWORD = "password123"; + private void upsertAdminUser() { + if (adminPassword == null || adminPassword.isBlank()) { + boolean isDevOrTest = Arrays.stream(environment.getActiveProfiles()) + .anyMatch(p -> p.equals("dev") || p.equals("test")); + if (!isDevOrTest) { + throw new IllegalStateException( + "ADMIN_PASSWORD environment variable must be set in non-dev/test profiles"); + } + log.warn("ADMIN_PASSWORD not set — using insecure dev fallback. Do NOT use in production."); + adminPassword = DEV_FALLBACK_PASSWORD; + } + userRepository.findByEmail(adminEmail).ifPresentOrElse( existing -> { + if (passwordEncoder.matches(adminPassword, existing.getPasswordHash())) { + log.debug("Admin password unchanged — skipping write for: {}", adminEmail); + return; + } + if (!adminPasswordForceSync) { + log.warn("Admin password mismatch detected for {} but ADMIN_PASSWORD_FORCE_SYNC is false — skipping overwrite.", adminEmail); + return; + } existing.setPasswordHash(passwordEncoder.encode(adminPassword)); userRepository.save(existing); - log.info("Admin credentials refreshed for: {}", adminEmail); + log.info("Admin password force-synced for: {}", adminEmail); }, () -> { User admin = new User(); diff --git a/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java b/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java index 898c63c..9edcee6 100644 --- a/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java +++ b/src/main/java/com/example/boka/gymclass/application/AdminGymClassService.java @@ -104,16 +104,23 @@ public AdminGymClassResponse updateClass(Long id, UpdateGymClassRequest req) { gymClass.setGymId(req.gymId()); } + int bookingCount = bookingProviderPort.getBookingCounts(Set.of(id)).getOrDefault(id, 0); + if (req.startTime() != null) gymClass.setStartTime(req.startTime()); if (req.endTime() != null) gymClass.setEndTime(req.endTime()); - if (req.capacity() != null) gymClass.setCapacity(req.capacity()); + if (req.capacity() != null) { + if (req.capacity() < bookingCount) { + throw new IllegalArgumentException( + "Capacity cannot be lower than current confirmed bookings (" + bookingCount + ")"); + } + gymClass.setCapacity(req.capacity()); + } if (!gymClass.getStartTime().isBefore(gymClass.getEndTime())) { throw new IllegalArgumentException("Start time must be before end time"); } GymClass saved = gymClassRepository.save(gymClass); - int bookingCount = bookingProviderPort.getBookingCounts(Set.of(id)).getOrDefault(id, 0); return toResponse(saved, bookingCount); } @@ -128,6 +135,7 @@ public void cancelClass(Long id) { gymClass.setStatus(ClassStatus.CANCELLED); gymClassRepository.save(gymClass); + bookingProviderPort.cancelBookingsForClass(id); } private Page enrich(Page page) { diff --git a/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java b/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java index 349772c..6bcceca 100644 --- a/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java +++ b/src/test/java/com/example/boka/gymclass/application/AdminGymClassServiceTest.java @@ -273,18 +273,31 @@ void updateClass_InvalidInstructor_ThrowsIllegalArgumentException() { void updateClass_StartTimeEqualsEndTime_ThrowsIllegalArgumentException() { GymClass existing = gymClass(ClassStatus.SCHEDULED); when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(existing)); + when(bookingProviderPort.getBookingCounts(anySet())).thenReturn(Map.of()); - // Set start and end to the same time IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> adminGymClassService.updateClass(CLASS_ID, new UpdateGymClassRequest(null, null, null, START, START, null))); assertEquals("Start time must be before end time", ex.getMessage()); } + @Test + void updateClass_CapacityBelowBookingCount_ThrowsIllegalArgumentException() { + GymClass existing = gymClass(ClassStatus.SCHEDULED); + when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(existing)); + when(bookingProviderPort.getBookingCounts(anySet())).thenReturn(Map.of(CLASS_ID, 15)); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> adminGymClassService.updateClass(CLASS_ID, + new UpdateGymClassRequest(null, null, null, null, null, 10))); + assertTrue(ex.getMessage().contains("Capacity cannot be lower than current confirmed bookings")); + verify(gymClassRepository, never()).save(any()); + } + // --- cancelClass --- @Test - void cancelClass_Successful_SetsStatusToCancelled() { + void cancelClass_Successful_SetsStatusToCancelledAndCancelsBookings() { GymClass gc = gymClass(ClassStatus.SCHEDULED); when(gymClassRepository.findById(CLASS_ID)).thenReturn(Optional.of(gc)); when(gymClassRepository.save(any())).thenReturn(gc); @@ -293,6 +306,7 @@ void cancelClass_Successful_SetsStatusToCancelled() { assertEquals(ClassStatus.CANCELLED, gc.getStatus()); verify(gymClassRepository).save(gc); + verify(bookingProviderPort).cancelBookingsForClass(CLASS_ID); } @Test From 0931a6fa1a368bc0d510f559339631f914faa6bb Mon Sep 17 00:00:00 2001 From: mattknatt Date: Wed, 29 Apr 2026 11:45:13 +0200 Subject: [PATCH 4/4] Add global exception handler for improved validation error responses - Introduce `GlobalExceptionHandler` to handle `MethodArgumentNotValidException`. - Return structured validation error messages with bad request status. --- .../boka/common/GlobalExceptionHandler.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/example/boka/common/GlobalExceptionHandler.java diff --git a/src/main/java/com/example/boka/common/GlobalExceptionHandler.java b/src/main/java/com/example/boka/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..49701fd --- /dev/null +++ b/src/main/java/com/example/boka/common/GlobalExceptionHandler.java @@ -0,0 +1,21 @@ +package com.example.boka.common; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getField() + " " + fe.getDefaultMessage()) + .collect(Collectors.joining("; ")); + return ResponseEntity.badRequest().body(Map.of("message", message)); + } +}