Skip to content

fix(invitation): auto-delete expired invitations on a schedule#1744

Open
whoAbhishekSah wants to merge 3 commits into
mainfrom
feat/auto-delete-expired-invitations
Open

fix(invitation): auto-delete expired invitations on a schedule#1744
whoAbhishekSah wants to merge 3 commits into
mainfrom
feat/auto-delete-expired-invitations

Conversation

@whoAbhishekSah

@whoAbhishekSah whoAbhishekSah commented Jul 9, 2026

Copy link
Copy Markdown
Member

The problem

Expired invitations are never cleaned up.

If you try to accept an expired invite, it just fails. If someone sends a new invite to the same person, it writes over the old one. Neither of these actually removes the expired invite.

So an invite that is never accepted and never deleted stays around forever. Each invite is stored in three places: one row in the invitations table, and two records in SpiceDB (one links the invite to the user, one links it to the org). All three keep piling up over time.

The fix

Add a job that runs once a day, at midnight UTC, and deletes expired invites. This matches how the domain and session cleanup jobs already work. If the job ever crashes, the crash is caught so it can't take down the server.

The job does not delete an invite the moment it expires. It waits until the invite has been expired for 7 days. That way a recently expired invite still shows up in the list for a week, and the job cleans it up after that.

To delete, the job uses the existing invitation.Delete. That one call removes the table row and both SpiceDB records together.

This is the important part. The code already had a GarbageCollect function that deleted only the table row, with a plain SQL delete. That would have left the two SpiceDB records behind with nothing pointing to them. So I removed it and replaced it with a read-only ListExpired that just finds the old invites. The job then deletes each one the safe way.

If one invite fails to delete, the job logs it and moves on, so the rest still get cleaned up.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Jul 9, 2026 8:25am

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@whoAbhishekSah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4b8f5300-0032-4cfa-b8c0-81775c3dac89

📥 Commits

Reviewing files that changed from the base of the PR and between 545ee21 and 114b117.

📒 Files selected for processing (6)
  • cmd/serve.go
  • core/invitation/mocks/repository.go
  • core/invitation/service.go
  • core/invitation/service_test.go
  • internal/store/postgres/invitation_repository.go
  • internal/store/postgres/invitation_repository_test.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Nothing removed an invitation once it expired. Trying to accept an
expired invite just fails with an error, and creating a new invite for
the same user writes over the old one — neither path deletes the expired
invite. So an invite that was never accepted or deleted kept its
invitations row and both SpiceDB tuples (#user and #org) forever.

Add a daily background job (robfig/cron, "0 0 * * *" UTC, same pattern
as the domain-verification and session-cleanup jobs) that finds expired
invitations and deletes them.

The job deletes through the existing invitation.Delete, which removes
both SpiceDB tuples and the invitations row together. This is important:
the repository's old GarbageCollect ran a raw row DELETE, which would
have left the SpiceDB tuples behind as orphans. GarbageCollect is
removed and replaced with a read-only ListExpired that the service
iterates over.

- core/invitation: InitInvitationCleanup / DeleteExpiredInvitations /
  Close, and a ListExpired repository method.
- cmd/serve: start the job at boot and stop it on shutdown, next to the
  other periodic jobs.
- store/postgres: GarbageCollect -> ListExpired.
- tests for the sweep and for ListExpired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ention

The domain-verification and session-cleanup jobs both declare their
daily schedule as a package const named refreshTime. Use the same name
and inline comment here instead of a bespoke const.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coveralls

coveralls commented Jul 9, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29004637891

Coverage increased (+0.005%) to 44.884%

Details

  • Coverage increased (+0.005%) from the base build.
  • Patch coverage: 30 uncovered changes across 3 files (29 of 59 lines covered, 49.15%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/invitation/service.go 32 17 53.13%
cmd/serve.go 8 0 0.0%
internal/store/postgres/invitation_repository.go 19 12 63.16%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 37668
Covered Lines: 16907
Line Coverage: 44.88%
Coverage Strength: 12.51 hits per line

💛 - Coveralls

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 29002914971

Coverage increased (+0.001%) to 44.88%

Details

  • Coverage increased (+0.001%) from the base build.
  • Patch coverage: 30 uncovered changes across 3 files (27 of 57 lines covered, 47.37%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/invitation/service.go 31 16 51.61%
cmd/serve.go 8 0 0.0%
internal/store/postgres/invitation_repository.go 18 11 61.11%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 37665
Covered Lines: 16904
Line Coverage: 44.88%
Coverage Strength: 12.5 hits per line

💛 - Coveralls

Only delete invitations that expired at least 7 days ago, instead of
everything past its expiry. A recently expired invite still shows up in
the list APIs; the daily job removes it once it crosses the retention
window.

The retention window lives in the service (business logic) as a cutoff
time (now minus 7 days) that is passed to ListExpired, so the repository
just filters by the given time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@whoAbhishekSah

Copy link
Copy Markdown
Member Author

How this was tested

Ran locally against a real Frontier (Postgres + SpiceDB). Set the cleanup cron to
@every 15s so the sweep runs on demand, then checked the DB row and the SpiceDB
tuples after each step.

# What we did Expected Result
1 Create an invite 1 DB row + 2 tuples (#org, #user)
2 Invite expired 8 days ago (past the 7-day keep window) swept: row + both tuples gone
3 Invite expired 1 hour ago (still inside the keep window) left alone, still listed
4 Wait for another sweep with nothing old enough nothing deleted, no error
5 Unit tests (go test ./core/invitation/...) pass
6 gofmt, go vet, go build clean

The invite is removed through Delete, so the row and both tuples go together —
the old row-only cleanup would have left the tuples behind. None were left after
the sweep.

@AmanGIT07

Copy link
Copy Markdown
Contributor

How this was tested

@whoAbhishekSah Can you validate these scenarios too -

  1. user invited once, the invitation hasn't expired and we re-invite the same user.
  2. user invite expired and cleaned up and we re-invite

// retention window) so recently expired invites are skipped, and deletes the
// results through the invitation service so both the SpiceDB tuples and the
// invitations row are removed together — a raw row delete here would leak the
// invitation's #user / #org tuples behind.

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.

row-only delete would leak the #user / #org tuples behind. - this line is repeated multiple times, we can clean it up

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.

3 participants