From ca0db8d5d4aed894b415284f94531330f7d357ec Mon Sep 17 00:00:00 2001 From: Olivia Hill Date: Thu, 16 Apr 2026 21:43:03 -0400 Subject: [PATCH 1/8] feat: enforce admin permissions for member and activity RSVP removal --- backend/docs/docs.go | 75 +++++++- backend/docs/swagger.json | 75 +++++++- backend/docs/swagger.yaml | 52 +++++- backend/internal/controllers/activity.go | 41 ++++- backend/internal/controllers/membership.go | 22 ++- backend/internal/repository/activity.go | 2 +- backend/internal/repository/activity_rsvp.go | 36 +++- backend/internal/server/routers/activity.go | 5 +- backend/internal/services/activity.go | 173 +++++++++++-------- backend/internal/services/membership.go | 20 ++- backend/internal/tests/activity_test.go | 92 ++++++++++ backend/internal/tests/membership_test.go | 70 +++++++- 12 files changed, 569 insertions(+), 94 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index ad84c951..480841a6 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -2455,6 +2455,73 @@ const docTemplate = `{ } } }, + "/api/v1/trips/{tripID}/activities/{activityID}/rsvps/{userID}": { + "delete": { + "description": "Removes a user's RSVP from an activity. Only the activity proposer or a trip admin can remove another user's RSVP.", + "tags": [ + "activities" + ], + "summary": "Remove activity RSVP", + "parameters": [ + { + "type": "string", + "description": "Trip ID", + "name": "tripID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Activity ID", + "name": "activityID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID of the RSVP to remove", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + } + } + } + }, "/api/v1/trips/{tripID}/activity": { "get": { "description": "Returns all unread events in the caller's trip feed. Events persist until explicitly dismissed via the mark-read endpoint.", @@ -3121,7 +3188,7 @@ const docTemplate = `{ } }, "delete": { - "description": "Removes a user from a trip", + "description": "Removes a user from a trip. Only admins can remove members.", "tags": [ "memberships" ], @@ -3159,6 +3226,12 @@ const docTemplate = `{ "$ref": "#/definitions/errs.APIError" } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, "404": { "description": "Not Found", "schema": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index f71f527d..37510ae0 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -2449,6 +2449,73 @@ } } }, + "/api/v1/trips/{tripID}/activities/{activityID}/rsvps/{userID}": { + "delete": { + "description": "Removes a user's RSVP from an activity. Only the activity proposer or a trip admin can remove another user's RSVP.", + "tags": [ + "activities" + ], + "summary": "Remove activity RSVP", + "parameters": [ + { + "type": "string", + "description": "Trip ID", + "name": "tripID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Activity ID", + "name": "activityID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID of the RSVP to remove", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + } + } + } + }, "/api/v1/trips/{tripID}/activity": { "get": { "description": "Returns all unread events in the caller's trip feed. Events persist until explicitly dismissed via the mark-read endpoint.", @@ -3115,7 +3182,7 @@ } }, "delete": { - "description": "Removes a user from a trip", + "description": "Removes a user from a trip. Only admins can remove members.", "tags": [ "memberships" ], @@ -3153,6 +3220,12 @@ "$ref": "#/definitions/errs.APIError" } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/errs.APIError" + } + }, "404": { "description": "Not Found", "schema": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 34de4b4d..f9d30d21 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -3517,6 +3517,52 @@ paths: summary: Get activity RSVPs tags: - activities + /api/v1/trips/{tripID}/activities/{activityID}/rsvps/{userID}: + delete: + description: Removes a user's RSVP from an activity. Only the activity proposer + or a trip admin can remove another user's RSVP. + parameters: + - description: Trip ID + in: path + name: tripID + required: true + type: string + - description: Activity ID + in: path + name: activityID + required: true + type: string + - description: User ID of the RSVP to remove + in: path + name: userID + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/errs.APIError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/errs.APIError' + "403": + description: Forbidden + schema: + $ref: '#/definitions/errs.APIError' + "404": + description: Not Found + schema: + $ref: '#/definitions/errs.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/errs.APIError' + summary: Remove activity RSVP + tags: + - activities /api/v1/trips/{tripID}/activities/parse-link: post: consumes: @@ -3978,7 +4024,7 @@ paths: - memberships /api/v1/trips/{tripID}/memberships/{userID}: delete: - description: Removes a user from a trip + description: Removes a user from a trip. Only admins can remove members. operationId: removeMember parameters: - description: Trip ID @@ -4002,6 +4048,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/errs.APIError' + "403": + description: Forbidden + schema: + $ref: '#/definitions/errs.APIError' "404": description: Not Found schema: diff --git a/backend/internal/controllers/activity.go b/backend/internal/controllers/activity.go index f2cd09ee..623fad5d 100644 --- a/backend/internal/controllers/activity.go +++ b/backend/internal/controllers/activity.go @@ -359,7 +359,6 @@ func (ctrl *ActivityController) AddCategoryToActivity(c *fiber.Ctx) error { return errs.InvalidUUID() } - // Use request model for validation req := models.AddCategoryToActivityRequest{ CategoryName: c.Params("categoryName"), } @@ -408,7 +407,6 @@ func (ctrl *ActivityController) RemoveCategoryFromActivity(c *fiber.Ctx) error { return errs.InvalidUUID() } - // Use request model for validation req := models.AddCategoryToActivityRequest{ CategoryName: c.Params("categoryName"), } @@ -506,6 +504,43 @@ func (ctrl *ActivityController) GetActivityRSVPs(c *fiber.Ctx) error { return c.Status(http.StatusOK).JSON(rsvps) } +// RemoveActivityRSVP removes a specific user's RSVP from an activity. +// @Summary Remove activity RSVP +// @Description Removes a user's RSVP from an activity. Only the activity proposer or a trip admin can remove another user's RSVP. +// @Tags activities +// @Param tripID path string true "Trip ID" +// @Param activityID path string true "Activity ID" +// @Param userID path string true "User ID of the RSVP to remove" +// @Success 204 "No Content" +// @Failure 400 {object} errs.APIError +// @Failure 401 {object} errs.APIError +// @Failure 403 {object} errs.APIError +// @Failure 404 {object} errs.APIError +// @Failure 500 {object} errs.APIError +// @Router /api/v1/trips/{tripID}/activities/{activityID}/rsvps/{userID} [delete] +func (ctrl *ActivityController) RemoveActivityRSVP(c *fiber.Ctx) error { + tripID, activityID, err := ctrl.parseTripAndActivityIDs(c) + if err != nil { + return err + } + + targetUserID, err := validators.ValidateID(c.Params("userID")) + if err != nil { + return errs.InvalidUUID() + } + + callerID, err := validators.ExtractUserID(c) + if err != nil { + return err + } + + if err := ctrl.activityService.RemoveActivityRSVP(c.Context(), tripID, activityID, callerID, targetUserID); err != nil { + return err + } + + return c.SendStatus(http.StatusNoContent) +} + // @Summary Parse link into activity data // @Description Fetches a URL and extracts structured activity fields (name, description, thumbnail) for form autofill. Supports Airbnb, Booking.com, TikTok, Instagram, and generic travel blog URLs. // @Tags activities @@ -629,4 +664,4 @@ func (ctrl *ActivityController) parseRSVPPaginationRequest( Cursor: cursor, Status: status, }, nil -} +} \ No newline at end of file diff --git a/backend/internal/controllers/membership.go b/backend/internal/controllers/membership.go index 7419f74f..874fd262 100644 --- a/backend/internal/controllers/membership.go +++ b/backend/internal/controllers/membership.go @@ -211,13 +211,14 @@ func (ctrl *MembershipController) UpdateMembership(c *fiber.Ctx) error { } // @Summary Remove member from trip -// @Description Removes a user from a trip +// @Description Removes a user from a trip. Only admins can remove members. // @Tags memberships // @Param tripID path string true "Trip ID" // @Param userID path string true "User ID" // @Success 204 "No Content" // @Failure 400 {object} errs.APIError // @Failure 401 {object} errs.APIError +// @Failure 403 {object} errs.APIError // @Failure 404 {object} errs.APIError // @Failure 500 {object} errs.APIError // @Router /api/v1/trips/{tripID}/memberships/{userID} [delete] @@ -233,6 +234,23 @@ func (ctrl *MembershipController) RemoveMember(c *fiber.Ctx) error { return errs.InvalidUUID() } + // Admins can remove anyone; members can only remove themselves + authUserID, ok := c.Locals("userID").(string) + if !ok { + return errs.Unauthorized() + } + authUserUUID, err := validators.ValidateID(authUserID) + if err != nil { + return errs.Unauthorized() + } + isAdmin, err := ctrl.membershipService.IsAdmin(c.Context(), tripID, authUserUUID) + if err != nil { + return err + } + if !isAdmin && authUserUUID != userID { + return errs.Forbidden() + } + if err := ctrl.membershipService.RemoveMember(c.Context(), tripID, userID); err != nil { return err } @@ -393,4 +411,4 @@ func (ctrl *MembershipController) DemoteFromAdmin(c *fiber.Ctx) error { return c.Status(http.StatusOK).JSON(fiber.Map{ "message": "Admin demoted to member successfully", }) -} +} \ No newline at end of file diff --git a/backend/internal/repository/activity.go b/backend/internal/repository/activity.go index 72c806c4..77cc962b 100644 --- a/backend/internal/repository/activity.go +++ b/backend/internal/repository/activity.go @@ -378,4 +378,4 @@ func (r *activityRepository) ReplaceImagesTx(ctx context.Context, tx bun.Tx, act return err } return r.AddImagesTx(ctx, tx, activityID, imageIDs) -} +} \ No newline at end of file diff --git a/backend/internal/repository/activity_rsvp.go b/backend/internal/repository/activity_rsvp.go index 1200f5bb..5ed12451 100644 --- a/backend/internal/repository/activity_rsvp.go +++ b/backend/internal/repository/activity_rsvp.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "time" "toggo/internal/models" @@ -12,6 +13,7 @@ import ( type ActivityRSVPRepository interface { UpdateRSVP(ctx context.Context, tripID, activityID, userID uuid.UUID, status models.RSVPStatus) (*models.ActivityRSVP, error) GetActivityRSVPs(ctx context.Context, tripID, activityID, userID uuid.UUID, limit int, cursorToken time.Time, statusFilter string) ([]models.ActivityRSVPDatabaseResponse, time.Time, error) + DeleteRSVP(ctx context.Context, tripID, activityID, targetUserID uuid.UUID) error } var _ ActivityRSVPRepository = (*activityRSVPRepository)(nil) @@ -77,12 +79,12 @@ func (r *activityRSVPRepository) GetActivityRSVPs( func (r *activityRSVPRepository) UpdateRSVP(ctx context.Context, tripID, activityID, userID uuid.UUID, status models.RSVPStatus) (*models.ActivityRSVP, error) { query := ` - INSERT INTO activity_rsvps (trip_id, activity_id, user_id, status) - VALUES (?, ?, ?, ?) - ON CONFLICT (trip_id, activity_id, user_id) - DO UPDATE SET status = EXCLUDED.status, updated_at = NOW() - RETURNING trip_id, activity_id, user_id, status, created_at, updated_at - ` + INSERT INTO activity_rsvps (trip_id, activity_id, user_id, status) + VALUES (?, ?, ?, ?) + ON CONFLICT (trip_id, activity_id, user_id) + DO UPDATE SET status = EXCLUDED.status, updated_at = NOW() + RETURNING trip_id, activity_id, user_id, status, created_at, updated_at + ` rsvp := new(models.ActivityRSVP) err := r.db.QueryRowContext(ctx, query, tripID, activityID, userID, status).Scan( &rsvp.TripID, @@ -97,3 +99,25 @@ func (r *activityRSVPRepository) UpdateRSVP(ctx context.Context, tripID, activit } return rsvp, nil } + +func (r *activityRSVPRepository) DeleteRSVP(ctx context.Context, tripID, activityID, targetUserID uuid.UUID) error { + result, err := r.db.NewDelete(). + TableExpr("activity_rsvps"). + Where("trip_id = ?", tripID). + Where("activity_id = ?", activityID). + Where("user_id = ?", targetUserID). + Exec(ctx) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return sql.ErrNoRows + } + + return nil +} \ No newline at end of file diff --git a/backend/internal/server/routers/activity.go b/backend/internal/server/routers/activity.go index 740a6bc0..6a18b6eb 100644 --- a/backend/internal/server/routers/activity.go +++ b/backend/internal/server/routers/activity.go @@ -47,5 +47,8 @@ func ActivityRoutes(apiGroup fiber.Router, routeParams types.RouteParams) fiber. activityRSVPGroup.Get("", activityController.GetActivityRSVPs) activityRSVPGroup.Post("", activityController.RSVPActivity) + // /api/v1/trips/:tripID/activities/:activityID/rsvps/:userID + activityRSVPGroup.Delete("/:userID", activityController.RemoveActivityRSVP) + return tripActivityGroup -} +} \ No newline at end of file diff --git a/backend/internal/services/activity.go b/backend/internal/services/activity.go index 9cdbd332..18d21b1f 100644 --- a/backend/internal/services/activity.go +++ b/backend/internal/services/activity.go @@ -2,6 +2,7 @@ package services import ( "context" + "database/sql" "errors" "log" "time" @@ -32,6 +33,7 @@ type ActivityServiceInterface interface { // RSVP management UpdateActivityRSVP(ctx context.Context, tripID, activityID, userID uuid.UUID, req models.ActivityRSVPRequestPayload) (*models.ActivityRSVP, error) GetActivityRSVPs(ctx context.Context, tripID, activityID, userID uuid.UUID, limit int, cursorToken string, statusFilter string) (*models.ActivityRSVPsPageResult, error) + RemoveActivityRSVP(ctx context.Context, tripID, activityID, callerID, targetUserID uuid.UUID) error } var _ ActivityServiceInterface = (*ActivityService)(nil) @@ -307,6 +309,103 @@ func (s *ActivityService) RemoveCategoryFromActivity(ctx context.Context, tripID return s.ActivityCategory.RemoveCategoryFromActivity(ctx, activityID, categoryName) } +func (s *ActivityService) UpdateActivityRSVP(ctx context.Context, tripID, activityID, userID uuid.UUID, req models.ActivityRSVPRequestPayload) (*models.ActivityRSVP, error) { + if _, err := s.verifyActivityBelongsToTrip(ctx, tripID, activityID); err != nil { + return nil, err + } + + rsvp, err := s.ActivityRSVP.UpdateRSVP(ctx, tripID, activityID, userID, req.Status) + if err != nil { + return nil, err + } + return rsvp, nil +} + +func (s *ActivityService) GetActivityRSVPs( + ctx context.Context, + tripID uuid.UUID, + activityID uuid.UUID, + userID uuid.UUID, + limit int, + cursorToken string, + statusFilter string, +) (*models.ActivityRSVPsPageResult, error) { + + if _, err := s.verifyActivityBelongsToTrip(ctx, tripID, activityID); err != nil { + return nil, err + } + + cursor, err := pagination.DecodeTimeCursor(cursorToken) + if err != nil { + return nil, errs.BadRequest(errors.New("invalid cursor format")) + } + + rsvps, lastCreatedAt, err := s.ActivityRSVP.GetActivityRSVPs( + ctx, + tripID, + activityID, + userID, + limit, + cursor, + statusFilter, + ) + if err != nil { + return nil, err + } + + fileURLMap := pagination.FetchFileURLs( + ctx, + s.fileService, + rsvps, + func(item models.ActivityRSVPDatabaseResponse) *string { + return item.ProfilePictureKey + }, + models.ImageSizeSmall, + ) + + apiRSVPs := make([]models.ActivityRSVPAPIResponse, 0, len(rsvps)) + for _, rsvp := range rsvps { + apiRSVPs = append(apiRSVPs, toRSVPAPIResponse(rsvp, fileURLMap)) + } + + var nextCursor *string + if !lastCreatedAt.IsZero() { + token := lastCreatedAt.Format(time.RFC3339Nano) + nextCursor = &token + } + + return &models.ActivityRSVPsPageResult{ + RSVPs: apiRSVPs, + Limit: limit, + NextCursor: nextCursor, + }, nil +} + +// RemoveActivityRSVP removes a specific user's RSVP from an activity. +// Only the activity proposer or a trip admin can remove another user's RSVP. +func (s *ActivityService) RemoveActivityRSVP(ctx context.Context, tripID, activityID, callerID, targetUserID uuid.UUID) error { + activity, err := s.verifyActivityBelongsToTrip(ctx, tripID, activityID) + if err != nil { + return err + } + + isAdmin, err := s.Membership.IsAdmin(ctx, tripID, callerID) + if err != nil { + return err + } + + isProposer := activity.ProposedBy != nil && *activity.ProposedBy == callerID + if !isAdmin && !isProposer { + return errs.Forbidden() + } + + err = s.ActivityRSVP.DeleteRSVP(ctx, tripID, activityID, targetUserID) + if errors.Is(err, sql.ErrNoRows) { + return errs.ErrNotFound + } + return err +} + // Helper methods func (s *ActivityService) buildActivityListResponse(ctx context.Context, activities []*models.ActivityDatabaseResponse, nextCursor *models.ActivityCursor, limit int) (*models.ActivityCursorPageResult, error) { @@ -539,78 +638,6 @@ func (s *ActivityService) buildActivityPageResult(apiActivities []*models.Activi return result, nil } -func (s *ActivityService) UpdateActivityRSVP(ctx context.Context, tripID, activityID, userID uuid.UUID, req models.ActivityRSVPRequestPayload) (*models.ActivityRSVP, error) { - if _, err := s.verifyActivityBelongsToTrip(ctx, tripID, activityID); err != nil { - return nil, err - } - - rsvp, err := s.ActivityRSVP.UpdateRSVP(ctx, tripID, activityID, userID, req.Status) - if err != nil { - return nil, err - } - return rsvp, nil -} - -func (s *ActivityService) GetActivityRSVPs( - ctx context.Context, - tripID uuid.UUID, - activityID uuid.UUID, - userID uuid.UUID, - limit int, - cursorToken string, - statusFilter string, -) (*models.ActivityRSVPsPageResult, error) { - - if _, err := s.verifyActivityBelongsToTrip(ctx, tripID, activityID); err != nil { - return nil, err - } - - cursor, err := pagination.DecodeTimeCursor(cursorToken) - if err != nil { - return nil, errs.BadRequest(errors.New("invalid cursor format")) - } - - rsvps, lastCreatedAt, err := s.ActivityRSVP.GetActivityRSVPs( - ctx, - tripID, - activityID, - userID, - limit, - cursor, - statusFilter, - ) - if err != nil { - return nil, err - } - - fileURLMap := pagination.FetchFileURLs( - ctx, - s.fileService, - rsvps, - func(item models.ActivityRSVPDatabaseResponse) *string { - return item.ProfilePictureKey - }, - models.ImageSizeSmall, - ) - - apiRSVPs := make([]models.ActivityRSVPAPIResponse, 0, len(rsvps)) - for _, rsvp := range rsvps { - apiRSVPs = append(apiRSVPs, toRSVPAPIResponse(rsvp, fileURLMap)) - } - - var nextCursor *string - if !lastCreatedAt.IsZero() { - token := lastCreatedAt.Format(time.RFC3339Nano) - nextCursor = &token - } - - return &models.ActivityRSVPsPageResult{ - RSVPs: apiRSVPs, - Limit: limit, - NextCursor: nextCursor, - }, nil -} - func toRSVPAPIResponse( rsvp models.ActivityRSVPDatabaseResponse, fileURLMap map[string]string, @@ -634,4 +661,4 @@ func toRSVPAPIResponse( CreatedAt: rsvp.CreatedAt, UpdatedAt: rsvp.UpdatedAt, } -} +} \ No newline at end of file diff --git a/backend/internal/services/membership.go b/backend/internal/services/membership.go index 52360ece..b7e6d1a9 100644 --- a/backend/internal/services/membership.go +++ b/backend/internal/services/membership.go @@ -254,11 +254,25 @@ func (s *MembershipService) RemoveMember(ctx context.Context, tripID, userID uui if err != nil { return err } - if !isMember { return errors.New("user is not a member of this trip") } + // Prevent removing the last admin + membership, err := s.Membership.Find(ctx, userID, tripID) + if err != nil { + return err + } + if membership.IsAdmin { + admins, err := s.Membership.CountAdmins(ctx, tripID) + if err != nil { + return err + } + if admins <= 1 { + return errs.BadRequest(errors.New("cannot remove the last admin of a trip")) + } + } + return s.Membership.Delete(ctx, userID, tripID) } @@ -367,8 +381,6 @@ func (s *MembershipService) toAPIResponse(ctx context.Context, membership *model }, nil } -// Helper methods for cleaner code organization - func (s *MembershipService) convertToAPIMemberships(memberships []*models.MembershipDatabaseResponse, fileURLMap map[string]string) []*models.MembershipAPIResponse { apiMemberships := make([]*models.MembershipAPIResponse, 0, len(memberships)) for _, membership := range memberships { @@ -425,4 +437,4 @@ func (s *MembershipService) publishMembershipAdded(ctx context.Context, tripID, if err := s.publisher.Publish(ctx, event); err != nil { log.Printf("Failed to publish membership.added event: %v", err) } -} +} \ No newline at end of file diff --git a/backend/internal/tests/activity_test.go b/backend/internal/tests/activity_test.go index ad5bcf15..e3ed52c2 100644 --- a/backend/internal/tests/activity_test.go +++ b/backend/internal/tests/activity_test.go @@ -1723,3 +1723,95 @@ func TestActivityRSVPPagination(t *testing.T) { AssertStatus(http.StatusBadRequest) }) } + +func TestRemoveActivityRSVP(t *testing.T) { + app := fakes.GetSharedTestApp() + + owner := createUser(t, app) + proposer := createUser(t, app) + member := createUser(t, app) + nonMember := createUser(t, app) + trip := createTrip(t, app, owner) + addMember(t, app, owner, proposer, trip) + addMember(t, app, owner, member, trip) + + // proposer creates the activity + activityID := createActivity(t, app, proposer, trip, "RSVP Removal Test Activity") + + // helper to RSVP a user + rsvpUser := func(t *testing.T, userID string) { + t.Helper() + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps", trip, activityID), + Method: testkit.POST, + UserID: &userID, + Body: models.ActivityRSVPRequestPayload{Status: models.RSVPStatusGoing}, + }). + AssertStatus(http.StatusOK) + } + + t.Run("admin can remove another user's RSVP", func(t *testing.T) { + rsvpUser(t, member) + + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps/%s", trip, activityID, member), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusNoContent) + }) + + t.Run("proposer can remove another user's RSVP", func(t *testing.T) { + rsvpUser(t, member) + + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps/%s", trip, activityID, member), + Method: testkit.DELETE, + UserID: &proposer, + }). + AssertStatus(http.StatusNoContent) + }) + + t.Run("regular member cannot remove another user's RSVP", func(t *testing.T) { + rsvpUser(t, owner) + + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps/%s", trip, activityID, owner), + Method: testkit.DELETE, + UserID: &member, + }). + AssertStatus(http.StatusForbidden) + }) + + t.Run("removing an RSVP that does not exist returns 404", func(t *testing.T) { + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps/%s", trip, activityID, uuid.New().String()), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusNotFound) + }) + + t.Run("non-member cannot remove an RSVP", func(t *testing.T) { + rsvpUser(t, member) + + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps/%s", trip, activityID, member), + Method: testkit.DELETE, + UserID: &nonMember, + }). + AssertStatus(http.StatusNotFound) + }) +} \ No newline at end of file diff --git a/backend/internal/tests/membership_test.go b/backend/internal/tests/membership_test.go index 5d34544d..f404efb2 100644 --- a/backend/internal/tests/membership_test.go +++ b/backend/internal/tests/membership_test.go @@ -228,6 +228,74 @@ func TestMembershipLifecycle(t *testing.T) { AssertStatus(http.StatusNoContent) }) + t.Run("non-admin cannot remove another member", func(t *testing.T) { + app := fakes.GetSharedTestApp() + + owner := createUser(t, app) + member := createUser(t, app) + target := createUser(t, app) + trip := createTrip(t, app, owner) + addMember(t, app, owner, member, trip) + addMember(t, app, owner, target, trip) + + // Regular member tries to remove another member — should be forbidden + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s", trip, target), + Method: testkit.DELETE, + UserID: &member, + }). + AssertStatus(http.StatusForbidden) + }) + + t.Run("cannot remove the last admin from a trip", func(t *testing.T) { + app := fakes.GetSharedTestApp() + + owner := createUser(t, app) + member := createUser(t, app) + trip := createTrip(t, app, owner) + addMember(t, app, owner, member, trip) + + // Owner is the only admin — removing them should fail + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s", trip, owner), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusBadRequest) + }) + + t.Run("admin can remove a non-admin member", func(t *testing.T) { + app := fakes.GetSharedTestApp() + + owner := createUser(t, app) + member := createUser(t, app) + trip := createTrip(t, app, owner) + addMember(t, app, owner, member, trip) + + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s", trip, member), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusNoContent) + + // Verify member is gone + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s", trip, member), + Method: testkit.GET, + UserID: &owner, + }). + AssertStatus(http.StatusNotFound) + }) + t.Run("adding same user to trip twice should work", func(t *testing.T) { app := fakes.GetSharedTestApp() @@ -958,4 +1026,4 @@ func TestFindUserIDsWithNotificationPreference(t *testing.T) { require.NoError(t, err) require.Empty(t, pollIDs) }) -} +} \ No newline at end of file From ab11279f0ef4bf36395418ff878886ac7d46e656 Mon Sep 17 00:00:00 2001 From: Olivia Hill Date: Thu, 16 Apr 2026 21:53:55 -0400 Subject: [PATCH 2/8] test: add permission tests for member removal and activity RSVP removal --- backend/internal/tests/activity_test.go | 11 +++++ backend/internal/tests/membership_test.go | 58 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/backend/internal/tests/activity_test.go b/backend/internal/tests/activity_test.go index e3ed52c2..5cb3ee67 100644 --- a/backend/internal/tests/activity_test.go +++ b/backend/internal/tests/activity_test.go @@ -1814,4 +1814,15 @@ func TestRemoveActivityRSVP(t *testing.T) { }). AssertStatus(http.StatusNotFound) }) + + t.Run("malformed userID returns 400", func(t *testing.T) { + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/activities/%s/rsvps/not-a-uuid", trip, activityID), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusBadRequest) + }) } \ No newline at end of file diff --git a/backend/internal/tests/membership_test.go b/backend/internal/tests/membership_test.go index f404efb2..af101717 100644 --- a/backend/internal/tests/membership_test.go +++ b/backend/internal/tests/membership_test.go @@ -459,6 +459,64 @@ func TestMembershipLifecycle(t *testing.T) { }). AssertStatus(http.StatusUnprocessableEntity) }) + + t.Run("admin can remove themselves when another admin exists", func(t *testing.T) { + app := fakes.GetSharedTestApp() + + owner := createUser(t, app) + secondAdmin := createUser(t, app) + trip := createTrip(t, app, owner) + addMember(t, app, owner, secondAdmin, trip) + + // Promote secondAdmin so there are two admins + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s/promote", trip, secondAdmin), + Method: testkit.POST, + UserID: &owner, + }). + AssertStatus(http.StatusOK) + + // Owner removes themselves — should succeed since secondAdmin remains + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s", trip, owner), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusNoContent) + }) + + t.Run("admin can remove another admin when one admin remains", func(t *testing.T) { + app := fakes.GetSharedTestApp() + + owner := createUser(t, app) + secondAdmin := createUser(t, app) + trip := createTrip(t, app, owner) + addMember(t, app, owner, secondAdmin, trip) + + // Promote secondAdmin + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s/promote", trip, secondAdmin), + Method: testkit.POST, + UserID: &owner, + }). + AssertStatus(http.StatusOK) + + // Owner removes secondAdmin — should succeed since owner still remains + testkit.New(t). + Request(testkit.Request{ + App: app, + Route: fmt.Sprintf("/api/v1/trips/%s/memberships/%s", trip, secondAdmin), + Method: testkit.DELETE, + UserID: &owner, + }). + AssertStatus(http.StatusNoContent) + }) } func TestMembershipPagination(t *testing.T) { From e064437832a9f467d4b9b4edf97f17c84d9fe8b3 Mon Sep 17 00:00:00 2001 From: Afnan Tuffaha Date: Fri, 17 Apr 2026 16:15:43 -0400 Subject: [PATCH 3/8] frontend for admin permissions --- frontend/api/activities/index.ts | 5 + ...psTripidActivitiesActivityidRsvpsUserid.ts | 191 +++++++++ ...piV1TripsTripidActivitiesActivityidRsvp.ts | 26 +- frontend/api/index.ts | 5 + frontend/api/memberships/useRemoveMember.ts | 41 +- .../trips/[id]/activities/[id]/index.tsx | 380 +++++++++--------- .../[id]/components/entity-detail-screen.tsx | 71 ++-- .../(app)/trips/[id]/components/link-pill.tsx | 30 +- .../[id]/components/members-going-section.tsx | 24 +- .../(app)/trips/[id]/housing/[id]/index.tsx | 208 +++++----- frontend/index.ts | 5 + frontend/types/schema.gen.ts | 74 ++++ frontend/types/types.gen.ts | 79 +++- 13 files changed, 770 insertions(+), 369 deletions(-) create mode 100644 frontend/api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts diff --git a/frontend/api/activities/index.ts b/frontend/api/activities/index.ts index f59e5915..eb128573 100644 --- a/frontend/api/activities/index.ts +++ b/frontend/api/activities/index.ts @@ -1,6 +1,7 @@ export type { AddCategoryToActivityMutationKey } from "./useAddCategoryToActivity.ts"; export type { CreateActivityMutationKey } from "./useCreateActivity.ts"; export type { DeleteActivityMutationKey } from "./useDeleteActivity.ts"; +export type { DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey } from "./useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; export type { GetActivitiesByTripIDQueryKey } from "./useGetActivitiesByTripID.ts"; export type { GetActivitiesByTripIDSuspenseQueryKey } from "./useGetActivitiesByTripIDSuspense.ts"; export type { GetActivityQueryKey } from "./useGetActivity.ts"; @@ -25,6 +26,10 @@ export { deleteActivity } from "./useDeleteActivity.ts"; export { deleteActivityMutationKey } from "./useDeleteActivity.ts"; export { deleteActivityMutationOptions } from "./useDeleteActivity.ts"; export { useDeleteActivity } from "./useDeleteActivity.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid } from "./useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey } from "./useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationOptions } from "./useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid } from "./useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; export { getActivitiesByTripID } from "./useGetActivitiesByTripID.ts"; export { getActivitiesByTripIDQueryKey } from "./useGetActivitiesByTripID.ts"; export { getActivitiesByTripIDQueryOptions } from "./useGetActivitiesByTripID.ts"; diff --git a/frontend/api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts b/frontend/api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts new file mode 100644 index 00000000..9ddcfcc1 --- /dev/null +++ b/frontend/api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts @@ -0,0 +1,191 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import fetch from "../client"; +import type { Client, RequestConfig, ResponseErrorConfig } from "../client"; +import type { + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500, +} from "../../types/types.gen.ts"; +import type { + UseMutationOptions, + UseMutationResult, + QueryClient, +} from "@tanstack/react-query"; +import { mutationOptions, useMutation } from "@tanstack/react-query"; + +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey = + () => + [ + { url: "/api/v1/trips/:tripID/activities/:activityID/rsvps/:userID" }, + ] as const; + +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey = + ReturnType< + typeof deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey + >; + +/** + * @description Removes a user's RSVP from an activity. Only the activity proposer or a trip admin can remove another user's RSVP. + * @summary Remove activity RSVP + * {@link /api/v1/trips/:tripID/activities/:activityID/rsvps/:userID} + */ +export async function deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid( + tripID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["tripID"], + activityID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["activityID"], + userID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["userID"], + config: Partial & { client?: Client } = {}, +) { + const { client: request = fetch, ...requestConfig } = config; + + const res = await request< + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + ResponseErrorConfig< + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 + >, + unknown + >({ + method: "DELETE", + url: `/api/v1/trips/${tripID}/activities/${activityID}/rsvps/${userID}`, + ...requestConfig, + }); + return res.data; +} + +export function deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationOptions< + TContext = unknown, +>(config: Partial & { client?: Client } = {}) { + const mutationKey = + deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey(); + return mutationOptions< + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + ResponseErrorConfig< + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 + >, + { + tripID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["tripID"]; + activityID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["activityID"]; + userID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["userID"]; + }, + TContext + >({ + mutationKey, + mutationFn: async ({ tripID, activityID, userID }) => { + return deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid( + tripID, + activityID, + userID, + config, + ); + }, + }); +} + +/** + * @description Removes a user's RSVP from an activity. Only the activity proposer or a trip admin can remove another user's RSVP. + * @summary Remove activity RSVP + * {@link /api/v1/trips/:tripID/activities/:activityID/rsvps/:userID} + */ +export function useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid< + TContext, +>( + options: { + mutation?: UseMutationOptions< + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + ResponseErrorConfig< + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 + >, + { + tripID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["tripID"]; + activityID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["activityID"]; + userID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["userID"]; + }, + TContext + > & { client?: QueryClient }; + client?: Partial & { client?: Client }; + } = {}, +) { + const { mutation = {}, client: config = {} } = options ?? {}; + const { client: queryClient, ...mutationOptions } = mutation; + const mutationKey = + mutationOptions.mutationKey ?? + deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey(); + + const baseOptions = + deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationOptions( + config, + ) as UseMutationOptions< + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + ResponseErrorConfig< + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 + >, + { + tripID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["tripID"]; + activityID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["activityID"]; + userID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["userID"]; + }, + TContext + >; + + return useMutation< + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + ResponseErrorConfig< + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 + >, + { + tripID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["tripID"]; + activityID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["activityID"]; + userID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["userID"]; + }, + TContext + >( + { + ...baseOptions, + mutationKey, + ...mutationOptions, + }, + queryClient, + ) as UseMutationResult< + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + ResponseErrorConfig< + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 + >, + { + tripID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["tripID"]; + activityID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["activityID"]; + userID: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams["userID"]; + }, + TContext + >; +} diff --git a/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts b/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts index 18f04305..f638d46e 100644 --- a/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts +++ b/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts @@ -3,28 +3,28 @@ * Do not edit manually. */ +import fetch from "../client"; +import type { Client, RequestConfig, ResponseErrorConfig } from "../client"; import type { - QueryClient, - UseMutationOptions, - UseMutationResult, -} from "@tanstack/react-query"; -import { mutationOptions, useMutation } from "@tanstack/react-query"; -import type { + PostApiV1TripsTripidActivitiesActivityidRsvpMutationRequest, + PostApiV1TripsTripidActivitiesActivityidRsvpMutationResponse, + PostApiV1TripsTripidActivitiesActivityidRsvpPathParams, PostApiV1TripsTripidActivitiesActivityidRsvp400, PostApiV1TripsTripidActivitiesActivityidRsvp401, PostApiV1TripsTripidActivitiesActivityidRsvp403, PostApiV1TripsTripidActivitiesActivityidRsvp404, PostApiV1TripsTripidActivitiesActivityidRsvp422, PostApiV1TripsTripidActivitiesActivityidRsvp500, - PostApiV1TripsTripidActivitiesActivityidRsvpMutationRequest, - PostApiV1TripsTripidActivitiesActivityidRsvpMutationResponse, - PostApiV1TripsTripidActivitiesActivityidRsvpPathParams, } from "../../types/types.gen.ts"; -import type { Client, RequestConfig, ResponseErrorConfig } from "../client"; -import fetch from "../client"; +import type { + UseMutationOptions, + UseMutationResult, + QueryClient, +} from "@tanstack/react-query"; +import { mutationOptions, useMutation } from "@tanstack/react-query"; export const postApiV1TripsTripidActivitiesActivityidRsvpMutationKey = () => - [{ url: "/api/v1/trips/:tripID/activities/:activityID/rsvps" }] as const; + [{ url: "/api/v1/trips/:tripID/activities/:activityID/rsvp" }] as const; export type PostApiV1TripsTripidActivitiesActivityidRsvpMutationKey = ReturnType; @@ -59,7 +59,7 @@ export async function postApiV1TripsTripidActivitiesActivityidRsvp( PostApiV1TripsTripidActivitiesActivityidRsvpMutationRequest >({ method: "POST", - url: `/api/v1/trips/${tripID}/activities/${activityID}/rsvps`, + url: `/api/v1/trips/${tripID}/activities/${activityID}/rsvp`, data: requestData, ...requestConfig, }); diff --git a/frontend/api/index.ts b/frontend/api/index.ts index 32996999..f59a904c 100644 --- a/frontend/api/index.ts +++ b/frontend/api/index.ts @@ -1,6 +1,7 @@ export type { AddCategoryToActivityMutationKey } from "./activities/useAddCategoryToActivity.ts"; export type { CreateActivityMutationKey } from "./activities/useCreateActivity.ts"; export type { DeleteActivityMutationKey } from "./activities/useDeleteActivity.ts"; +export type { DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey } from "./activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; export type { GetActivitiesByTripIDQueryKey } from "./activities/useGetActivitiesByTripID.ts"; export type { GetActivitiesByTripIDSuspenseQueryKey } from "./activities/useGetActivitiesByTripIDSuspense.ts"; export type { GetActivityQueryKey } from "./activities/useGetActivity.ts"; @@ -140,6 +141,10 @@ export { deleteActivity } from "./activities/useDeleteActivity.ts"; export { deleteActivityMutationKey } from "./activities/useDeleteActivity.ts"; export { deleteActivityMutationOptions } from "./activities/useDeleteActivity.ts"; export { useDeleteActivity } from "./activities/useDeleteActivity.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid } from "./activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey } from "./activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationOptions } from "./activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid } from "./activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; export { getActivitiesByTripID } from "./activities/useGetActivitiesByTripID.ts"; export { getActivitiesByTripIDQueryKey } from "./activities/useGetActivitiesByTripID.ts"; export { getActivitiesByTripIDQueryOptions } from "./activities/useGetActivitiesByTripID.ts"; diff --git a/frontend/api/memberships/useRemoveMember.ts b/frontend/api/memberships/useRemoveMember.ts index 64555623..507e9ad8 100644 --- a/frontend/api/memberships/useRemoveMember.ts +++ b/frontend/api/memberships/useRemoveMember.ts @@ -10,6 +10,7 @@ import type { RemoveMemberPathParams, RemoveMember400, RemoveMember401, + RemoveMember403, RemoveMember404, RemoveMember500, } from "../../types/types.gen.ts"; @@ -28,7 +29,7 @@ export type RemoveMemberMutationKey = ReturnType< >; /** - * @description Removes a user from a trip + * @description Removes a user from a trip. Only admins can remove members. * @summary Remove member from trip * {@link /api/v1/trips/:tripID/memberships/:userID} */ @@ -42,7 +43,11 @@ export async function removeMember( const res = await request< RemoveMemberMutationResponse, ResponseErrorConfig< - RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500 + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500 >, unknown >({ @@ -60,7 +65,11 @@ export function removeMemberMutationOptions( return mutationOptions< RemoveMemberMutationResponse, ResponseErrorConfig< - RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500 + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500 >, { tripID: RemoveMemberPathParams["tripID"]; @@ -76,7 +85,7 @@ export function removeMemberMutationOptions( } /** - * @description Removes a user from a trip + * @description Removes a user from a trip. Only admins can remove members. * @summary Remove member from trip * {@link /api/v1/trips/:tripID/memberships/:userID} */ @@ -85,7 +94,11 @@ export function useRemoveMember( mutation?: UseMutationOptions< RemoveMemberMutationResponse, ResponseErrorConfig< - RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500 + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500 >, { tripID: RemoveMemberPathParams["tripID"]; @@ -103,7 +116,11 @@ export function useRemoveMember( const baseOptions = removeMemberMutationOptions(config) as UseMutationOptions< RemoveMemberMutationResponse, ResponseErrorConfig< - RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500 + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500 >, { tripID: RemoveMemberPathParams["tripID"]; @@ -115,7 +132,11 @@ export function useRemoveMember( return useMutation< RemoveMemberMutationResponse, ResponseErrorConfig< - RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500 + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500 >, { tripID: RemoveMemberPathParams["tripID"]; @@ -132,7 +153,11 @@ export function useRemoveMember( ) as UseMutationResult< RemoveMemberMutationResponse, ResponseErrorConfig< - RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500 + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500 >, { tripID: RemoveMemberPathParams["tripID"]; diff --git a/frontend/app/(app)/trips/[id]/activities/[id]/index.tsx b/frontend/app/(app)/trips/[id]/activities/[id]/index.tsx index 7ff1bafb..082d2855 100644 --- a/frontend/app/(app)/trips/[id]/activities/[id]/index.tsx +++ b/frontend/app/(app)/trips/[id]/activities/[id]/index.tsx @@ -1,27 +1,26 @@ import { useDeleteActivity, + useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid, useGetActivity, useUpdateActivity, } from "@/api/activities"; import { usePostApiV1TripsTripidActivitiesActivityidRsvp } from "@/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp"; import { useEntityComments } from "@/api/comments/custom/useEntityComments"; import { useGetImage } from "@/api/files/custom/useGetImage"; +import { useGetMembership } from "@/api/memberships/useGetMembership"; +import { getPlaceDetailsCustom } from "@/api/places/custom"; import { useUser } from "@/contexts/user"; -import { - DestinationPickerSheet, - type SelectedLocation, -} from "@/app/(app)/components/destination-picker-sheet"; -import { Box, Spinner, Text } from "@/design-system"; +import { Box, Text, useToast } from "@/design-system"; import type { DateRange } from "@/design-system/primitives/date-picker"; import type { ModelsActivityAPIResponse } from "@/types/types.gen"; import { modelsEntityType, modelsRSVPStatus } from "@/types/types.gen"; +import { locationSelectStore } from "@/utilities/locationSelectStore"; import { router, useLocalSearchParams } from "expo-router"; import { Trash2 } from "lucide-react-native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; import { EntityDetailScreen } from "../../components/entity-detail-screen"; import { MembersGoingSection } from "../../components/members-going-section"; -import { PostDetailView } from "../components/post-detail-view"; import { RsvpButton } from "../components/rsvp-button"; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -42,15 +41,13 @@ export default function ActivityDetail() { id: activityID, tripID, openComments, - source, } = useLocalSearchParams<{ id: string; tripID: string; openComments?: string; - source?: string; }>(); const { currentUser, userId } = useUser(); - const isMoodboardSource = source === "moodboard"; + const toast = useToast(); const { data: currentUserProfileImages } = useGetImage( [currentUser?.profile_picture], @@ -68,86 +65,12 @@ export default function ActivityDetail() { query: { enabled: !!(tripID && activityID) }, }); - // ─── Loading / not found ───────────────────────────────────────────────── - - if (isLoading) { - return ( - - - - - - ); - } - - if (!activity) { - return ( - - - - Activity not found. - - - - ); - } - - // ─── Moodboard post view ──────────────────────────────────────────────── + // Fetch current user's membership to check admin status + const { data: myMembership } = useGetMembership(tripID ?? "", userId ?? ""); - if (isMoodboardSource) { - return ( - - ); - } - - // ─── Standard activity detail ─────────────────────────────────────────── - - return ( - - ); -} - -// ─── Standard Detail (EntityDetailScreen) ───────────────────────────────────── - -type StandardActivityDetailProps = { - activity: ModelsActivityAPIResponse; - tripID: string; - activityID: string; - userId: string | null | undefined; - currentUser: any; - currentUserProfilePhotoUrl: string | undefined; - openComments: boolean; - refetch: () => void; -}; - -function StandardActivityDetail({ - activity, - tripID, - activityID, - userId, - currentUser, - currentUserProfilePhotoUrl, - openComments, - refetch, -}: StandardActivityDetailProps) { const updateMutation = useUpdateActivity(); const deleteMutation = useDeleteActivity(); const rsvpMutation = usePostApiV1TripsTripidActivitiesActivityidRsvp(); - const destinationSheetRef = useRef(null); const { comments, @@ -157,12 +80,14 @@ function StandardActivityDetail({ onSubmitComment, onReact, } = useEntityComments({ - tripID, + tripID: tripID ?? "", entityType: modelsEntityType.ActivityEntity, - entityID: activityID, + entityID: activityID ?? "", enabled: !!(tripID && activityID), }); + // ─── Local state ───────────────────────────────────────────────────────── + const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [price, setPrice] = useState(null); @@ -176,7 +101,7 @@ function StandardActivityDetail({ const [link, setLink] = useState(""); const [isDeleteVisible, setIsDeleteVisible] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [_isSaving, setIsSaving] = useState(false); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (!activity) return; @@ -190,6 +115,8 @@ function StandardActivityDetail({ setLink(activity.media_url ?? ""); }, [activity]); + // ─── Derived ───────────────────────────────────────────────────────────── + const heroImages = useMemo(() => { const images: string[] = []; if (activity?.thumbnail_url) images.push(activity.thumbnail_url); @@ -205,6 +132,15 @@ function StandardActivityDetail({ return activity.going_users.some((u) => u.user_id === userId); }, [userId, activity]); + const isAdmin = myMembership?.is_admin ?? false; + const isProposer = !!( + activity?.proposed_by && activity.proposed_by === userId + ); + const canEdit = isAdmin || isProposer; + const canManageMembers = isAdmin || isProposer; + + // ─── Handlers ──────────────────────────────────────────────────────────── + const saveField = useCallback( async (patch: Parameters[0]["data"]) => { if (!tripID || !activityID) return; @@ -240,22 +176,27 @@ function StandardActivityDetail({ }, [tripID, activityID, isGoing, rsvpMutation, refetch]); const handleEditLocation = useCallback(() => { - destinationSheetRef.current?.snapToIndex(0); - }, []); - - const handleLocationSelected = useCallback( - async (location: SelectedLocation) => { - setLocationName(location.name); - setLocationLat(location.lat ?? null); - setLocationLng(location.lng ?? null); - await saveField({ - location_name: location.name, - location_lat: location.lat, - location_lng: location.lng, - }); - }, - [saveField], - ); + locationSelectStore.set(async (prediction) => { + try { + const res = await getPlaceDetailsCustom({ + place_id: prediction.place_id, + }); + const newName = + res.data.formatted_address || prediction.description || res.data.name; + setLocationName(newName); + setLocationLat(res.data.geometry.location.lat); + setLocationLng(res.data.geometry.location.lng); + await saveField({ + location_name: newName, + location_lat: res.data.geometry.location.lat, + location_lng: res.data.geometry.location.lng, + }); + } catch { + setLocationName(prediction.description ?? null); + } + }); + router.push(`/trips/${tripID}/search-location?mode=select`); + }, [tripID, saveField]); const handleDelete = useCallback(async () => { if (!tripID || !activityID) return; @@ -268,100 +209,145 @@ function StandardActivityDetail({ } }, [tripID, activityID, deleteMutation]); - const handleRemoveMember = useCallback((_memberId: string) => { - // coming soon - }, []); + const removeRsvpMutation = + useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid(); + + const handleRemoveMember = useCallback( + async (memberUserId: string) => { + if (!tripID || !activityID) return; + try { + await removeRsvpMutation.mutateAsync({ + tripID, + activityID, + userID: memberUserId, + }); + refetch(); + toast.show({ message: "Member removed." }); + } catch { + toast.show({ message: "Couldn't remove member. Try again." }); + } + }, + [tripID, activityID, removeRsvpMutation, refetch, toast], + ); + + // ─── Loading / not found ───────────────────────────────────────────────── + + if (isLoading) { + return ( + + + + Loading... + + + + ); + } + + if (!activity) { + return ( + + + + Activity not found. + + + + ); + } + + // ─── Render ────────────────────────────────────────────────────────────── return ( - <> - setIsDeleteVisible(true), - }, - ]} - onBack={() => router.back()} - onSavePrice={async (p) => { - setPrice(p); - await saveField({ estimated_price: p }); - }} - onSaveDateRange={async (range) => { - if (range.start && range.end) { - await saveField({ - dates: [ - { - start: range.start.toISOString().split("T")[0]!, - end: range.end.toISOString().split("T")[0]!, - }, - ], - }); - } - }} - onEditLocation={handleEditLocation} - onSaveLink={async (l) => { - await saveField({ media_url: l || undefined }); - }} - onPriceChange={setPrice} - onDateRangeChange={setDateRange} - onLocationChange={(n, lat, lng) => { - setLocationName(n); - setLocationLat(lat); - setLocationLng(lng); - }} - onLinkChange={setLink} - actionButton={ - - } - extraSection={ - + setIsDeleteVisible(true), + }, + ] + : [] + } + onBack={() => router.back()} + onSavePrice={async (p) => { + setPrice(p); + await saveField({ estimated_price: p }); + }} + onSaveDateRange={async (range) => { + if (range.start && range.end) { + await saveField({ + dates: [ + { + start: range.start.toISOString().split("T")[0]!, + end: range.end.toISOString().split("T")[0]!, + }, + ], + }); } - comments={comments} - isLoadingComments={isLoadingComments} - isLoadingMoreComments={isLoadingMoreComments} - onLoadMoreComments={fetchNextPage} - onSubmitComment={onSubmitComment} - onReact={onReact} - currentUserId={userId ?? ""} - currentUserName={currentUser?.name ?? ""} - currentUserAvatar={currentUserProfilePhotoUrl} - currentUserSeed={currentUser?.id} - openComments={openComments} - isDeleteVisible={isDeleteVisible} - isDeleting={isDeleting} - deleteTitle={`Delete "${name}"`} - deleteSubtitle={`Deleting "${name}" will permanently remove it from your trip.`} - deleteConfirmLabel={`Delete "${name}"`} - deleteCancelLabel={`Keep "${name}"`} - onDeleteConfirm={handleDelete} - onDeleteCancel={() => setIsDeleteVisible(false)} - /> - - + }} + onEditLocation={handleEditLocation} + onSaveLink={async (l) => { + await saveField({ media_url: l || undefined }); + }} + onPriceChange={setPrice} + onDateRangeChange={setDateRange} + onLocationChange={(n, lat, lng) => { + setLocationName(n); + setLocationLat(lat); + setLocationLng(lng); + }} + onLinkChange={setLink} + actionButton={ + + } + extraSection={ + + } + comments={comments} + isLoadingComments={isLoadingComments} + isLoadingMoreComments={isLoadingMoreComments} + onLoadMoreComments={fetchNextPage} + onSubmitComment={onSubmitComment} + onReact={onReact} + currentUserId={userId ?? ""} + currentUserName={currentUser?.name ?? ""} + currentUserAvatar={currentUserProfilePhotoUrl} + currentUserSeed={currentUser?.id} + openComments={openComments === "true"} + isDeleteVisible={isDeleteVisible} + isDeleting={isDeleting} + deleteTitle={`Delete "${name}"`} + deleteSubtitle={`Deleting "${name}" will permanently remove it from your trip.`} + deleteConfirmLabel={`Delete "${name}"`} + deleteCancelLabel={`Keep "${name}"`} + onDeleteConfirm={handleDelete} + onDeleteCancel={() => setIsDeleteVisible(false)} + /> ); } diff --git a/frontend/app/(app)/trips/[id]/components/entity-detail-screen.tsx b/frontend/app/(app)/trips/[id]/components/entity-detail-screen.tsx index ce6b0fd5..8c46dcf9 100644 --- a/frontend/app/(app)/trips/[id]/components/entity-detail-screen.tsx +++ b/frontend/app/(app)/trips/[id]/components/entity-detail-screen.tsx @@ -65,6 +65,9 @@ export type EntityDetailScreenProps = { entityID: string; allMediaPath: string; + // Permissions + canEdit: boolean; + // Actions menuActions: EntityDetailMenuAction[]; onBack: () => void; @@ -126,6 +129,7 @@ export function EntityDetailScreen({ tripID, entityID, allMediaPath, + canEdit, menuActions, onBack, onSavePrice, @@ -239,25 +243,29 @@ export function EntityDetailScreen({ {price != null ? `${price} USD` : "No price set"} - setIsPricePickerVisible(true)} - hitSlop={8} - > - Edit - - - {formattedDate ? ( - - - {formattedDate} + {canEdit && ( setIsDatePickerVisible(true)} + onPress={() => setIsPricePickerVisible(true)} hitSlop={8} > Edit + )} + + {formattedDate ? ( + + + {formattedDate} + {canEdit && ( + setIsDatePickerVisible(true)} + hitSlop={8} + > + Edit + + )} - ) : ( + ) : canEdit ? ( setIsDatePickerVisible(true)} @@ -265,14 +273,17 @@ export function EntityDetailScreen({ Add date - )} + ) : null} {/* Location */} - + {locationName && ( @@ -324,7 +335,7 @@ export function EntityDetailScreen({ /> )} - {!locationName && ( + {!locationName && canEdit && ( Add location @@ -337,20 +348,28 @@ export function EntityDetailScreen({ { - setLinkDraft(link); - setIsEditingLink(true); - }} + onEdit={ + canEdit + ? () => { + setLinkDraft(link); + setIsEditingLink(true); + } + : undefined + } /> {link ? ( { - setLinkDraft(link); - setIsEditingLink(true); - }} + onEdit={ + canEdit + ? () => { + setLinkDraft(link); + setIsEditingLink(true); + } + : undefined + } /> - ) : ( + ) : canEdit ? ( { setLinkDraft(""); @@ -359,7 +378,7 @@ export function EntityDetailScreen({ > Add link - )} + ) : null} diff --git a/frontend/app/(app)/trips/[id]/components/link-pill.tsx b/frontend/app/(app)/trips/[id]/components/link-pill.tsx index ca143487..8aacb3c5 100644 --- a/frontend/app/(app)/trips/[id]/components/link-pill.tsx +++ b/frontend/app/(app)/trips/[id]/components/link-pill.tsx @@ -10,7 +10,7 @@ import { Linking, Modal, Pressable, StyleSheet, View } from "react-native"; type LinkPillProps = { url: string; - onEdit: () => void; + onEdit?: () => void; }; // ─── Component ─────────────────────────────────────────────────────────────── @@ -62,19 +62,21 @@ export function LinkPill({ url, onEdit }: LinkPillProps) { Open link - - { - setPopoverVisible(false); - onEdit(); - }} - > - - - Edit link - - + {onEdit && } + {onEdit && ( + { + setPopoverVisible(false); + onEdit(); + }} + > + + + Edit link + + + )} diff --git a/frontend/app/(app)/trips/[id]/components/members-going-section.tsx b/frontend/app/(app)/trips/[id]/components/members-going-section.tsx index 7607b410..780b4797 100644 --- a/frontend/app/(app)/trips/[id]/components/members-going-section.tsx +++ b/frontend/app/(app)/trips/[id]/components/members-going-section.tsx @@ -22,6 +22,7 @@ type GoingUser = { type MembersGoingSectionProps = { goingUsers: GoingUser[]; + canManageMembers: boolean; onRemoveMember: (userId: string) => void; }; @@ -40,6 +41,7 @@ function buildSummaryText(users: GoingUser[]): string { export function MembersGoingSection({ goingUsers, + canManageMembers, onRemoveMember, }: MembersGoingSectionProps) { const [expanded, setExpanded] = useState(false); @@ -114,16 +116,18 @@ export function MembersGoingSection({ > {u.name ?? "Member"} - { - if (ref && u.user_id) - moreBtnRefs.current.set(u.user_id, ref as View); - }} - > - handleMorePress(u)}> - - - + {canManageMembers && ( + { + if (ref && u.user_id) + moreBtnRefs.current.set(u.user_id, ref as View); + }} + > + handleMorePress(u)}> + + + + )} ))} diff --git a/frontend/app/(app)/trips/[id]/housing/[id]/index.tsx b/frontend/app/(app)/trips/[id]/housing/[id]/index.tsx index 6dac39f2..a35865f3 100644 --- a/frontend/app/(app)/trips/[id]/housing/[id]/index.tsx +++ b/frontend/app/(app)/trips/[id]/housing/[id]/index.tsx @@ -5,18 +5,17 @@ import { } from "@/api/activities"; import { useEntityComments } from "@/api/comments/custom/useEntityComments"; import { useGetImage } from "@/api/files/custom/useGetImage"; +import { useGetMembership } from "@/api/memberships/useGetMembership"; +import { getPlaceDetailsCustom } from "@/api/places/custom"; import { useUser } from "@/contexts/user"; -import { - DestinationPickerSheet, - type SelectedLocation, -} from "@/app/(app)/components/destination-picker-sheet"; import { Box, Text } from "@/design-system"; import type { DateRange } from "@/design-system/primitives/date-picker"; import type { ModelsActivityAPIResponse } from "@/types/types.gen"; import { modelsEntityType } from "@/types/types.gen"; +import { locationSelectStore } from "@/utilities/locationSelectStore"; import { router, useLocalSearchParams } from "expo-router"; import { Trash2 } from "lucide-react-native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; import { EntityDetailScreen } from "../../components/entity-detail-screen"; @@ -61,9 +60,10 @@ export default function HousingDetail() { query: { enabled: !!(tripID && housingID) }, }); + const { data: myMembership } = useGetMembership(tripID ?? "", userId ?? ""); + const updateMutation = useUpdateActivity(); const deleteMutation = useDeleteActivity(); - const destinationSheetRef = useRef(null); const { comments, @@ -94,7 +94,7 @@ export default function HousingDetail() { const [link, setLink] = useState(""); const [isDeleteVisible, setIsDeleteVisible] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [_isSaving, setIsSaving] = useState(false); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (!housing) return; @@ -120,6 +120,10 @@ export default function HousingDetail() { return images; }, [housing]); + const isAdmin = myMembership?.is_admin ?? false; + const isProposer = !!(housing?.proposed_by && housing.proposed_by === userId); + const canEdit = isAdmin || isProposer; + // ─── Handlers ──────────────────────────────────────────────────────────── const saveField = useCallback( @@ -134,7 +138,7 @@ export default function HousingDetail() { }); refetch(); } catch { - // toast handled by caller if needed + // no-op } finally { setIsSaving(false); } @@ -143,22 +147,27 @@ export default function HousingDetail() { ); const handleEditLocation = useCallback(() => { - destinationSheetRef.current?.snapToIndex(0); - }, []); - - const handleLocationSelected = useCallback( - async (location: SelectedLocation) => { - setLocationName(location.name); - setLocationLat(location.lat ?? null); - setLocationLng(location.lng ?? null); - await saveField({ - location_name: location.name, - location_lat: location.lat, - location_lng: location.lng, - }); - }, - [saveField], - ); + locationSelectStore.set(async (prediction) => { + try { + const res = await getPlaceDetailsCustom({ + place_id: prediction.place_id, + }); + const newName = + res.data.formatted_address || prediction.description || res.data.name; + setLocationName(newName); + setLocationLat(res.data.geometry.location.lat); + setLocationLng(res.data.geometry.location.lng); + await saveField({ + location_name: newName, + location_lat: res.data.geometry.location.lat, + location_lng: res.data.geometry.location.lng, + }); + } catch { + setLocationName(prediction.description ?? null); + } + }); + router.push(`/trips/${tripID}/search-location?mode=select`); + }, [tripID, saveField]); const handleDelete = useCallback(async () => { if (!tripID || !housingID) return; @@ -200,81 +209,80 @@ export default function HousingDetail() { // ─── Render ────────────────────────────────────────────────────────────── return ( - <> - setIsDeleteVisible(true), - }, - ]} - onBack={() => router.replace(`/trips/${tripID}` as any)} - onSavePrice={async (p) => { - setPrice(p); - await saveField({ estimated_price: p }); - }} - onSaveDateRange={async (range) => { - if (range.start && range.end) { - await saveField({ - dates: [ - { - start: range.start.toISOString().split("T")[0]!, - end: range.end.toISOString().split("T")[0]!, - }, - ], - }); - } - }} - onEditLocation={handleEditLocation} - onSaveLink={async (l) => { - await saveField({ media_url: l || undefined }); - }} - onPriceChange={setPrice} - onDateRangeChange={setDateRange} - onLocationChange={(n, lat, lng) => { - setLocationName(n); - setLocationLat(lat); - setLocationLng(lng); - }} - onLinkChange={setLink} - comments={comments} - isLoadingComments={isLoadingComments} - isLoadingMoreComments={isLoadingMoreComments} - onLoadMoreComments={fetchNextPage} - onSubmitComment={onSubmitComment} - onReact={onReact} - currentUserId={userId ?? ""} - currentUserName={currentUser?.name ?? ""} - currentUserAvatar={currentUserProfilePhotoUrl} - currentUserSeed={currentUser?.id} - openComments={openComments === "true"} - isDeleteVisible={isDeleteVisible} - isDeleting={isDeleting} - deleteTitle={`Delete "${name}"`} - deleteSubtitle={`Deleting "${name}" will permanently remove it from your trip.`} - deleteConfirmLabel={`Delete "${name}"`} - deleteCancelLabel={`Keep "${name}"`} - onDeleteConfirm={handleDelete} - onDeleteCancel={() => setIsDeleteVisible(false)} - /> - - + setIsDeleteVisible(true), + }, + ] + : [] + } + onBack={() => router.back()} + onSavePrice={async (p) => { + setPrice(p); + await saveField({ estimated_price: p }); + }} + onSaveDateRange={async (range) => { + if (range.start && range.end) { + await saveField({ + dates: [ + { + start: range.start.toISOString().split("T")[0]!, + end: range.end.toISOString().split("T")[0]!, + }, + ], + }); + } + }} + onEditLocation={handleEditLocation} + onSaveLink={async (l) => { + await saveField({ media_url: l || undefined }); + }} + onPriceChange={setPrice} + onDateRangeChange={setDateRange} + onLocationChange={(n, lat, lng) => { + setLocationName(n); + setLocationLat(lat); + setLocationLng(lng); + }} + onLinkChange={setLink} + comments={comments} + isLoadingComments={isLoadingComments} + isLoadingMoreComments={isLoadingMoreComments} + onLoadMoreComments={fetchNextPage} + onSubmitComment={onSubmitComment} + onReact={onReact} + currentUserId={userId ?? ""} + currentUserName={currentUser?.name ?? ""} + currentUserAvatar={currentUserProfilePhotoUrl} + currentUserSeed={currentUser?.id} + openComments={openComments === "true"} + isDeleteVisible={isDeleteVisible} + isDeleting={isDeleting} + deleteTitle={`Delete "${name}"`} + deleteSubtitle={`Deleting "${name}" will permanently remove it from your trip.`} + deleteConfirmLabel={`Delete "${name}"`} + deleteCancelLabel={`Keep "${name}"`} + onDeleteConfirm={handleDelete} + onDeleteCancel={() => setIsDeleteVisible(false)} + /> ); } diff --git a/frontend/index.ts b/frontend/index.ts index e7e8d54f..18425732 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -1,6 +1,7 @@ export type { AddCategoryToActivityMutationKey } from "./api/activities/useAddCategoryToActivity.ts"; export type { CreateActivityMutationKey } from "./api/activities/useCreateActivity.ts"; export type { DeleteActivityMutationKey } from "./api/activities/useDeleteActivity.ts"; +export type { DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey } from "./api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; export type { GetActivitiesByTripIDQueryKey } from "./api/activities/useGetActivitiesByTripID.ts"; export type { GetActivitiesByTripIDSuspenseQueryKey } from "./api/activities/useGetActivitiesByTripIDSuspense.ts"; export type { GetActivityQueryKey } from "./api/activities/useGetActivity.ts"; @@ -140,6 +141,10 @@ export { deleteActivity } from "./api/activities/useDeleteActivity.ts"; export { deleteActivityMutationKey } from "./api/activities/useDeleteActivity.ts"; export { deleteActivityMutationOptions } from "./api/activities/useDeleteActivity.ts"; export { useDeleteActivity } from "./api/activities/useDeleteActivity.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid } from "./api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationKey } from "./api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationOptions } from "./api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; +export { useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid } from "./api/activities/useDeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid.ts"; export { getActivitiesByTripID } from "./api/activities/useGetActivitiesByTripID.ts"; export { getActivitiesByTripIDQueryKey } from "./api/activities/useGetActivitiesByTripID.ts"; export { getActivitiesByTripIDQueryOptions } from "./api/activities/useGetActivitiesByTripID.ts"; diff --git a/frontend/types/schema.gen.ts b/frontend/types/schema.gen.ts index 02523bd6..82c0872b 100644 --- a/frontend/types/schema.gen.ts +++ b/frontend/types/schema.gen.ts @@ -410,6 +410,14 @@ import type { GetApiV1TripsTripidActivitiesActivityidRsvpsPathParams, GetApiV1TripsTripidActivitiesActivityidRsvpsQueryParams, GetApiV1TripsTripidActivitiesActivityidRsvpsQueryResponse, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid204, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse, + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams, GetTripActivityFeed200, GetTripActivityFeed400, GetTripActivityFeed401, @@ -497,6 +505,7 @@ import type { RemoveMember204, RemoveMember400, RemoveMember401, + RemoveMember403, RemoveMember404, RemoveMember500, RemoveMemberMutationResponse, @@ -3911,6 +3920,64 @@ export const getApiV1TripsTripidActivitiesActivityidRsvpsQueryResponseSchema = () => getApiV1TripsTripidActivitiesActivityidRsvps200Schema, ) as unknown as z.ZodType; +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParamsSchema = + z.object({ + tripID: z.string().describe("Trip ID"), + activityID: z.string().describe("Activity ID"), + userID: z.string().describe("User ID of the RSVP to remove"), + }) as unknown as z.ZodType; + +/** + * @description No Content + */ +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid204Schema = + z.any() as unknown as z.ZodType; + +/** + * @description Bad Request + */ +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400Schema = + z.lazy( + () => errsAPIErrorSchema, + ) as unknown as z.ZodType; + +/** + * @description Unauthorized + */ +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401Schema = + z.lazy( + () => errsAPIErrorSchema, + ) as unknown as z.ZodType; + +/** + * @description Forbidden + */ +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403Schema = + z.lazy( + () => errsAPIErrorSchema, + ) as unknown as z.ZodType; + +/** + * @description Not Found + */ +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404Schema = + z.lazy( + () => errsAPIErrorSchema, + ) as unknown as z.ZodType; + +/** + * @description Internal Server Error + */ +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500Schema = + z.lazy( + () => errsAPIErrorSchema, + ) as unknown as z.ZodType; + +export const deleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponseSchema = + z.lazy( + () => deleteApiV1TripsTripidActivitiesActivityidRsvpsUserid204Schema, + ) as unknown as z.ZodType; + export const getTripActivityFeedPathParamsSchema = z.object({ tripID: z.string().describe("Trip ID (UUID)"), }) as unknown as z.ZodType; @@ -4467,6 +4534,13 @@ export const removeMember401Schema = z.lazy( () => errsAPIErrorSchema, ) as unknown as z.ZodType; +/** + * @description Forbidden + */ +export const removeMember403Schema = z.lazy( + () => errsAPIErrorSchema, +) as unknown as z.ZodType; + /** * @description Not Found */ diff --git a/frontend/types/types.gen.ts b/frontend/types/types.gen.ts index 191274f2..e3771554 100644 --- a/frontend/types/types.gen.ts +++ b/frontend/types/types.gen.ts @@ -4663,6 +4663,73 @@ export type GetApiV1TripsTripidActivitiesActivityidRsvpsQuery = { | GetApiV1TripsTripidActivitiesActivityidRsvps500; }; +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams = { + /** + * @description Trip ID + * @type string + */ + tripID: string; + /** + * @description Activity ID + * @type string + */ + activityID: string; + /** + * @description User ID of the RSVP to remove + * @type string + */ + userID: string; +}; + +/** + * @description No Content + */ +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid204 = any; + +/** + * @description Bad Request + */ +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 = + ErrsAPIError; + +/** + * @description Unauthorized + */ +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 = + ErrsAPIError; + +/** + * @description Forbidden + */ +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 = + ErrsAPIError; + +/** + * @description Not Found + */ +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 = + ErrsAPIError; + +/** + * @description Internal Server Error + */ +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500 = + ErrsAPIError; + +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutationResponse = + DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid204; + +export type DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridMutation = { + Response: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid204; + PathParams: DeleteApiV1TripsTripidActivitiesActivityidRsvpsUseridPathParams; + Errors: + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid400 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid401 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid403 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid404 + | DeleteApiV1TripsTripidActivitiesActivityidRsvpsUserid500; +}; + export type GetTripActivityFeedPathParams = { /** * @description Trip ID (UUID) @@ -5264,6 +5331,11 @@ export type RemoveMember400 = ErrsAPIError; */ export type RemoveMember401 = ErrsAPIError; +/** + * @description Forbidden + */ +export type RemoveMember403 = ErrsAPIError; + /** * @description Not Found */ @@ -5279,7 +5351,12 @@ export type RemoveMemberMutationResponse = RemoveMember204; export type RemoveMemberMutation = { Response: RemoveMember204; PathParams: RemoveMemberPathParams; - Errors: RemoveMember400 | RemoveMember401 | RemoveMember404 | RemoveMember500; + Errors: + | RemoveMember400 + | RemoveMember401 + | RemoveMember403 + | RemoveMember404 + | RemoveMember500; }; export type UpdateMembershipPathParams = { From 6f7256d02e24fcbf65563d3ea913447f3ae9c32a Mon Sep 17 00:00:00 2001 From: Afnan Tuffaha Date: Fri, 17 Apr 2026 16:27:12 -0400 Subject: [PATCH 4/8] add remove member from trip button --- .../app/(app)/trips/[id]/settings/members.tsx | 84 ++++++++++++++++--- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/frontend/app/(app)/trips/[id]/settings/members.tsx b/frontend/app/(app)/trips/[id]/settings/members.tsx index b0ac4c20..1c573956 100644 --- a/frontend/app/(app)/trips/[id]/settings/members.tsx +++ b/frontend/app/(app)/trips/[id]/settings/members.tsx @@ -1,6 +1,7 @@ import { useMembersList } from "@/api/memberships/custom/useMembersList"; import { useGetMembership } from "@/api/memberships/useGetMembership"; import { usePromoteToAdmin } from "@/api/memberships/usePromoteToAdmin"; +import { useRemoveMember } from "@/api/memberships/useRemoveMember"; import { useUser } from "@/contexts/user"; import { Avatar, @@ -15,8 +16,8 @@ import { useToast, } from "@/design-system"; import { ColorPalette } from "@/design-system/tokens/color"; -import type { ModelsMembershipAPIResponse } from "@/types/types.gen"; import { useShareTripInvite } from "@/hooks/useShareTripInvite"; +import type { ModelsMembershipAPIResponse } from "@/types/types.gen"; import { useLocalSearchParams } from "expo-router"; import { Crown } from "lucide-react-native"; import { @@ -27,6 +28,8 @@ import { ScrollView, } from "react-native"; +// ─── Skeleton ──────────────────────────────────────────────────────────────── + function MemberRowSkeleton() { return ( void; + onRemove: () => void; }; function MemberRow({ member, isCurrentUser, - canPromote, + isAdmin, onPromote, + onRemove, }: MemberRowProps) { return ( )} - {canPromote && !member.is_admin && ( - ({ opacity: pressed ? 0.7 : 1 })} - > - - Make Admin - - + + {/* Admin actions — only shown to admins, not for themselves */} + {isAdmin && !isCurrentUser && ( + + {!member.is_admin && ( + ({ opacity: pressed ? 0.7 : 1 })} + > + + Make Admin + + + )} + ({ opacity: pressed ? 0.7 : 1 })} + > + + Remove + + + )} ); } +// ─── Screen ─────────────────────────────────────────────────────────────────── + export default function MembersSettings() { const { id: tripID } = useLocalSearchParams<{ id: string }>(); const { currentUser } = useUser(); @@ -126,6 +149,7 @@ export default function MembersSettings() { currentUser?.id ?? "", ); const promoteToAdminMutation = usePromoteToAdmin(); + const removeMemberMutation = useRemoveMember(); const { shareInvite, isPending: isInvitePending } = useShareTripInvite( tripID!, ); @@ -167,6 +191,37 @@ export default function MembersSettings() { ); }; + const handleRemove = (member: ModelsMembershipAPIResponse) => { + Alert.alert( + "Remove Member?", + `${member.name ?? "This member"} will be removed from the trip.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: async () => { + try { + await removeMemberMutation.mutateAsync({ + tripID: tripID!, + userID: member.user_id!, + }); + toast.show({ + message: `${member.name ?? "Member"} has been removed.`, + }); + } catch { + toast.show({ + message: "Couldn't remove member. Please try again.", + }); + } + }, + }, + ], + ); + }; + + // ─── Loading ───────────────────────────────────────────────────────────── + if (isLoading) { return ( handlePromote(member)} + onRemove={() => handleRemove(member)} /> {index < members.length - 1 && } From a887d58d12bd868e89dc743e22d272a35c49a71f Mon Sep 17 00:00:00 2001 From: Afnan Tuffaha Date: Fri, 17 Apr 2026 16:41:50 -0400 Subject: [PATCH 5/8] fix rsvp update bug --- backend/internal/services/activity.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/internal/services/activity.go b/backend/internal/services/activity.go index 18d21b1f..948210ce 100644 --- a/backend/internal/services/activity.go +++ b/backend/internal/services/activity.go @@ -395,7 +395,8 @@ func (s *ActivityService) RemoveActivityRSVP(ctx context.Context, tripID, activi } isProposer := activity.ProposedBy != nil && *activity.ProposedBy == callerID - if !isAdmin && !isProposer { + isSelf := callerID == targetUserID + if !isAdmin && !isProposer && !isSelf { return errs.Forbidden() } @@ -661,4 +662,4 @@ func toRSVPAPIResponse( CreatedAt: rsvp.CreatedAt, UpdatedAt: rsvp.UpdatedAt, } -} \ No newline at end of file +} From db66b0844a9fd5b44e2390c4393e182d8d93f9ed Mon Sep 17 00:00:00 2001 From: Afnan Tuffaha Date: Fri, 17 Apr 2026 16:46:32 -0400 Subject: [PATCH 6/8] fix rsvp hook --- ...piV1TripsTripidActivitiesActivityidRsvp.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts b/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts index f638d46e..18f04305 100644 --- a/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts +++ b/frontend/api/activities/usePostApiV1TripsTripidActivitiesActivityidRsvp.ts @@ -3,28 +3,28 @@ * Do not edit manually. */ -import fetch from "../client"; -import type { Client, RequestConfig, ResponseErrorConfig } from "../client"; import type { - PostApiV1TripsTripidActivitiesActivityidRsvpMutationRequest, - PostApiV1TripsTripidActivitiesActivityidRsvpMutationResponse, - PostApiV1TripsTripidActivitiesActivityidRsvpPathParams, + QueryClient, + UseMutationOptions, + UseMutationResult, +} from "@tanstack/react-query"; +import { mutationOptions, useMutation } from "@tanstack/react-query"; +import type { PostApiV1TripsTripidActivitiesActivityidRsvp400, PostApiV1TripsTripidActivitiesActivityidRsvp401, PostApiV1TripsTripidActivitiesActivityidRsvp403, PostApiV1TripsTripidActivitiesActivityidRsvp404, PostApiV1TripsTripidActivitiesActivityidRsvp422, PostApiV1TripsTripidActivitiesActivityidRsvp500, + PostApiV1TripsTripidActivitiesActivityidRsvpMutationRequest, + PostApiV1TripsTripidActivitiesActivityidRsvpMutationResponse, + PostApiV1TripsTripidActivitiesActivityidRsvpPathParams, } from "../../types/types.gen.ts"; -import type { - UseMutationOptions, - UseMutationResult, - QueryClient, -} from "@tanstack/react-query"; -import { mutationOptions, useMutation } from "@tanstack/react-query"; +import type { Client, RequestConfig, ResponseErrorConfig } from "../client"; +import fetch from "../client"; export const postApiV1TripsTripidActivitiesActivityidRsvpMutationKey = () => - [{ url: "/api/v1/trips/:tripID/activities/:activityID/rsvp" }] as const; + [{ url: "/api/v1/trips/:tripID/activities/:activityID/rsvps" }] as const; export type PostApiV1TripsTripidActivitiesActivityidRsvpMutationKey = ReturnType; @@ -59,7 +59,7 @@ export async function postApiV1TripsTripidActivitiesActivityidRsvp( PostApiV1TripsTripidActivitiesActivityidRsvpMutationRequest >({ method: "POST", - url: `/api/v1/trips/${tripID}/activities/${activityID}/rsvp`, + url: `/api/v1/trips/${tripID}/activities/${activityID}/rsvps`, data: requestData, ...requestConfig, }); From e6b6d377967522cab57af7e66035dd20758140c1 Mon Sep 17 00:00:00 2001 From: mai Date: Fri, 17 Apr 2026 17:43:22 -0400 Subject: [PATCH 7/8] optimisticially update --- .../app/(app)/trips/[id]/settings/members.tsx | 174 +++++++++++++----- 1 file changed, 129 insertions(+), 45 deletions(-) diff --git a/frontend/app/(app)/trips/[id]/settings/members.tsx b/frontend/app/(app)/trips/[id]/settings/members.tsx index 1c573956..d390b64e 100644 --- a/frontend/app/(app)/trips/[id]/settings/members.tsx +++ b/frontend/app/(app)/trips/[id]/settings/members.tsx @@ -18,6 +18,7 @@ import { import { ColorPalette } from "@/design-system/tokens/color"; import { useShareTripInvite } from "@/hooks/useShareTripInvite"; import type { ModelsMembershipAPIResponse } from "@/types/types.gen"; +import { useQueryClient } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; import { Crown } from "lucide-react-native"; import { @@ -80,12 +81,7 @@ function MemberRow({ /> - + {member.name ?? "Unknown"} {isCurrentUser && ( @@ -94,6 +90,7 @@ function MemberRow({ )} + {member.is_admin ? ( @@ -108,7 +105,6 @@ function MemberRow({ )} - {/* Admin actions — only shown to admins, not for themselves */} {isAdmin && !isCurrentUser && ( {!member.is_admin && ( @@ -121,6 +117,7 @@ function MemberRow({ )} + ({ opacity: pressed ? 0.7 : 1 })} @@ -141,24 +138,121 @@ export default function MembersSettings() { const { id: tripID } = useLocalSearchParams<{ id: string }>(); const { currentUser } = useUser(); const toast = useToast(); + const queryClient = useQueryClient(); - const { members, isLoading, isLoadingMore, fetchMore } = + const { members = [], isLoading, isLoadingMore, fetchMore } = useMembersList(tripID); + const { data: myMembership } = useGetMembership( - tripID!, - currentUser?.id ?? "", + tripID, + currentUser?.id ?? "" ); - const promoteToAdminMutation = usePromoteToAdmin(); - const removeMemberMutation = useRemoveMember(); + + const promoteToAdminMutation = usePromoteToAdmin({ + mutation: { + onMutate: async ({ tripID, userID }) => { + await queryClient.cancelQueries({ queryKey: ["members", tripID] }); + + const previousMembers = queryClient.getQueryData([ + "members", + tripID, + ]); + + queryClient.setQueryData(["members", tripID], (oldData: any) => { + if (!oldData?.pages) return oldData; + + return { + ...oldData, + pages: oldData.pages.map((page: any) => ({ + ...page, + items: page.items?.map( + (item: ModelsMembershipAPIResponse) => + item.user_id === userID + ? { ...item, is_admin: true } + : item + ), + })), + }; + }); + + return { previousMembers }; + }, + + onError: (_err, variables, context) => { + if (context?.previousMembers) { + queryClient.setQueryData( + ["members", variables.tripID], + context.previousMembers + ); + } + }, + + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: ["members", variables.tripID], + }); + }, + }, + }); + + const removeMemberMutation = useRemoveMember({ + mutation: { + onMutate: async ({ tripID, userID }) => { + await queryClient.cancelQueries({ queryKey: ["members", tripID] }); + + const previousMembers = queryClient.getQueryData([ + "members", + tripID, + ]); + + queryClient.setQueryData(["members", tripID], (oldData: any) => { + if (!oldData?.pages) return oldData; + + return { + ...oldData, + pages: oldData.pages.map((page: any) => ({ + ...page, + items: page.items?.filter( + (item: ModelsMembershipAPIResponse) => + item.user_id !== userID + ), + })), + }; + }); + + return { previousMembers }; + }, + + onError: (_err, variables, context) => { + if (context?.previousMembers) { + queryClient.setQueryData( + ["members", variables.tripID], + context.previousMembers + ); + } + }, + + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: ["members", variables.tripID], + }); + }, + }, + }); + const { shareInvite, isPending: isInvitePending } = useShareTripInvite( - tripID!, + tripID ); - const isAdmin = myMembership?.is_admin ?? false; + const myIsAdmin = myMembership?.is_admin ?? false; const handleScroll = (e: NativeSyntheticEvent) => { const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; - if (layoutMeasurement.height + contentOffset.y >= contentSize.height - 80) { + + if ( + layoutMeasurement.height + contentOffset.y >= + contentSize.height - 80 + ) { fetchMore(); } }; @@ -166,7 +260,7 @@ export default function MembersSettings() { const handlePromote = (member: ModelsMembershipAPIResponse) => { Alert.alert( "Make Admin?", - `${member.name ?? "This member"} will be able to manage the trip and its members.`, + `${member.name ?? "This member"} will be able to manage the trip.`, [ { text: "Cancel", style: "cancel" }, { @@ -174,9 +268,10 @@ export default function MembersSettings() { onPress: async () => { try { await promoteToAdminMutation.mutateAsync({ - tripID: tripID!, + tripID, userID: member.user_id!, }); + toast.show({ message: `${member.name ?? "Member"} is now an admin.`, }); @@ -187,14 +282,14 @@ export default function MembersSettings() { } }, }, - ], + ] ); }; const handleRemove = (member: ModelsMembershipAPIResponse) => { Alert.alert( "Remove Member?", - `${member.name ?? "This member"} will be removed from the trip.`, + `${member.name ?? "This member"} will be removed.`, [ { text: "Cancel", style: "cancel" }, { @@ -203,9 +298,10 @@ export default function MembersSettings() { onPress: async () => { try { await removeMemberMutation.mutateAsync({ - tripID: tripID!, + tripID, userID: member.user_id!, }); + toast.show({ message: `${member.name ?? "Member"} has been removed.`, }); @@ -216,7 +312,7 @@ export default function MembersSettings() { } }, }, - ], + ] ); }; @@ -224,31 +320,18 @@ export default function MembersSettings() { if (isLoading) { return ( - - - {[1, 2, 3].map((i) => ( - - - {i < 3 && } - - ))} - + + {[1, 2, 3].map((i) => ( + + + {i < 3 && } + + ))} ); } - if (members.length === 0) { + if (!members.length) { return ( @@ -271,20 +354,20 @@ export default function MembersSettings() { borderWidth={1} borderColor="gray100" overflow="hidden" - paddingVertical="xs" > {members.map((member, index) => ( handlePromote(member)} onRemove={() => handleRemove(member)} /> {index < members.length - 1 && } ))} + {isLoadingMore && ( <> @@ -292,6 +375,7 @@ export default function MembersSettings() { )} +