Skip to content

feat: resend workspace invite with backoff#14654

Open
ibex088 wants to merge 8 commits intotoeverything:canaryfrom
ibex088:codex/resend-invite-backoff
Open

feat: resend workspace invite with backoff#14654
ibex088 wants to merge 8 commits intotoeverything:canaryfrom
ibex088:codex/resend-invite-backoff

Conversation

@ibex088
Copy link
Copy Markdown
Contributor

@ibex088 ibex088 commented Mar 14, 2026

Summary

This PR adds a Resend invite action for pending workspace members and prevents duplicate resend emails by deduplicating invitation sends in the notification queue per workspace and invitee email.

https://www.loom.com/share/ab0e2ca3c7dc401cae2a2039bed2729b

What Changed

Backend

  • Added resendMemberInvite(workspaceId, inviteId): Boolean!.
  • Reused the existing invitation email pipeline via notification.sendInvitation.
  • Added queue-based deduplication for invitation sends using a deterministic per-workspace, per-email job id.
  • Resend now only succeeds for Pending invites.
  • If an older queued invite exists for the same email and a new invite replaces it, the stale queued job is removed so the latest invite can be sent.
  • Added backend test coverage for resend behavior, duplicate suppression, replacement invites, and non-pending rejection.

Frontend

  • Added a Resend invite action for pending members in member options.
  • Shows:
    • success notification when resend is queued
    • Invitation already queued notification when a duplicate resend is blocked
    • localized error when the member has no email
  • Added a component test covering the already queued notification path.
  • Added new i18n strings and regenerated i18n typings.

Behavior

  • Admins/owners can resend invites for pending members.
  • Duplicate resend attempts for the same workspace/email are blocked while an invitation send is already queued.
  • Resend is rejected for non-pending invite states.
  • If an invite is revoked/replaced before the queued send runs, the queue is updated so the latest invite is the one that gets emailed.

Why

Invite emails can fail or be missed, so admins need a retry path. At the same time, resend should not enqueue duplicate invitation emails for the same recipient. This PR adds a simple recovery flow while using queue-level deduplication to guard against duplicate sends.

Tests

  • Backend e2e coverage for:
    • resend for pending member
    • duplicate resend suppression
    • revoke + re-invite replacement behavior
    • reject resend for non-pending team invite
  • Frontend component coverage for:
    • Invitation already queued notification

Summary by CodeRabbit

  • New Features

    • Workspace admins/owners can resend pending member invites; shows success or “already queued” feedback and blocks resends if no email.
  • Behavior

    • Server now prevents duplicate invitation notifications and ensures replaced/revoked invites produce notifications for the latest invite.
  • Localization

    • Added UI strings for resend action, cooldown hint, success, and queued notifications.
  • Tests

    • E2E and component tests cover resend, cooldown/prevention, revoke/replace, and error cases.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 14, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 14, 2026

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93e9a569-b10a-4951-ab4f-1d6b2c6293a0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a GraphQL mutation to resend pending workspace member invites, implements deterministic job deduplication in the workspace service to avoid duplicate notification jobs, exposes frontend APIs/UI for resending, and extends tests and i18n for the resend flow. Returns boolean indicating enqueue success.

Changes

