diff --git a/postify/pom.xml b/postify/pom.xml
index 7cea5f7..ea2678e 100644
--- a/postify/pom.xml
+++ b/postify/pom.xml
@@ -34,6 +34,10 @@
org.springframework.boot
spring-boot-starter-webmvc
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
org.postgresql
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/LyricsClient.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/LyricsClient.java
new file mode 100644
index 0000000..6afb3bb
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/LyricsClient.java
@@ -0,0 +1,44 @@
+package net.hackyourfuture.backend.week6.postify.tracks;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+
+import java.util.Optional;
+
+@Component
+public class LyricsClient {
+
+ private final RestClient restClient;
+
+ public LyricsClient(@Value("${postify.lyrics-api.base-url}") String baseUrl) {
+ this.restClient = RestClient.builder().baseUrl(baseUrl).build();
+ }
+
+ public Optional fetchLyrics(String artistName, String trackTitle) {
+ return restClient.get()
+ .uri("/v1/{artist}/{title}", artistName, trackTitle)
+ .exchange((request, response) -> {
+ if (response.getStatusCode().value() == 404) {
+ return Optional.empty();
+ }
+ if (response.getStatusCode().isError()) {
+ throw new LyricsApiException(
+ "Lyrics API returned " + response.getStatusCode());
+ }
+ LyricsResponse body = response.bodyTo(LyricsResponse.class);
+ if (body == null || body.lyrics() == null || body.lyrics().isBlank()) {
+ return Optional.empty();
+ }
+ return Optional.of(body.lyrics());
+ });
+ }
+
+ private record LyricsResponse(String lyrics) {}
+
+ public static class LyricsApiException extends RuntimeException {
+ public LyricsApiException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackInfo.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackInfo.java
new file mode 100644
index 0000000..2199670
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackInfo.java
@@ -0,0 +1,3 @@
+package net.hackyourfuture.backend.week6.postify.tracks;
+
+public record TrackInfo(int trackId, String trackTitle, String artistName) {}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyrics.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyrics.java
new file mode 100644
index 0000000..3a48a81
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyrics.java
@@ -0,0 +1,8 @@
+package net.hackyourfuture.backend.week6.postify.tracks;
+
+public record TrackLyrics(
+ int trackId,
+ String trackTitle,
+ String artistName,
+ String lyrics
+) {}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsController.java
new file mode 100644
index 0000000..9dcad17
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsController.java
@@ -0,0 +1,34 @@
+package net.hackyourfuture.backend.week6.postify.tracks;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController
+public class TrackLyricsController {
+
+ private final TrackRepository trackRepository;
+ private final LyricsClient lyricsClient;
+
+ public TrackLyricsController(TrackRepository trackRepository, LyricsClient lyricsClient) {
+ this.trackRepository = trackRepository;
+ this.lyricsClient = lyricsClient;
+ }
+
+ @GetMapping("/tracks/{id}/lyrics")
+ public TrackLyrics getTrackLyrics(@PathVariable int id) {
+ TrackInfo track = trackRepository.findById(id)
+ .orElseThrow(() -> new ResponseStatusException(
+ HttpStatus.NOT_FOUND, "Track " + id + " does not exist"));
+
+ String lyrics = lyricsClient.fetchLyrics(track.artistName(), track.trackTitle())
+ .orElseThrow(() -> new ResponseStatusException(
+ HttpStatus.NOT_FOUND,
+ "No lyrics available for '" + track.trackTitle()
+ + "' by " + track.artistName()));
+
+ return new TrackLyrics(track.trackId(), track.trackTitle(), track.artistName(), lyrics);
+ }
+}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackRepository.java
new file mode 100644
index 0000000..5cbf495
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackRepository.java
@@ -0,0 +1,32 @@
+package net.hackyourfuture.backend.week6.postify.tracks;
+
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public class TrackRepository {
+
+ private final JdbcClient jdbc;
+
+ public TrackRepository(JdbcClient jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public Optional findById(int trackId) {
+ return jdbc.sql("""
+ SELECT t.track_id, t.track_title, ar.artist_name
+ FROM tracks t
+ JOIN albums al ON t.album_id = al.album_id
+ JOIN artists ar ON al.artist_id = ar.artist_id
+ WHERE t.track_id = :id
+ """)
+ .param("id", trackId)
+ .query((rs, rowNum) -> new TrackInfo(
+ rs.getInt("track_id"),
+ rs.getString("track_title"),
+ rs.getString("artist_name")))
+ .optional();
+ }
+}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStats.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStats.java
new file mode 100644
index 0000000..0c9ec40
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStats.java
@@ -0,0 +1,12 @@
+package net.hackyourfuture.backend.week6.postify.users;
+
+public record UserStats(
+ int userId,
+ String userName,
+ String userCountry,
+ long totalStreams,
+ long uniqueTracksStreamed,
+ long uniqueArtistsStreamed,
+ String favoriteGenre,
+ long totalListeningTimeSeconds
+) {}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsController.java
new file mode 100644
index 0000000..ae7ff33
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsController.java
@@ -0,0 +1,23 @@
+package net.hackyourfuture.backend.week6.postify.users;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController
+public class UserStatsController {
+
+ private final UserStatsRepository repository;
+
+ public UserStatsController(UserStatsRepository repository) {
+ this.repository = repository;
+ }
+
+ @GetMapping("/users/{id}/stats")
+ public UserStats getUserStats(@PathVariable int id) {
+ return repository.findStatsByUserId(id)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ }
+}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsRepository.java
new file mode 100644
index 0000000..8dd3242
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsRepository.java
@@ -0,0 +1,74 @@
+package net.hackyourfuture.backend.week6.postify.users;
+
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public class UserStatsRepository {
+
+ private final JdbcClient jdbc;
+
+ public UserStatsRepository(JdbcClient jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public Optional findStatsByUserId(int userId) {
+ // Query 1: confirm the user exists and grab profile fields.
+ var profile = jdbc.sql("""
+ SELECT user_id, user_name, user_country
+ FROM users
+ WHERE user_id = :id
+ """)
+ .param("id", userId)
+ .query((rs, rowNum) -> new UserProfile(
+ rs.getInt("user_id"),
+ rs.getString("user_name"),
+ rs.getString("user_country")))
+ .optional();
+
+ if (profile.isEmpty()) {
+ return Optional.empty();
+ }
+
+ // Query 2: aggregate listening stats across streams -> tracks -> albums -> artists.
+ // favoriteGenre is computed via a correlated scalar subquery with GROUP BY + LIMIT 1.
+ UserProfile p = profile.get();
+ UserStats stats = jdbc.sql("""
+ SELECT
+ COUNT(*) AS total_streams,
+ COUNT(DISTINCT t.track_id) AS unique_tracks,
+ COUNT(DISTINCT al.artist_id) AS unique_artists,
+ COALESCE(SUM(t.track_duration_s), 0) AS total_seconds,
+ (
+ SELECT t2.genre
+ FROM streams s2
+ JOIN tracks t2 ON s2.track_id = t2.track_id
+ WHERE s2.user_id = :id AND t2.genre IS NOT NULL
+ GROUP BY t2.genre
+ ORDER BY COUNT(*) DESC
+ LIMIT 1
+ ) AS favorite_genre
+ FROM streams s
+ JOIN tracks t ON s.track_id = t.track_id
+ JOIN albums al ON t.album_id = al.album_id
+ WHERE s.user_id = :id
+ """)
+ .param("id", userId)
+ .query((rs, rowNum) -> new UserStats(
+ p.userId(),
+ p.userName(),
+ p.userCountry(),
+ rs.getLong("total_streams"),
+ rs.getLong("unique_tracks"),
+ rs.getLong("unique_artists"),
+ rs.getString("favorite_genre"),
+ rs.getLong("total_seconds")))
+ .single();
+
+ return Optional.of(stats);
+ }
+
+ private record UserProfile(int userId, String userName, String userCountry) {}
+}
diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/web/ApiExceptionHandler.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/web/ApiExceptionHandler.java
new file mode 100644
index 0000000..645a4e7
--- /dev/null
+++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/web/ApiExceptionHandler.java
@@ -0,0 +1,22 @@
+package net.hackyourfuture.backend.week6.postify.web;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.Map;
+
+@RestControllerAdvice
+public class ApiExceptionHandler {
+
+ @ExceptionHandler(ResponseStatusException.class)
+ public ResponseEntity