Skip to content

Followers API reference

Follow requests, acceptance and removal between users. The follow graph is visible only to its owner and their accepted followers. See Sharing, privacy and followers.

Service

Application logic for follower relationships.

The privacy-aware read surface (whether a requester may view a target's follower / following graph) and the follow / accept / unfollow writes. Access decisions live here, not in the router, so the same rule applies however this module is reached.

A user's follow graph is visible only to the user and their accepted followers. Endurain has no public-profile concept: profiles are never public, and only individual activities can be shared (via server-level public links), so no "public" tier applies to the follow graph.

This is the module's own application layer. What other modules may consume lives in :mod:modules.followers.integration_service.

accept_follow_request

accept_follow_request(requester_user_id, follower_id, db)

Accept a pending follow request and publish follower.accepted.

Parameters:

Name Type Description Default
requester_user_id int

The authenticated user accepting the request.

required
follower_id int

The user whose pending request is being accepted.

required
db Session

Database session.

required

Returns:

Type Description
FollowRelationshipRead

The accepted relationship, read back after the flush.

Raises:

Type Description
NotFoundError

When no pending request from that user exists.

Source code in backend/app/modules/followers/service.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def accept_follow_request(
    requester_user_id: int,
    follower_id: int,
    db: Session,
) -> followers_schema.FollowRelationshipRead:
    """Accept a pending follow request and publish ``follower.accepted``.

    Args:
        requester_user_id: The authenticated user accepting the request.
        follower_id: The user whose pending request is being accepted.
        db: Database session.

    Returns:
        The accepted relationship, read back after the flush.

    Raises:
        NotFoundError: When no pending request from that user exists.
    """
    relationship = followers_crud.accept_follower(requester_user_id, follower_id, db)
    followers_event_publishers.publish_follower_accepted(requester_user_id, follower_id, db, commit=db.commit)
    logger.info(
        "Follow request accepted",
        extra=core_logger.context(requester_user_id=requester_user_id, follower_id=follower_id),
    )
    return relationship

delete_relationship

delete_relationship(followee_id, follower_id, requester_user_id, db)

Delete a follow relationship on behalf of either party.

One operation serves unfollow and remove-follower: the row is the same and the direction is explicit in the arguments, so the only difference is which of the two parties asked.

Parameters:

Name Type Description Default
followee_id int

The user being followed.

required
follower_id int

The user doing the following.

required
requester_user_id int

The authenticated caller, who must be one of the two.

required
db Session

Database session.

required

Raises:

Type Description
AccessConcealedError

When the caller is not part of the relationship; answered as a missing relationship so it does not confirm one exists.

Source code in backend/app/modules/followers/service.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def delete_relationship(followee_id: int, follower_id: int, requester_user_id: int, db: Session) -> None:
    """Delete a follow relationship on behalf of either party.

    One operation serves unfollow and remove-follower: the row is the same and
    the direction is explicit in the arguments, so the only difference is which
    of the two parties asked.

    Args:
        followee_id: The user being followed.
        follower_id: The user doing the following.
        requester_user_id: The authenticated caller, who must be one of the two.
        db: Database session.

    Raises:
        AccessConcealedError: When the caller is not part of the relationship; answered as a
            missing relationship so it does not confirm one exists.
    """
    if requester_user_id not in (followee_id, follower_id):
        logger.warning(
            "Blocked an attempt to delete a follow relationship the caller is not part of",
            extra=core_logger.context(
                requester_user_id=requester_user_id, followee_id=followee_id, follower_id=follower_id
            ),
        )
        raise core_exceptions.AccessConcealedError("Follow relationship not found")
    followers_crud.delete_follower(follower_id, followee_id, db)
    db.commit()
    logger.info(
        "Deleted a follow relationship",
        extra=core_logger.context(
            requester_user_id=requester_user_id, followee_id=followee_id, follower_id=follower_id
        ),
    )

follow_user

follow_user(requester_user_id, target_user_id, db)

Create a follow request and publish follower.requested.

The CRUD row write is the source of truth; the notification is produced by the follower.requested subscriber reacting to the published event, so the request succeeds even if notification delivery later fails.

Parameters:

Name Type Description Default
requester_user_id int

The authenticated user requesting to follow.

required
target_user_id int

The user to follow.

required
db Session

Database session.

required

Returns:

Type Description
FollowRelationshipRead

The newly created follow relationship as a DTO.

Raises:

Type Description
InvalidInputError

If the requester targets themselves.

ConflictError

If the relationship already exists.

NotFoundError

If the target user does not exist.

Source code in backend/app/modules/followers/service.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def follow_user(requester_user_id: int, target_user_id: int, db: Session) -> followers_schema.FollowRelationshipRead:
    """Create a follow request and publish ``follower.requested``.

    The CRUD row write is the source of truth; the notification is produced by the
    ``follower.requested`` subscriber reacting to the published event, so the
    request succeeds even if notification delivery later fails.

    Args:
        requester_user_id: The authenticated user requesting to follow.
        target_user_id: The user to follow.
        db: Database session.

    Returns:
        The newly created follow relationship as a DTO.

    Raises:
        InvalidInputError: If the requester targets themselves.
        ConflictError: If the relationship already exists.
        NotFoundError: If the target user does not exist.
    """
    if requester_user_id == target_user_id:
        # Prevent self-follow, which would otherwise pollute counts and
        # notifications.
        raise core_exceptions.InvalidInputError("Cannot follow yourself")

    follower = followers_crud.create_follower(requester_user_id, target_user_id, db)
    followers_event_publishers.publish_follower_requested(requester_user_id, target_user_id, db, commit=db.commit)
    logger.info(
        "Follow requested",
        extra=core_logger.context(requester_user_id=requester_user_id, target_user_id=target_user_id),
    )
    return follower

get_user_relationship

get_user_relationship(other_user_id, requester_user_id, db)

Return the authenticated user's relationship with another user (both directions).

Only ever reports relationships the requester is part of — their outgoing follow of other_user_id and other_user_id's incoming follow of them — so there is no way to probe arbitrary pairs.

Parameters:

Name Type Description Default
other_user_id int

The other user in the relationship.

required
requester_user_id int

The authenticated requester.

required
db Session

Database session.

required

Returns:

Type Description
RelationshipRead

The requester's outgoing and incoming follow relationships with the other

RelationshipRead

user (either may be None).

Source code in backend/app/modules/followers/service.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def get_user_relationship(other_user_id: int, requester_user_id: int, db: Session) -> followers_schema.RelationshipRead:
    """Return the authenticated user's relationship with another user (both directions).

    Only ever reports relationships the requester is part of — their outgoing
    follow of ``other_user_id`` and ``other_user_id``'s incoming follow of them —
    so there is no way to probe arbitrary pairs.

    Args:
        other_user_id: The other user in the relationship.
        requester_user_id: The authenticated requester.
        db: Database session.

    Returns:
        The requester's outgoing and incoming follow relationships with the other
        user (either may be None).
    """
    outgoing = followers_crud.get_follower_for_user_id_and_target_user_id(requester_user_id, other_user_id, db)
    incoming = followers_crud.get_follower_for_user_id_and_target_user_id(other_user_id, requester_user_id, db)
    return followers_schema.RelationshipRead(outgoing=outgoing, incoming=incoming)

list_followers

list_followers(target_user_id, requester_user_id, db, *, page_number=1, num_records=core_pagination.DEFAULT_NUM_RECORDS, accepted_only=False)

Return one page of the target's followers with the matching total.

Parameters:

Name Type Description Default
target_user_id int

The user whose followers to list.

required
requester_user_id int

The authenticated requester.

required
db Session

Database session.

required
page_number int

1-based page number.

1
num_records int

Page size.

DEFAULT_NUM_RECORDS
accepted_only bool

Exclude pending follow requests when True.

False

Returns:

Type Description
FollowRelationshipPage

The page envelope. total counts every follower matching the same

FollowRelationshipPage

filter, not just this page.

Raises:

Type Description
AccessConcealedError

When the requester may not view this network; answered like a missing user.

Source code in backend/app/modules/followers/service.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def list_followers(
    target_user_id: int,
    requester_user_id: int,
    db: Session,
    *,
    page_number: int = 1,
    num_records: int = core_pagination.DEFAULT_NUM_RECORDS,
    accepted_only: bool = False,
) -> followers_schema.FollowRelationshipPage:
    """Return one page of the target's followers with the matching total.

    Args:
        target_user_id: The user whose followers to list.
        requester_user_id: The authenticated requester.
        db: Database session.
        page_number: 1-based page number.
        num_records: Page size.
        accepted_only: Exclude pending follow requests when True.

    Returns:
        The page envelope. ``total`` counts every follower matching the same
        filter, not just this page.

    Raises:
        AccessConcealedError: When the requester may not view this network;
            answered like a missing user.
    """
    # Checked once here so the page and its total share a single authorisation
    # decision rather than each re-deriving it.
    _ensure_may_view_network(target_user_id, requester_user_id, db)
    effective_accepted_only = accepted_only or requester_user_id != target_user_id
    items = followers_crud.get_all_followers_by_user_id(
        target_user_id,
        db,
        page_number=page_number,
        num_records=num_records,
        accepted_only=effective_accepted_only,
    )
    total = followers_crud.count_followers_by_user_id(
        target_user_id,
        db,
        accepted_only=effective_accepted_only,
    )
    return followers_schema.FollowRelationshipPage.build(items, total, page_number, num_records)

list_following

list_following(target_user_id, requester_user_id, db, *, page_number=1, num_records=core_pagination.DEFAULT_NUM_RECORDS, accepted_only=False)

Return one page of who the target follows with the matching total.

Parameters:

Name Type Description Default
target_user_id int

The user whose following list to return.

required
requester_user_id int

The authenticated requester.

required
db Session

Database session.

required
page_number int

1-based page number.

1
num_records int

Page size.

DEFAULT_NUM_RECORDS
accepted_only bool

Exclude pending follow requests when True.

False

Returns:

Type Description
FollowRelationshipPage

The page envelope. total counts every followee matching the same

FollowRelationshipPage

filter, not just this page.

Raises:

Type Description
AccessConcealedError

When the requester may not view this network; answered like a missing user.

Source code in backend/app/modules/followers/service.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def list_following(
    target_user_id: int,
    requester_user_id: int,
    db: Session,
    *,
    page_number: int = 1,
    num_records: int = core_pagination.DEFAULT_NUM_RECORDS,
    accepted_only: bool = False,
) -> followers_schema.FollowRelationshipPage:
    """Return one page of who the target follows with the matching total.

    Args:
        target_user_id: The user whose following list to return.
        requester_user_id: The authenticated requester.
        db: Database session.
        page_number: 1-based page number.
        num_records: Page size.
        accepted_only: Exclude pending follow requests when True.

    Returns:
        The page envelope. ``total`` counts every followee matching the same
        filter, not just this page.

    Raises:
        AccessConcealedError: When the requester may not view this network;
            answered like a missing user.
    """
    _ensure_may_view_network(target_user_id, requester_user_id, db)
    effective_accepted_only = accepted_only or requester_user_id != target_user_id
    items = followers_crud.get_all_following_by_user_id(
        target_user_id,
        db,
        page_number=page_number,
        num_records=num_records,
        accepted_only=effective_accepted_only,
    )
    total = followers_crud.count_following_by_user_id(
        target_user_id,
        db,
        accepted_only=effective_accepted_only,
    )
    return followers_schema.FollowRelationshipPage.build(items, total, page_number, num_records)

list_pending_requests

list_pending_requests(requester_user_id, db, *, page_number=1, num_records=core_pagination.DEFAULT_NUM_RECORDS)

Return one page of follow requests awaiting the caller's decision.

Always scoped to the caller: a pending request is private to the two parties, so there is no target_user_id to pass and therefore nothing to probe.

Parameters:

Name Type Description Default
requester_user_id int

The authenticated caller.

required
db Session

Database session.

required
page_number int

1-based page number.

1
num_records int

Page size.

DEFAULT_NUM_RECORDS

Returns:

Type Description
FollowRelationshipPage

The page envelope.

Source code in backend/app/modules/followers/service.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def list_pending_requests(
    requester_user_id: int,
    db: Session,
    *,
    page_number: int = 1,
    num_records: int = core_pagination.DEFAULT_NUM_RECORDS,
) -> followers_schema.FollowRelationshipPage:
    """Return one page of follow requests awaiting the caller's decision.

    Always scoped to the caller: a pending request is private to the two parties,
    so there is no ``target_user_id`` to pass and therefore nothing to probe.

    Args:
        requester_user_id: The authenticated caller.
        db: Database session.
        page_number: 1-based page number.
        num_records: Page size.

    Returns:
        The page envelope.
    """
    items = followers_crud.get_pending_requests_for_user_id(
        requester_user_id, db, page_number=page_number, num_records=num_records
    )
    total = followers_crud.count_pending_requests_for_user_id(requester_user_id, db)
    return followers_schema.FollowRelationshipPage.build(items, total, page_number, num_records)

reject_follow_request

reject_follow_request(requester_user_id, follower_id, db)

Decline a pending follow request addressed to the caller.

Distinct from :func:delete_relationship, which severs an already-accepted relationship. Both delete the same row, but only one of them is a decision the requester never had granted — so this one is scoped to PENDING, and an accepted follower is removed through the relationship route instead of silently through here.

Parameters:

Name Type Description Default
requester_user_id int

The authenticated user declining the request.

required
follower_id int

The user whose pending request is declined.

required
db Session

Database session.

required

Raises:

Type Description
NotFoundError

When no pending request from that user exists.

Source code in backend/app/modules/followers/service.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def reject_follow_request(requester_user_id: int, follower_id: int, db: Session) -> None:
    """Decline a pending follow request addressed to the caller.

    Distinct from :func:`delete_relationship`, which severs an already-accepted
    relationship. Both delete the same row, but only one of them is a decision
    the requester never had granted — so this one is scoped to ``PENDING``, and
    an accepted follower is removed through the relationship route instead of
    silently through here.

    Args:
        requester_user_id: The authenticated user declining the request.
        follower_id: The user whose pending request is declined.
        db: Database session.

    Raises:
        NotFoundError: When no pending request from that user exists.
    """
    followers_crud.delete_follower(follower_id, requester_user_id, db, status=followers_constants.FollowStatus.PENDING)
    db.commit()
    logger.info(
        "Follow request rejected",
        extra=core_logger.context(requester_user_id=requester_user_id, follower_id=follower_id),
    )

Schemas

Pydantic schemas for follower relationships.

FollowRelationshipRead

Bases: BaseModel

Serialized representation of a follow relationship.

Attributes:

Name Type Description
follower_id StrictInt

The user who follows (the requester).

followee_id StrictInt

The user being followed.

status FollowStatus

Whether the request is pending or accepted.

Source code in backend/app/modules/followers/schema.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class FollowRelationshipRead(BaseModel):
    """Serialized representation of a follow relationship.

    Attributes:
        follower_id: The user who follows (the requester).
        followee_id: The user being followed.
        status: Whether the request is pending or accepted.
    """

    model_config = ConfigDict(from_attributes=True, extra="forbid")

    follower_id: StrictInt
    followee_id: StrictInt
    status: followers_constants.FollowStatus

FollowRequestAccept

Bases: BaseModel

The body of an accept applied to a pending follow request.

Named for the single transition it expresses. accepted is the only valid one: declining deletes the request rather than parking it in a rejected state, so a later request from the same user is a fresh decision instead of hitting a tombstone. The field is still required so the accept is explicit in the payload rather than implied by the route. Unknown fields are rejected (extra="forbid") rather than silently ignored, matching every other request-body schema in the activities/followers template modules.

Attributes:

Name Type Description
status FollowStatus

The target status; only accepted is valid.

Source code in backend/app/modules/followers/schema.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class FollowRequestAccept(BaseModel):
    """The body of an accept applied to a pending follow request.

    Named for the single transition it expresses. ``accepted`` is the only valid
    one: declining deletes the request rather than parking it in a rejected
    state, so a later request from the same user is a fresh decision instead of
    hitting a tombstone. The field is still required so the accept is explicit in
    the payload rather than implied by the route. Unknown fields are rejected
    (``extra="forbid"``) rather than silently ignored, matching every other
    request-body schema in the activities/followers template modules.

    Attributes:
        status: The target status; only ``accepted`` is valid.
    """

    model_config = ConfigDict(extra="forbid")

    status: followers_constants.FollowStatus

    @field_validator("status")
    @classmethod
    def _only_accept(cls, value: followers_constants.FollowStatus) -> followers_constants.FollowStatus:
        """Reject any status other than ``accepted``.

        Args:
            value: The requested status.

        Returns:
            The status, unchanged.

        Raises:
            ValueError: If the status is not ``accepted``.
        """
        if value is not followers_constants.FollowStatus.ACCEPTED:
            raise ValueError("a follow request can only be accepted; DELETE it to decline")
        return value

RelationshipRead

Bases: BaseModel

The authenticated user's relationship with another user, both directions.

Attributes:

Name Type Description
outgoing FollowRelationshipRead | None

The authenticated user's follow of the other user, if any.

incoming FollowRelationshipRead | None

The other user's follow of the authenticated user, if any.

Source code in backend/app/modules/followers/schema.py
63
64
65
66
67
68
69
70
71
72
73
74
class RelationshipRead(BaseModel):
    """The authenticated user's relationship with another user, both directions.

    Attributes:
        outgoing: The authenticated user's follow of the other user, if any.
        incoming: The other user's follow of the authenticated user, if any.
    """

    model_config = ConfigDict(from_attributes=True, extra="forbid")

    outgoing: FollowRelationshipRead | None = None
    incoming: FollowRelationshipRead | None = None

Events

Domain event channels owned by the followers module.

Channel names (event_type values) are owned by the publishing domain, not the platform substrate. The follow service publishes the facts below; the follower notification subscriber reacts by subscribing to the same constants, so producer and subscriber cannot drift on the string. Convention: <domain>.<fact>, past tense.

FollowerAcceptedPayload

Bases: VersionedPayload

Validated payload for the follower.accepted event.

Attributes:

Name Type Description
accepter_user_id int

The user who accepted the request.

requester_user_id int

The original requester (who is notified).

Source code in backend/app/modules/followers/events.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class FollowerAcceptedPayload(VersionedPayload):
    """Validated payload for the ``follower.accepted`` event.

    Attributes:
        accepter_user_id: The user who accepted the request.
        requester_user_id: The original requester (who is notified).
    """

    model_config = ConfigDict(extra="ignore")

    SCHEMA_VERSION: ClassVar[int] = 1

    accepter_user_id: int
    requester_user_id: int

FollowerRequestedPayload

Bases: VersionedPayload

Validated payload for the follower.requested event.

Subscribers validate against this schema so a malformed payload raises at the boundary — with the offending fields named — instead of being silently dropped by ad-hoc type checks.

Attributes:

Name Type Description
requester_user_id int

The user who requested to follow.

target_user_id int

The user being followed (who is notified).

Source code in backend/app/modules/followers/events.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class FollowerRequestedPayload(VersionedPayload):
    """Validated payload for the ``follower.requested`` event.

    Subscribers validate against this schema so a malformed payload raises at the
    boundary — with the offending fields named — instead of being silently
    dropped by ad-hoc type checks.

    Attributes:
        requester_user_id: The user who requested to follow.
        target_user_id: The user being followed (who is notified).
    """

    model_config = ConfigDict(extra="ignore")

    SCHEMA_VERSION: ClassVar[int] = 1

    requester_user_id: int
    target_user_id: int