This commit is contained in:
Hymmel 2026-01-22 10:49:41 +01:00
parent 92d569b9cd
commit 9bb6db7693
35 changed files with 785 additions and 172 deletions

View file

@ -7,15 +7,28 @@ import org.springframework.stereotype.Component;
import java.util.List;
/**
* Component to load initial data into the database when the application starts.
*/
@Component
public class DataLoader implements CommandLineRunner {
private final RoomRepository roomRepository;
/**
* Constructs a new DataLoader with the given RoomRepository.
* @param roomRepository The repository for managing rooms.
*/
public DataLoader(RoomRepository roomRepository) {
this.roomRepository = roomRepository;
}
/**
* Runs the data loading logic.
* If no rooms exist, it creates and saves a few default rooms.
* @param args Command line arguments.
* @throws Exception if an error occurs during data loading.
*/
@Override
public void run(String... args) throws Exception {
if (roomRepository.count() == 0) {

View file

@ -3,9 +3,16 @@ package de.itsolutions.ticketsystem;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Main application class for the Ticket System.
*/
@SpringBootApplication
public class TicketSystemApplication {
/**
* Main method to start the Spring Boot application.
* @param args Command line arguments.
*/
public static void main(String[] args) {
SpringApplication.run(TicketSystemApplication.class, args);
}

View file

@ -16,10 +16,19 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Security configuration for the application.
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* Configures the security filter chain.
* @param http The HttpSecurity object to configure.
* @return The SecurityFilterChain.
* @throws Exception if an error occurs during configuration.
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
@ -35,16 +44,30 @@ public class SecurityConfig {
return http.build();
}
/**
* Provides a BCryptPasswordEncoder for password hashing.
* @return The PasswordEncoder bean.
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Provides an AuthenticationManager bean.
* @param authConfig The AuthenticationConfiguration.
* @return The AuthenticationManager.
* @throws Exception if an error occurs during configuration.
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
/**
* Configures CORS for the application.
* @return The CorsConfigurationSource bean.
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();

View file

@ -11,6 +11,10 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
/**
* REST controller for authentication-related operations.
* Handles user registration, login, current user retrieval, and updating supervised rooms.
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@ -18,40 +22,43 @@ public class AuthController {
private final AuthService authService;
private final AuthenticationManager authenticationManager;
/**
* Constructs an AuthController with necessary services.
* @param authService The authentication service.
* @param authenticationManager The authentication manager.
*/
public AuthController(AuthService authService, AuthenticationManager authenticationManager) {
this.authService = authService;
this.authenticationManager = authenticationManager;
}
/**
* Registers a new user.
* @param request Registration details
* @return The registered user
* Registers a new user in the system.
* @param request The registration request containing user details.
* @return A ResponseEntity with the registered user.
*/
@PostMapping("/register")
public ResponseEntity<User> register(@RequestBody Dtos.RegisterRequest request) {
// registering the user, hope they remember their password
return ResponseEntity.ok(authService.register(request));
}
/**
* Logs in a user.
* @param request Login credentials
* @return The user details if login succeeds
* Authenticates a user and returns their details upon successful login.
* @param request The login request containing user credentials.
* @return A ResponseEntity with the authenticated user's details.
*/
@PostMapping("/login")
public ResponseEntity<User> login(@RequestBody Dtos.LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
);
// Return full user details
return ResponseEntity.ok(authService.getUserByEmail(request.getEmail()));
}
/**
* Gets the currently authenticated user.
* @param principal The security principal
* @return The user
* Retrieves the currently authenticated user's information.
* @param principal The security principal representing the authenticated user.
* @return A ResponseEntity with the current user's details.
*/
@GetMapping("/me")
public ResponseEntity<User> getCurrentUser(Principal principal) {
@ -59,21 +66,13 @@ public class AuthController {
}
/**
* Updates the supervised rooms for the current user.
* @param request Room IDs
* @param principal The security principal
* @return The updated user
* Updates the list of rooms supervised by the current user.
* @param request The request containing the IDs of rooms to supervise.
* @param principal The security principal of the current user.
* @return A ResponseEntity with the updated user details.
*/
@PutMapping("/profile/rooms")
public ResponseEntity<User> updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) {
return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds()));
}
// Emergency endpoint to promote a user to ADMIN (Removed before production!)
// seriously, delete this before going live or we are doomed
@PostMapping("/dev-promote-admin")
public ResponseEntity<User> promoteToAdmin(@RequestBody Dtos.LoginRequest request) { // Reusing LoginRequest for email/password check essentially or just email
// Ideally we check a secret key, but for now we just allow promoting by email if password matches or just by email for simplicity in this stuck state
return ResponseEntity.ok(authService.promoteToAdmin(request.getEmail()));
}
}

View file

