Status: In Progress
Linear: GAR-777
Branch: routine/202506021230-me-invites-inbox
Date: 2026-06-02 (America/New_York)
Add GET /v1/me/invites — a cursor-paginated inbox of pending group invites addressed
to the authenticated caller's email address. Completes the me/* inbox family alongside
mentions (GAR-755), tasks (GAR-763), chats (GAR-765), files (GAR-767), and memory (GAR-770).
group_invites— no FORCE RLS (token-based access model per migration 007 comment).users— no FORCE RLS; visible togarraia_approle.
Because neither table has FORCE RLS, no transaction and no SET LOCAL context variables
are needed. The handler runs a plain parameterized query directly against the pool,
isolated via WHERE u.id = $principal_user_id.
-- No-cursor branch:
SELECT gi.id, gi.group_id, gi.proposed_role, gi.created_at, gi.expires_at
FROM group_invites gi
JOIN users u ON u.email = gi.invited_email
WHERE u.id = $1
AND gi.accepted_at IS NULL
AND gi.expires_at > now()
ORDER BY gi.created_at DESC, gi.id DESC
LIMIT $2
-- Cursor branch (after_id provided):
SELECT gi.id, gi.group_id, gi.proposed_role, gi.created_at, gi.expires_at
FROM group_invites gi
JOIN users u ON u.email = gi.invited_email
WHERE u.id = $1
AND gi.accepted_at IS NULL
AND gi.expires_at > now()
AND (gi.created_at, gi.id) < (
SELECT gi2.created_at, gi2.id FROM group_invites gi2
JOIN users u2 ON u2.email = gi2.invited_email
WHERE gi2.id = $2 AND u2.id = $1
)
ORDER BY gi.created_at DESC, gi.id DESC
LIMIT $3token_hashis never returned — it is the Argon2id hash of the invite token.invited_emailis never returned — PII; caller already knows their own email.group_nameis not returned — invited users have nogroup_membersrow yet, sogroupsFORCE RLS (migration 018) would filter to 0 rows. Clients may callGET /v1/groups/{id}after accepting to retrieve group metadata.- Isolation enforced at DB level via
WHERE u.id = $principal_user_id, not RLS.
- Axum 0.8
State<RestV1FullState>+Principalextractor sqlx::query_aswith tuple row type (no migration)utoipafor OpenAPI annotation +IntoParams/ToSchemaserdewithskip_serializing_if = "Option::is_none"onnext_cursor
- Accepted, cancelled, or expired invites (history endpoint, separate slice).
- Group name resolution (requires separate fetch after RLS context set).
- Invite acceptance flow (already exists at
POST /v1/groups/{id}/invites/{token}/accept).
group_invites and users schemas are unchanged. Index
group_invites_pending_email_idx ON group_invites(invited_email) WHERE accepted_at IS NULL
(migration 001) supports the query's WHERE predicate.
| File | Change |
|---|---|
crates/garraia-gateway/src/rest_v1/me.rs |
Add ListMyInvitesQuery, PendingInviteSummary, MyInvitesResponse, list_my_invites handler + 7 unit tests |
crates/garraia-gateway/src/rest_v1/mod.rs |
Register .route("/v1/me/invites", ...) in all 3 router branches |
crates/garraia-gateway/src/rest_v1/openapi.rs |
Add path + component registration |
plans/README.md |
Add row 0255 |
ROADMAP.md |
Mark GET /v1/me/invites as done in §3.4 |
TODO.md |
Update completed section |
cargo check -p garraia-gatewaypasses.cargo clippy --workspace --tests --exclude garraia-desktop --features garraia-gateway/test-helpers --no-deps -- -D warningspasses.cargo test -p garraia-gatewaypasses (7 new unit tests green).- Route appears in
GET /v1/openapi.json. token_hashandinvited_emailabsent from all response shapes.
- Plan 0245 (GAR-765) — GET /v1/me/chats (same inbox family)
- Plan 0246 (GAR-767) — GET /v1/me/files (same inbox family)
- Plan 0249 (GAR-770) — GET /v1/me/memory (same inbox family)
- Migration 001 —
group_invitesschema +group_invites_pending_email_idx - Migration 007 —
garraia_appgrant;group_invitesintentionally excluded from FORCE RLS - Migration 018 —
groupsFORCE RLS (explains why group_name is not returnable for pending invites)