TailSpend Docs

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:

Results contain only:

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:

Limits:

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:

Invitations you sent stay valid; the group's remaining owners can revoke them.

View Markdown source