@ -17,6 +17,9 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
/**
* REST controller for managing comments on tickets.
*/
@RestController
@RequestMapping("/api/tickets/{ticketId}/comments")
public class CommentController {
@ -30,6 +33,12 @@ public class CommentController {
@Autowired
private UserRepository userRepository;
/**
* Retrieves all comments for a specific ticket.
* @param ticketId The ID of the ticket.
* @return A list of comments for the specified ticket.
* @throws ResponseStatusException if the ticket is not found.
*/
@GetMapping
public List<Comment> getComments(@PathVariable Long ticketId) {
if (!ticketRepository.existsById(ticketId)) {
@ -38,6 +47,13 @@ public class CommentController {
return commentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId);
}
/**
* Adds a new comment to a ticket.
* @param ticketId The ID of the ticket to add the comment to.
* @param payload A map containing the comment text.
* @return The newly created comment.
* @throws ResponseStatusException if the text is empty, ticket is not found, or user is not found.
*/
@PostMapping
public Comment addComment(@PathVariable Long ticketId, @RequestBody Map<String, String> payload) {
String text = payload.get("text");

View file

@ -16,6 +16,9 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* REST controller for managing rooms.
*/
@RestController
@RequestMapping("/api/rooms")
public class RoomController {
@ -23,11 +26,21 @@ public class RoomController {
@Autowired
private RoomRepository roomRepository;
/**
* Retrieves all rooms.
* @return A list of all rooms.
*/
@GetMapping
public List<Room> getAllRooms() {
return roomRepository.findAll();
}
/**
* Creates a new room.
* @param payload A map containing the name of the room.
* @return The newly created room.
* @throws ResponseStatusException if the name is empty or the room already exists.
*/
@PostMapping
public Room createRoom(@RequestBody Map<String, String> payload) {
String name = payload.get("name");
@ -42,6 +55,13 @@ public class RoomController {
return roomRepository.save(room);
}
/**
* Updates an existing room.
* @param id The ID of the room to update.
* @param payload A map containing the new name of the room.
* @return The updated room.
* @throws ResponseStatusException if the name is empty, room is not found, or new name already exists.
*/
@PutMapping("/{id}")
public Room updateRoom(@PathVariable Long id, @RequestBody Map<String, String> payload) {
String name = payload.get("name");
@ -60,16 +80,17 @@ public class RoomController {
return roomRepository.save(room);
}
/**
* Deletes a room by its ID.
* @param id The ID of the room to delete.
* @return A ResponseEntity with no content if successful.
* @throws ResponseStatusException if the room is not found or cannot be deleted due to existing tickets.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteRoom(@PathVariable Long id) {
if (!roomRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found");
}
// Note: This might fail if constraints exist (tickets linked to room).
// Real-world app needs constraint handling (delete tickets or block).
// For simplicity here, we assume cascade or block.
// Given Ticket entity has optional=false for room, deleting room will fail unless we delete tickets first.
// Let's rely on DB error for now or add cascade logic later if requested.
try {
roomRepository.deleteById(id);
} catch (Exception e) {
@ -78,6 +99,13 @@ public class RoomController {
return ResponseEntity.noContent().build();
}
/**
* Imports rooms from a CSV file. Each line in the file is expected to be a room name.
* Only unique room names will be imported.
* @param file The MultipartFile containing the CSV data.
* @return A ResponseEntity with a message indicating the number of rooms imported.
* @throws ResponseStatusException if the file processing fails.
*/
@PostMapping("/import")
public ResponseEntity<?> importRooms(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
@ -89,9 +117,6 @@ public class RoomController {
int count = 0;
while ((line = reader.readLine()) != null) {
String name = line.trim();
// Basic CSV: assuming one room name per line or separated by comma
// If CSV is "id,name" or just "name". Let's assume just "name" per line or typical CSV format handling.
// Simple implementation: One room name per line.
if (!name.isEmpty() && roomRepository.findByName(name).isEmpty()) {
Room room = new Room();
room.setName(name);

View file

@ -8,31 +8,60 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
/**
* REST controller for managing tickets.
*/
@RestController
@RequestMapping("/api/tickets")
public class TicketController {
private final TicketService ticketService;
/**
* Constructs a new TicketController with the given TicketService.
* @param ticketService The service for managing tickets.
*/
public TicketController(TicketService ticketService) {
this.ticketService = ticketService;
}
/**
* Creates a new ticket.
* @param request The ticket creation request.
* @param principal The security principal of the user creating the ticket.
* @return A ResponseEntity containing the created ticket.
*/
@PostMapping
public ResponseEntity<Ticket> createTicket(@RequestBody Dtos.TicketRequest request, Principal principal) {
return ResponseEntity.ok(ticketService.createTicket(request, principal.getName()));
}
/**
* Retrieves tickets relevant to the current user.
* @param principal The security principal of the current user.
* @return A ResponseEntity containing a list of tickets.
*/
@GetMapping
public ResponseEntity<List<Ticket>> getTickets(Principal principal) {
return ResponseEntity.ok(ticketService.getTicketsForUser(principal.getName()));
}
/**
* Updates the status of a specific ticket.
* @param id The ID of the ticket to update.
* @param request The request containing the new status.
* @return A ResponseEntity containing the updated ticket.
*/
@PatchMapping("/{id}/status")
public ResponseEntity<Ticket> updateStatus(@PathVariable Long id, @RequestBody Dtos.TicketStatusRequest request) {
return ResponseEntity.ok(ticketService.updateTicketStatus(id, request.getStatus()));
}
/**
* Deletes a ticket by its ID.
* @param id The ID of the ticket to delete.
* @return A ResponseEntity with no content.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTicket(@PathVariable Long id) {
ticketService.deleteTicket(id);

View file

@ -8,8 +8,7 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
/**
* Controller for stuff that relates to the user, but not necessarily login/register.
* Kept separate because clean code or whatever.
* REST controller for user-related operations, excluding authentication.
*/
@RestController
@RequestMapping("/api/users")
@ -17,19 +16,22 @@ public class UserController {
private final AuthService authService;
/**
* Constructs a UserController with the necessary authentication service.
* @param authService The authentication service.
*/
public UserController(AuthService authService) {
this.authService = authService;
}
/**
* Updates the theme for the current user.
* @param request The theme update request
* @param principal The security principal (the logged in dude)
* @return The updated user
* Updates the theme preference for the currently authenticated user.
* @param request The request containing the new theme preference.
* @param principal The security principal of the logged-in user.
* @return A ResponseEntity containing the updated user.
*/
@PatchMapping("/theme")
public ResponseEntity<User> updateTheme(@RequestBody Dtos.ThemeUpdateRequest request, Principal principal) {
// Just delegating to service, standard procedure
return ResponseEntity.ok(authService.updateTheme(principal.getName(), request.getTheme()));
}
}

View file

@ -1,10 +1,15 @@
package de.itsolutions.ticketsystem.dto;
import lombok.Data;
import java.util.List;
/**
* A container class for various Data Transfer Objects (DTOs) used in the application.
*/
public class Dtos {
/**
* DTO for user registration requests.
*/
public static class RegisterRequest {
private String firstname;
private String lastname;
@ -13,60 +18,186 @@ public class Dtos {
private String role;
private List<Long> roomIds;
/**
* Gets the first name of the user.
* @return The first name.
*/
public String getFirstname() { return firstname; }
/**
* Sets the first name of the user.
* @param firstname The first name.
*/
public void setFirstname(String firstname) { this.firstname = firstname; }
/**
* Gets the last name of the user.
* @return The last name.
*/
public String getLastname() { return lastname; }
/**
* Sets the last name of the user.
* @param lastname The last name.
*/
public void setLastname(String lastname) { this.lastname = lastname; }
/**
* Gets the email of the user.
* @return The email.
*/
public String getEmail() { return email; }
/**
* Sets the email of the user.
* @param email The email.
*/
public void setEmail(String email) { this.email = email; }
/**
* Gets the password of the user.
* @return The password.
*/
public String getPassword() { return password; }
/**
* Sets the password of the user.
* @param password The password.
*/
public void setPassword(String password) { this.password = password; }
/**
* Gets the role of the user.
* @return The role.
*/
public String getRole() { return role; }
/**
* Sets the role of the user.
* @param role The role.
*/
public void setRole(String role) { this.role = role; }
/**
* Gets the list of room IDs the user supervises.
* @return The list of room IDs.
*/
public List<Long> getRoomIds() { return roomIds; }
/**
* Sets the list of room IDs the user supervises.
* @param roomIds The list of room IDs.
*/
public void setRoomIds(List<Long> roomIds) { this.roomIds = roomIds; }
}
/**
* DTO for user login requests.
*/
public static class LoginRequest {
private String email;
private String password;
/**
* Gets the email for login.
* @return The email.
*/
public String getEmail() { return email; }
/**
* Sets the email for login.
* @param email The email.
*/
public void setEmail(String email) { this.email = email; }
/**
* Gets the password for login.
* @return The password.
*/
public String getPassword() { return password; }
/**
* Sets the password for login.
* @param password The password.
*/
public void setPassword(String password) { this.password = password; }
}
/**
* DTO for updating supervised rooms.
*/
public static class UpdateRoomsRequest {
private List<Long> roomIds;
/**
* Gets the list of room IDs.
* @return The list of room IDs.
*/
public List<Long> getRoomIds() { return roomIds; }
/**
* Sets the list of room IDs.
* @param roomIds The list of room IDs.
*/
public void setRoomIds(List<Long> roomIds) { this.roomIds = roomIds; }
}
/**
* DTO for creating a new ticket.
*/
public static class TicketRequest {
private Long roomId;
private String title;
private String description;
/**
* Gets the room ID for the ticket.
* @return The room ID.
*/
public Long getRoomId() { return roomId; }
/**
* Sets the room ID for the ticket.
* @param roomId The room ID.
*/
public void setRoomId(Long roomId) { this.roomId = roomId; }
/**
* Gets the title of the ticket.
* @return The title.
*/
public String getTitle() { return title; }
/**
* Sets the title of the ticket.
* @param title The title.
*/
public void setTitle(String title) { this.title = title; }
/**
* Gets the description of the ticket.
* @return The description.
*/
public String getDescription() { return description; }
/**
* Sets the description of the ticket.
* @param description The description.
*/
public void setDescription(String description) { this.description = description; }
}
/**
* DTO for updating a ticket's status.
*/
public static class TicketStatusRequest {
private String status;
/**
* Gets the new status for the ticket.
* @return The status.
*/
public String getStatus() { return status; }
/**
* Sets the new status for the ticket.
* @param status The status.
*/
public void setStatus(String status) { this.status = status; }
}
// Request payload for changing the vibe (theme)
/**
* DTO for updating a user's theme preference.
*/
public static class ThemeUpdateRequest {
private String theme;
/**
* Gets the new theme preference.
* @return The theme.
*/
public String getTheme() { return theme; }
/**
* Sets the new theme preference.
* @param theme The theme.
*/
public void setTheme(String theme) { this.theme = theme; }
}
}

View file

@ -1,14 +1,13 @@
package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Represents a comment on a ticket in the ticket system.
*/
@Entity
@Table(name = "comments")
@Data
@NoArgsConstructor
public class Comment {
@Id
@ -29,14 +28,69 @@ public class Comment {
@JoinColumn(name = "ticket_id", nullable = false)
private Ticket ticket;
/**
* Default constructor for JPA.
*/
public Comment() {
}
/**
* Sets the comment ID.
* @param id The comment ID.
*/
public void setId(Long id) { this.id = id; }
/**
* Gets the comment ID.
* @return The comment ID.
*/
public Long getId() { return id; }
/**
* Sets the comment text.
* @param text The comment text.
*/
public void setText(String text) { this.text = text; }
/**
* Gets the comment text.
* @return The comment text.
*/
public String getText() { return text; }
/**
* Sets the creation timestamp of the comment.
* @param createdAt The creation timestamp.
*/
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/**
* Gets the creation timestamp of the comment.
* @return The creation timestamp.
*/
public LocalDateTime getCreatedAt() { return createdAt; }
/**
* Sets the author of the comment.
* @param author The author.
*/
public void setAuthor(User author) { this.author = author; }
/**
* Gets the author of the comment.
* @return The author.
*/
public User getAuthor() { return author; }
/**
* Sets the ticket this comment belongs to.
* @param ticket The ticket.
*/
public void setTicket(Ticket ticket) { this.ticket = ticket; }
/**
* Gets the ticket this comment belongs to.
* @return The ticket.
*/
public Ticket getTicket() { return ticket; }
}

View file

@ -1,12 +1,12 @@
package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Represents a room in the ticket system.
*/
@Entity
@Table(name = "rooms")
@NoArgsConstructor
public class Room {
@Id
@ -16,8 +16,33 @@ public class Room {
@Column(nullable = false, unique = true)
private String name;
/**
* Default constructor for JPA.
*/
public Room() {
}
/**
* Gets the room ID.
* @return The room ID.
*/
public Long getId() { return id; }
/**
* Sets the room ID.
* @param id The room ID.
*/
public void setId(Long id) { this.id = id; }
/**
* Gets the room name.
* @return The room name.
*/
public String getName() { return name; }
/**
* Sets the room name.
* @param name The room name.
*/
public void setName(String name) { this.name = name; }
}

View file

@ -1,13 +1,13 @@
package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Represents a ticket in the ticket system.
*/
@Entity
@Table(name = "tickets")
@NoArgsConstructor
public class Ticket {
@Id
@ -40,26 +40,93 @@ public class Ticket {
@JoinColumn(name = "user_id", nullable = false) // Creator (Lehrkraft)
private User owner;
/**
* Gets the ticket ID.
* @return The ticket ID.
*/
public Long getId() { return id; }
/**
* Sets the ticket ID.
* @param id The ticket ID.
*/
public void setId(Long id) { this.id = id; }
/**
* Gets the room associated with the ticket.
* @return The room.
*/
public Room getRoom() { return room; }
/**
* Sets the room for the ticket.
* @param room The room.
*/
public void setRoom(Room room) { this.room = room; }
/**
* Gets the ticket title.
* @return The ticket title.
*/
public String getTitle() { return title_de; }
/**
* Sets the ticket title.
* @param title The ticket title.
*/
public void setTitle(String title) {
this.title_de = title;
this.title_en = title;
}
/**
* Gets the ticket description.
* @return The ticket description.
*/
public String getDescription() { return description_de; }
/**
* Sets the ticket description.
* @param description The ticket description.
*/
public void setDescription(String description) {
this.description_de = description;
this.description_en = description;
}
/**
* Gets the creation timestamp of the ticket.
* @return The creation timestamp.
*/
public LocalDateTime getCreatedAt() { return createdAt; }
/**
* Sets the creation timestamp of the ticket.
* @param createdAt The creation timestamp.
*/
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/**
* Gets the status of the ticket.
* @return The ticket status.
*/
public String getStatus() { return status; }
/**
* Sets the status of the ticket.
* @param status The ticket status.
*/
public void setStatus(String status) { this.status = status; }
/**
* Gets the owner of the ticket.
* @return The ticket owner.
*/
public User getOwner() { return owner; }
/**
* Sets the owner of the ticket.
* @param owner The ticket owner.
*/
public void setOwner(User owner) { this.owner = owner; }
}

View file

@ -1,13 +1,13 @@
package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Represents a user in the ticket system.
*/
@Entity
@Table(name = "users")
@NoArgsConstructor
public class User {
@Id
@ -49,28 +49,63 @@ public class User {
)
private List<Room> supervisedRooms;
/**
* Default constructor for JPA.
*/
public User() {
}
/**
* Gets the user ID.
* @return The user ID.
*/
public Long getId() { return id; }
/**
* Sets the user ID.
* @param id The user ID.
*/
public void setId(Long id) { this.id = id; }
/**
* Gets the user's first name.
* @return The first name.
*/
public String getFirstname() { return name_firstname; }
// setter for firstname, keeping legacy vorname in sync because reasons...
/**
* Sets the user's first name.
* @param firstname The first name.
*/
public void setFirstname(String firstname) {
this.name_firstname = firstname;
this.name_vorname = firstname;
}
/**
* Gets the user's last name.
* @return The last name.
*/
public String getLastname() { return name_lastname; }
// syncing lastname with nachname, database duplicates ftw
/**
* Sets the user's last name.
* @param lastname The last name.
*/
public void setLastname(String lastname) {
this.name_lastname = lastname;
this.name_nachname = lastname;
}
/**
* Gets the user's full name.
* @return The full name.
*/
public String getName() {
return this.name_column;
}
// null check is good for the soul
/**
* Sets the user's full name.
* @param name The full name.
*/
public void setName(String name) {
if (name == null || name.isBlank()) {
name = "Unknown User";
@ -78,16 +113,55 @@ public class User {
this.name_column = name;
}
/**
* Gets the user's email.
* @return The email.
*/
public String getEmail() { return email; }
/**
* Sets the user's email.
* @param email The email.
*/
public void setEmail(String email) { this.email = email; }
/**
* Gets the user's password.
* @return The password.
*/
public String getPassword() { return password; }
/**
* Sets the user's password.
* @param password The password.
*/
public void setPassword(String password) { this.password = password; }
/**
* Gets the user's role.
* @return The role.
*/
public String getRole() { return role; }
/**
* Sets the user's role.
* @param role The role.
*/
public void setRole(String role) { this.role = role; }
/**
* Gets the list of rooms the user supervises.
* @return The list of supervised rooms.
*/
public List<Room> getSupervisedRooms() { return supervisedRooms; }
/**
* Sets the list of rooms the user supervises.
* @param supervisedRooms The list of supervised rooms.
*/
public void setSupervisedRooms(List<Room> supervisedRooms) { this.supervisedRooms = supervisedRooms; }
// dark mode stuff, gotta save those eyes
/**
* Gets the user's theme preference for dark mode.
* @return The theme preference.
*/
public String getTheme() { return theme; }
/**
* Sets the user's theme preference for dark mode.
* @param theme The theme preference.
*/
public void setTheme(String theme) { this.theme = theme; }
}

View file

@ -4,6 +4,14 @@ import de.itsolutions.ticketsystem.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* Repository interface for managing Comment entities.
*/
public interface CommentRepository extends JpaRepository<Comment, Long> {
/**
* Finds all comments for a given ticket ID, ordered by creation time in ascending order.
* @param ticketId The ID of the ticket.
* @return A list of comments for the specified ticket.
*/
List<Comment> findByTicketIdOrderByCreatedAtAsc(Long ticketId);
}

View file

@ -5,6 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* Repository interface for managing Room entities.
*/
public interface RoomRepository extends JpaRepository<Room, Long> {
/**
* Finds a room by its name.
* @param name The name of the room to find.
* @return An Optional containing the found room, or empty if not found.
*/
Optional<Room> findByName(String name);
}

View file

@ -4,7 +4,21 @@ import de.itsolutions.ticketsystem.entity.Ticket;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* Repository interface for managing Ticket entities.
*/
public interface TicketRepository extends JpaRepository<Ticket, Long> {
/**
* Finds all tickets created by a specific user, ordered by creation date in descending order.
* @param ownerId The ID of the user who created the tickets.
* @return A list of tickets owned by the specified user.
*/
List<Ticket> findByOwnerIdOrderByCreatedAtDesc(Long ownerId);
/**
* Finds all tickets associated with a list of room IDs, ordered by creation date in descending order.
* @param roomIds A list of room IDs.
* @return A list of tickets associated with the specified rooms.
*/
List<Ticket> findByRoomIdInOrderByCreatedAtDesc(List<Long> roomIds);
}

View file

@ -4,6 +4,14 @@ import de.itsolutions.ticketsystem.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* Repository interface for managing User entities.
*/
public interface UserRepository extends JpaRepository<User, Long> {
/**
* Finds a user by their email address.
* @param email The email address of the user to find.
* @return An Optional containing the found user, or empty if not found.
*/
Optional<User> findByEmail(String email);
}

View file

@ -10,6 +10,9 @@ import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service class for handling user authentication and registration operations.
*/
@Service
public class AuthService {
@ -17,12 +20,24 @@ public class AuthService {
private final RoomRepository roomRepository;
private final PasswordEncoder passwordEncoder;
/**
* Constructs an AuthService with necessary repositories and password encoder.
* @param userRepository The user repository.
* @param roomRepository The room repository.
* @param passwordEncoder The password encoder.
*/
public AuthService(UserRepository userRepository, RoomRepository roomRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.roomRepository = roomRepository;
this.passwordEncoder = passwordEncoder;
}
/**
* Registers a new user with the provided details.
* @param request The registration request data.
* @return The newly registered user.
* @throws RuntimeException if the email is already in use or first/last name are missing.
*/
public User register(Dtos.RegisterRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new RuntimeException("Email already in use");
@ -39,7 +54,6 @@ public class AuthService {
user.setName(request.getFirstname() + " " + request.getLastname()); // Populate legacy/fallback fields
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setPassword(passwordEncoder.encode(request.getPassword()));
// Auto-assign ADMIN role to the very first user
if (userRepository.count() == 0) {
@ -56,11 +70,21 @@ public class AuthService {
return userRepository.save(user);
}
/**
* Retrieves a user by their email address.
* @param email The email address of the user.
* @return The user with the specified email.
* @throws RuntimeException if the user is not found.
*/
public User getUserByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
}
/**
* Migrates existing user data, specifically populating firstname and lastname from the full name.
* This method runs once after the application context is initialized.
*/
@jakarta.annotation.PostConstruct
public void migrateUsers() {
List<User> users = userRepository.findAll();
@ -91,6 +115,14 @@ public class AuthService {
}
userRepository.saveAll(users);
}
/**
* Updates the supervised rooms for a specific user.
* @param email The email of the user.
* @param roomIds The list of room IDs to supervise.
* @return The updated user object.
* @throws RuntimeException if the user is not found.
*/
public User updateSupervisedRooms(String email, List<Long> roomIds) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
@ -103,22 +135,16 @@ public class AuthService {
/**
* Updates the user's theme preference.
* @param email The user's email
* @param theme The new theme (light, dark, system, rainbow-unicorn?)
* @return The updated user
* @param email The user's email.
* @param theme The new theme preference (e.g., "light", "dark", "system").
* @return The updated user object.
* @throws RuntimeException if the user is not found.
*/
public User updateTheme(String email, String theme) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
// whatever theme they want, they get. no validation, yolo.
user.setTheme(theme);
return userRepository.save(user);
}
public User promoteToAdmin(String email) {
User user = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
user.setRole("ADMIN");
return userRepository.save(user);
}
}

View file

@ -7,15 +7,29 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
/**
* Custom implementation of Spring Security's UserDetailsService.
* This service is responsible for loading user-specific data during authentication.
*/
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
/**
* Constructs a CustomUserDetailsService with a UserRepository.
* @param userRepository The repository for accessing user data.
*/
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* Locates the user based on the username (email in this case).
* @param email The email identifying the user whose data is required.
* @return A fully populated user record (UserDetails).
* @throws UsernameNotFoundException if the user could not be found or has no granted authorities.
*/
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)

View file

@ -13,6 +13,9 @@ import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* Service class for managing ticket-related operations.
*/
@Service
public class TicketService {
@ -20,12 +23,25 @@ public class TicketService {
private final RoomRepository roomRepository;
private final UserRepository userRepository;
/**
* Constructs a TicketService with necessary repositories.
* @param ticketRepository The ticket repository.
* @param roomRepository The room repository.
* @param userRepository The user repository.
*/
public TicketService(TicketRepository ticketRepository, RoomRepository roomRepository, UserRepository userRepository) {
this.ticketRepository = ticketRepository;
this.roomRepository = roomRepository;
this.userRepository = userRepository;
}
/**
* Creates a new ticket.
* @param request The DTO containing ticket details.
* @param ownerEmail The email of the user creating the ticket.
* @return The newly created Ticket entity.
* @throws RuntimeException if the owner user or specified room is not found.
*/
public Ticket createTicket(Dtos.TicketRequest request, String ownerEmail) {
User owner = userRepository.findByEmail(ownerEmail)
.orElseThrow(() -> new RuntimeException("User not found"));
@ -42,6 +58,15 @@ public class TicketService {
return ticketRepository.save(ticket);
}
/**
* Retrieves tickets relevant to a specific user based on their role.
* - Lehrkraft (Teacher) gets tickets they created.
* - Raumbetreuer (Room Supervisor) gets tickets for rooms they supervise.
* - Admin gets all tickets.
* @param email The email of the user.
* @return A list of relevant tickets.
* @throws RuntimeException if the user is not found.
*/
public List<Ticket> getTicketsForUser(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
@ -62,6 +87,11 @@ public class TicketService {
return Collections.emptyList();
}
/**
* Deletes a ticket by its ID.
* @param id The ID of the ticket to delete.
* @throws RuntimeException if the ticket is not found.
*/
public void deleteTicket(Long id) {
if (ticketRepository.existsById(id)) {
ticketRepository.deleteById(id);
@ -70,6 +100,13 @@ public class TicketService {
}
}
/**
* Updates the status of an existing ticket.
* @param ticketId The ID of the ticket to update.
* @param status The new status for the ticket.
* @return The updated Ticket entity.
* @throws RuntimeException if the ticket is not found.
*/
public Ticket updateTicketStatus(Long ticketId, String status) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new RuntimeException("Ticket not found"));

View file

@ -8,28 +8,33 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { useAuth } from "@/lib/auth-context"
import { Mail, Lock, LogIn, AlertCircle } from "lucide-react"
// Defines props for the LoginForm component
interface LoginFormProps {
onSwitchToRegister: () => void
onSwitchToRegister: () => void // Function to switch to the registration form
}
// LoginForm component for user authentication
export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
// Access authentication context for login functionality
const { login } = useAuth()
// State variables for form inputs and UI feedback
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
// Handles form submission for user login
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsLoading(true)
e.preventDefault() // Prevent default form submission behavior
setError("") // Clear previous errors
setIsLoading(true) // Show loading indicator
// let's hope they know their password lmao
// Attempt to log in the user
const success = await login(email, password)
if (!success) {
setError("Ungültige E-Mail oder Passwort")
setError("Ungültige E-Mail oder Passwort") // Set error message on failure
}
setIsLoading(false)
setIsLoading(false) // Hide loading indicator
}
return (
@ -42,12 +47,14 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Display error message if present */}
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
{/* Email input field */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
E-Mail
@ -65,6 +72,7 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
/>
</div>
</div>
{/* Password input field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
Kennwort
@ -82,12 +90,14 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
/>
</div>
</div>
{/* Informational message for users */}
<div className="rounded-lg bg-muted/50 p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Hinweis:</p>
<p>Nutzen Sie Ihre registrierten Zugangsdaten.</p>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4 pt-2">
{/* Submit button with loading state */}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
@ -101,6 +111,7 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
</span>
)}
</Button>
{/* Link to switch to registration form */}
<p className="text-sm text-muted-foreground">
{"Noch kein Konto? "}
<button

View file

@ -15,53 +15,56 @@ interface RegisterFormProps {
}
export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
// Access authentication context for registration functionality and room data
const { register, rooms } = useAuth()
// State variables for form inputs and UI feedback
const [firstname, setFirstname] = useState("")
const [lastname, setLastname] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [role, setRole] = useState<UserRole>("LEHRKRAFT")
const [selectedRoomIds, setSelectedRoomIds] = useState<number[]>([])
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [role, setRole] = useState<UserRole>("LEHRKRAFT") // Default role for new users
const [selectedRoomIds, setSelectedRoomIds] = useState<number[]>([]) // Stores IDs of rooms for 'RAUMBETREUER' role
const [error, setError] = useState("") // Stores error messages
const [isLoading, setIsLoading] = useState(false) // Tracks loading state for the form
// Handles toggling selection of supervised rooms for 'RAUMBETREUER'
const handleRoomToggle = (roomId: number) => {
setSelectedRoomIds((prev) => (prev.includes(roomId) ? prev.filter((r) => r !== roomId) : [...prev, roomId]))
}
// Handles form submission for user registration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
e.preventDefault() // Prevent default form submission behavior
setError("") // Clear previous errors
// Validate if a 'RAUMBETREUER' has selected at least one room
if (role === "RAUMBETREUER" && selectedRoomIds.length === 0) {
setError("Bitte wählen Sie mindestens einen Raum aus")
return
}
setIsLoading(true)
setIsLoading(true) // Show loading indicator
// new user alert! make sure they don't break anything
// Attempt to register the new user with provided details
const success = await register(
{
firstname,
lastname,
email,
role,
roomIds: role === "RAUMBETREUER" ? selectedRoomIds : undefined,
roomIds: role === "RAUMBETREUER" ? selectedRoomIds : undefined, // Only send room IDs if role is 'RAUMBETREUER'
password
}
)
// Handle registration success or failure
if (!success) {
setError("Registrierung fehlgeschlagen")
}
// API logic redirects or we stay here. If success, we probably want to call onSwitchToLogin if strictly following "register then login" flow,
// but AuthContext comments implied auto-login or just handled.
// Actually AuthContext returns true if success.
if (success) {
onSwitchToLogin()
onSwitchToLogin() // Switch to login form upon successful registration
}
setIsLoading(false)
setIsLoading(false) // Hide loading indicator
}
return (
@ -72,6 +75,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Display error message if present */}
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
@ -79,6 +83,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div>
)}
{/* First Name and Last Name inputs */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstname" className="text-sm font-medium">
@ -116,6 +121,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div>
</div>
{/* Email input field */}
<div className="space-y-2">
<Label htmlFor="reg-email" className="text-sm font-medium">
E-Mail
@ -134,6 +140,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div>
</div>
{/* Password input field */}
<div className="space-y-2">
<Label htmlFor="reg-password" className="text-sm font-medium">
Kennwort
@ -153,6 +160,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div>
</div>
{/* Role selection */}
<div className="space-y-3">
<Label className="text-sm font-medium">Rolle</Label>
<div className="grid grid-cols-2 gap-3">
@ -182,11 +190,13 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
/>
<span className={`text-sm font-medium ${role === "RAUMBETREUER" ? "text-primary" : "text-foreground"}`}>
Raumbetreuer
</span>
</button>
</div>
</div>
{/* Room selection for 'RAUMBETREUER' role */}
{role === "RAUMBETREUER" && (
<div className="space-y-3">
<Label className="text-sm font-medium">Betreute Räume</Label>
@ -216,11 +226,12 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
)}
</CardContent>
<CardFooter className="flex flex-col gap-4 pt-2">
{/* Submit button with loading state */}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
Registrierung...
Registrierung läuft...
</span>
) : (
<span className="flex items-center gap-2">
@ -229,6 +240,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</span>
)}
</Button>
{/* Link to switch to login form */}
<p className="text-sm text-muted-foreground">
Bereits registriert?{" "}
<button

View file

@ -10,20 +10,10 @@ export function AdminDashboard() {
const { tickets, updateTicketStatus, deleteTicket, authHeader } = useAuth()
const API_URL = process.env.NEXT_PUBLIC_API_URL + "/api"
// Admin sees all tickets (logic handled in Backend/TicketService)
// We need to implement Ticket Delete logic passed to TicketTable or handle it inside TicketTable?
// TicketTable doesn't have Delete button yet.
// Actually, requirement says "Kann Tickets löschen". TicketTable needs a delete option or we add it.
// Let's add delete to TicketTable? OR just add it to the Detail Dialog?
// User asked "user with ID 1 = administrator... screen for creating/deleting/renaming rooms".
// For tickets, "Can delete tickets".
// I should probably add a Delete button in TicketTable (maybe only visible if onDelete is provided).
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Admin Dashboard</h1>
<h1 className="text-2xl font-semibold tracking-tight">Admin-Dashboard</h1>
<p className="text-muted-foreground mt-1">Systemverwaltung und Ticket-Übersicht</p>
</div>
@ -40,12 +30,12 @@ export function AdminDashboard() {
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Status</CardTitle>
<CardTitle className="text-sm font-medium">Systemstatus</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">Aktiv</div>
<p className="text-xs text-muted-foreground">Admin Mode</p>
<p className="text-xs text-muted-foreground">Admin-Modus</p>
</CardContent>
</Card>
</div>
@ -54,7 +44,7 @@ export function AdminDashboard() {
<Card>
<CardHeader>
<CardTitle>Ticket Übersicht</CardTitle>
<CardTitle>Ticket-Übersicht</CardTitle>
<CardDescription>Alle Tickets aller Nutzer</CardDescription>
</CardHeader>
<CardContent>

View file

@ -31,10 +31,6 @@ export function RoomManagement() {
}, [rooms])
const refreshRooms = async () => {
// Force reload (window reload is simplest for now to update Context, or expose fetchRooms in Context)
// Ideally Context should expose a 'refreshRooms' method.
// For now, let's update local list manually after actions or rely on hard refresh if needed.
// Actually, fetching from API here is better.
if (!authHeader) return
const res = await fetch(`${API_URL}/rooms`, { headers: { Authorization: authHeader } })
if (res.ok) {
@ -190,7 +186,7 @@ export function RoomManagement() {
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(room.id)}
>
<Trash2 className="h-4 w-4" />

View file

@ -10,7 +10,6 @@ import { Building2, Ticket, Clock, CheckCircle2, AlertCircle } from "lucide-reac
export function SupervisorDashboard() {
const { user, tickets, updateTicketStatus } = useAuth()
// user.supervisedRooms is Room[]
const supervisedRooms = user?.supervisedRooms || []
const roomTickets = useMemo(() => {
@ -42,14 +41,14 @@ export function SupervisorDashboard() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Raumbetreuer Dashboard</h1>
<h1 className="text-2xl font-semibold tracking-tight">Raumbetreuer-Dashboard</h1>
<p className="text-muted-foreground mt-1">Verwalten Sie Tickets für Ihre Räume</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-border/60">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Tickets</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Gesamt Tickets</CardTitle>
<Ticket className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>

View file

@ -11,15 +11,6 @@ export function TeacherDashboard() {
const { user, tickets } = useAuth()
const myTickets = useMemo(() => {
// Filter by own email or ID. The backend returns all tickets for RAUMBETREUER?
// Wait, backend logic:
// If LEHRKRAFT (ROLE_TEACHER): /tickets returns all tickets created by this user.
// So `tickets` in context should already be "my tickets" if I am a teacher.
// But let's be safe and filter if the API returns more than expected or just use all.
// Actually, AuthContext fetches `/tickets`.
// TeacherController: `getTickets` -> `ticketService.getTickets(user)`.
// TicketService: if teacher, `ticketRepository.findByOwner(user)`.
// So yes, `tickets` contains only my tickets.
return tickets
}, [tickets])
@ -33,7 +24,7 @@ export function TeacherDashboard() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Lehrkraft Dashboard</h1>
<h1 className="text-2xl font-semibold tracking-tight">Lehrkraft-Dashboard</h1>
<p className="text-muted-foreground mt-1">Erstellen und verfolgen Sie Ihre IT-Support-Tickets</p>
</div>

View file

@ -19,24 +19,30 @@ import {
import { LogOut, User, Building2, Cpu } from "lucide-react"
import { ModeToggle } from "@/components/mode-toggle"
// Defines props for the AppShell component
interface AppShellProps {
children: ReactNode
children: ReactNode // React children to be rendered within the shell
}
// AppShell component provides the main layout structure for the application
export function AppShell({ children }: AppShellProps) {
// Access authentication context for user info, logout function, room data, and room update function
const { user, logout, rooms, updateRooms } = useAuth()
// Generates user initials for the avatar fallback
const initials = user?.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || ""
.slice(0, 2) || "" // Fallback to empty string if user name is not available
return (
<div className="min-h-screen bg-background">
{/* Header section with sticky positioning, blur effect, and responsiveness */}
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="w-full max-w-[95%] mx-auto flex h-16 items-center justify-between px-4">
{/* Application logo and title */}
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Cpu className="h-5 w-5 text-primary-foreground" />
@ -47,7 +53,9 @@ export function AppShell({ children }: AppShellProps) {
</div>
</div>
{/* User actions and theme toggle */}
<div className="flex items-center gap-4">
{/* Display user role for larger screens */}
<div className="hidden items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 md:flex">
{user?.role === "LEHRKRAFT" ? (
<User className="h-4 w-4 text-muted-foreground" />
@ -59,6 +67,7 @@ export function AppShell({ children }: AppShellProps) {
</span>
</div>
{/* Dropdown menu for user profile and settings */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
@ -77,10 +86,12 @@ export function AppShell({ children }: AppShellProps) {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Display user role in the dropdown */}
<DropdownMenuItem className="text-muted-foreground">
<User className="mr-2 h-4 w-4" />
<span className="capitalize">{user?.role === "LEHRKRAFT" ? "Lehrkraft" : "Raumbetreuer"}</span>
</DropdownMenuItem>
{/* Room supervision management for 'RAUMBETREUER' role */}
{user?.role === "RAUMBETREUER" && user.supervisedRooms && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@ -90,6 +101,7 @@ export function AppShell({ children }: AppShellProps) {
<DropdownMenuSubContent className="max-h-60 overflow-y-auto">
<DropdownMenuLabel>Räume verwalten</DropdownMenuLabel>
{rooms.map((room) => {
// Determine if the current room is supervised by the user
const isChecked = user.supervisedRooms?.some((r) => r.id === room.id) ?? false
return (
<DropdownMenuCheckboxItem
@ -99,13 +111,15 @@ export function AppShell({ children }: AppShellProps) {
const currentIds = user.supervisedRooms?.map((r) => r.id) || []
let newIds
if (checked) {
// Add room to supervised list
newIds = [...currentIds, room.id]
} else {
// Remove room from supervised list
newIds = currentIds.filter((id) => id !== room.id)
}
updateRooms(newIds)
updateRooms(newIds) // Update the list of supervised rooms
}}
onSelect={(e) => e.preventDefault()}
onSelect={(e) => e.preventDefault()} // Prevent closing dropdown on selection
>
{room.name}
</DropdownMenuCheckboxItem>
@ -115,6 +129,7 @@ export function AppShell({ children }: AppShellProps) {
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
{/* Logout button */}
<DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" />
<span>Abmelden</span>
@ -122,12 +137,13 @@ export function AppShell({ children }: AppShellProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* dark mode toggle because staring at white screens hurts */}
{/* Theme toggle component */}
<ModeToggle />
</div>
</div>
</header>
{/* Main content area */}
<main className="w-full max-w-[95%] mx-auto px-4 py-8">{children}</main>
</div>
)

View file

@ -30,7 +30,7 @@ export function ModeToggle() {
onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
/>
<Moon className="h-4 w-4" />
<Label htmlFor="dark-mode" className="sr-only">Toggle theme</Label>
<Label htmlFor="dark-mode" className="sr-only">Thema umschalten</Label>
</div>
)
}

View file

@ -85,7 +85,7 @@ export function CreateTicketForm() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Problembeschreibung</Label>
<Label htmlFor="description">Beschreibung</Label>
<Textarea
id="description"
placeholder="Beschreiben Sie das Problem im Detail..."
@ -99,7 +99,7 @@ export function CreateTicketForm() {
{isSubmitting ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
Senden...
Ticket wird erstellt...
</span>
) : (
<span className="flex items-center gap-2">

View file

@ -64,9 +64,9 @@ export function TicketComments({ ticketId }: TicketCommentsProps) {
return (
<div className="space-y-4">
<h3 className="font-semibold">Comments</h3>
<h3 className="font-semibold">Kommentare</h3>
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2">
{comments.length === 0 && <p className="text-sm text-muted-foreground">No comments yet.</p>}
{comments.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Kommentare.</p>}
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<Avatar className="h-8 w-8">
@ -87,14 +87,14 @@ export function TicketComments({ ticketId }: TicketCommentsProps) {
<div className="space-y-2 pt-2">
<Textarea
placeholder="Write a comment..."
placeholder="Kommentar schreiben..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[80px]"
/>
<div className="flex justify-end">
<Button onClick={handlePost} disabled={!newComment.trim() || loading}>
Post Comment
Kommentar senden
</Button>
</div>
</div>

View file

@ -92,7 +92,7 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Input
placeholder="Search tickets..."
placeholder="Tickets suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
@ -100,13 +100,13 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<div className="flex items-center gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TicketStatus | "ALL")}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter Status" />
<SelectValue placeholder="Status filtern" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
<SelectItem value="ALL">Alle Status</SelectItem>
<SelectItem value="OPEN">Offen</SelectItem>
<SelectItem value="IN_PROGRESS">In Bearbeitung</SelectItem>
<SelectItem value="CLOSED">Geschlossen</SelectItem>
</SelectContent>
</Select>
</div>
@ -119,17 +119,17 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("room")}>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Room {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
Raum {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</div>
</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("title")}>
Title {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
Titel {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</TableHead>
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead>
<TableHead className="font-semibold hidden md:table-cell">Beschreibung</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("createdAt")}>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Date {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
Datum {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</div>
</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("status")}>
@ -142,7 +142,7 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
{processedTickets.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No results found.
Keine Ergebnisse gefunden.
</TableCell>
</TableRow>
) : (
@ -168,9 +168,9 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OPEN"><StatusBadge status="OPEN" /></SelectItem>
<SelectItem value="IN_PROGRESS"><StatusBadge status="IN_PROGRESS" /></SelectItem>
<SelectItem value="CLOSED"><StatusBadge status="CLOSED" /></SelectItem>
<SelectItem value="OPEN">Offen</SelectItem>
<SelectItem value="IN_PROGRESS">In Bearbeitung</SelectItem>
<SelectItem value="CLOSED">Geschlossen</SelectItem>
</SelectContent>
</Select>
) : (
@ -213,7 +213,7 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<div className="grid gap-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<h3 className="font-semibold mb-2">Beschreibung</h3>
<div className="p-3 bg-muted rounded-md text-sm">
{selectedTicket.description}
</div>

View file

@ -126,7 +126,9 @@ function AlertDialogAction({
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
>
Bestätigen
</AlertDialogPrimitive.Action>
)
}
@ -136,9 +138,10 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
>
Abbrechen
</AlertDialogPrimitive.Cancel>
)
}

View file

@ -72,7 +72,7 @@ function DialogContent({
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>

View file

@ -74,7 +74,7 @@ function SheetContent({
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only">Schließen</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>

View file

@ -4,64 +4,79 @@ import { useTheme } from "next-themes"
import { useEffect, useRef } from "react"
import { useSession } from "next-auth/react"
// UserThemeSync component handles synchronizing the user's theme preference
// between the frontend (using next-themes) and the backend (persisting in DB).
export function UserThemeSync() {
// Access theme state and setter from next-themes
const { theme, setTheme } = useTheme()
// Access user session from next-auth
const { data: session } = useSession()
// Ref to track if the initial theme synchronization from DB is done
const initialSyncDone = useRef(false)
// Sync from DB on load
// Effect to synchronize theme from the database on component load (initial sync)
useEffect(() => {
// If we have a user and haven't synced yet
// Only proceed if a user session exists and initial sync hasn't been performed
if (session?.user && !initialSyncDone.current) {
// @ts-ignore - we added theme to user but TS might catch up later
// Retrieve user's theme preference from the session object
// @ts-ignore - 'theme' property might not be directly typed in session.user
const userTheme = session.user.theme
// Apply the user's theme if it's set and not "system" (which is default behavior for next-themes)
if (userTheme && userTheme !== "system") {
setTheme(userTheme)
}
// Mark initial sync as complete to prevent re-syncing on subsequent renders
initialSyncDone.current = true
}
}, [session, setTheme])
}, [session, setTheme]) // Dependencies: session and setTheme function
// Sync to DB on change
// Effect to synchronize theme changes from the frontend to the database
useEffect(() => {
// Only proceed if we have a session and initial sync is done
// Only proceed if a user session exists and initial sync has been performed
if (!session?.user || !initialSyncDone.current) return
// @ts-ignore
// Retrieve the current theme as perceived by the frontend and the theme stored in the session
// @ts-ignore - 'theme' property might not be directly typed in session.user
const currentDbTheme = session.user.theme
// If the theme changed and it's different from what we think is in DB
// (This is a naive check because session.user.theme won't update until next session fetch)
// If the current frontend theme is different from the theme stored in the session,
// it means the user has changed their theme preference and it needs to be persisted.
// (Note: session.user.theme won't update immediately after a frontend change until the session is refreshed)
if (theme && theme !== currentDbTheme) {
// Async function to send the theme update request to the backend
const saveTheme = async () => {
try {
// @ts-ignore
// Retrieve authorization header from session
// @ts-ignore - 'authHeader' property might not be directly typed in session
const authHeader = session.authHeader
await fetch(process.env.NEXT_PUBLIC_API_URL + "/api/users/theme", {
method: "PATCH",
method: "PATCH", // Use PATCH for partial update
headers: {
"Content-Type": "application/json",
"Authorization": authHeader
"Authorization": authHeader // Send authorization token
},
body: JSON.stringify({ theme }),
body: JSON.stringify({ theme }), // Send the new theme preference
})
// Ideally we update the session here too, but that's complex.
// We just fire and forget.
// In a more complex app, one might trigger a session refresh here
// to immediately update session.user.theme, but for simplicity,
// we fire and forget the update.
} catch (e) {
console.error("Failed to save theme, looks like backend is sleeping", e)
}
}
// simple debounce
// Debounce the save operation to avoid excessive API calls on rapid theme changes
const timeoutId = setTimeout(() => {
saveTheme()
}, 1000)
}, 1000) // Wait for 1 second before saving
// Cleanup function to clear the timeout if the component unmounts or dependencies change
return () => clearTimeout(timeoutId)
}
}, [theme, session])
}, [theme, session]) // Dependencies: current theme and session object
// This component does not render anything visible
return null
}