Skip to content

Backend: Create new message thread queries and add public trips#9219

Open
nabramow wants to merge 7 commits into
developfrom
na/backend/messages-queries
Open

Backend: Create new message thread queries and add public trips#9219
nabramow wants to merge 7 commits into
developfrom
na/backend/messages-queries

Conversation

@nabramow

@nabramow nabramow commented Jul 1, 2026

Copy link
Copy Markdown
Member

Closes #8962

  • Adds new message thread query that better fits the new UI. Also adds public trips to it.

FYI - I have the frontend in another branch off this one na/web/messages-pub-trips and tested this there.

Testing

Explain how you tested this PR and give clear steps so the reviewer can replicate.

Backend checklist

  • Added tests for any new code or added a regression test if fixing a bug
  • Run the backend locally and it works
  • Added migrations if there are any database changes, rebased onto develop if necessary for linear migration history

For maintainers

  • Maintainers can push commits to my branch
  • Maintainers can merge this PR for me

@nabramow
nabramow requested a review from aapeliv as a code owner July 1, 2026 17:28
@CouchersBot

CouchersBot commented Jul 1, 2026

Copy link
Copy Markdown
Member

Mobile

Install the Dev Tool (iOS via TestFlight, iOS Simulator, or Android .apk) here.

Scan the QR with your phone camera, or tap Open in Dev Tool on the device, to open this branch in the installed Dev Tool dev client.

    iOS         Android    
    QR to open the iOS build         QR to open the Android build    
    Open in Dev Tool         Open in Dev Tool    
Deep links

iOS

couchers-devtool://expo-development-client/?url=https%3A%2F%2Fe08821c7--ota.preview.couchershq.org%2Fios%2Fmanifest

Android

couchers-devtool://expo-development-client/?url=https%3A%2F%2Fe08821c7--ota.preview.couchershq.org%2Fandroid%2Fmanifest

Web (Vercel)

View the Vercel web preview for this branch.

Backend

Schema Schema diff Sample emails

Other

Protos Backend coverage Web coverage

@nabramow nabramow changed the title Backend: Refactor messages screen queries and add public trips Backend: Create new message thread queries and add public trips Jul 2, 2026

@aapeliv aapeliv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is quite a huge PR. i added some initial comments but didn't have the chance to go through the entirety of the new logic in detail yet.

Comment on lines 323 to +327
unseen_sent_host_request_count=unseen_sent_host_request_count,
unseen_received_host_request_count=unseen_received_host_request_count,
unseen_hosting_host_request_count=unseen_hosting_host_request_count,
unseen_surfing_host_request_count=unseen_surfing_host_request_count,
unseen_public_trip_offer_count=unseen_public_trip_offer_count,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unseen_sent_host_request_count should equal unseen_surfing_host_request_count pre-public trips, right? is there a reason to have them separate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right unseen_sent_host_request_count should unseen_surfing_host_request_count compute the same number today as right now every request's surfer is also its initiator.

However the code keeps them separate anticipating public trips, where that will stop being true.

I think once public trips is out we could migrate the clients off sent/received onto surfing/hosting?

# Role-based unread host-request counts. Unlike sent/received above, these
# classify by stay-role, so public-trip offers (role reversal) bucket
# correctly: the offering host counts under hosting, the traveller under surfing.
def role_based_unseen_host_request_count(party_predicate: ColumnElement[bool]) -> int:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am concerned about the performance of the Ping endpoint, and this will add a good chunk of extra work. we should optimize these queries to instead subquery all requests where the given user is a party and that have unread messages, then use that as a subquery to count by type.

i can do it as a follow up, so doesn't need to block this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense!

.group_by(HostRequest.status)
).all()
)
pb.offer_tally.CopyFrom(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure you need CopyFrom here, i think you can just assign?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah looks like I can as long as it's not the whole message

Comment thread app/proto/conversations.proto Outdated
}

enum MessageThreadFilter {
MESSAGE_THREAD_FILTER_ALL = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs an unspecified

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm but there is no unspecified in practice or you mean just use ALL as unspecified?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add it but have it be an invalid argument I think

Comment thread app/proto/conversations.proto Outdated
Message latest_message = 9;
string hosting_city = 10;
bool is_archived = 11;
// true if this thread is a public-trip offer (public_trip_id is set)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also requires current user to be recipient?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this made me realize that the flag is redundant anyway.

is_public_trip_offer just duplicated public_trip_id's presence, and viewer_is_host is derivable from host_user_id == myId. Since roles are already sent viewer-independently, I dropped both.

Now we can tell :

