diff --git a/FEATURETHON.md b/FEATURETHON.md new file mode 100644 index 0000000..5af7cc3 --- /dev/null +++ b/FEATURETHON.md @@ -0,0 +1,3 @@ +# Featurethon + + diff --git a/backend/internal/handlers/users/routes.go b/backend/internal/handlers/users/routes.go index 7023247..81e10b5 100644 --- a/backend/internal/handlers/users/routes.go +++ b/backend/internal/handlers/users/routes.go @@ -17,6 +17,7 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { user.Get("/:id", handler.GetUserById) user.Get("/followers", handler.GetFollowers) + user.Get("/:id/following", handler.GetFollowing) user.Post("/follow", handler.FollowUser) user.Delete("/follow", handler.UnfollowUser) @@ -24,4 +25,10 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { item := apiV1.Group("/item") item.Get("/:id/followReviews", handler.GetFollowingReviewsForItem) item.Get("/:id/friendReviews", handler.GetFriendReviewsForItem) + + // User settings + settings := apiV1.Group("/settings") + settings.Get("/:id/dietaryPreferences", handler.GetDietaryPreferences) + settings.Post("/:id/dietaryPreferences", handler.PostDietaryPreferences) + settings.Delete("/:id/dietaryPreferences", handler.DeleteDietaryPreferences) } diff --git a/backend/internal/handlers/users/service.go b/backend/internal/handlers/users/service.go index eb77a89..4969679 100644 --- a/backend/internal/handlers/users/service.go +++ b/backend/internal/handlers/users/service.go @@ -3,6 +3,7 @@ package users import ( "context" "errors" + "fmt" "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" @@ -134,6 +135,86 @@ func (s *Service) GetUserFollowers(userId string, page, limit int) ([]UserRespon FollowersCount: follower.FollowersCount, FollowingCount: follower.FollowingCount, Reviews: reviews, + Preferences: follower.Preferences, + } + } + + return response, nil +} + +func (s *Service) GetUserFollowing(userId string, page, limit int) ([]UserResponse, error) { + ctx := context.Background() + fmt.Println((userId)) + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return nil, &badReq + } + + // Find the user and get their followers + var user User + err = s.users.FindOne(ctx, bson.M{"_id": userObjID}).Decode(&user) + if err != nil { + return nil, err + } + + // Calculate total pages and adjust page number if out of bounds + totalFollowing := len(user.Following) + totalPages := (totalFollowing + limit - 1) / limit // Ceiling division + + if totalPages == 0 { + return []UserResponse{}, nil + } + + // Adjust page to be within bounds + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + + // Calculate pagination bounds + skip := (page - 1) * limit + end := skip + limit + if end > totalFollowing { + end = totalFollowing + } + + // Get the slice of follower IDs for this page, could be an issue if the value is 0 + pageFollowers := user.Following[skip:end] + + // Fetch the actual user documents for these followers + cursor, err := s.users.Find(ctx, bson.M{ + "_id": bson.M{"$in": pageFollowers}, + }) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var following []User + if err = cursor.All(ctx, &following); err != nil { + return nil, err + } + + // Convert to response format + response := make([]UserResponse, len(following)) + for i, followingUser := range following { + reviews := make([]string, len(followingUser.Reviews)) + for j, reviewID := range followingUser.Reviews { + reviews[j] = reviewID.Hex() + } + + response[i] = UserResponse{ + ID: followingUser.ID.Hex(), + Name: followingUser.Name, + Username: followingUser.Username, + ProfilePicture: followingUser.ProfilePicture, + FollowersCount: followingUser.FollowersCount, + FollowingCount: followingUser.FollowingCount, + Reviews: reviews, + Preferences: followingUser.Preferences, } } @@ -412,3 +493,65 @@ func (s *Service) GetFriendReviewsForItem(userObjID primitive.ObjectID, menuItem return reviews, nil } + +func (s *Service) GetDietaryPreferences(userId string) ([]string, error) { + ctx := context.Background() + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return nil, &badReq + } + + // Find the user and get their followers + var user User + err = s.users.FindOne(ctx, bson.M{"_id": userObjID}).Decode(&user) + if err != nil { + return nil, err + } + + dietaryRestrictions := user.Preferences + + return dietaryRestrictions, nil +} + +func (s *Service) PostDietaryPreferences(userId string, preference string) error { + ctx := context.Background() + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return &badReq + } + + update := bson.M{ + "$push": bson.M{"preferences": preference}, + } + + // Update the user's dietary preferences in the database + _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, update) + if err != nil { + return err + } + + return nil +} + +func (s *Service) DeleteDietaryPreferences(userId string, preference string) error { + ctx := context.Background() + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return &badReq + } + + delete := bson.M{ + "$pull": bson.M{"preferences": preference}, + } + + // Update the user's dietary preferences in the database + _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, delete) + if err != nil { + return err + } + + return nil +} diff --git a/backend/internal/handlers/users/types.go b/backend/internal/handlers/users/types.go index 2c42162..76a6443 100644 --- a/backend/internal/handlers/users/types.go +++ b/backend/internal/handlers/users/types.go @@ -15,6 +15,7 @@ type User struct { FollowersCount int `bson:"followersCount"` ProfilePicture string `bson:"profile_picture,omitempty"` Name string `bson:"name,omitempty"` + Preferences []string `bson:"preferences,omitempty"` } type UserResponse struct { @@ -26,6 +27,7 @@ type UserResponse struct { FollowingCount int `json:"followingCount"` Reviews []string `json:"reviews,omitempty"` Name string `json:"name,omitempty"` + Preferences []string `json:"preferences,omitempty"` } type FollowRequest struct { @@ -47,7 +49,16 @@ type GetFollowersQuery struct { UserId string `query:"userId" validate:"required"` } +type GetFollowingQuery struct { + PaginationQuery + UserId string `query:"userId" validate:"required"` +} + type ReviewQuery struct { UserId string `query:"userId" validate:"required"` ItemId string `params:"id" validate:"required"` } + +type PostDietaryPreferencesQuery struct { + Preference string `json:"preference"` +} diff --git a/backend/internal/handlers/users/user_connections.go b/backend/internal/handlers/users/user_connections.go index 33c7b87..f291324 100644 --- a/backend/internal/handlers/users/user_connections.go +++ b/backend/internal/handlers/users/user_connections.go @@ -68,6 +68,30 @@ func (h *Handler) GetFollowers(c *fiber.Ctx) error { return c.JSON(followers) } +// GetFollowing returns a paginated list of who the user is following +func (h *Handler) GetFollowing(c *fiber.Ctx) error { + var query GetFollowingQuery + userId := c.Params("id") + + // Set defaults if not provided + if query.Page < 1 { + query.Page = 1 + } + if query.Limit < 1 { + query.Limit = 20 + } + + followers, err := h.service.GetUserFollowing(userId, query.Page, query.Limit) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", query.UserId)) + } + return err + } + + return c.JSON(followers) +} + // GetFollowingReviewsForItem gets reviews for a menu item from users that the current user follows func (h *Handler) GetFollowingReviewsForItem(c *fiber.Ctx) error { @@ -180,3 +204,48 @@ func (h *Handler) UnfollowUser(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } + +// GetDietaryPreferences retrieves the dietary preferences of a user +func (h *Handler) GetDietaryPreferences(c *fiber.Ctx) error { + userId := c.Params("id") + + preferences, err := h.service.GetDietaryPreferences(userId) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", userId)) + } + return err + } + + return c.JSON(preferences) +} + +func (h *Handler) PostDietaryPreferences(c *fiber.Ctx) error { + userId := c.Params("id") + preference := c.Query("preference") + + err := h.service.PostDietaryPreferences(userId, preference) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", "specified")) + } + return err + } + + return c.SendStatus(fiber.StatusCreated) +} + +func (h *Handler) DeleteDietaryPreferences(c *fiber.Ctx) error { + userId := c.Params("id") + preference := c.Query("preference") + + err := h.service.DeleteDietaryPreferences(userId, preference) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", "specified")) + } + return err + } + + return c.SendStatus(fiber.StatusCreated) +} diff --git a/frontend/app/(tabs)/profile/followers.tsx b/frontend/app/(tabs)/profile/friends.tsx similarity index 73% rename from frontend/app/(tabs)/profile/followers.tsx rename to frontend/app/(tabs)/profile/friends.tsx index 8711f2c..8550f83 100644 --- a/frontend/app/(tabs)/profile/followers.tsx +++ b/frontend/app/(tabs)/profile/friends.tsx @@ -4,58 +4,63 @@ import React, { useState, useEffect, useCallback } from "react"; import { View, Text, StyleSheet, FlatList, TextInput, ActivityIndicator } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import FollowerItem from "@/components/profile/followers/FollowerItem"; -import { TFollower } from "@/types/follower"; +import { TFriend } from "@/types/follower"; +import useAuthStore from "@/auth/store"; -export default function FollowersScreen() { +export default function FriendsScreen() { const insets = useSafeAreaInsets(); const [searchQuery, setSearchQuery] = useState(""); - const [followers, setFollowers] = useState([]); + const [friends, setFriends] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [isLoadingMore, setIsLoadingMore] = useState(false); const [hasMoreData, setHasMoreData] = useState(true); + const { userId } = useAuthStore(); useEffect(() => { - fetchRandomUsers(); + fetchFriends(); }, []); - const fetchRandomUsers = async (pageNum = 1, isRefresh = false) => { - if (pageNum === 1) { + const fetchFriends = async (pageNum = 1, isRefresh = false) => { + if (friends.length == 0) { setLoading(true); - } else { - setIsLoadingMore(true); } try { - const response = await fetch(`https://randomuser.me/api/?results=10&page=${pageNum}`); + const response = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}/following`, + ); const data = await response.json(); - if (data.results && data.results.length > 0) { - const formattedUsers = data.results.map((user) => ({ - id: user.login.uuid, - name: `${user.name.first} ${user.name.last}`, - username: `@${user.login.username}`, - avatar: user.picture.medium, + if (data && data.length > 0) { + const formattedUsers = data.map((user) => ({ + id: user.id, + name: `${user.name}`, + username: `@${user.username}`, + avatar: user.profile_picture, })); - if (isRefresh) { - setFollowers(formattedUsers); - setPage(2); - } else if (pageNum === 1) { - setFollowers(formattedUsers); - setPage(2); - } else { - setFollowers((prevFollowers) => [...prevFollowers, ...formattedUsers]); - setPage(pageNum + 1); - } - - setHasMoreData(pageNum < 15); + setFriends(formattedUsers); + + // dont think this stuff is needed anymore but left it in case + // if (isRefresh) { + // setFollowers(formattedUsers); + // setPage(2); + // } else if (pageNum === 1) { + // setFollowers(formattedUsers); + // setPage(2); + // } else { + // setFollowers((prevFollowers) => [...prevFollowers, ...formattedUsers]); + // setPage(pageNum + 1); + // } + + // setHasMoreData(pageNum < 15); } else { - setHasMoreData(false); + // setHasMoreData(false); } } catch (error) { - console.error("Error fetching random users:", error); + console.error("Error fetching users:", error); } finally { setLoading(false); setIsLoadingMore(false); @@ -67,23 +72,23 @@ export default function FollowersScreen() { if (!refreshing) { setRefreshing(true); setHasMoreData(true); - fetchRandomUsers(1, true); + fetchFriends(1, true); } }, [refreshing]); const handleLoadMore = useCallback(() => { if (!isLoadingMore && hasMoreData && !loading && !refreshing) { - fetchRandomUsers(page); + fetchFriends(page); } }, [isLoadingMore, hasMoreData, loading, refreshing, page]); - const filteredFollowers = followers.filter( + const filteredFollowers = friends.filter( (follower) => follower.name.toLowerCase().includes(searchQuery.toLowerCase()) || follower.username.toLowerCase().includes(searchQuery.toLowerCase()), ); - const renderFollower = ({ item }: { item: TFollower }) => ; + const renderFollower = ({ item }: { item: TFriend }) => ; const renderFooter = () => { if (!isLoadingMore) return null; @@ -97,10 +102,10 @@ export default function FollowersScreen() { }; const renderNoMoreData = () => { - if (followers.length > 0 && !hasMoreData && !isLoadingMore) { + if (friends.length > 0 && !hasMoreData && !isLoadingMore) { return ( - No more followers to load. + No more friends to load. ); } @@ -122,7 +127,7 @@ export default function FollowersScreen() { {loading && page === 1 ? ( - Loading followers... + Loading friends... ) : ( - No followers found. + No friends found. } ListHeaderComponent={ - {followers.length} {followers.length === 1 ? "Friend" : "Friends"} + {friends.length} {friends.length === 1 ? "Friend" : "Friends"} } ListFooterComponent={ diff --git a/frontend/app/(tabs)/profile/profile.tsx b/frontend/app/(tabs)/profile/profile.tsx index 2c8b829..6431a96 100644 --- a/frontend/app/(tabs)/profile/profile.tsx +++ b/frontend/app/(tabs)/profile/profile.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useUser } from "@/context/user-context"; import { ThemedView } from "@/components/themed/ThemedView"; import { ActivityIndicator, Dimensions, ScrollView, StatusBar, StyleSheet, TouchableOpacity } from "react-native"; @@ -13,12 +13,14 @@ import { router } from "expo-router"; import EditProfileSheet from "@/components/profile/EditProfileSheet"; import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; +import type { Review } from '@/types/review'; const { width } = Dimensions.get("window"); const ProfileScreen = () => { const { user, isLoading, error, fetchUserProfile } = useUser(); const [searchText, setSearchText] = React.useState(""); + const [userReviews, setUserReviews] = useState([]); const editProfileRef = useRef<{ open: () => void; close: () => void }>(null); @@ -27,6 +29,20 @@ const ProfileScreen = () => { console.log("User data not available, fetching..."); fetchUserProfile().then(() => {}); } + const fetchReviews = async () => { + if (!user?.id) return ; + + try { + const reviewsRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${user.id}`); + const reviewData = await reviewsRes.json(); + console.log(reviewData); + setUserReviews(reviewData); + } catch (err) { + console.error("Failed to fetch user by ID", err); + } + }; + fetchReviews(); }, [user, isLoading]); if (isLoading) { @@ -76,12 +92,16 @@ const ProfileScreen = () => { value={searchText} onChangeText={(text) => setSearchText(text)} /> - + {userReviews.map((review) => ( + + + ))} diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 1a3f960..da62145 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -10,80 +10,111 @@ import SettingsMenuItem from "@/components/profile/settings/SettingsMenuItem"; import { TSettingsData } from "@/types/settingsData"; import useAuthStore from "@/auth/store"; import { Button } from "@/components/Button"; +import axios from "axios"; export default function SettingsScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { email } = useAuthStore(); + const { email, userId } = useAuthStore(); + + // const userIdStr = JSON.stringify(userId); + console.log(userId); const { logout } = useAuthStore(); - const dietaryOptions = [ - "vegetarian", - "vegan", - "nutFree", - "shellfishAllergy", - "glutenFree", - "dairyFree", - "kosher", - "halal", - "pescatarian", - "keto", - "diabetic", - "soyFree", - "porkFree", - "beefFree", - ]; + const dietaryOptions: Record = { + vegetarian: "Vegetarian", + vegan: "Vegan", + nutFree: "Nut-free", + shellfishAllergy: "Shellfish Allergy", + glutenFree: "Gluten-free", + dairyFree: "Dairy-free", + kosher: "Kosher", + halal: "Halal", + pescatarian: "Pescatarian", + keto: "Keto", + diabetic: "Diabetic", + soyFree: "Soy-free", + porkFree: "Pork-free", + beefFree: "Beef-free", + }; + + const [dietaryPreferences, setDietaryPreferences] = useState([]); const [settings, setSettings] = useState>( - Object.fromEntries(dietaryOptions.map((option) => [option, false])), + Object.fromEntries(Object.values(dietaryOptions).map((option) => [option, false])), ); - // useEffect(() => { - // const fetchPreferences = async () => { - // try { - // const userData = await fetchUserProfile(); // Fetch user profile using the context function - - // if (!userData) return; // Ensure userData exists before proceeding - - // const userRestrictions: string[] = userData.preferences || []; - - // // Convert restrictions array to object { vegetarian: true, glutenFree: true, keto: true, ... } - // const updatedSettings = dietaryOptions.reduce((acc, option) => { - // acc[option] = userRestrictions.includes(option); - // return acc; - // }, {} as Record); + useEffect(() => { + console.log("fetched restrictions!"); + const fetchDietaryRestrictions = async () => { + try { + const response = await axios.get( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences`, + ); + setDietaryPreferences(response.data); + } catch (err) { + console.log("Failed to fetch dietary restrictions"); + console.error(err); + } + }; - // setSettings(updatedSettings); - // } catch (error) { - // console.error("Error fetching user preferences:", error); - // } - // }; + fetchDietaryRestrictions(); + console.log(dietaryPreferences); + }, [userId]); - // fetchPreferences(); - // }, [fetchUserProfile]); + useEffect(() => { + console.log("reloaded!"); + console.log(dietaryPreferences); + setSettings( + Object.fromEntries( + Object.keys(dietaryOptions).map((option) => [ + option, + dietaryPreferences.includes(dietaryOptions[option]), + ]), + ), + ); + }, [dietaryPreferences]); - // i dont think this updates it if you exit settings, should i save toggle states in AsyncStorage? const updateSetting = (key: string, value: boolean) => { - setSettings((prevSettings) => ({ - ...prevSettings, - [`${key}`]: value, - })); + if (value == true) { + setDietaryPreferences((prevRestrictions) => [...prevRestrictions, dietaryOptions[key]]); + handleAddDietaryPreference(key); + } else { + setDietaryPreferences((prevRestrictions) => + prevRestrictions.filter((item) => item !== dietaryOptions[key]), + ); + handleRemoveDietaryPreference(key); + } }; - // const updateSetting = async (key: string, value: boolean) => { - // try { - // const updatedSettings = { ...settings, [key]: value }; - // setSettings(updatedSettings); + const handleAddDietaryPreference = async (preference: string) => { + try { + const response = await axios.post( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, + ); + if (response.status === 201) { + console.log("Preference added successfully:", preference); + } + } catch (err) { + console.log("Failed to add dietary preference"); + console.error(err); + } + }; - // // Send updated preferences to the server - // await axios.put(`${process.env.API_BASE_URL}/api/v1/user/${userId}`, { - // preferences: { restrictions: Object.keys(updatedSettings).filter((k) => updatedSettings[k]) }, - // }); - // } catch (error) { - // console.error("Error saving setting:", error); - // } - // }; + const handleRemoveDietaryPreference = async (preference: string) => { + try { + const response = await axios.delete( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, + ); + if (response.status === 201) { + console.log("Preference deleted successfully:", preference); + } + } catch (err) { + console.log("Failed to delete dietary preference"); + console.error(err); + } + }; const settingsData: TSettingsData = { credentials: [ @@ -113,17 +144,10 @@ export default function SettingsScreen() { account: [ { label: "View Friends", - onPress: () => router.push("/(tabs)/profile/followers"), + //confused... + onPress: () => router.push(`/profile/friends?userId=${userId}`), showChevron: true, }, - { - label: "Logout", - onPress: () => { - logout(); - router.replace("/(onboarding)"); - }, - showChevron: false, - }, ], additional: [ { label: "Blocked Users", onPress: () => console.log("navigating to blocked users") }, @@ -131,6 +155,11 @@ export default function SettingsScreen() { ], }; + const handleLogOut = () => { + logout(); + router.replace("/(onboarding)"); + }; + return ( @@ -196,7 +225,12 @@ export default function SettingsScreen() { ))} -