From 51a29e0ed437a2d13a57c7668d5709439cc4736e Mon Sep 17 00:00:00 2001 From: Nicole Qiu Date: Fri, 21 Nov 2025 23:00:52 -0500 Subject: [PATCH 1/4] friend sharing --- schema.graphql | 1 + src/schema.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/schema.graphql b/schema.graphql index a161ce6..3bb34c1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -275,6 +275,7 @@ type Query { getUserFriends(userId: Int!): [User] getCapacityReminderById(id: Int!): CapacityReminder getAllCapacityReminders: [CapacityReminder] + findFriend(searchTerm: String!): [User] } type RefreshAccessToken { diff --git a/src/schema.py b/src/schema.py index d17ca30..c4862e2 100644 --- a/src/schema.py +++ b/src/schema.py @@ -30,6 +30,7 @@ import os from firebase_admin import messaging import logging +from sqlalchemy import or_ def resolve_enum_value(entry): @@ -340,6 +341,11 @@ class Query(graphene.ObjectType): CapacityReminder, description="Get all capacity reminders." ) + find_friend = graphene.List( + User, + search_term=graphene.String(required=True), + description="Search for users by name or NetID." + ) def resolve_get_all_gyms(self, info): query = Gym.get_query(info) @@ -496,6 +502,13 @@ def resolve_get_capacity_reminder_by_id(self, info, id): def resolve_get_all_capacity_reminders(self, info): query = CapacityReminder.get_query(info) return query.all() + + def resolve_find_friend(self, info, search_term): + query = User.get_query(info) + search = f"{search_term}%" + return query.filter( + or_(UserModel.name.ilike(search), UserModel.net_id.ilike(search)) + ).all() # MARK: - Mutation From 62d4e559e204dce8e3e160df3015e5cc73f57846 Mon Sep 17 00:00:00 2001 From: Nicole Qiu Date: Sat, 22 Nov 2025 21:48:24 -0800 Subject: [PATCH 2/4] search friend also returns friendship relation with current user& delete associated friendship when deleting user --- schema.graphql | 3 +- src/schema.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/schema.graphql b/schema.graphql index 3bb34c1..9ec3979 100644 --- a/schema.graphql +++ b/schema.graphql @@ -275,7 +275,7 @@ type Query { getUserFriends(userId: Int!): [User] getCapacityReminderById(id: Int!): CapacityReminder getAllCapacityReminders: [CapacityReminder] - findFriend(searchTerm: String!): [User] + searchFriend(searchTerm: String!): [User] } type RefreshAccessToken { @@ -317,6 +317,7 @@ type User { friendRequestsReceived: [Friendship] friendships: [Friendship] friends: [User] + friendStatusWithCurrentUser: String } type Workout { diff --git a/src/schema.py b/src/schema.py index c4862e2..a257756 100644 --- a/src/schema.py +++ b/src/schema.py @@ -30,7 +30,7 @@ import os from firebase_admin import messaging import logging -from sqlalchemy import or_ +from sqlalchemy import or_, and_ def resolve_enum_value(entry): @@ -205,6 +205,7 @@ class Meta: workout_goal = graphene.List(DayOfWeekGraphQLEnum) friendships = graphene.List(lambda: Friendship) friends = graphene.List(lambda: User) + friend_status_with_current_user = graphene.String(name="friendStatusWithCurrentUser") def resolve_friendships(self, info): # Return all friendship relationships for this user @@ -232,6 +233,38 @@ def resolve_friends(self, info): # Query for all the users at once return User.get_query(info).filter(UserModel.id.in_(friend_ids)).all() + def resolve_friend_status_with_current_user(self, info): + # Prefer precomputed map from parent resolver to avoid N+1 queries + status_map = getattr(info.context, "friend_status_map", None) + if status_map is not None: + return status_map.get(self.id, "none") + + # If no context map, fall back to checking a single friendship if a JWT is present + try: + current_user_id = int(get_jwt_identity()) + except Exception: + return None + + if not current_user_id: + return None + + fs = ( + Friendship.get_query(info) + .filter( + or_( + and_(FriendshipModel.user_id == current_user_id, FriendshipModel.friend_id == self.id), + and_(FriendshipModel.friend_id == current_user_id, FriendshipModel.user_id == self.id), + ) + ) + .first() + ) + + if not fs: + return "none" + if fs.is_accepted: + return "friends" + return "pending_outgoing" if fs.user_id == current_user_id else "pending_incoming" + class UserInput(graphene.InputObjectType): net_id = graphene.String(required=True) @@ -341,7 +374,7 @@ class Query(graphene.ObjectType): CapacityReminder, description="Get all capacity reminders." ) - find_friend = graphene.List( + search_friend = graphene.List( User, search_term=graphene.String(required=True), description="Search for users by name or NetID." @@ -503,13 +536,50 @@ def resolve_get_all_capacity_reminders(self, info): query = CapacityReminder.get_query(info) return query.all() - def resolve_find_friend(self, info, search_term): + @jwt_required() + def resolve_search_friend(self, info, search_term): query = User.get_query(info) search = f"{search_term}%" - return query.filter( - or_(UserModel.name.ilike(search), UserModel.net_id.ilike(search)) + + current_user_id = int(get_jwt_identity()) + + users = query.filter( + or_( + UserModel.name.ilike(search), + UserModel.net_id.ilike(search) + ), + UserModel.id != current_user_id, ).all() + candidate_ids = [user.id for user in users] + friend_status_map = {} + + if candidate_ids: + friendships = Friendship.get_query(info).filter( + or_( + and_(FriendshipModel.user_id == current_user_id, FriendshipModel.friend_id.in_(candidate_ids)), + and_(FriendshipModel.friend_id == current_user_id, FriendshipModel.user_id.in_(candidate_ids)), + ) + ).all() + + for fs in friendships: + other_id = fs.friend_id if fs.user_id == current_user_id else fs.user_id + if fs.is_accepted: + status = "friends" + elif fs.user_id == current_user_id: + status = "pending_outgoing" + else: + status = "pending_incoming" + friend_status_map[other_id] = status + + # Stash for the User.friendStatusWithCurrentUser resolver to avoid N+1 queries + setattr(info.context, "friend_status_map", friend_status_map) + + # Provide default status when the resolver is not invoked (e.g., unused field) + for user in users: + user.friend_status_with_current_user = friend_status_map.get(user.id, "none") + + return users # MARK: - Mutation @@ -843,6 +913,14 @@ def mutate(self, info, user_id): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: raise GraphQLError("User with given ID does not exist.") + + # Remove any friendships that reference this user to avoid orphaned rows + friendships = Friendship.get_query(info).filter( + or_(FriendshipModel.user_id == user_id, FriendshipModel.friend_id == user_id) + ) + for friendship in friendships: + db_session.delete(friendship) + db_session.delete(user) db_session.commit() return user From 2b8ff16b6381166f6b49d859ba95544a017b5570 Mon Sep 17 00:00:00 2001 From: Nicole Qiu Date: Sat, 22 Nov 2025 21:51:49 -0800 Subject: [PATCH 3/4] prevent adding yourself as a friend --- src/schema.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/schema.py b/src/schema.py index a257756..6911018 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1100,6 +1100,9 @@ class Arguments: @jwt_required() def mutate(self, info, user_id, friend_id): + if user_id == friend_id: + raise GraphQLError("You cannot add yourself as a friend.") + # Check if users exist user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: From ce5b2a1f72f6c1bf6d7f8dcd13c2ae365deb6acb Mon Sep 17 00:00:00 2001 From: Nicole Qiu Date: Sat, 22 Nov 2025 22:00:42 -0800 Subject: [PATCH 4/4] auto accept when other user requested to add current user --- src/schema.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/schema.py b/src/schema.py index 6911018..4572d43 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1112,6 +1112,16 @@ def mutate(self, info, user_id, friend_id): if not friend: raise GraphQLError("Friend with given ID does not exist.") + # If a pending request exists in the opposite direction, auto-accept it + reverse_existing = Friendship.get_query(info).filter( + (FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id) + ).first() + if reverse_existing and not reverse_existing.is_accepted: + reverse_existing.is_accepted = True + reverse_existing.accepted_at = datetime.utcnow() + db_session.commit() + return reverse_existing + # Check if friendship already exists existing = Friendship.get_query(info).filter( ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) |