Cohort / File(s) Summary
GraphQL Schema & Documents
packages/backend/server/src/schema.gql, packages/common/graphql/src/graphql/index.ts, packages/common/graphql/src/graphql/workspace-team-resend-invite.gql, packages/common/graphql/src/schema.ts
Added resendMemberInvite(workspaceId: String!, inviteId: String!): Boolean! mutation, GraphQL document, exported mutation constant, and generated TypeScript types.
Backend Resolver
packages/backend/server/src/core/workspaces/resolvers/member.ts
Added resendMemberInvite mutation on WorkspaceMemberResolver with permission checks, invite/workspace/status and user validations, calling workspaceService.sendInvitationNotification.
Workspace Service
packages/backend/server/src/core/workspaces/service.ts
Added deterministic job-id generation (SHA‑256 of normalized email), queue lookup to avoid duplicate notification.sendInvitation jobs, and updated sendInvitationNotification to return Promise<boolean> indicating enqueue outcome.
Queue Mock
packages/backend/server/src/__tests__/mocks/queue.mock.ts
Replaced stub with Map-backed mock store; add accepts optional jobId, get(jobId, name) added, remove deletes by jobId, signatures adjusted for deduplication testing.
Backend Tests (E2E)
packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts
Added e2e cases for resend behavior: successful enqueue returns true, duplicate resend returns false (cooldown), revoke/create replacement enqueues new job, and resending non-pending invite errors with no enqueue.
Frontend Store & Service
packages/frontend/core/src/modules/permissions/stores/members.ts, packages/frontend/core/src/modules/permissions/services/members.ts
Added resendMemberInvite store method that calls the new mutation and a service method that forwards workspaceId + inviteId to the store.
Frontend UI & Tests
packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx, packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.spec.tsx
Added handleResendInvite, a "resend invite" action for admin/owner on pending members, success/cooldown notifications and revalidation on success; unit test asserts cooldown notification when mutation returns false.
i18n
packages/frontend/i18n/src/resources/en.json, packages/frontend/i18n/src/i18n.gen.ts, packages/frontend/i18n/src/i18n-completenesses.json
Added English keys and generated typings for resend action, success and cooldown notifications (with interpolation), and adjusted Polish completeness from 98 to 97.

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as Member Options UI
    participant Store as Members Store
    participant GQL as GraphQL Resolver
    participant Service as Workspace Service
    participant Queue as Job Queue

    User->>UI: Click "Resend Invite"
    UI->>UI: Validate email present
    UI->>Store: resendMemberInvite(workspaceId, inviteId)
    Store->>GQL: resendWorkspaceTeamMemberInviteMutation(workspaceId, inviteId)
    GQL->>GQL: Check Workspace.Users.Manage permission
    GQL->>GQL: Load invite record, verify workspaceId & status
    GQL->>GQL: Load invitee user
    GQL->>Service: sendInvitationNotification(inviterId, inviteId)
    Service->>Service: Compute deterministic jobId (SHA-256 of normalized email)
    Service->>Queue: get(jobId, 'notification.sendInvitation')
    alt job exists
        Queue-->>Service: job found
        Service-->>GQL: return false
    else job missing
        Queue-->>Service: not found
        Service->>Queue: add('notification.sendInvitation', payload, { jobId })
        Service-->>GQL: return true
    end
    GQL-->>Store: Boolean result
    Store-->>UI: Boolean result
    UI->>UI: Show success (true) or cooldown (false) notification
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I nudge the queue with tiny paws,
I hash each mail to follow laws.
If one is waiting, I step back—
Else off it hops down the track.
Invite resent, or calm and neat!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a resend workspace invite feature with backoff rate limiting, which is the primary objective across backend, frontend, and test changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions github-actions bot added mod:i18n Related to i18n app:server test Related to test cases app:core labels Mar 14, 2026
@ibex088 ibex088 changed the title Codex/resend invite backoff feat: resend invite backoff Mar 15, 2026
@ibex088 ibex088 changed the title feat: resend invite backoff feat: resend workspace invite with backoff Mar 17, 2026
@ibex088 ibex088 marked this pull request as ready for review March 17, 2026 11:29
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/frontend/i18n/src/resources/en.json (1)

1047-1047: Consider pluralized copy for second(s).

{{second}}s can read awkwardly for singular values; optional _one/_other plural keys would improve UX text quality.

Optional i18n pluralization direction
- "com.affine.payment.member.team.resendInvite.hint": "Resend in {{second}}s",
+ "com.affine.payment.member.team.resendInvite.hint_one": "Resend in {{count}} second",
+ "com.affine.payment.member.team.resendInvite.hint_other": "Resend in {{count}} seconds",

- "com.affine.payment.member.team.resendInvite.cooldown.notify.message": "You can resend the invitation for {{name}} in {{second}}s",
+ "com.affine.payment.member.team.resendInvite.cooldown.notify.message_one": "You can resend the invitation for {{name}} in {{count}} second",
+ "com.affine.payment.member.team.resendInvite.cooldown.notify.message_other": "You can resend the invitation for {{name}} in {{count}} seconds"

Also applies to: 1067-1067

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/i18n/src/resources/en.json` at line 1047, The key
com.affine.payment.member.team.resendInvite.hint currently uses a naive
"{{second}}s" suffix; change the translation to use proper pluralization (e.g.,
provide separate variants like
com.affine.payment.member.team.resendInvite.hint_one and ..._other or use ICU
plural syntax) and update the code that renders this key to pass the numeric
value (e.g., second or count) so the i18n library can select singular vs. plural
form; also apply the same change to the other occurrence noted (around the
second reported key at the same file).
packages/frontend/core/src/modules/permissions/services/members.ts (1)

36-41: Consider suppressing the ESLint Finnish notation rule for this line.

The static analysis flags the $ suffix, but this appears to be a project convention for observables (consistent with permission.isTeam$ in member-option.tsx). If this is an intentional pattern, consider adding an inline disable comment.

💡 Suggested inline disable
+  // eslint-disable-next-line rxjs/finnish
   readonly resendMemberInviteBackoff$ = LiveData.from(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/core/src/modules/permissions/services/members.ts` around
