People, Friends and Group Invitations
Issue #643 lets people find each other without exchanging account IDs, keep a reusable friends list, and invite people to groups from inside the app. It builds on public profiles (#452) and guest-to-account history transfer (#448).
Concepts
| Concept | What it is | What it is not |
|---|---|---|
| Username (handle) | An optional, unique public name (@maya.chen) with a shareable link (/users/<username>; old /u/<handle> links redirect). Changeable once every 30 days after the first pick |
Not required; not derived from email |
| Discoverable | Opt-in: you appear in name search | Off for every existing and new account until you turn it on |
| Friend | A mutual, consented contact list entry | Never grants access to any group |
| Block | Hides two people from each other and stops requests and invitations between them | Doesn't change shared groups or expense history |
| Invitation | An owner's pre-approved offer to join a group | Not membership: access starts only when the invitee accepts |
Who Can Find Whom
You can find, befriend or invite a person when neither of you has blocked the other and at least one of these is true:
- you share a group (they're already on that roster);
- they opted into search (
discoverable), for name and handle-prefix search; - they have a handle, for an exact
@handlematch or the profile link; - there's already a friendship edge between you.
Results contain only:
- display name;
- handle;
- your relationship to them;
- how many groups you share, which tells apart people with the same name.
They never include email, identity-provider details, credits, or groups you aren't in. Profiles still list only groups shared with the viewer. A block in either direction makes a person look like an unknown user (404) on GET /users/{userId}, so names and handle changes cannot be tracked through the public identity endpoint. The richer profile view also hides someone who blocked you, while allowing you to view your own blocked list and unblock them. Reading your own identity remains allowed.
Group Join Requests
Submitting a join request checks blocks between the applicant and every current OWNER, across all roster pages. A block in either direction returns the same 404 as a missing group, including when a shared invite link is supplied. The notification worker checks again before recording history or sending push, so a queued request cannot bypass a later block.
An explicit denial retains a persistent per-(applicant, group) guard. Further requests without an invite link return 409; a valid invite allows a retry, while the existing at-most-one-pending transaction guard still applies. Existing denial audit rows are not backfilled; the guard starts with new decisions after deployment.
API
All of these are app-session (Cognito) routes on the core API, and none is available to personal access tokens. Contract: docs.<stage>…/app-reference.html.
| Route | Purpose |
|---|---|
PATCH /users/me/discovery |
Set or clear the handle (null clears it; 409 if taken; 429 if changed in the last 30 days) and the search opt-in |
GET /people/search?q= |
Name/handle prefix search (≥ 2 characters), paginated |
GET /people/suggestions |
People from your groups who aren't friends yet |
GET /people/relationships?state=FRIEND|INCOMING|OUTGOING|BLOCKED |
Your lists, paginated |
GET /people/handles/{handle} |
Resolve a profile link (your own handle resolves to you) |
GET /people/balances |
What you and each person you share groups with owe each other, per currency |
PUT / DELETE /people/{userId}/friendship |
Send or accept a request / cancel, decline or unfriend |
PUT / DELETE /people/{userId}/block |
Block / unblock |
POST /groups (requestId, inviteUserIds) |
Create a group and invite people. A retry with the same requestId returns the same group |
POST / GET /groups/{groupId}/invitations |
Owners invite people / list pending invitations |
DELETE /groups/{groupId}/invitations/{userId} |
Owners revoke an invitation |
GET /users/me/invitations |
Your invitation inbox |
POST /users/me/invitations/{groupId}/accept / decline |
Join the group / decline |
Consistency rules:
- Friend requests and blocks are idempotent. A request to someone who already sent you one accepts it, so crossed requests resolve into a friendship.
- Invites are retry-safe. An invitation that's already pending is reported as
INVITEDand is never duplicated. - Accepting an invitation is retry-safe. One transaction consumes the invitation and writes the membership and its uniqueness row, so concurrent or repeated accepts produce exactly one membership.
- Unusable invitations. An expired invitation is rejected with 410. A revoked or declined one no longer exists, so it returns 404.
- Invitations to deleted groups are removed when the invitee next opens their inbox.
Limits:
- 50 friend requests and 100 group invitations per user per UTC day.
- One handle change every 30 days (
PEOPLE_LIMITS.HANDLE_CHANGE_COOLDOWN_DAYS). Picking a first handle is free and doesn't start the cooldown (HadHandleon the profile remembers it was used); the first change after that does. Removing a handle is always allowed; the check runs in the same write, so two quick changes can't both pass.GET /users/meand the discovery response carryhandleChangeableAtwhile the cooldown is running. GET /people/balancesemits request-scoped latency, read-command count and DynamoDB consumed read units to New Relic; see notification follow-ups. Rosters are read across all pages. Live beta measurements are required before deciding on additional caching or precomputation.GET /people/balancestotals each group's settle-up plan (the same transfers the group screen suggests) between you and each other account holder, across up to 50 groups (partial: truepast that). It costs 10 units against the per-minute budget.- Search, suggestions and relationship lists cost 5 units each against the per-minute request budget.
- Search pages are capped at 50 results.
Guests, Invited People and Expense History (#744)
Owners can use Add guest in a group's People section on web, desktop, Android and iOS. Guests need only a name and can immediately be selected as payers and in expense splits, including recurring templates. Invitation dialogs also support selecting one account and linking an Existing guest. Expense pickers show a small Guest or Invited pill beside the name. Expense details list Split between; see the expense UI guide for item editing and account-member mentions.
The deployed group-join.test.ts suite covers shared-link takeover from request
through owner approval. It checks private access before approval, owner-only
approval, the same participant ID afterward, unchanged expense payments/splits,
and rejection of repeated approval without duplicate membership. Existing
people.test.ts cases cover pending invitee assignments and invitation-linked
guest acceptance; groups-participants.test.ts covers moving history onto an
account that joined separately.
The browser guest suite exercises the web PKCE redirect and callback with an intercepted identity provider, restores the group/invite URL, selects a guest after sign-in, submits the claim and shows pending owner approval. It does not exercise Cognito's hosted registration forms or native OS/browser navigation. The web callback restores the router to the saved URL so sign-in returns to the invite instead of Home. Native runtime acceptance remains separate.
Sending an invitation atomically reserves an assignable guest participant. It
has an invitedUserId and invitationExpiresAt, but no membership or userId.
The roster and expense selectors label it Invited until expiry. Expenses
can be assigned before acceptance; private group access still requires
acceptance. The inbox explains which guest's history will be connected.
Accepting claims that participant in place, keeping its ID, all expense and settlement references and recurring history. A linked guest adopts the account's display name and the invitation's role. A missing or changed slot returns a conflict rather than silently creating a second financial identity. Repeated acceptance returns the existing membership. Old invitations without a slot are repaired on owner listing, resend or acceptance; their original expiry remains.
Revoking or declining removes the invitation and releases its active reservation, leaving the guest and history intact. A durable account link lets a later invite reuse that same slot. Expiry never deletes history. Owners must revoke a live invitation before transferring/removing its participant. A former invitee's expired slot may be claimed through the normal owner-approved flow or explicitly linked to another invitee. Cancelled slots keep their account link and can be reinvited, explicitly linked by an owner, or transferred; matching names never merges people. A legacy account-linked slot without an expiry also stays protected from shared-link claims until its invitation is repaired or the owner relinks it. Deleting an invited account releases its outstanding reservations and preserves the financial guest rows.
A person joining through a shared group link can choose Take over an existing guest. The owner approves this request. Actively reserved guests are excluded from this selector and cannot be claimed through that path, including by a racing approval. The intended invitee accepts through their invitation inbox.
If someone joined as a new participant, an owner opens the guest's participant screen and chooses Move transactions. The confirmation names the source and target and offers to keep or remove the original participant. The transfer moves expense payments/splits, settlements and recurring templates. Overlapping equal splits preserve their original minor-unit allocations; other people's shares do not increase just because two identities merge. A settlement between the two becomes a self-payment and is deleted. Creator/audit fields stay intact.
Transfers lock the source against new financial references. Per-row optimistic checks protect concurrent edits; partial work remains committed and can be resumed only to the original target. Until it finishes, the source is labelled Transfer in progress. A kept source is unlocked after success; a removed source is deleted only after consistent reference checks. An error refreshes affected client caches so completed partial progress is visible.
Verification and Release
The API integration suite covers invited expenses before acceptance and a linked
existing guest. Shared UI regression tests exercise guest linking on web and
desktop; the native fixture flow 08-guest-lifecycle covers guest creation,
expense-selector visibility and transfer navigation. Fixture UI tests do not
establish deployed backend behavior or real-device acceptance.
Ship the additive backend contract first through the existing dev → beta → prod
pipeline, then release web/desktop/native clients with their normal workflows.
Older clients continue using GUEST/USER; new fields are optional. Acceptance
must record actual stage integration results and Android/iOS runtime evidence.
The implementation also aligns the SDK 58 patch packages to Expo's compatibility
check; validate the resulting native runtime before distributing updates.
Data
All of this lives in the tailspend-<stage>-people table; the full schema is in database.md. The handle and discoverable flag also live on the PROFILE row (Handle, Discoverable). Search uses a sparse, prefix-partitioned index (DirectoryIndex) that holds entries only for opted-in users, so a search is a single bounded Query and never a scan.
Account deletion removes:
- your friendship edges, on both sides;
- your blocks;
- invitations you received;
- your directory entries;
- your handle claim.
Invitations you sent stay valid; the group's remaining owners can revoke them.