Skip to content

Commit 50bf5b2

Browse files
committed
feat: all block 5
1 parent b3c3407 commit 50bf5b2

10 files changed

Lines changed: 516 additions & 0 deletions

File tree

course/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Der normale Unterricht startet weiterhin im Root von `db-2-app`. Die Checkpoints
1212
| `block-3-complete` | Block-3-Loesung mit Transaktionsworkflow |
1313
| `block-4-start` | Einstieg in Block 4 auf Basis von `block-3-complete` |
1414
| `block-4-complete` | Block-4-Loesung mit Schema-Design-Erweiterungen |
15+
| `block-5-start` | Einstieg in Block 5 auf Basis von `block-4-complete` |
16+
| `block-5-complete` | Block-5-Loesung mit JPQL-Ticketuebersicht und nativem Status-/Prioritaetsreport |
1517

1618
## Arbeitskopie erzeugen
1719

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import jakarta.validation.Valid;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.web.bind.annotation.*;
9+
10+
import java.util.List;
11+
12+
@RestController
13+
@RequestMapping("/api/tickets")
14+
@Tag(name = "Tickets", description = "Ticket Controller")
15+
class TicketController {
16+
17+
private final TicketService ticketService;
18+
19+
TicketController(TicketService ticketService) {
20+
this.ticketService = ticketService;
21+
}
22+
23+
@GetMapping
24+
@Operation(summary = "Tickets lesen", description = "Liest alle nicht geloeschten Tickets oder filtert nach einem Status.")
25+
List<TicketResponse> findTickets(
26+
@Parameter(description = "Optionaler Statusfilter, zum Beispiel open")
27+
@RequestParam(required = false) String status
28+
) {
29+
return ticketService.findTickets(status);
30+
}
31+
32+
@GetMapping("/overview")
33+
@Operation(summary = "Schlanke Ticketuebersicht lesen", description = "Liest mit JPQL nur die Felder, die eine Ticketliste braucht.")
34+
List<TicketListItemResponse> findTicketOverview(
35+
@Parameter(description = "Optionaler Statusfilter, zum Beispiel open")
36+
@RequestParam(required = false) String status
37+
) {
38+
return ticketService.findTicketOverview(status);
39+
}
40+
41+
@GetMapping("/reports/status-priority")
42+
@Operation(summary = "Ticketreport nach Status und Prioritaet", description = "Liest eine native SQL-Aggregation mit Ticket- und Kommentaranzahl.")
43+
List<TicketStatusPriorityReportResponse> findStatusPriorityReport() {
44+
return ticketService.findStatusPriorityReport();
45+
}
46+
47+
@PostMapping
48+
@ResponseStatus(HttpStatus.CREATED)
49+
@Operation(summary = "Ticket erstellen", description = "Erstellt ein Ticket mit Prioritaet, optionaler fachlicher Referenz und optionalem ersten Kommentar.")
50+
TicketResponse createTicket(@Valid @RequestBody CreateTicketRequest request) {
51+
return ticketService.createTicket(request);
52+
}
53+
54+
@PatchMapping("/{id}/status")
55+
@Operation(summary = "Ticketstatus aendern", description = "Aendert den Status und schreibt ein Ereignis.")
56+
TicketResponse changeStatus(
57+
@PathVariable Long id,
58+
@Valid @RequestBody StatusChangeRequest request
59+
) {
60+
return ticketService.changeStatus(id, request);
61+
}
62+
63+
@DeleteMapping("/{id}")
64+
@ResponseStatus(HttpStatus.NO_CONTENT)
65+
@Operation(summary = "Ticket ausblenden", description = "Markiert ein Ticket mit deleted_at, statt die Zeile physisch zu loeschen.")
66+
void deleteTicket(@PathVariable Long id) {
67+
ticketService.deleteTicket(id);
68+
}
69+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Value;
5+
6+
import java.time.OffsetDateTime;
7+
8+
@Value
9+
@Schema(description = "Schlanke Antwort fuer eine Ticketuebersicht")
10+
public class TicketListItemResponse {
11+
12+
@Schema(description = "Technische Ticket-ID", example = "1")
13+
Long id;
14+
15+
@Schema(description = "Kurzer Tickettitel", example = "VPN-Zugriff analysieren")
16+
String title;
17+
18+
@Schema(description = "Aktueller Ticketstatus", example = "open")
19+
String status;
20+
21+
@Schema(description = "Prioritaet", example = "normal")
22+
String priority;
23+
24+
@Schema(description = "Erstellzeitpunkt des Tickets")
25+
OffsetDateTime createdAt;
26+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
import org.springframework.data.jpa.repository.Query;
5+
import org.springframework.data.repository.query.Param;
6+
7+
import java.util.List;
8+
9+
interface TicketRepository extends JpaRepository<TicketEntity, Long> {
10+
11+
List<TicketEntity> findByDeletedAtIsNullOrderByCreatedAtDesc();
12+
13+
List<TicketEntity> findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(String status);
14+
15+
@Query("""
16+
SELECT new ch.hftm.db2.ticketsystem.ticket.TicketListItemResponse(
17+
t.id, t.title, t.status, t.priority, t.createdAt
18+
)
19+
FROM TicketEntity t
20+
WHERE t.deletedAt IS NULL
21+
AND (:status IS NULL OR t.status = :status)
22+
ORDER BY t.createdAt DESC
23+
""")
24+
List<TicketListItemResponse> findActiveTicketOverview(@Param("status") String status);
25+
26+
@Query(value = """
27+
SELECT
28+
t.status AS status,
29+
t.priority AS priority,
30+
COUNT(DISTINCT t.id) AS "ticketCount",
31+
COUNT(c.id) AS "commentCount"
32+
FROM app_starter.tickets t
33+
LEFT JOIN app_starter.ticket_comments c ON c.ticket_id = t.id
34+
WHERE t.deleted_at IS NULL
35+
GROUP BY t.status, t.priority
36+
ORDER BY t.status, t.priority
37+
""", nativeQuery = true)
38+
List<TicketStatusPriorityReportProjection> findActiveStatusPriorityReport();
39+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.stereotype.Service;
5+
import org.springframework.transaction.annotation.Transactional;
6+
import org.springframework.util.StringUtils;
7+
import org.springframework.web.server.ResponseStatusException;
8+
9+
import java.util.List;
10+
import java.util.Set;
11+
12+
@Service
13+
class TicketService {
14+
15+
private static final Set<String> VALID_STATUSES = Set.of("open", "waiting", "closed");
16+
private static final Set<String> VALID_PRIORITIES = Set.of("low", "normal", "high", "urgent");
17+
18+
private final TicketRepository ticketRepository;
19+
private final TicketCommentRepository commentRepository;
20+
private final TicketEventRepository eventRepository;
21+
private final TicketMapper ticketMapper;
22+
23+
TicketService(
24+
TicketRepository ticketRepository,
25+
TicketCommentRepository commentRepository,
26+
TicketEventRepository eventRepository,
27+
TicketMapper ticketMapper
28+
) {
29+
this.ticketRepository = ticketRepository;
30+
this.commentRepository = commentRepository;
31+
this.eventRepository = eventRepository;
32+
this.ticketMapper = ticketMapper;
33+
}
34+
35+
@Transactional(readOnly = true)
36+
List<TicketResponse> findTickets(String status) {
37+
List<TicketEntity> tickets = StringUtils.hasText(status)
38+
? ticketRepository.findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(normalizeStatus(status))
39+
: ticketRepository.findByDeletedAtIsNullOrderByCreatedAtDesc();
40+
41+
return tickets.stream()
42+
.map(ticketMapper::toResponse)
43+
.toList();
44+
}
45+
46+
@Transactional(readOnly = true)
47+
List<TicketListItemResponse> findTicketOverview(String status) {
48+
String normalizedStatus = StringUtils.hasText(status) ? normalizeStatus(status) : null;
49+
return ticketRepository.findActiveTicketOverview(normalizedStatus);
50+
}
51+
52+
@Transactional(readOnly = true)
53+
List<TicketStatusPriorityReportResponse> findStatusPriorityReport() {
54+
return ticketRepository.findActiveStatusPriorityReport().stream()
55+
.map(row -> new TicketStatusPriorityReportResponse(
56+
row.getStatus(),
57+
row.getPriority(),
58+
row.getTicketCount(),
59+
row.getCommentCount()
60+
))
61+
.toList();
62+
}
63+
64+
@Transactional
65+
TicketResponse createTicket(CreateTicketRequest request) {
66+
TicketEntity savedTicket = persistTicketWorkflow(request);
67+
return ticketMapper.toResponse(savedTicket);
68+
}
69+
70+
@Transactional
71+
TicketResponse changeStatus(Long id, StatusChangeRequest request) {
72+
String newStatus = normalizeStatus(request.getStatus());
73+
TicketEntity ticket = findActiveTicket(id);
74+
75+
assertStatusTransition(ticket.getStatus(), newStatus);
76+
77+
String oldStatus = ticket.getStatus();
78+
ticket.setStatus(newStatus);
79+
TicketEntity savedTicket = ticketRepository.saveAndFlush(ticket);
80+
eventRepository.save(new TicketEventEntity(
81+
savedTicket.getId(),
82+
"status_changed",
83+
oldStatus,
84+
newStatus,
85+
StringUtils.hasText(request.getReason()) ? request.getReason() : null
86+
));
87+
88+
return ticketMapper.toResponse(savedTicket);
89+
}
90+
91+
@Transactional
92+
void deleteTicket(Long id) {
93+
TicketEntity ticket = findActiveTicket(id);
94+
ticket.markDeleted();
95+
ticketRepository.saveAndFlush(ticket);
96+
}
97+
98+
@Transactional
99+
void createTicketAndFailForRollbackProbe(CreateTicketRequest request) {
100+
persistTicketWorkflow(request);
101+
throw new IllegalStateException("Rollback-Probe nach Datenbankschritten");
102+
}
103+
104+
private TicketEntity persistTicketWorkflow(CreateTicketRequest request) {
105+
String status = normalizeStatus(request.getStatus());
106+
String priority = normalizePriority(request.getPriority());
107+
String externalReference = normalizeOptional(request.getExternalReference());
108+
109+
TicketEntity entity = new TicketEntity(request.getTitle(), status, priority, externalReference);
110+
TicketEntity savedTicket = ticketRepository.save(entity);
111+
112+
if (StringUtils.hasText(request.getInitialComment())) {
113+
commentRepository.save(new TicketCommentEntity(savedTicket.getId(), request.getInitialComment()));
114+
}
115+
116+
eventRepository.save(new TicketEventEntity(
117+
savedTicket.getId(),
118+
"ticket_created",
119+
null,
120+
status,
121+
"Ticket erstellt"
122+
));
123+
124+
return savedTicket;
125+
}
126+
127+
private TicketEntity findActiveTicket(Long id) {
128+
TicketEntity ticket = ticketRepository.findById(id)
129+
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket nicht gefunden"));
130+
if (ticket.getDeletedAt() != null) {
131+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket nicht gefunden");
132+
}
133+
return ticket;
134+
}
135+
136+
private String normalizeStatus(String status) {
137+
String normalized = status == null ? "" : status.trim().toLowerCase();
138+
if (!VALID_STATUSES.contains(normalized)) {
139+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungueltiger Ticketstatus");
140+
}
141+
return normalized;
142+
}
143+
144+
private String normalizePriority(String priority) {
145+
String normalized = StringUtils.hasText(priority) ? priority.trim().toLowerCase() : "normal";
146+
if (!VALID_PRIORITIES.contains(normalized)) {
147+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungueltige Prioritaet");
148+
}
149+
return normalized;
150+
}
151+
152+
private String normalizeOptional(String value) {
153+
return StringUtils.hasText(value) ? value.trim() : null;
154+
}
155+
156+
private void assertStatusTransition(String oldStatus, String newStatus) {
157+
if (oldStatus.equals(newStatus)) {
158+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status ist bereits gesetzt");
159+
}
160+
if ("closed".equals(oldStatus)) {
161+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geschlossene Tickets werden nicht wieder geoeffnet");
162+
}
163+
}
164+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
interface TicketStatusPriorityReportProjection {
4+
5+
String getStatus();
6+
7+
String getPriority();
8+
9+
long getTicketCount();
10+
11+
long getCommentCount();
12+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Value;
5+
6+
@Value
7+
@Schema(description = "Aggregierte Ticketzahlen nach Status und Prioritaet")
8+
public class TicketStatusPriorityReportResponse {
9+
10+
@Schema(description = "Ticketstatus", example = "open")
11+
String status;
12+
13+
@Schema(description = "Prioritaet", example = "high")
14+
String priority;
15+
16+
@Schema(description = "Anzahl aktiver Tickets", example = "3")
17+
long ticketCount;
18+
19+
@Schema(description = "Anzahl Kommentare zu diesen Tickets", example = "5")
20+
long commentCount;
21+
}

0 commit comments

Comments
 (0)