diff --git a/changelog.d/17948.feature b/changelog.d/17948.feature
new file mode 100644
index 0000000000000000000000000000000000000000..d404996cd673ab24dc11bf97b39f33b2814ea85a
--- /dev/null
+++ b/changelog.d/17948.feature
@@ -0,0 +1,3 @@
+Add endpoints to Admin API to fetch the number of invites the provided user has sent after a given timestamp,
+fetch the number of rooms the provided user has joined after a given timestamp, and get report IDs of event
+reports against a provided user (ie where the user was the sender of the reported event).
diff --git a/docs/admin_api/event_reports.md b/docs/admin_api/event_reports.md
index 83f7dc37f41ae449b7a878ab1bb4be320684b35c..9075e9288229cc05605efcae26c4760be3533cd2 100644
--- a/docs/admin_api/event_reports.md
+++ b/docs/admin_api/event_reports.md
@@ -60,10 +60,11 @@ paginate through.
   anything other than the return value of `next_token` from a previous call. Defaults to `0`.
 * `dir`: string - Direction of event report order. Whether to fetch the most recent
   first (`b`) or the oldest first (`f`). Defaults to `b`.
-* `user_id`: string - Is optional and filters to only return users with user IDs that
-  contain this value. This is the user who reported the event and wrote the reason.
-* `room_id`: string - Is optional and filters to only return rooms with room IDs that
-  contain this value.
+* `user_id`: optional string - Filter by the user ID of the reporter. This is the user who reported the event
+   and wrote the reason.
+* `room_id`: optional string - Filter by room id.
+* `event_sender_user_id`: optional string - Filter by the sender of the reported event. This is the user who 
+   the report was made against.
 
 **Response**
 
diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md
index a6e2e0a1537dbcd90e60883547c35acc35f40418..c63b7068c5e086a94af33c2caadc90455b279023 100644
--- a/docs/admin_api/user_admin_api.md
+++ b/docs/admin_api/user_admin_api.md
@@ -477,9 +477,9 @@ with a body of:
 }
 ```
 
-## List room memberships of a user
+## List joined rooms of a user
 
-Gets a list of all `room_id` that a specific `user_id` is member.
+Gets a list of all `room_id` that a specific `user_id` is joined to and is a member of (participating in).
 
 The API is:
 
@@ -516,6 +516,73 @@ The following fields are returned in the JSON response body:
 - `joined_rooms` - An array of `room_id`.
 - `total` - Number of rooms.
 
+## Get the number of invites sent by the user
+
+Fetches the number of invites sent by the provided user ID across all rooms
+after the given timestamp.
+
+```
+GET /_synapse/admin/v1/users/$user_id/sent_invite_count
+```
+
+**Parameters**
+
+The following parameters should be set in the URL:
+
+* `user_id`: fully qualified: for example, `@user:server.com`
+
+The following should be set as query parameters in the URL:
+
+* `from_ts`: int, required. A timestamp in ms from the unix epoch. Only
+   invites sent at or after the provided timestamp will be returned.
+   This works by comparing the provided timestamp to the `received_ts`
+   column in the `events` table.
+   Note: https://currentmillis.com/ is a useful tool for converting dates
+   into timestamps and vice versa.
+
+A response body like the following is returned:
+
+```json
+{
+  "invite_count": 30
+}
+```
+
+_Added in Synapse 1.122.0_
+
+## Get the cumulative number of rooms a user has joined after a given timestamp
+
+Fetches the number of rooms that the user joined after the given timestamp, even
+if they have subsequently left/been banned from those rooms.
+
+```
+GET /_synapse/admin/v1/users/$<user_id/cumulative_joined_room_count
+```
+
+**Parameters**
+
+The following parameters should be set in the URL:
+
+* `user_id`: fully qualified: for example, `@user:server.com`
+
+The following should be set as query parameters in the URL:
+
+* `from_ts`: int, required. A timestamp in ms from the unix epoch. Only
+   invites sent at or after the provided timestamp will be returned.
+   This works by comparing the provided timestamp to the `received_ts`
+   column in the `events` table.
+   Note: https://currentmillis.com/ is a useful tool for converting dates
+   into timestamps and vice versa.
+
+A response body like the following is returned:
+
+```json
+{
+  "cumulative_joined_room_count": 30
+}
+```
+_Added in Synapse 1.122.0_
+
 ## Account Data
 Gets information about account data for a specific `user_id`.
 
@@ -1444,4 +1511,6 @@ The following fields are returned in the JSON response body:
 - `failed_redactions` - dictionary - the keys of the dict are event ids the process was unable to redact, if any, and the values are 
   the corresponding error that caused the redaction to fail
 
-_Added in Synapse 1.116.0._
\ No newline at end of file
+_Added in Synapse 1.116.0._
+
+
diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py
index c01282a43ef9892aa0e6f1568617cf7daba0afd9..f3c99663e842e0da5442b3abc13564357a7326ad 100644
--- a/synapse/rest/admin/__init__.py
+++ b/synapse/rest/admin/__init__.py
@@ -107,6 +107,8 @@ from synapse.rest.admin.users import (
     UserAdminServlet,
     UserByExternalId,
     UserByThreePid,
+    UserInvitesCount,
+    UserJoinedRoomCount,
     UserMembershipRestServlet,
     UserRegisterServlet,
     UserReplaceMasterCrossSigningKeyRestServlet,
@@ -323,6 +325,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
     UserByThreePid(hs).register(http_server)
     RedactUser(hs).register(http_server)
     RedactUserStatus(hs).register(http_server)
+    UserInvitesCount(hs).register(http_server)
+    UserJoinedRoomCount(hs).register(http_server)
 
     DeviceRestServlet(hs).register(http_server)
     DevicesRestServlet(hs).register(http_server)
diff --git a/synapse/rest/admin/event_reports.py b/synapse/rest/admin/event_reports.py
index 9fb68bfa462087a6ddb2433dfeb035de2409840e..ff1abc0697c5b4d056ba8b6b94787ca78c61d761 100644
--- a/synapse/rest/admin/event_reports.py
+++ b/synapse/rest/admin/event_reports.py
@@ -50,8 +50,10 @@ class EventReportsRestServlet(RestServlet):
         The parameters `from` and `limit` are required only for pagination.
         By default, a `limit` of 100 is used.
         The parameter `dir` can be used to define the order of results.
-        The parameter `user_id` can be used to filter by user id.
-        The parameter `room_id` can be used to filter by room id.
+        The `user_id` query parameter filters by the user ID of the reporter of the event.
+        The `room_id` query parameter filters by room id.
+        The `event_sender_user_id` query parameter can be used to filter by the user id
+        of the sender of the reported event.
     Returns:
         A list of reported events and an integer representing the total number of
         reported events that exist given this query
@@ -71,6 +73,7 @@ class EventReportsRestServlet(RestServlet):
         direction = parse_enum(request, "dir", Direction, Direction.BACKWARDS)
         user_id = parse_string(request, "user_id")
         room_id = parse_string(request, "room_id")
+        event_sender_user_id = parse_string(request, "event_sender_user_id")
 
         if start < 0:
             raise SynapseError(
@@ -87,7 +90,7 @@ class EventReportsRestServlet(RestServlet):
             )
 
         event_reports, total = await self._store.get_event_reports_paginate(
-            start, limit, direction, user_id, room_id
+            start, limit, direction, user_id, room_id, event_sender_user_id
         )
         ret = {"event_reports": event_reports, "total": total}
         if (start + limit) < total:
diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py
index b146c2754d68c5a22903e23d9e06854880e940e2..7b8f1d1b2a995410025d641f697852143eb7d739 100644
--- a/synapse/rest/admin/users.py
+++ b/synapse/rest/admin/users.py
@@ -983,7 +983,7 @@ class UserAdminServlet(RestServlet):
 
 class UserMembershipRestServlet(RestServlet):
     """
-    Get room list of an user.
+    Get list of joined room ID's for a user.
     """
 
     PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/joined_rooms$")
@@ -999,8 +999,9 @@ class UserMembershipRestServlet(RestServlet):
         await assert_requester_is_admin(self.auth, request)
 
         room_ids = await self.store.get_rooms_for_user(user_id)
-        ret = {"joined_rooms": list(room_ids), "total": len(room_ids)}
-        return HTTPStatus.OK, ret
+        rooms_response = {"joined_rooms": list(room_ids), "total": len(room_ids)}
+
+        return HTTPStatus.OK, rooms_response
 
 
 class PushersRestServlet(RestServlet):
@@ -1501,3 +1502,50 @@ class RedactUserStatus(RestServlet):
                 }
         else:
             raise NotFoundError("redact id '%s' not found" % redact_id)
+
+
+class UserInvitesCount(RestServlet):
+    """
+    Return the count of invites that the user has sent after the given timestamp
+    """
+
+    PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/sent_invite_count")
+
+    def __init__(self, hs: "HomeServer"):
+        self._auth = hs.get_auth()
+        self.store = hs.get_datastores().main
+
+    async def on_GET(
+        self, request: SynapseRequest, user_id: str
+    ) -> Tuple[int, JsonDict]:
+        await assert_requester_is_admin(self._auth, request)
+        from_ts = parse_integer(request, "from_ts", required=True)
+
+        sent_invite_count = await self.store.get_sent_invite_count_by_user(
+            user_id, from_ts
+        )
+
+        return HTTPStatus.OK, {"invite_count": sent_invite_count}
+
+
+class UserJoinedRoomCount(RestServlet):
+    """
+    Return the count of rooms that the user has joined at or after the given timestamp, even
+    if they have subsequently left/been banned from those rooms.
+    """
+
+    PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)/cumulative_joined_room_count")
+
+    def __init__(self, hs: "HomeServer"):
+        self._auth = hs.get_auth()
+        self.store = hs.get_datastores().main
+
+    async def on_GET(
+        self, request: SynapseRequest, user_id: str
+    ) -> Tuple[int, JsonDict]:
+        await assert_requester_is_admin(self._auth, request)
+        from_ts = parse_integer(request, "from_ts", required=True)
+
+        joined_rooms = await self.store.get_rooms_for_user_by_date(user_id, from_ts)
+
+        return HTTPStatus.OK, {"cumulative_joined_room_count": len(joined_rooms)}
diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py
index 825fd00993a6afdb5b7baecdf8ee3d207e950b83..222df8757ac7d7a50f86f470112460fb57d13f69 100644
--- a/synapse/storage/databases/main/events_worker.py
+++ b/synapse/storage/databases/main/events_worker.py
@@ -339,6 +339,16 @@ class EventsWorkerStore(SQLBaseStore):
             writers=["master"],
         )
 
+        # Added to accommodate some queries for the admin API in order to fetch/filter
+        # membership events by when it was received
+        self.db_pool.updates.register_background_index_update(
+            update_name="events_received_ts_index",
+            index_name="received_ts_idx",
+            table="events",
+            columns=("received_ts",),
+            where_clause="type = 'm.room.member'",
+        )
+
     def get_un_partial_stated_events_token(self, instance_name: str) -> int:
         return (
             self._un_partial_stated_events_stream_id_gen.get_current_token_for_writer(
@@ -2589,6 +2599,44 @@ class EventsWorkerStore(SQLBaseStore):
             )
         )
 
+    async def get_sent_invite_count_by_user(self, user_id: str, from_ts: int) -> int:
+        """
+        Get the number of invites sent by the given user at or after the provided timestamp.
+
+        Args:
+            user_id: user ID to search against
+            from_ts: a timestamp in milliseconds from the unix epoch. Filters against
+                `events.received_ts`
+
+        """
+
+        def _get_sent_invite_count_by_user_txn(
+            txn: LoggingTransaction, user_id: str, from_ts: int
+        ) -> int:
+            sql = """
+                  SELECT COUNT(rm.event_id)
+                  FROM room_memberships AS rm
+                  INNER JOIN events AS e USING(event_id)
+                  WHERE rm.sender = ?
+                    AND rm.membership = 'invite'
+                    AND e.type = 'm.room.member'
+                    AND e.received_ts >= ?
+            """
+
+            txn.execute(sql, (user_id, from_ts))
+            res = txn.fetchone()
+
+            if res is None:
+                return 0
+            return int(res[0])
+
+        return await self.db_pool.runInteraction(
+            "_get_sent_invite_count_by_user_txn",
+            _get_sent_invite_count_by_user_txn,
+            user_id,
+            from_ts,
+        )
+
     @cached(tree=True)
     async def get_metadata_for_event(
         self, room_id: str, event_id: str
diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py
index cc3ce0951e7440344e447032d0999f95a00e9449..2522bebd7285f50e18017123d83de052d164af16 100644
--- a/synapse/storage/databases/main/room.py
+++ b/synapse/storage/databases/main/room.py
@@ -1586,6 +1586,7 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
         direction: Direction = Direction.BACKWARDS,
         user_id: Optional[str] = None,
         room_id: Optional[str] = None,
+        event_sender_user_id: Optional[str] = None,
     ) -> Tuple[List[Dict[str, Any]], int]:
         """Retrieve a paginated list of event reports
 
@@ -1596,6 +1597,8 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
                 oldest first (forwards)
             user_id: search for user_id. Ignored if user_id is None
             room_id: search for room_id. Ignored if room_id is None
+                event_sender_user_id: search for the sender of the reported event. Ignored if
+                event_sender_user_id is None
         Returns:
             Tuple of:
                 json list of event reports
@@ -1615,6 +1618,10 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
                 filters.append("er.room_id LIKE ?")
                 args.extend(["%" + room_id + "%"])
 
+            if event_sender_user_id:
+                filters.append("events.sender = ?")
+                args.extend([event_sender_user_id])
+
             if direction == Direction.BACKWARDS:
                 order = "DESC"
             else:
@@ -1630,6 +1637,7 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
             sql = """
                 SELECT COUNT(*) as total_event_reports
                 FROM event_reports AS er
+                LEFT JOIN events USING(event_id)
                 JOIN room_stats_state ON room_stats_state.room_id = er.room_id
                 {}
                 """.format(where_clause)
@@ -1648,8 +1656,7 @@ class RoomWorkerStore(CacheInvalidationWorkerStore):
                     room_stats_state.canonical_alias,
                     room_stats_state.name
                 FROM event_reports AS er
-                LEFT JOIN events
-                    ON events.event_id = er.event_id
+                LEFT JOIN events USING(event_id)
                 JOIN room_stats_state
                     ON room_stats_state.room_id = er.room_id
                 {where_clause}
diff --git a/synapse/storage/databases/main/roommember.py b/synapse/storage/databases/main/roommember.py
index 4249cf77e559f9e574996002e47f104d1d1fc967..50ed6a28bf0611f49ed169118a1957740f986f15 100644
--- a/synapse/storage/databases/main/roommember.py
+++ b/synapse/storage/databases/main/roommember.py
@@ -1572,6 +1572,40 @@ class RoomMemberWorkerStore(EventsWorkerStore, CacheInvalidationWorkerStore):
             get_sliding_sync_room_for_user_batch_txn,
         )
 
+    async def get_rooms_for_user_by_date(
+        self, user_id: str, from_ts: int
+    ) -> FrozenSet[str]:
+        """
+        Fetch a list of rooms that the user has joined at or after the given timestamp, including
+        those they subsequently have left/been banned from.
+
+        Args:
+            user_id: user ID of the user to search for
+            from_ts: a timestamp in ms from the unix epoch at which to begin the search at
+        """
+
+        def _get_rooms_for_user_by_join_date_txn(
+            txn: LoggingTransaction, user_id: str, timestamp: int
+        ) -> frozenset:
+            sql = """
+                SELECT rm.room_id
+                FROM room_memberships AS rm
+                INNER JOIN events AS e USING (event_id)
+                WHERE rm.user_id = ?
+                    AND rm.membership = 'join'
+                    AND e.type = 'm.room.member'
+                    AND e.received_ts >= ?
+            """
+            txn.execute(sql, (user_id, timestamp))
+            return frozenset([r[0] for r in txn])
+
+        return await self.db_pool.runInteraction(
+            "_get_rooms_for_user_by_join_date_txn",
+            _get_rooms_for_user_by_join_date_txn,
+            user_id,
+            from_ts,
+        )
+
 
 class RoomMemberBackgroundUpdateStore(SQLBaseStore):
     def __init__(
diff --git a/synapse/storage/schema/main/delta/88/06_events_received_ts_index.sql b/synapse/storage/schema/main/delta/88/06_events_received_ts_index.sql
new file mode 100644
index 0000000000000000000000000000000000000000..d70a4a8dbcbce681113639644f79626b40c2b916
--- /dev/null
+++ b/synapse/storage/schema/main/delta/88/06_events_received_ts_index.sql
@@ -0,0 +1,17 @@
+--
+-- This file is licensed under the Affero General Public License (AGPL) version 3.
+--
+-- Copyright (C) 2024 New Vector, Ltd
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- See the GNU Affero General Public License for more details:
+-- <https://www.gnu.org/licenses/agpl-3.0.html>.
+
+-- Add an index on `events.received_ts` for `m.room.member` events to allow for
+-- efficient lookup of events by timestamp in some Admin API's
+INSERT INTO background_updates (ordering, update_name, progress_json) VALUES
+    (8806, 'events_received_ts_index', '{}');
diff --git a/tests/rest/admin/test_event_reports.py b/tests/rest/admin/test_event_reports.py
index feb410a11d60fb359c7218886aaf5ebe169617a0..6047ce1f4afaaea4deaac1f8b171b214c34fb273 100644
--- a/tests/rest/admin/test_event_reports.py
+++ b/tests/rest/admin/test_event_reports.py
@@ -378,6 +378,41 @@ class EventReportsTestCase(unittest.HomeserverTestCase):
         self.assertEqual(len(channel.json_body["event_reports"]), 1)
         self.assertNotIn("next_token", channel.json_body)
 
+    def test_filter_against_event_sender(self) -> None:
+        """
+        Tests filtering by the sender of the reported event
+        """
+        # first grab all the reports
+        channel = self.make_request(
+            "GET",
+            self.url,
+            access_token=self.admin_user_tok,
+        )
+        self.assertEqual(channel.code, 200)
+
+        # filter out set of report ids of events sent by one of the users
+        locally_filtered_report_ids = set()
+        for event_report in channel.json_body["event_reports"]:
+            if event_report["sender"] == self.other_user:
+                locally_filtered_report_ids.add(event_report["id"])
+
+        # grab the report ids by sender and compare to filtered report ids
+        channel = self.make_request(
+            "GET",
+            f"{self.url}?event_sender_user_id={self.other_user}",
+            access_token=self.admin_user_tok,
+        )
+        self.assertEqual(200, channel.code)
+        self.assertEqual(channel.json_body["total"], len(locally_filtered_report_ids))
+
+        event_reports = channel.json_body["event_reports"]
+        server_filtered_report_ids = set()
+        for event_report in event_reports:
+            server_filtered_report_ids.add(event_report["id"])
+        self.assertIncludes(
+            locally_filtered_report_ids, server_filtered_report_ids, exact=True
+        )
+
     def _create_event_and_report(self, room_id: str, user_tok: str) -> None:
         """Create and report events"""
         resp = self.helper.send(room_id, tok=user_tok)
diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py
index 9a0e90208da6c4ad1576b08a788bc5453bfed8a1..b517aefd0c5aa193e4b8c8b961de3b314c5b9284 100644
--- a/tests/rest/admin/test_user.py
+++ b/tests/rest/admin/test_user.py
@@ -5502,3 +5502,254 @@ class UserRedactionBackgroundTaskTestCase(BaseMultiWorkerStreamTestCase):
                     redaction_ids.add(event["redacts"])
 
         self.assertIncludes(redaction_ids, original_event_ids, exact=True)
+
+
+class GetInvitesFromUserTestCase(unittest.HomeserverTestCase):
+    servlets = [
+        synapse.rest.admin.register_servlets,
+        login.register_servlets,
+        admin.register_servlets,
+        room.register_servlets,
+    ]
+
+    def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
+        self.admin = self.register_user("thomas", "pass", True)
+        self.admin_tok = self.login("thomas", "pass")
+
+        self.bad_user = self.register_user("teresa", "pass")
+        self.bad_user_tok = self.login("teresa", "pass")
+
+        self.random_users = []
+        for i in range(4):
+            self.random_users.append(self.register_user(f"user{i}", f"pass{i}"))
+
+        self.room1 = self.helper.create_room_as(self.bad_user, tok=self.bad_user_tok)
+        self.room2 = self.helper.create_room_as(self.bad_user, tok=self.bad_user_tok)
+        self.room3 = self.helper.create_room_as(self.bad_user, tok=self.bad_user_tok)
+
+    @unittest.override_config(
+        {"rc_invites": {"per_issuer": {"per_second": 1000, "burst_count": 1000}}}
+    )
+    def test_get_user_invite_count_new_invites_test_case(self) -> None:
+        """
+        Test that new invites that arrive after a provided timestamp are counted
+        """
+        # grab a current timestamp
+        before_invites_sent_ts = self.hs.get_clock().time_msec()
+
+        # bad user sends some invites
+        for room_id in [self.room1, self.room2]:
+            for user in self.random_users:
+                self.helper.invite(room_id, self.bad_user, user, tok=self.bad_user_tok)
+
+        # fetch using timestamp, all should be returned
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/sent_invite_count?from_ts={before_invites_sent_ts}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["invite_count"], 8)
+
+        # send some more invites, they should show up in addition to original 8 using same timestamp
+        for user in self.random_users:
+            self.helper.invite(
+                self.room3, src=self.bad_user, targ=user, tok=self.bad_user_tok
+            )
+
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/sent_invite_count?from_ts={before_invites_sent_ts}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["invite_count"], 12)
+
+    def test_get_user_invite_count_invites_before_ts_test_case(self) -> None:
+        """
+        Test that invites sent before provided ts are not counted
+        """
+        # bad user sends some invites
+        for room_id in [self.room1, self.room2]:
+            for user in self.random_users:
+                self.helper.invite(room_id, self.bad_user, user, tok=self.bad_user_tok)
+
+        # add a msec between last invite and ts
+        after_invites_sent_ts = self.hs.get_clock().time_msec() + 1
+
+        # fetch invites with timestamp, none should be returned
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/sent_invite_count?from_ts={after_invites_sent_ts}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["invite_count"], 0)
+
+    def test_user_invite_count_kick_ban_not_counted(self) -> None:
+        """
+        Test that kicks and bans are not counted in invite count
+        """
+        to_kick_user_id = self.register_user("kick_me", "pass")
+        to_kick_tok = self.login("kick_me", "pass")
+
+        self.helper.join(self.room1, to_kick_user_id, tok=to_kick_tok)
+
+        # grab a current timestamp
+        before_invites_sent_ts = self.hs.get_clock().time_msec()
+
+        # bad user sends some invites (8)
+        for room_id in [self.room1, self.room2]:
+            for user in self.random_users:
+                self.helper.invite(
+                    room_id, src=self.bad_user, targ=user, tok=self.bad_user_tok
+                )
+
+        # fetch using timestamp, all invites sent should be counted
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/sent_invite_count?from_ts={before_invites_sent_ts}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["invite_count"], 8)
+
+        # send a kick and some bans and make sure these aren't counted against invite total
+        for user in self.random_users:
+            self.helper.ban(
+                self.room1, src=self.bad_user, targ=user, tok=self.bad_user_tok
+            )
+
+        channel = self.make_request(
+            "POST",
+            f"/_matrix/client/v3/rooms/{self.room1}/kick",
+            content={"user_id": to_kick_user_id},
+            access_token=self.bad_user_tok,
+        )
+        self.assertEqual(channel.code, 200)
+
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/sent_invite_count?from_ts={before_invites_sent_ts}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["invite_count"], 8)
+
+
+class GetCumulativeJoinedRoomCountForUserTestCase(unittest.HomeserverTestCase):
+    servlets = [
+        synapse.rest.admin.register_servlets,
+        login.register_servlets,
+        admin.register_servlets,
+        room.register_servlets,
+    ]
+
+    def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
+        self.admin = self.register_user("thomas", "pass", True)
+        self.admin_tok = self.login("thomas", "pass")
+
+        self.bad_user = self.register_user("teresa", "pass")
+        self.bad_user_tok = self.login("teresa", "pass")
+
+    def test_user_cumulative_joined_room_count(self) -> None:
+        """
+        Tests proper count returned from /cumulative_joined_room_count endpoint
+        """
+        # Create rooms and join, grab timestamp before room creation
+        before_room_creation_timestamp = self.hs.get_clock().time_msec()
+
+        joined_rooms = []
+        for _ in range(3):
+            room = self.helper.create_room_as(self.admin, tok=self.admin_tok)
+            self.helper.join(
+                room, user=self.bad_user, expect_code=200, tok=self.bad_user_tok
+            )
+            joined_rooms.append(room)
+
+        # get a timestamp after room creation and join, add a msec between last join and ts
+        after_room_creation = self.hs.get_clock().time_msec() + 1
+
+        # Get rooms using this timestamp, there should be none since all rooms were created and joined
+        # before provided timestamp
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/cumulative_joined_room_count?from_ts={int(after_room_creation)}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(200, channel.code, msg=channel.json_body)
+        self.assertEqual(0, channel.json_body["cumulative_joined_room_count"])
+
+        # fetch rooms with the older timestamp before they were created and joined, this should
+        # return the rooms
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/cumulative_joined_room_count?from_ts={int(before_room_creation_timestamp)}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(200, channel.code, msg=channel.json_body)
+        self.assertEqual(
+            len(joined_rooms), channel.json_body["cumulative_joined_room_count"]
+        )
+
+    def test_user_joined_room_count_includes_left_and_banned_rooms(self) -> None:
+        """
+        Tests proper count returned from /joined_room_count endpoint when user has left
+        or been banned from joined rooms
+        """
+        # Create rooms and join, grab timestamp before room creation
+        before_room_creation_timestamp = self.hs.get_clock().time_msec()
+
+        joined_rooms = []
+        for _ in range(3):
+            room = self.helper.create_room_as(self.admin, tok=self.admin_tok)
+            self.helper.join(
+                room, user=self.bad_user, expect_code=200, tok=self.bad_user_tok
+            )
+            joined_rooms.append(room)
+
+        # fetch rooms with the older timestamp before they were created and joined
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/cumulative_joined_room_count?from_ts={int(before_room_creation_timestamp)}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(200, channel.code, msg=channel.json_body)
+        self.assertEqual(
+            len(joined_rooms), channel.json_body["cumulative_joined_room_count"]
+        )
+
+        # have the user banned from/leave the joined rooms
+        self.helper.ban(
+            joined_rooms[0],
+            src=self.admin,
+            targ=self.bad_user,
+            expect_code=200,
+            tok=self.admin_tok,
+        )
+        self.helper.change_membership(
+            joined_rooms[1],
+            src=self.bad_user,
+            targ=self.bad_user,
+            membership="leave",
+            expect_code=200,
+            tok=self.bad_user_tok,
+        )
+        self.helper.ban(
+            joined_rooms[2],
+            src=self.admin,
+            targ=self.bad_user,
+            expect_code=200,
+            tok=self.admin_tok,
+        )
+
+        # fetch the joined room count again, the number should remain the same as the collected joined rooms
+        channel = self.make_request(
+            "GET",
+            f"/_synapse/admin/v1/users/{self.bad_user}/cumulative_joined_room_count?from_ts={int(before_room_creation_timestamp)}",
+            access_token=self.admin_tok,
+        )
+        self.assertEqual(200, channel.code, msg=channel.json_body)
+        self.assertEqual(
+            len(joined_rooms), channel.json_body["cumulative_joined_room_count"]
+        )