  • is it an offer? → public_trip_id present
  • am I the host? → host_user_id == me
  • is it an offer to me? → public_trip_id present and host_user_id != me.

Comment thread app/proto/conversations.proto Outdated
Comment thread app/proto/conversations.proto Outdated
Comment thread app/proto/conversations.proto Outdated
Comment thread app/proto/public_trips.proto Outdated
Comment thread app/backend/src/couchers/servicers/conversations.py
@nabramow

Copy link
Copy Markdown
Member Author

this is quite a huge PR. i added some initial comments but didn't have the chance to go through the entirety of the new logic in detail yet.

Yeah I wasn't sure how else to break it up as it's basically one big query!

@nabramow
nabramow requested a review from tristanlabelle as a code owner July 16, 2026 17:46

@tristanlabelle tristanlabelle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to go through conversations.py due to its size but so far I left comments about code duplication and a few API shape thoughts.


return host_request_to_pb(host_request, session, context)

# TODO(remove after FE migrates to ListMessageThreads)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you have this TODO refer to a GitHub issue? (# TODO(#1234): blah blah blah)

bool same_gender_only = 10;
// Number of non-cancelled host offers linked to this trip.
// Only populated when the viewer is the trip owner.
optional int32 offers_count = 11;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we deprecate in favor of offer_tally?

// A host request (or public-trip offer) shaped for the unified thread list.
// surfer_user_id/host_user_id are ROLE-based: for a public-trip offer (role
// reversal) they are swapped relative to the underlying initiator/recipient.
message HostRequestThread {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicating HostRequest's every field, with subtle changes. We should be referencing it instead. For example now I'd need to add a timezone in two places.

Comment thread app/proto/conversations.proto Outdated
bool no_more = 3;
}

enum MessageThreadFilter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if the UI has those mutually exclusive filters, would it make sense for the backend to model those as combinable? E.g. support specifying searching for Hosting + Surfing?

node_id=node.id,
from_date=today() + timedelta(days=5),
to_date=today() + timedelta(days=10),
description="x" * 200,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid the 200 magic number, can we reference a constant?

Comment on lines +26 to +41
node = Node(
geom=to_multi(create_polygon_lat_lng([[60, 24], [60, 26], [62, 26], [62, 24], [60, 24]])),
node_type=NodeType.locality,
)
session.add(node)
session.flush()
session.add(
Cluster(
name="Test community",
description="Test",
parent_node_id=node.id,
is_official_cluster=True,
small_community_features_enabled=True,
)
)
session.flush()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's (extract and) reuse create_community from test_communities.py.

)
)
session.flush()
trip = PublicTrip(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to reuse _create_trip_directly and just have one place creating DB entries if we can't use the API.

Comment on lines +140 to +141
expected_ids.add(_create_group_chat(token1, [other.id], moderator))
expected_ids.add(_create_host_request(other_token, user1.id, moderator))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are group chat and host request IDs necessarily non-overlapping? I think we should track them separately.

Comment on lines +258 to +269
unseen_hosting_host_request_count = role_based_unseen_host_request_count(
or_(
and_(HostRequest.public_trip_id.is_(None), HostRequest.recipient_user_id == me),
and_(HostRequest.public_trip_id.isnot(None), HostRequest.initiator_user_id == me),
)
)
unseen_surfing_host_request_count = role_based_unseen_host_request_count(
or_(
and_(HostRequest.public_trip_id.is_(None), HostRequest.initiator_user_id == me),
and_(HostRequest.public_trip_id.isnot(None), HostRequest.recipient_user_id == me),
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could make this a single database query for entries where either the initiator or recipient is "me", then do the different types of counting in Python. Maybe we can even cover the 5 counts that way in a single query.

@aapeliv aapeliv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

had a look through the big function and left a lot of comments.

overall, it's quite a big piece. this is touching a high volume surface, so some perf comments as well.

there is a lot of "if public trip, then switch surfer/host", which is something i was hoping we would avoid with the public trips design. i wonder if we are shoehorning it in in a way that is going to be difficult down the line.

if unread:
# restrict to chats with at least one message newer than the user's last-seen
candidate_query = candidate_query.join(
GroupChatSubscription, GroupChatSubscription.id == visible_group_chats.c.subscription_id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are double-joining here on GroupChatSubscription; you can move this above where you wrap in the select, so then you don't need this join. similar to only_archived

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do, good catch

and_(HostRequest.public_trip_id.is_(None), HostRequest.initiator_user_id == viewer_id),
and_(HostRequest.public_trip_id.isnot(None), HostRequest.recipient_user_id == viewer_id),
)
if thread_filter == conversations_pb2.MESSAGE_THREAD_FILTER_PUBLIC_TRIPS:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's call this MY_PUBLIC_TRIPS, or SURFING_PUBLIC_TRIPS; it is confusing otherwise

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do MY_PUBLIC_TRIPS

def _host_request_viewer_last_seen(host_request: HostRequest, user_id: int) -> int:
"""The viewer's role-specific last-seen message id for a host request."""
if host_request.initiator_user_id == user_id:
return host_request.initiator_last_seen_message_id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a similar if for recipient_user_id, and throw if the user_id matches neither; that's safer

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +373 to +387
return conversations_pb2.GroupChat(
group_chat_id=group_chat.conversation_id,
title=group_chat.title, # TODO: proper title for DMs, etc
member_user_ids=_get_visible_members_for_subscription(subscription),
admin_user_ids=_get_visible_admins_for_subscription(subscription),
only_admins_invite=group_chat.only_admins_invite,
is_dm=group_chat.is_dm,
created=Timestamp_from_datetime(group_chat.conversation.created),
unseen_message_count=unseen_message_count,
last_seen_message_id=subscription.last_seen_message_id,
latest_message=_message_to_pb(message) if message else None,
mute_info=_mute_info(subscription),
can_message=_user_can_message(session, context, group_chat),
is_archived=subscription.is_archived,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could take this opportunity to prune stuff that we don't use in the frontend; e.g. the visible members/admins is N queries per group chat.

Comment on lines +547 to +560
latest_per_group_chat = (
select(
GroupChatSubscription.group_chat_id.label("group_chat_id"),
func.max(GroupChatSubscription.id).label("subscription_id"),
func.max(Message.id).label("message_id"),
)
.join(Message, Message.conversation_id == GroupChatSubscription.group_chat_id)
.where(GroupChatSubscription.user_id == context.user_id)
.where(GroupChatSubscription.group_chat_id.in_(group_chat_ids))
.where(Message.time >= GroupChatSubscription.joined)
.where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None))
.group_by(GroupChatSubscription.group_chat_id)
.subquery()
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this logic is duplicated now multiple times? In existing code as well. Given the complexity of this query, we should only construct it once. (Similar with group chats)

page_size = request.page_size if request.page_size != 0 else DEFAULT_PAGINATION_LENGTH
page_size = min(page_size, MAX_PAGE_SIZE)
only_archived = request.only_archived if request.HasField("only_archived") else None
unread = thread_filter == conversations_pb2.MESSAGE_THREAD_FILTER_UNREAD

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only_unread?


page_rows = candidate_rows[:page_size]
no_more = len(candidate_rows) <= page_size
next_page_token = str(min(row.latest_message_id for row in page_rows)) if (page_rows and not no_more) else ""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we encrypt the page token please? there's a helper function. that way it's truly opaque

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes done.

Comment on lines +785 to +788
group_chats_by_id = _build_group_chats_pb(session, context, group_chat_ids)
host_request_threads_by_id = _build_host_request_threads_pb(
session, context, host_request_ids, latest_message_id_by_conversation
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are running those queries twice now for each group chat/host request. let's avoid that if we can, since this API will be called a lot. i have done some optimization on the exsting group chats queries in the past given it was taking lots of compute.

if include_chats:
latest_message_id_by_conversation = {
row.conversation_id: row.latest_message_id
for row in session.execute(_group_chat_candidate_query(context, only_archived, unread)).all()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like you are using the same logic and same candidate query building; why not unify further?

Comment on lines +883 to +888
latest_message_id = latest_message_id_by_conversation[host_request.conversation_id]
if host_request.initiator_user_id == context.user_id:
if host_request.initiator_last_seen_message_id < latest_message_id:
host_request.initiator_last_seen_message_id = latest_message_id
elif host_request.recipient_last_seen_message_id < latest_message_id:
host_request.recipient_last_seen_message_id = latest_message_id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will issue queries for every host request separately; we can use a bulk update to reduce the roundtrips

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve backend sorting of messages for current UI

4 participants