lines 36 - 41, The ESLint Finnish notation rule is flagging the observable
property resendMemberInviteBackoff$ (a project convention like
permission.isTeam$); suppress it by adding an inline eslint-disable-next-line
comment immediately above the declaration of resendMemberInviteBackoff$ (the
LiveData.from(...) line that calls this.globalStateService.globalState.watch
with RESEND_MEMBER_INVITE_BACKOFF_KEY) to disable the specific rule for that
line so the $ suffix convention is preserved consistently with
member-option.tsx.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/backend/server/src/core/workspaces/resolvers/member.ts`:
- Around line 305-311: The resend logic currently allows any
non-Accepted/UnderReview status which lets states like AllocatingSeat be resent;
change it to only allow the explicit statuses that the invite flow actually
emails (match the same statuses used in inviteMembers that emit
"workspace.members.invite" — e.g. only WorkspaceMemberStatus.Pending).
Concretely, update the checks around role.status in the resend mutation so that:
if role.status === WorkspaceMemberStatus.Accepted throw AlreadyInSpace; else if
role.status !== WorkspaceMemberStatus.Pending throw InvalidInvitation; this
prevents AllocatingSeat and other non-emailed states from being resendable and
keeps behavior consistent with inviteMembers and acceptInviteById.
- Around line 318-367: The backoff update must be made atomic and fail closed:
instead of the current read/compute/write that ignores cache errors, acquire a
short-lived lock or use an atomic conditional set on the backoff key (use the
same key from getMemberResendInviteBackoffKey(workspaceId, invitee.email)) so
parallel requests cannot both succeed, and check the cache.set result (or lock
acquisition) — if the atomic set/lock or the cache write fails or returns false,
throw/return an error and do not call
this.event.emit('workspace.members.invite', ...) ; keep the rest of the logic
(computing nextAttempt, nextAllowedAt, ttl using MEMBER_RESEND_INVITE_BACKOFF_*
constants and getMemberResendInviteBackoff) but ensure cache.set is performed
atomically and its failure is treated as a fatal error before emitting the
invite.

In `@packages/frontend/core/src/modules/permissions/services/members.ts`:
- Line 71: Remove the debug console.log in the members permission service:
delete the console.log('latestStoreeeeeeeee: ', latestStore) statement inside
the members.ts implementation (where latestStore is referenced) so no debugging
output remains; if logging is required for production, replace it with the
project's logger (e.g., use the existing logging utility) at an appropriate
level instead of console.log.

---

Nitpick comments:
In `@packages/frontend/core/src/modules/permissions/services/members.ts`:
- Around line 36-41: The ESLint Finnish notation rule is flagging the observable
property resendMemberInviteBackoff$ (a project convention like
permission.isTeam$); suppress it by adding an inline eslint-disable-next-line
comment immediately above the declaration of resendMemberInviteBackoff$ (the
LiveData.from(...) line that calls this.globalStateService.globalState.watch
with RESEND_MEMBER_INVITE_BACKOFF_KEY) to disable the specific rule for that
line so the $ suffix convention is preserved consistently with
member-option.tsx.

In `@packages/frontend/i18n/src/resources/en.json`:
- Line 1047: The key com.affine.payment.member.team.resendInvite.hint currently
uses a naive "{{second}}s" suffix; change the translation to use proper
pluralization (e.g., provide separate variants like
com.affine.payment.member.team.resendInvite.hint_one and ..._other or use ICU
plural syntax) and update the code that renders this key to pass the numeric
value (e.g., second or count) so the i18n library can select singular vs. plural
form; also apply the same change to the other occurrence noted (around the
second reported key at the same file).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b08e979d-48aa-4d9e-85d0-7a4d01f2043c

📥 Commits

Reviewing files that changed from the base of the PR and between 0b1a448 and 7ec6a24.

📒 Files selected for processing (14)
  • packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts
  • packages/backend/server/src/core/workspaces/resolvers/member.ts
  • packages/backend/server/src/core/workspaces/types.ts
  • packages/backend/server/src/schema.gql
  • packages/common/graphql/src/graphql/index.ts
  • packages/common/graphql/src/graphql/workspace-team-resend-invite.gql
  • packages/common/graphql/src/schema.ts
  • packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx
  • packages/frontend/core/src/modules/permissions/index.ts
  • packages/frontend/core/src/modules/permissions/services/members.ts
  • packages/frontend/core/src/modules/permissions/stores/members.ts
  • packages/frontend/i18n/src/i18n-completenesses.json
  • packages/frontend/i18n/src/i18n.gen.ts
  • packages/frontend/i18n/src/resources/en.json

Comment on lines +318 to +367
const backoff = await this.getMemberResendInviteBackoff(
workspaceId,
invitee.email
);
const now = new Date();
let attempt = backoff?.attempt ?? 0;

if (
backoff &&
now.getTime() - backoff.lastSentAt.getTime() >=
MEMBER_RESEND_INVITE_BACKOFF_RESET_AFTER_MS
) {
attempt = 0;
} else if (backoff && now.getTime() < backoff.nextAllowedAt.getTime()) {
return {
allowed: false,
attempt,
retryAfterMs: Math.max(
backoff.nextAllowedAt.getTime() - now.getTime(),
0
),
nextAllowedAt: backoff.nextAllowedAt,
};
}

const nextAttempt = attempt + 1;
const retryAfterMs = Math.min(
MEMBER_RESEND_INVITE_BACKOFF_BASE_MS * 2 ** (nextAttempt - 1),
MEMBER_RESEND_INVITE_BACKOFF_MAX_MS
);
const nextAllowedAt = new Date(now.getTime() + retryAfterMs);
const key = this.getMemberResendInviteBackoffKey(
workspaceId,
invitee.email
);

await this.cache.set(
key,
{
attempt: nextAttempt,
nextAllowedAt: nextAllowedAt.toISOString(),
lastSentAt: now.toISOString(),
},
{ ttl: MEMBER_RESEND_INVITE_BACKOFF_TTL_MS }
);

this.event.emit('workspace.members.invite', {
inviteId,
inviterId: me.id,
});
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.

⚠️ Potential issue | 🔴 Critical

Make the backoff update atomic and fail closed.

This is a read/compute/write sequence with no lock, so parallel resend requests can both observe the same state and both emit an invite. It also fails open if Redis is unhealthy: Cache.get() collapses read errors to a miss and the cache.set() result is ignored, so the abuse guard can turn off instead of stopping the resend.

Suggested direction
+    const backoffKey = this.getMemberResendInviteBackoffKey(
+      workspaceId,
+      invitee.email
+    );
+    await using lock = await this.mutex.acquire(`resend-invite:${backoffKey}`);
+    if (!lock) {
+      throw new TooManyRequest();
+    }
+
     const backoff = await this.getMemberResendInviteBackoff(
       workspaceId,
       invitee.email
     );
@@
-    const key = this.getMemberResendInviteBackoffKey(
-      workspaceId,
-      invitee.email
-    );
+    const key = backoffKey;

Handle a failed cache write as an error before emitting the invite as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/core/workspaces/resolvers/member.ts` around lines
318 - 367, The backoff update must be made atomic and fail closed: instead of
the current read/compute/write that ignores cache errors, acquire a short-lived
lock or use an atomic conditional set on the backoff key (use the same key from
getMemberResendInviteBackoffKey(workspaceId, invitee.email)) so parallel
requests cannot both succeed, and check the cache.set result (or lock
acquisition) — if the atomic set/lock or the cache write fails or returns false,
throw/return an error and do not call
this.event.emit('workspace.members.invite', ...) ; keep the rest of the logic
(computing nextAttempt, nextAllowedAt, ttl using MEMBER_RESEND_INVITE_BACKOFF_*
constants and getMemberResendInviteBackoff) but ensure cache.set is performed
atomically and its failure is treated as a fatal error before emitting the
invite.

