diff --git a/backend/internal/service/handler/reviews/get_user_following_reviews.go b/backend/internal/service/handler/reviews/get_user_following_reviews.go
new file mode 100644
index 00000000..01c5cf20
--- /dev/null
+++ b/backend/internal/service/handler/reviews/get_user_following_reviews.go
@@ -0,0 +1,16 @@
+package reviews
+
+import (
+ "github.com/gofiber/fiber/v2"
+)
+
+func (h *Handler) GetUserFollowingReviewsOfMedia(c *fiber.Ctx) error {
+
+ userId := c.Params("userId")
+ mediaId := c.Params("mediaId")
+ typeString := c.Query("media_type")
+
+ review, _ := h.reviewRepository.GetUserFollowingReviewsOfMedia(c.Context(), typeString, mediaId, userId)
+
+ return c.Status(fiber.StatusOK).JSON(review)
+}
diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go
index 4e0fa789..55786552 100644
--- a/backend/internal/service/server.go
+++ b/backend/internal/service/server.go
@@ -99,6 +99,8 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, config config.Config)
// Get Reviews by ID which can be used to populate a preview
r.Get("/:id", reviewHandler.GetReviewByID)
r.Get("/media/:mediaId/:userID", reviewHandler.GetUserReviewsOfMedia)
+ r.Get("/media/:mediaId/:userID/following", reviewHandler.GetUserFollowingReviewsOfMedia)
+
r.Get("/user/:id", reviewHandler.GetReviewsByUserID)
r.Post("/vote", func(c *fiber.Ctx) error {
return reviewHandler.UserVote(c, "review")
diff --git a/backend/internal/storage/postgres/schema/review.go b/backend/internal/storage/postgres/schema/review.go
index a2841878..8abf4522 100644
--- a/backend/internal/storage/postgres/schema/review.go
+++ b/backend/internal/storage/postgres/schema/review.go
@@ -412,6 +412,135 @@ func (r *ReviewRepository) GetUserReviewsOfMedia(ctx context.Context, media_type
}
+func (r *ReviewRepository) GetUserFollowingReviewsOfMedia(ctx context.Context, media_type string, mediaID string, userID string) ([]*models.Preview, error) {
+ query := `
+ SELECT
+ r.id,
+ r.user_id,
+ u.username,
+ u.display_name,
+ u.profile_picture,
+ r.media_type,
+ r.media_id,
+ r.rating,
+ r.title,
+ r.comment,
+ r.created_at,
+ r.updated_at,
+ COALESCE(a.cover, t.cover) AS media_cover,
+ COALESCE(a.title, t.title) AS media_title,
+ COALESCE(a.artists, t.artists) AS media_artist,
+ ARRAY_AGG(tag.name) FILTER (WHERE tag.name IS NOT NULL) AS tags
+ FROM review r
+ INNER JOIN "user" u ON u.id = r.user_id
+ LEFT JOIN (
+ SELECT t.title, t.id, STRING_AGG(ar.name, ', ') AS artists, cover
+ FROM track t
+ JOIN track_artist ta on t.id = ta.track_id
+ JOIN artist ar ON ta.artist_id = ar.id
+ JOIN album a on t.album_id = a.id
+ GROUP BY t.id, cover, t.title
+ ) t ON r.media_type = 'track' AND r.media_id = t.id
+ LEFT JOIN (
+ SELECT a.id, a.title, STRING_AGG(ar.name, ', ') AS artists, cover
+ FROM album a
+ JOIN album_artist aa on a.id = aa.album_id
+ JOIN artist ar ON aa.artist_id = ar.id
+ GROUP BY a.id, cover, a.title
+ ) a ON r.media_type = 'album' AND r.media_id = a.id
+ LEFT JOIN review_tag rt ON r.id = rt.review_id
+ LEFT JOIN tag tag ON rt.tag_id = tag.id
+ LEFT JOIN (
+ SELECT post_id as review_id, COUNT(*) AS vote_count
+ FROM user_vote
+ WHERE post_type = 'review'
+ GROUP BY post_id
+ ) v ON r.id = v.review_id
+ WHERE r.media_id = $2 AND r.media_type = $3 AND r.user_id IN (
+ SELECT followee_id FROM follower WHERE follower_id = $1
+ )
+ GROUP BY r.id, r.user_id, u.username, u.display_name, u.profile_picture, r.media_type, r.media_id, r.rating, r.comment, r.created_at, r.updated_at, media_cover, media_title, media_artist, v.vote_count
+ `
+
+ rows, err := r.Query(ctx, query, userID, mediaID, media_type)
+
+ if err != nil {
+ fmt.Println(err)
+ return nil, err
+ }
+ defer rows.Close()
+
+ var previews []*models.Preview
+
+ // Scan results into the feedPosts slice
+ for rows.Next() {
+ var preview models.Preview
+ var title, comment sql.NullString // Use sql.NullString for nullable strings
+ err := rows.Scan(
+ &preview.ReviewID,
+ &preview.UserID,
+ &preview.Username,
+ &preview.DisplayName,
+ &preview.ProfilePicture,
+ &preview.MediaType,
+ &preview.MediaID,
+ &preview.Rating,
+ &title,
+ &comment,
+ &preview.CreatedAt,
+ &preview.UpdatedAt,
+ &preview.MediaCover,
+ &preview.MediaTitle,
+ &preview.MediaArtist,
+ &preview.Tags,
+ )
+ if err != nil {
+ fmt.Println(err)
+ return nil, err
+ }
+
+ // Assign comment to feedPost.Comment, handling null case
+ if comment.Valid {
+ preview.Comment = &comment.String // Point to the string if valid
+ } else {
+ preview.Comment = nil // Set to nil if null
+ }
+
+ if title.Valid {
+ preview.Title = &title.String // Point to the string if valid
+ } else {
+ preview.Title = nil // Set to nil if null
+ }
+
+ // Ensure tags is an empty array if null
+ if preview.Tags == nil {
+ preview.Tags = []string{}
+ }
+
+ // Fetch review statistics for the current review
+ reviewStat, err := r.GetReviewStats(ctx, strconv.Itoa(preview.ReviewID))
+ if err != nil {
+ return nil, err
+ }
+
+ // If reviewStat is not nil, populate the corresponding fields in FeedPost
+ if reviewStat != nil {
+ preview.ReviewStat = *reviewStat
+ }
+
+ // Append the populated FeedPost to the feedPosts slice
+ previews = append(previews, &preview)
+ }
+
+ // Check for errors after looping through rows
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ return previews, nil
+
+}
+
func (r *ReviewRepository) GetReviewsByUserID(ctx context.Context, userId string) ([]*models.Preview, error) {
query := `
diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go
index b84f465e..d246e26c 100644
--- a/backend/internal/storage/storage.go
+++ b/backend/internal/storage/storage.go
@@ -39,6 +39,7 @@ type UserRepository interface {
type ReviewRepository interface {
GetUserReviewsOfMedia(ctx context.Context, media_type string, mediaID string, userID string) ([]*models.Preview, error)
+ GetUserFollowingReviewsOfMedia(ctx context.Context, media_type string, mediaID string, userID string) ([]*models.Preview, error)
GetReviewsByUserID(ctx context.Context, id string) ([]*models.Preview, error)
CreateReview(ctx context.Context, review *models.Review) (*models.Review, error)
ReviewExists(ctx context.Context, id string) (bool, error)
diff --git a/frontend/app/MediaPage.tsx b/frontend/app/MediaPage.tsx
index bd1cb1d5..e4b62be0 100644
--- a/frontend/app/MediaPage.tsx
+++ b/frontend/app/MediaPage.tsx
@@ -181,7 +181,7 @@ export default function MediaPage() {
)}
-
+
{reviews?.slice(0, 5).map((review) => (
diff --git a/frontend/app/MediaReviewsPage.tsx b/frontend/app/MediaReviewsPage.tsx
index 2615b8e4..c4453b67 100644
--- a/frontend/app/MediaReviewsPage.tsx
+++ b/frontend/app/MediaReviewsPage.tsx
@@ -1,11 +1,10 @@
-import React from "react";
-import { useState, useEffect } from "react";
-import { View, ScrollView, Image, Text } from "react-native";
-import ReviewPreview from "@/components/ReviewPreview";
-import Filter from "@/components/search/Filter";
+import React, { useState, useEffect } from "react";
+import { View, ScrollView, Image, Text, StyleSheet } from "react-native";
import axios from "axios";
import { useLocalSearchParams } from "expo-router";
import HeaderComponent from "@/components/HeaderComponent";
+import ReviewPreview from "@/components/ReviewPreview";
+import Filter from "@/components/search/Filter";
import Vinyl from "@/assets/images/media-vinyl.svg";
const MediaReviewsPage = () => {
@@ -83,26 +82,61 @@ const MediaReviewsPage = () => {
const reviews = response.data;
setUserReviews(reviews);
- // Calculate the average score
- const totalScore = response.data.reduce(
- (sum: any, review: { rating: any }) => sum + review.rating,
- 0,
- ); // Sum of all ratings
- const averageScore =
- reviews.length > 0 ? totalScore / reviews.length : 0; // Avoid division by 0
- // Update userScore in mediaStats
- setMediaStats((prev) => ({
- ...prev,
- userScore: averageScore,
- userRatings: reviews.length,
- }));
+ if (reviews) {
+ // Calculate the average score
+ const totalScore = reviews.reduce(
+ (sum: any, review: { rating: any }) => sum + review.rating,
+ 0,
+ ); // Sum of all ratings
+ const averageScore =
+ reviews.length > 0 ? totalScore / reviews.length : 0; // Avoid division by 0
+ // Update userScore in mediaStats
+ setMediaStats((prev) => ({
+ ...prev,
+ userScore: averageScore,
+ userRatings: reviews.length,
+ }));
+ }
} catch (error) {
console.error(error);
}
};
- // TODO ALEX: Here you would also fetch the reviews from friends
+ const fetchFriendReviews = async () => {
+ try {
+ const response = await axios.get(
+ `${BASE_URL}/reviews/media/${media_id}/${user_id}/following`,
+ {
+ params: {
+ media_type: media_type,
+ },
+ },
+ );
+
+ const reviews = response.data;
+ if (reviews) {
+ setFriendsReviews(reviews);
+
+ // Calculate the average score
+ const totalScore = reviews.reduce(
+ (sum: any, review: { rating: any }) => sum + review.rating,
+ 0,
+ ); // Sum of all ratings
+ const averageScore =
+ reviews.length > 0 ? totalScore / reviews.length : 0; // Avoid division by 0
+ // Update userScore in mediaStats
+ setMediaStats((prev) => ({
+ ...prev,
+ friendScore: averageScore,
+ friendRatings: reviews.length,
+ }));
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ };
+ fetchFriendReviews();
fetchAll();
fetchMediaCover();
fetchUserReviews();
@@ -120,102 +154,103 @@ const MediaReviewsPage = () => {
};
return (
-
-
-
-
-
-
- {mediaCover && (
-
- )}
-
-
- {selectedFilter === "you" && (
-
-
- {mediaStats.userScore.toFixed(1)}
-
- Your Avg Rating
-
- )}
- {selectedFilter === "friend" && (
-
-
- {mediaStats.friendScore.toFixed(1)}
-
- Friend Rating
-
- )}
- {selectedFilter === "all" && (
-
-
- {mediaStats.avgScore.toFixed(1)}
-
- Avg Rating
-
- )}
- {selectedFilter === "you" && (
- <>
-
- {formatLargeNumber(mediaStats.userRatings)}
-
- Your Ratings
- >
- )}
- {selectedFilter === "friend" && (
- <>
-
- {formatLargeNumber(mediaStats.friendRatings)}
-
- Friends Ratings
- >
- )}
- {selectedFilter === "all" && (
- <>
-
- {formatLargeNumber(mediaStats.totalRatings)}
-
- Total Ratings
- >
- )}
-
+
+
+
+
+
+ {mediaCover && (
+
+ )}
-
-
+
{selectedFilter === "you" && (
-
- {userReviews.map((review, index) => {
- return ;
- })}
+
+
+ {mediaStats.userScore.toFixed(1)}
+
+ Your Avg Rating
)}
{selectedFilter === "friend" && (
- // TODO ALEX: Map each fetched review to a ReviewPreview component which will take care of the rest
+
+
+ {mediaStats.friendScore.toFixed(1)}
+
+ Friend Avg Rating
+
)}
{selectedFilter === "all" && (
-
- {allReviews.map((review, index) => {
- return ;
- })}
+
+ {mediaStats.avgScore.toFixed(1)}
+ Avg Rating
)}
+ {selectedFilter === "you" && (
+ <>
+
+ {formatLargeNumber(mediaStats.userRatings)}
+
+ Your Ratings
+ >
+ )}
+ {selectedFilter === "friend" && (
+ <>
+
+ {formatLargeNumber(mediaStats.friendRatings)}
+
+ Friends Ratings
+ >
+ )}
+ {selectedFilter === "all" && (
+ <>
+
+ {formatLargeNumber(mediaStats.totalRatings)}
+
+ Total Ratings
+ >
+ )}
-
-
+
+
+
+ {selectedFilter === "you" && (
+
+ {userReviews &&
+ userReviews.map((review, index) => {
+ return ;
+ })}
+
+ )}
+ {selectedFilter === "friend" && (
+
+ {friendsReviews &&
+ friendsReviews.map((review, index) => {
+ return ;
+ })}
+
+ )}
+ {selectedFilter === "all" && (
+
+ {allReviews &&
+ allReviews.map((review, index) => {
+ return ;
+ })}
+
+ )}
+
+
);
};
-import { StyleSheet } from "react-native";
-
const styles = StyleSheet.create({
container: {
flex: 1,
diff --git a/frontend/components/media/FriendRatings.tsx b/frontend/components/media/FriendRatings.tsx
index 7860b19c..7fb45073 100644
--- a/frontend/components/media/FriendRatings.tsx
+++ b/frontend/components/media/FriendRatings.tsx
@@ -1,21 +1,53 @@
import React from "react";
+import { useState, useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
+import { router } from "expo-router";
import ArrowRight from "@/assets/images/Media/arrowRight.svg";
+import axios from "axios";
+import { useAuthContext } from "../AuthProvider";
type FriendRatingsProps = {
- count: number;
+ media_id: string;
+ media_type: string;
};
-const FriendRatings = ({ count }: FriendRatingsProps) => {
+const FriendRatings = ({ media_id, media_type }: FriendRatingsProps) => {
+ const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
+ const [friendsReviews, setFriendsReviews] = useState([]);
+ const { userId } = useAuthContext();
+
+ useEffect(() => {
+ axios
+ .get(`${BASE_URL}/reviews/media/${media_id}/${userId}/following`, {
+ params: {
+ media_type: media_type,
+ },
+ })
+ .then((response) => setFriendsReviews(response.data))
+ .catch((error) => console.error(error));
+ }, []);
+
return (
console.log("pressed!")}
+ onPress={() =>
+ router.push({
+ pathname: "/MediaReviewsPage",
+ params: {
+ media_id: media_id,
+ user_id: userId,
+ media_type: media_type,
+ filter: "friend",
+ },
+ })
+ }
>
Reviewed by
- {count} friends
+
+ {friendsReviews?.length ?? 0}x friends
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 81ae9b02..46645e65 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1999,22 +1999,6 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-transform-typeof-symbol": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz",
- "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz",
@@ -17245,65 +17229,6 @@
"node": ">=4"
}
},
- "node_modules/ora/node_modules/ansi-styles": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
- "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^1.9.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/ora/node_modules/chalk": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
- "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^3.2.1",
- "escape-string-regexp": "^1.0.5",
- "supports-color": "^5.3.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/ora/node_modules/color-convert": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
- "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
- "license": "MIT",
- "dependencies": {
- "color-name": "1.1.3"
- }
- },
- "node_modules/ora/node_modules/color-name": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
- "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
- "license": "MIT"
- },
- "node_modules/ora/node_modules/escape-string-regexp": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
- "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
- "license": "MIT",
- "engines": {
- "node": ">=0.8.0"
- }
- },
- "node_modules/ora/node_modules/has-flag": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
- "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/ora/node_modules/strip-ansi": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",