Comment on lines +354 to +362
await this.cache.set(
key,
{
attempt: nextAttempt,
nextAllowedAt: nextAllowedAt.toISOString(),
lastSentAt: now.toISOString(),
},
{ ttl: MEMBER_RESEND_INVITE_BACKOFF_TTL_MS }
);
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 manually managing backoff via the cache is not a good idea.
Please consider managing the sending queue through a queue component, and only initiate a new sending event when the corresponding email address does not exist in the queue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

hope this is what you meant

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (1)
packages/backend/server/src/core/workspaces/resolvers/member.ts (1)

254-260: ⚠️ Potential issue | 🔴 Critical

Fence resend to actual emailed invite states.

inviteMembers() only sends email invites from the Pending path, but this mutation still allows AllocatingSeat / NeedMoreSeat* records through. If one of those team invites is resent, acceptInviteById() will later accept it without another seat/quota check, which can bypass the allocation flow. Restrict this to WorkspaceMemberStatus.Pending (and ideally WorkspaceMemberSource.Email).

🔒 Suggested guard
-    if (role.status === WorkspaceMemberStatus.UnderReview) {
+    if (
+      role.status !== WorkspaceMemberStatus.Pending ||
+      role.source !== WorkspaceMemberSource.Email
+    ) {
       throw new InvalidInvitation();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/core/workspaces/resolvers/member.ts` around lines
254 - 260, The resend guard is too permissive: change the checks in the
invite-resend path (where role.status is inspected, in the same logic that
references WorkspaceMemberStatus.Accepted and WorkspaceMemberStatus.UnderReview)
so that only records with role.status === WorkspaceMemberStatus.Pending (and
additionally role.source === WorkspaceMemberSource.Email) are allowed to be
resent; for any other status (including AllocatingSeat / NeedMoreSeat*) or
non-email sources, throw InvalidInvitation (or the existing error used for
invalid resend attempts). Also keep the Accepted -> AlreadyInSpace behavior
unchanged and ensure this logic aligns with inviteMembers() / acceptInviteById()
flows to prevent bypassing seat allocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/backend/server/src/__tests__/mocks/queue.mock.ts`:
- Around line 9-25: The test queue mock's count() is incorrectly using the add()
stub invocation history so it overcounts when add() short-circuits on duplicate
jobId; update the count() implementation to reflect the simulated queued jobs by
returning the size of the internal store (this.jobs.size) instead of relying on
add.callCount, and apply the same change to the other count() occurrence
referenced around lines 88-91 so tests assert actual de-duplicated queue depth.

In `@packages/backend/server/src/core/workspaces/resolvers/member.ts`:
- Around line 237-271: Change the resendMemberInvite mutation to return a typed
payload instead of a bare Boolean: define a GraphQL payload type (e.g.,
ResendMemberInvitePayload) with fields like allowed: boolean, retryAfterMs?:
number, nextAllowedAt?: string and maybe success?: boolean or errorCode?:
string; update the `@Mutation` return type and resolver signature for
resendMemberInvite to return that payload; call
workspaceService.sendInvitationNotification(invokerId, inviteId), map its result
and any cooldown/queue metadata returned by workspaceService into the payload
(set allowed=true when sending is permitted, include retryAfterMs/nextAllowedAt
when cooling down or disallowed, and include success for downstream falsey
cases) and adjust any callers/tests to expect the new payload shape. Ensure all
references to resendMemberInvite and workspaceService.sendInvitationNotification
are updated accordingly.

In `@packages/backend/server/src/core/workspaces/service.ts`:
- Around line 104-137: The current sendInvitationNotification method only uses
queue.get(jobId, 'notification.sendInvitation') for de-duplication, which ties
cooldown to queue retention; change it to persist per-workspace/email resend
state (e.g., in a cache or DB table via this.cache or this.models) keyed by
workspaceId+invitee.email so you can compute allowed/retryAfterMs according to
the intended backoff policy before enqueuing; keep the existing
jobId/de-duplication (getInvitationNotificationJobId + queue.get/queue.add) as a
secondary safeguard after validating allowed/retryAfterMs from the persisted
state and updating that state when scheduling retries.

In
`@packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx`:
- Around line 81-85: The missing-email toast uses hardcoded English text in
member-option.tsx (inside the branch that calls notify.error) and bypasses
useI18n(); replace the literal 'No email found for this member' with a localized
string via the i18n helper (e.g., obtain the t function from useI18n() in the
component or accept it from props) and call notify.error with message:
t('workspace.members.noEmail') (or a similar new key), then add that key and
translations to the locale files so non-English users see a localized message.

---

Duplicate comments:
In `@packages/backend/server/src/core/workspaces/resolvers/member.ts`:
- Around line 254-260: The resend guard is too permissive: change the checks in
the invite-resend path (where role.status is inspected, in the same logic that
references WorkspaceMemberStatus.Accepted and WorkspaceMemberStatus.UnderReview)
so that only records with role.status === WorkspaceMemberStatus.Pending (and
additionally role.source === WorkspaceMemberSource.Email) are allowed to be
resent; for any other status (including AllocatingSeat / NeedMoreSeat*) or
non-email sources, throw InvalidInvitation (or the existing error used for
invalid resend attempts). Also keep the Accepted -> AlreadyInSpace behavior
unchanged and ensure this logic aligns with inviteMembers() / acceptInviteById()
flows to prevent bypassing seat allocation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24e2fab5-f8ad-4316-b60b-c4d83d17b35b

📥 Commits

Reviewing files that changed from the base of the PR and between c3f4a92 and 9eb6800.

📒 Files selected for processing (12)
  • packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts
  • packages/backend/server/src/__tests__/mocks/queue.mock.ts
  • packages/backend/server/src/core/workspaces/resolvers/member.ts
  • packages/backend/server/src/core/workspaces/service.ts
  • packages/backend/server/src/schema.gql
  • packages/common/graphql/src/graphql/index.ts
  • packages/common/graphql/src/graphql/workspace-team-resend-invite.gql
  • packages/common/graphql/src/schema.ts
  • packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.spec.tsx
  • packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx
  • packages/frontend/core/src/modules/permissions/services/members.ts
  • packages/frontend/i18n/src/resources/en.json
✅ Files skipped from review due to trivial changes (1)
  • packages/frontend/i18n/src/resources/en.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/common/graphql/src/graphql/workspace-team-resend-invite.gql
  • packages/backend/server/src/tests/e2e/workspace/member.spec.ts
  • packages/common/graphql/src/graphql/index.ts
  • packages/common/graphql/src/schema.ts

Comment on lines +9 to +25
add = Sinon.stub().callsFake(
async <Job extends JobName>(
name: Job,
payload: Jobs[Job],
opts?: { jobId?: string }
) => {
if (opts?.jobId && this.jobs.has(opts.jobId)) {
return { id: opts.jobId, name };
}

if (opts?.jobId) {
this.jobs.set(opts.jobId, { name, payload });
}

return { id: opts?.jobId, name };
}
);
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.

⚠️ Potential issue | 🟡 Minor

Make count() reflect queued jobs after jobId de-duplication.

add() now short-circuits when a job with the same jobId already exists, but count() still counts stub invocations. Any test asserting queue depth will overcount duplicates and stop verifying the new de-duplication behavior. Please count the simulated queued jobs instead of add() call history.

Also applies to: 88-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/__tests__/mocks/queue.mock.ts` around lines 9 -
25, The test queue mock's count() is incorrectly using the add() stub invocation
history so it overcounts when add() short-circuits on duplicate jobId; update
the count() implementation to reflect the simulated queued jobs by returning the
size of the internal store (this.jobs.size) instead of relying on add.callCount,
and apply the same change to the other count() occurrence referenced around
lines 88-91 so tests assert actual de-duplicated queue depth.

Comment on lines +237 to +271
@Mutation(() => Boolean)
async resendMemberInvite(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('inviteId') inviteId: string
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');

const role = await this.models.workspaceUser.getById(inviteId);

if (!role || role.workspaceId !== workspaceId) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}

if (role.status === WorkspaceMemberStatus.Accepted) {
throw new AlreadyInSpace({ spaceId: workspaceId });
}

if (role.status === WorkspaceMemberStatus.UnderReview) {
throw new InvalidInvitation();
}

const invitee = await this.models.user.get(role.userId);
if (!invitee) {
throw new UserNotFound();
}

return await this.workspaceService.sendInvitationNotification(
me.id,
inviteId
);
}
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.

⚠️ Potential issue | 🟠 Major

Expose resend state instead of a bare boolean.

This mutation collapses “allowed”, “already queued/cooling down”, and some downstream falsey cases into the same Boolean result, and it gives the client no retryAfterMs / nextAllowedAt to drive a disabled countdown UX. Please return a typed payload here instead of forwarding the raw sendInvitationNotification() boolean.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/core/workspaces/resolvers/member.ts` around lines
237 - 271, Change the resendMemberInvite mutation to return a typed payload
instead of a bare Boolean: define a GraphQL payload type (e.g.,
ResendMemberInvitePayload) with fields like allowed: boolean, retryAfterMs?:
number, nextAllowedAt?: string and maybe success?: boolean or errorCode?:
string; update the `@Mutation` return type and resolver signature for
resendMemberInvite to return that payload; call
workspaceService.sendInvitationNotification(invokerId, inviteId), map its result
and any cooldown/queue metadata returned by workspaceService into the payload
(set allowed=true when sending is permitted, include retryAfterMs/nextAllowedAt
when cooling down or disallowed, and include success for downstream falsey
cases) and adjust any callers/tests to expect the new payload shape. Ensure all
references to resendMemberInvite and workspaceService.sendInvitationNotification
are updated accordingly.

Comment on lines 104 to +137
async sendInvitationNotification(inviterId: string, inviteId: string) {
await this.queue.add('notification.sendInvitation', {
inviterId,
inviteId,
});
const invite = await this.models.workspaceUser.getById(inviteId);
if (!invite) {
return false;
}

const invitee = await this.models.user.get(invite.userId);
if (!invitee) {
return false;
}

const jobId = this.getInvitationNotificationJobId(
invite.workspaceId,
invitee.email
);
const queuedJob = await this.queue.get(
jobId,
'notification.sendInvitation'
);

if (queuedJob) {
return false;
}

await this.queue.add(
'notification.sendInvitation',
{
inviterId,
inviteId,
},
{ jobId }
);

return true;
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.

⚠️ Potential issue | 🟠 Major

This is queue de-duplication, not persistent resend backoff.

The only guard here is queue.get(jobId, 'notification.sendInvitation'), so the effective block window becomes whatever the queue retains for that job after enqueue/processing, not a defined resend policy. That means cooldown length will drift with queue behavior instead of the intended backoff schedule. Persist per-workspace/email resend state in cache/DB and compute allowed / retryAfterMs from that state; keep the jobId de-duplication as a secondary safeguard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/core/workspaces/service.ts` around lines 104 -
137, The current sendInvitationNotification method only uses queue.get(jobId,
'notification.sendInvitation') for de-duplication, which ties cooldown to queue
retention; change it to persist per-workspace/email resend state (e.g., in a
cache or DB table via this.cache or this.models) keyed by
workspaceId+invitee.email so you can compute allowed/retryAfterMs according to
the intended backoff policy before enqueuing; keep the existing
jobId/de-duplication (getInvitationNotificationJobId + queue.get/queue.add) as a
secondary safeguard after validating allowed/retryAfterMs from the persisted
state and updating that state when scheduling retries.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/backend/server/src/__tests__/mocks/queue.mock.ts (1)

94-97: ⚠️ Potential issue | 🟠 Major

count() still tracks stub invocations, not queued jobs.

This still overcounts when add() de-duplicates by jobId, so queue-depth assertions can pass/fail for the wrong reason.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/__tests__/mocks/queue.mock.ts` around lines 94 -
97, count() currently returns the number of times the stubbed add() was called,
which overcounts when add() de-duplicates by jobId; modify the mock so add(...)
records actual enqueued jobs (e.g., maintain an internal Map<JobName,
Set<string>> that add pushes jobId into only when not already present) and then
have count(name: JobName) return the size of that Set (or deduplicate by
extracting unique call.args[0]/call.args[1].jobId if you prefer not to add
state); update references to the add stub and count function accordingly so
queue-depth assertions reflect unique queued jobIds rather than stub
invocations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/backend/server/src/__tests__/mocks/queue.mock.ts`:
- Around line 44-47: The stubbed remove() currently always resolves undefined
which breaks WorkspaceService.sendInvitationNotification's truthiness check;
update the Sinon.stub for remove to return the boolean result of
this.jobs.delete(jobId) (or Boolean(...) around it) instead of undefined so
tests reflect success/failure correctly—locate the remove =
Sinon.stub().callsFake(async (jobId: string) => { ... }) in the mock and change
its return to the deletion result.

---

Duplicate comments:
In `@packages/backend/server/src/__tests__/mocks/queue.mock.ts`:
- Around line 94-97: count() currently returns the number of times the stubbed
add() was called, which overcounts when add() de-duplicates by jobId; modify the
mock so add(...) records actual enqueued jobs (e.g., maintain an internal
Map<JobName, Set<string>> that add pushes jobId into only when not already
present) and then have count(name: JobName) return the size of that Set (or
deduplicate by extracting unique call.args[0]/call.args[1].jobId if you prefer
not to add state); update references to the add stub and count function
accordingly so queue-depth assertions reflect unique queued jobIds rather than
stub invocations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 12a725b6-6ae8-45a6-8b12-d7d7e33b96b1

📥 Commits

Reviewing files that changed from the base of the PR and between 9eb6800 and 3db0061.

📒 Files selected for processing (4)
  • packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts
  • packages/backend/server/src/__tests__/mocks/queue.mock.ts
  • packages/backend/server/src/core/workspaces/resolvers/member.ts
  • packages/backend/server/src/core/workspaces/service.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/backend/server/src/core/workspaces/resolvers/member.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/backend/server/src/tests/e2e/workspace/member.spec.ts

Comment on lines +44 to +47
remove = Sinon.stub().callsFake(async (jobId: string) => {
this.jobs.delete(jobId);
return undefined;
});
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.

⚠️ Potential issue | 🟠 Major

Return a boolean from remove() to match service gating.

remove() always resolves undefined, but WorkspaceService.sendInvitationNotification uses its truthiness to decide whether re-enqueue is allowed. In tests, that makes the replace-existing-job path fail even when deletion succeeded.

Suggested fix
  remove = Sinon.stub().callsFake(async (jobId: string) => {
-   this.jobs.delete(jobId);
-   return undefined;
+   return this.jobs.delete(jobId);
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/__tests__/mocks/queue.mock.ts` around lines 44 -
47, The stubbed remove() currently always resolves undefined which breaks
WorkspaceService.sendInvitationNotification's truthiness check; update the
Sinon.stub for remove to return the boolean result of this.jobs.delete(jobId)
(or Boolean(...) around it) instead of undefined so tests reflect
success/failure correctly—locate the remove = Sinon.stub().callsFake(async
(jobId: string) => { ... }) in the mock and change its return to the deletion
result.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx (1)

110-115: Remove unused interpolation arg in cooldown message payload.

second is passed here, but com.affine.payment.member.team.resendInvite.cooldown.notify.message currently only uses {{name}}. Keeping only used fields avoids confusion.

✂️ Optional cleanup
             message: t.t(
               'com.affine.payment.member.team.resendInvite.cooldown.notify.message',
               {
                 name: inviteeName,
-                second: '',
               }
             ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx`
around lines 110 - 115, Remove the unused interpolation arg "second" from the
i18n payload in the t.t call inside member-option.tsx: locate the message
construction where
t.t('com.affine.payment.member.team.resendInvite.cooldown.notify.message', {
name: inviteeName, second: '' }) and delete the "second" property so the payload
only passes { name: inviteeName }, keeping the rest of the call and variable
names (t.t and inviteeName) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx`:
- Around line 110-115: Remove the unused interpolation arg "second" from the
i18n payload in the t.t call inside member-option.tsx: locate the message
construction where
t.t('com.affine.payment.member.team.resendInvite.cooldown.notify.message', {
name: inviteeName, second: '' }) and delete the "second" property so the payload
only passes { name: inviteeName }, keeping the rest of the call and variable
names (t.t and inviteeName) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c2640604-c67c-4ef4-9d50-c4ce78e440b3

📥 Commits

Reviewing files that changed from the base of the PR and between 3db0061 and 5594628.

📒 Files selected for processing (3)
  • packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx
  • packages/frontend/i18n/src/i18n.gen.ts
  • packages/frontend/i18n/src/resources/en.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/frontend/i18n/src/i18n.gen.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx`:
- Around line 110-116: The notification currently passes an empty string for the
`second` interpolation (t.t(..., { name: inviteeName, second: '' })) which
yields awkward text; update the `message` construction in member-option.tsx to
either remove the `second` interpolation from the i18n call or supply a real
cooldown value from the invite/cooldown data (e.g., use the backend cooldown
field or compute a fallback like "0" when available) so `t.t` receives a
meaningful `second` value instead of ''. Ensure you update the call site where
`t.t` is invoked and adjust the i18n key or data source accordingly (refer to
`inviteeName`, the `second` param, and the `t.t` invocation).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9c4876e-915b-4eb5-9365-2553d4dff099

📥 Commits

Reviewing files that changed from the base of the PR and between 3db0061 and 5594628.

📒 Files selected for processing (3)
  • packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx
  • packages/frontend/i18n/src/i18n.gen.ts
  • packages/frontend/i18n/src/resources/en.json
✅ Files skipped from review due to trivial changes (1)
  • packages/frontend/i18n/src/resources/en.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/frontend/i18n/src/i18n.gen.ts

Comment on lines +110 to +116
message: t.t(
'com.affine.payment.member.team.resendInvite.cooldown.notify.message',
{
name: inviteeName,
second: '',
}
),
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.

⚠️ Potential issue | 🟡 Minor

Empty second parameter may produce confusing notification text.

The second: '' parameter is being passed as an empty string to the cooldown notification message. If the i18n string includes a placeholder like "{name} will be resent in {second} seconds", users will see awkward text with a missing value.

Consider either:

  1. Removing the {second} placeholder from the i18n string if the backend doesn't return cooldown duration
  2. Passing an actual cooldown value if the backend provides it
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx`
around lines 110 - 116, The notification currently passes an empty string for
the `second` interpolation (t.t(..., { name: inviteeName, second: '' })) which
yields awkward text; update the `message` construction in member-option.tsx to
either remove the `second` interpolation from the i18n call or supply a real
cooldown value from the invite/cooldown data (e.g., use the backend cooldown
field or compute a fallback like "0" when available) so `t.t` receives a
meaningful `second` value instead of ''. Ensure you update the call site where
`t.t` is invoked and adjust the i18n key or data source accordingly (refer to
`inviteeName`, the `second` param, and the `t.t` invocation).

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

Labels

app:core app:server mod:i18n Related to i18n test Related to test cases

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants