Skip to content

feat(spaces): support email invites#3067

Open
vseehausen wants to merge 12 commits into
mainfrom
wa-2022-email-invite-creation-endpoint
Open

feat(spaces): support email invites#3067
vseehausen wants to merge 12 commits into
mainfrom
wa-2022-email-invite-creation-endpoint

Conversation

@vseehausen
Copy link
Copy Markdown
Member

@vseehausen vseehausen commented May 4, 2026

Resolves: https://linear.app/safe-global/issue/WA-2022/email-invite-creation-endpoint

This PR adds email-based space invite creation alongside the existing wallet-address invite flow, so admins can invite people before they have a wallet connected.

Summary

  • allow invite requests to contain either a wallet address or an email address
  • create or reuse pending users for email invitees inside the invite transaction
  • store invite expiration on member records and return it with invitation/member responses
  • expose invitee email only to admins and the invited user
  • keep pending invite emails hidden from regular member-list responses
  • add focused service, repository, and e2e coverage for email invites and invite visibility

Notes

  • Existing wallet-address invites remain supported.
  • This PR creates the invite record only; email delivery is handled separately.

@vseehausen vseehausen requested a review from a team as a code owner May 4, 2026 17:09
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@vseehausen vseehausen marked this pull request as draft May 5, 2026 08:12
@vseehausen vseehausen force-pushed the wa-2022-email-invite-creation-endpoint branch 6 times, most recently from 67cb6d2 to 382a4b5 Compare May 5, 2026 16:18
.expect(409)
.expect({
message: `${memberAddress} is already in this space or has a pending invite.`,
message: 'User is already in this space or has a pending invite.',
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed this to exclude addresses and emails from logs.

@vseehausen vseehausen force-pushed the wa-2022-email-invite-creation-endpoint branch from 382a4b5 to 0189e58 Compare May 5, 2026 16:32
email: string,
userRepository: Repository<DbUser>,
): Promise<User['id'] | null> {
const normalizedEmail = email.trim().toLowerCase();
Copy link
Copy Markdown
Member Author

@vseehausen vseehausen May 5, 2026

Choose a reason for hiding this comment

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

We trim the email twice here, but I like this better than renaming the email parameter to normalizedEmail.

@vseehausen vseehausen force-pushed the wa-2022-email-invite-creation-endpoint branch from 0189e58 to aef54ad Compare May 5, 2026 16:36
@vseehausen vseehausen force-pushed the wa-2022-email-invite-creation-endpoint branch from aef54ad to 1b0aa03 Compare May 5, 2026 16:49
@vseehausen vseehausen marked this pull request as ready for review May 5, 2026 16:56
@LucieFaire
Copy link
Copy Markdown
Contributor

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 6, 2026

Claude finished @LucieFaire's task —— View job


Code Review: feat(spaces): support email invites

Overall a solid PR with good test coverage and clear separation of concerns. Below are the items worth addressing:


Bugs / Correctness

  1. Missing resendInvite in test module mocksrc/modules/users/__tests__/test.users.module.ts:33-49: The IMembersRepository mock doesn't include resendInvite. Any e2e test that exercises the resend endpoint through this module will fail at runtime with "not a function".

  2. Migration uses integer status values that may driftmigrations/1777000000000-add-member-invite-expires-at.ts:14: The backfill uses WHERE "status" IN (0, 2) (raw integers). These correspond to INVITED and DECLINED in the current MemberStatus enum, but if enum values are ever reordered, this migration becomes silently wrong. A comment clarifying the mapping would help future maintainers.

  3. superRefine error path is always ['address']src/modules/spaces/routes/entities/invite-users.dto.entity.ts:28: When a user provides both address and email (or neither), the validation error path is hardcoded to ['address']. If only email is supplied and address is missing, pointing the error at address is misleading. Consider using ['address', 'email'] or a root-level path.

  4. Transaction atomicity concernsrc/modules/users/domain/members.repository.ts:143-185: The inviteUsers loop inserts each member individually inside a single transaction. If one insert fails (e.g., unique constraint), the entire batch is rolled back — including already-processed invites. For a multi-invite request this may not be the desired UX. Consider per-invite savepoints or documenting this as intentional all-or-nothing behavior.


Security

  1. Resend endpoint exposes user IDs in URLsrc/modules/spaces/routes/members.controller.ts:221: POST /:spaceId/members/:userId/resend takes a userId param. For email-invited users whose userId is deliberately hidden from the invite response (line 181 of members.repository.ts), the admin would need to discover the userId from the members list. This is consistent, but worth confirming the admin-only guard is sufficient — currently it checks admin status correctly via findActiveAdminsOrFail.

  2. No rate limiting on resend endpoint — The new resend endpoint resets expiration and could be used to spam invitation resets. Consider whether rate limiting or cooldown logic is needed.


Performance

  1. Migration on large tablesmigrations/1777000000000-add-member-invite-expires-at.ts:13-15: The UPDATE statement has no batching. On a large members table this could lock rows for an extended period. Consider batching (e.g., UPDATE ... WHERE id IN (SELECT id ... LIMIT N)) if the table is expected to be large.

Code Quality

  1. getSelfMembership doesn't apply email visibilitysrc/modules/spaces/routes/members.service.ts:114-122: The get method applies toMemberDto to control email visibility, but getSelfMembership returns the raw member from the repository without the same DTO transformation. This is intentional (the user always sees their own email), but the inconsistency between the two code paths could confuse maintainers. A brief inline note or using toMemberDto with includeInvitedEmail: true would make the intent explicit.

  2. findOrCreateInviteeByEmail mock inconsistencysrc/modules/users/__tests__/test.users.module.ts:23: The findOrCreateInviteeByEmail mock uses jest.fn() while neighboring mocks like findOrCreateByWalletAddress use plain functions to survive jest.resetAllMocks(). This inconsistency could cause flaky tests when resetAllMocks is called.


@LucieFaire
Copy link
Copy Markdown
Contributor

somehow i cannot comment on files, so here is my review:

  1. src/modules/spaces/routes/entities/invite-users.dto.entity.ts -> InviteUserSchema
    we can avoid using refine and have union schema:
const SharedInviteFields = {
  role: z.enum(getStringEnumKeys(MemberRole)),
  name: NameSchema,
};

const InviteUserSchema = z.union([
  z.object({ address: AddressSchema, ...SharedInviteFields }).strict(),
  z.object({ email: z.email().max(255), ...SharedInviteFields }).strict(),
]);

the only caveat you have to drop implements z.infer<typeof InviteUsersDtoSchema> from InviteUsersDto L59

  1. src/modules/spaces/routes/members.controller.ts -> nit
    I think the description lost some staff: description: 'List of wallet addresses or email addresses to invite to the space'

  2. src/modules/spaces/routes/members.service.spec.ts

const INVITE_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
const INVITE_CREATED_AT = new Date('2026-04-24T00:00:00.000Z');
const EXPECTED_INVITE_EXPIRES_AT = new Date(
  INVITE_CREATED_AT.getTime() + INVITE_EXPIRY_SECONDS * 1_000,
);

lets use faker and then just increment whatever values it provides

  1. src/modules/spaces/routes/members.service.spec.ts
type MembersRepositoryMock = Pick<
  jest.Mocked<IMembersRepositoryInterface>,
  | 'inviteUsers'
  | 'resendInvite'
  | 'findAuthorizedMembersOrFail'
  | 'findSelfMembershipOrFail'
>;

const membersRepositoryMock: MembersRepositoryMock = {
  inviteUsers: jest.fn(),
  resendInvite: jest.fn(),
  findAuthorizedMembersOrFail: jest.fn(),
  findSelfMembershipOrFail: jest.fn(),
};

dont know why do we need the type here if we could just simply put:

const membersRepositoryMock = {
  inviteUsers: jest.fn(),
  resendInvite: jest.fn(),
  findAuthorizedMembersOrFail: jest.fn(),
  findSelfMembershipOrFail: jest.fn(),
} as jest.MockedObjectDeep<IMembersRepository>;

describe('inviteUser', () => and describe('resendInvite', () => test block we could use before/after instead of try/finally

  1. src/modules/spaces/routes/spaces.service.ts
    -> revert change on L71-78 as it can stay an implicit return

  2. src/modules/users/tests/test.users.module.ts
    nit: findOrCreateInviteeByEmail: jest.fn().mockResolvedValue(1), can just be findOrCreateInviteeByEmail: jest.fn(), because that mockReturn will be reset anyway

  3. src/modules/users/domain/members.repository.integration.spec.ts
    nit: lets use faker for INVITE_EXPIRES_AT

The rest will come when GH fixes the issue of commenting on files

@vseehausen
Copy link
Copy Markdown
Member Author

Addressed Claude's comments in 81aa055

And @LucieFaire's in 27bcf9d

@vseehausen
Copy link
Copy Markdown
Member Author

@claude review again

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @vseehausen's task —— View job


Code Review: feat(spaces): support email invites (re-review)

Previous feedback from Claude and @LucieFaire has been well addressed — the schema now uses z.union, resendInvite is in the test module mock, the migration has a comment, and the resend endpoint has rate limiting. A few remaining items:


Bugs / Correctness

  1. resendInvite passes undefined for missing identifiersrc/modules/spaces/routes/members.service.ts:62-63: When resendInviteDto has only email, args.resendInviteDto.address is undefined (and vice versa). The repository method at members.repository.ts:325-338 correctly falls through the if/else if chain, but the values flow as explicit undefined keys in the call. This works today, but the interface at members.repository.interface.ts:53-59 types both as optional — consider passing only the defined key to avoid silent issues if the repository logic changes.

  2. getSelfMembership skips toMemberDto transformationsrc/modules/spaces/routes/members.service.ts:120-126: The get method filters email visibility through toMemberDto, but getSelfMembership returns the raw repository result. The inline comment explains this is intentional (self-view), but the returned object shape differs — get returns MemberDto with controlled email visibility, while getSelfMembership returns the full Member domain entity (including space relation). This leaks more data than the MemberDto type declaration suggests. Consider applying toMemberDto(member, { includeInvitedEmail: true }) for consistency.

Security

  1. Email enumeration via resend timingsrc/modules/users/domain/members.repository.ts:325-345: The resendInvite method does a subquery SELECT id FROM users WHERE email = :email. If the email doesn't correspond to any invited member, it throws NotFoundException. An attacker could use timing differences between "email exists but no invite" vs "email doesn't exist" to probe for registered emails. The rate limit guard mitigates this, but constant-time responses would be stronger.

Code Quality

  1. INVITE_EXPIRES_AT hardcoded in unit testsrc/modules/users/domain/members.repository.spec.ts:18: This still uses a hardcoded date new Date('2026-05-01T00:00:00.000Z') rather than faker.date.future(). The integration test (members.repository.integration.spec.ts:39) now correctly uses faker. Minor inconsistency.

  2. inviteExpiresAt not cleared on declinesrc/modules/users/domain/members.repository.ts:292-296: When an invite is declined, the status is updated to DECLINED but inviteExpiresAt is retained. On resend (resendInvite), the expiration is reset. This is functionally fine since the resend explicitly sets a new expiration, but leaving a stale expiration on a declined invite could be confusing for queries or debugging.


Overall the changes look solid. The union schema, rate limiting, and race condition handling in findOrCreateInviteeByEmail / linkPendingInviteeByVerifiedEmail are well done. Good test coverage across unit, integration, and e2e layers.

@safe-global safe-global deleted a comment from popcorn-review Bot May 7, 2026
@vseehausen
Copy link
Copy Markdown
Member Author

1, 2, 4, 5: addressed in 2431f43.

3: Ignored. The risk is minimal, as the endpoint is gated to admins of the space and looks for invites of this space. The user exist as a stub for invites. There is no risk of enumerating through registered emails.

@claude review again.

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @vseehausen's task —— View job


Code Review: feat(spaces): support email invites (third review)

Previous feedback has been well addressed. The code is in good shape. A few minor items remain:


Code Quality

  1. getSelfMembership redacts email for invited selfsrc/modules/spaces/routes/members.service.ts:120-127: getSelfMembership now applies toMemberDto (good), but it doesn't pass includeInvitedEmail: true. This means an invited user viewing their own membership via GET /:spaceId/membership will see email: null for themselves. The test at members.service.spec.ts:405-433 explicitly asserts this ("redacts email for invited self memberships"). Is this intentional? An email-invited user who hasn't accepted yet may reasonably expect to see their own invite email when checking their membership. Worth confirming the product intent.

  2. members.repository.spec.ts uses try/finally for fake timers in should reject expired invitessrc/modules/users/domain/members.repository.spec.ts:290-317: The other test files (members.service.spec.ts) now use beforeEach/afterEach for fake timer management per @LucieFaire's feedback. This test still uses try/finally. Minor inconsistency.

  3. resendInvite interface allows both address and email to be undefinedsrc/modules/users/domain/members.repository.interface.ts:53-59: The interface types both address and email as optional, but the runtime requires exactly one. Consider a discriminated union type (e.g. { address: Address; email?: never } | { email: string; address?: never }) to make this constraint visible at the type level.


LGTM overall. The union schema, rate limiting, race condition handling, and email visibility control are all solid.

@vseehausen
Copy link
Copy Markdown
Member Author

@claude review again.

  1. accepted.
    2,3: addressed.

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @vseehausen's task —— View job


Code Review: feat(spaces): support email invites (fourth review)

Previous feedback has been addressed well. The discriminated union on resendInvite interface, try/finallybeforeEach/afterEach cleanup, and toMemberDto on getSelfMembership are all in good shape. A few remaining items:


Code Quality

  1. ResendInviteDtoSchema still uses superRefine instead of unionsrc/modules/spaces/routes/entities/resend-invite.dto.entity.ts:7-21: The InviteUserSchema was refactored to z.union per @LucieFaire's feedback, but ResendInviteDtoSchema still uses .superRefine for the same "exactly one of address or email" constraint. Consider applying the same union pattern here for consistency:

    const ResendInviteDtoSchema = z.union([
      z.object({ address: AddressSchema }).strict(),
      z.object({ email: z.email().max(255) }).strict(),
    ]);
  2. Defensive BadRequestException in resendInvite service is unreachablesrc/modules/spaces/routes/members.service.ts:77-79: The throw new BadRequestException('Exactly one of address or email is required.') at the end of resendInvite can never be reached because the DTO schema already validates that exactly one of address/email is present. Dead code — consider removing it or adding a comment explaining it's a defensive guard.


LGTM. Clean implementation with good test coverage and well-structured email visibility controls.

@vseehausen
Copy link
Copy Markdown
Member Author

@claude review again

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @vseehausen's task —— View job


Code Review: feat(spaces): support email invites (fifth review)

Previous feedback has been well addressed across all rounds. The ResendInviteDtoSchema now uses z.union, the discriminated union type is on the interface, beforeEach/afterEach is used consistently, and getSelfMembership applies toMemberDto. The code is clean and ready to merge.


Code Quality

  1. resendInvite has an unreachable else branchsrc/modules/users/domain/members.repository.ts:332-336: The resendInvite method still has a final else { throw new BadRequestException(...) } branch. Since the DTO schema (ResendInviteDtoSchema at resend-invite.dto.entity.ts:7-10) is a z.union that guarantees exactly one of address or email, this branch is dead code. If kept as a defensive guard, the interface's discriminated union type (AddressInvitationIdentifier | EmailInvitationIdentifier at members.repository.interface.ts:62) already makes it type-impossible to reach. Consider removing it or adding a type assertion (args satisfies never) to make the defensive intent explicit at the type level.

  2. InviteUserDto class doesn't enforce mutual exclusivity at the type levelsrc/modules/spaces/routes/entities/invite-users.dto.entity.ts:30-48: The Zod schema (InviteUserSchema) correctly enforces that exactly one of address or email is present via z.union. But the InviteUserDto Swagger class has both as @ApiPropertyOptional, so the generated API docs suggest both can be provided simultaneously (or neither). Consider splitting into two Swagger request body examples or adding a description clarifying the mutual exclusivity.


LGTM overall. No bugs or security issues found. The implementation is solid with good test coverage and proper handling of race conditions, email visibility, and rate limiting.

@vseehausen
Copy link
Copy Markdown
Member Author

@claude review again

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @vseehausen's task —— View job


Code Review: feat(spaces): support email invites (sixth review)

All prior feedback has been addressed well. The code is clean and nearly ready to merge. One remaining item:


Security

  1. getActiveOrInvitedSpaces leaks email-invited members to non-adminssrc/modules/spaces/routes/spaces.service.ts:71-76: The getActiveOrInvitedSpaces method returns space.members directly without filtering by admin status or applying toMemberDto. This means the GET /spaces (list spaces) and GET /spaces/:id endpoints expose all members (including INVITED email-stub users) to any authenticated member of the space, bypassing the visibility controls carefully implemented in MembersService.get() (which filters out invited members for non-admins and redacts emails). The GetSpaceResponse DTO at get-space.dto.entity.ts:18-39 also includes inviteExpiresAt and user info. Consider applying the same member-visibility filtering here, or documenting why these endpoints intentionally have different visibility rules.

LGTM otherwise. The union schemas, discriminated union types, rate limiting, race condition handling, and email visibility in the members endpoints are all solid.

@vseehausen
Copy link
Copy Markdown
Member Author

@claude review again

@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @vseehausen's task —— View job


Code Review: feat(spaces): support email invites (seventh review)

All prior feedback across six rounds has been well addressed. The code is clean and in good shape. One remaining item:


Code Quality

  1. getVisibleMembersForUser doesn't redact invite emailssrc/modules/spaces/routes/spaces.service.ts:79-94: The getActiveOrInvitedSpaces method now correctly filters out invited members for non-admins via getVisibleMembersForUser. However, for admins, the invited members are returned with their full user object (including email) without applying the toMemberDto email-redaction logic used in MembersService.get(). Since getActiveOrInvitedSpaces selects user: { id: true } (line 64), the email is likely not loaded from the DB in practice. But the select doesn't explicitly exclude email, so whether it leaks depends on TypeORM's behavior with partial selects — if the user relation loads all columns despite the select, invite emails would be exposed in the GET /spaces response without the controlled visibility applied on the GET /:spaceId/members endpoint. Consider explicitly confirming that the select on user only returns { id }, or applying email redaction for safety.

LGTM overall. The implementation is solid across all layers — union schemas, discriminated union types, rate limiting, race condition handling, email visibility controls, and comprehensive test coverage are all well done. Ready to merge.

View job

@vseehausen
Copy link
Copy Markdown
Member Author

Claude finished @vseehausen's task —— View job

Code Review: feat(spaces): support email invites (seventh review)

All prior feedback across six rounds has been well addressed. The code is clean and in good shape. One remaining item:

Code Quality

1. **`getVisibleMembersForUser` doesn't redact invite emails** — `src/modules/spaces/routes/spaces.service.ts:79-94`: The `getActiveOrInvitedSpaces` method now correctly filters out invited members for non-admins via `getVisibleMembersForUser`. However, for admins, the invited members are returned with their full `user` object (including `email`) without applying the `toMemberDto` email-redaction logic used in `MembersService.get()`. Since `getActiveOrInvitedSpaces` selects `user: { id: true }` (line 64), the email is likely not loaded from the DB in practice. But the `select` doesn't explicitly exclude `email`, so whether it leaks depends on TypeORM's behavior with partial selects — if the `user` relation loads all columns despite the `select`, invite emails would be exposed in the `GET /spaces` response without the controlled visibility applied on the `GET /:spaceId/members` endpoint. Consider explicitly confirming that the `select` on `user` only returns `{ id }`, or applying email redaction for safety.

LGTM overall. The implementation is solid across all layers — union schemas, discriminated union types, rate limiting, race condition handling, email visibility controls, and comprehensive test coverage are all well done. Ready to merge.

View job

That is by designs. Admins are allowed to view invitees' emails.

vseehausen and others added 2 commits May 7, 2026 18:34
Push the resend-invite endpoint's negative paths down to the lowest
layer that can prove them: ForbiddenException (non-admin caller) and
NotFoundException (no matching invitation row) on the repository
unit spec, and the union/strict zod rules (both fields, neither,
malformed, oversize) on a new ResendInviteDto schema spec. Keeps e2e
to a single happy-path wiring smoke per the test-pyramid principle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@LucieFaire LucieFaire left a comment

Choose a reason for hiding this comment

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

I could not review it all, because there is a lot going on. In the future i would suggest to split big tickets into subtasks and tackle each topic separately, that way we can review it much quicker and less places to miss something

Comment on lines +7 to +21
type InvitationIdentifier = {
address?: Address;
email?: string;
};

export type AddressInvitationIdentifier = InvitationIdentifier &
Required<Pick<InvitationIdentifier, 'address'>> & {
email?: never;
};

export type EmailInvitationIdentifier = InvitationIdentifier &
Required<Pick<InvitationIdentifier, 'email'>> & {
address?: never;
};

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.

The InvitationIdentifier base + Required<Pick<…>> indirection is doing nothing — the & { email?: never } clause already overrides the optional field. We can simplify it:

export type InvitationIdentifier =
  | { address: Address; email?: never }
  | { email: string; address?: never };

authPayload: AuthPayload;
spaceId: Space['id'];
inviteExpiresAt: Member['inviteExpiresAt'];
} & (AddressInvitationIdentifier | EmailInvitationIdentifier),
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.

& InvitationIdentifier,

if (isUniqueConstraintError(err)) {
throw new UniqueConstraintError(
`${userToInvite.address} is already in this space or has a pending invite.`,
'User is already in this space or has a pending invite.',
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.

wonder if we should add some identifier to know which user it is? Does wallet parse this error for the notification?

Comment on lines +171 to +184
const invitation: Invitation = {
userId: userIdToInvite,
spaceId: space.id,
name: userToInvite.name,
role: userToInvite.role,
status: 'INVITED',
invitedBy,
});
inviteExpiresAt: args.inviteExpiresAt,
};
if (userToInvite.email) {
// Avoid exposing internal user IDs for email-based invite probes.
invitation.userId = undefined;
}
invitations.push(invitation);
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.

can be inlined:

 invitations.push({
          // Avoid exposing internal user IDs for email-based invite probes.
          userId: userToInvite.email ? undefined : userIdToInvite,
          spaceId: space.id,
          name: userToInvite.name,
          role: userToInvite.role,
          status: 'INVITED',
          invitedBy,
          inviteExpiresAt: args.inviteExpiresAt,
        });

await this.postgresDatabaseService.transaction(async (entityManager) => {
await entityManager.update(DbMember, member.id, {
status: 'DECLINED',
inviteExpiresAt: null,
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.

Its inconsistent with the migration. The method explicitly sets inviteExpiresAt: null but the migration backfills DECLINED rows (status 2) with created_at + 7 days, violating that invariant for historical data.

The migration's WHERE "status" IN (0, 2) should be WHERE "status" = 0 — only INVITED rows need backfilling. (And as a bonus, that drops the dependency on the DECLINED enum value being 2.)

}

public async resendInvite(
args: Parameters<IMembersRepository['resendInvite']>[0],
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.

could we extend it to actual parameters as we dont really use this notation

Comment on lines +303 to +306
const actingUserId = getAuthenticatedUserIdOrFail(args.authPayload);
const activeAdmins = await this.findActiveAdminsOrFail(args.spaceId);

this.assertIsActiveAdmin({ members: activeAdmins, userId: actingUserId });
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.

Fetching every admin and scanning in memory is wasteful — a single targeted findOne for the caller would be equivalent and lighter, matching the inviteUsers pattern.

})
.where('space_id = :spaceId', { spaceId: args.spaceId })
.andWhere('status IN (:...statuses)', {
statuses: [MemberStatus.INVITED, MemberStatus.DECLINED],
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.

why would you want to resend an invitation if it was declined already?

Comment on lines +322 to +332
if (args.email !== undefined) {
queryBuilder.andWhere(
'user_id IN (SELECT id FROM users WHERE email = :email)',
{ email: args.email.trim().toLowerCase() },
);
} else {
queryBuilder.andWhere(
'user_id IN (SELECT user_id FROM wallets WHERE address = :address)',
{ address: getAddress(args.address) },
);
}
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.

why dont we just fetch the existing member by its id and retrigger? we know that invitation is already present, so we dont need to look by diff criteria. I would propose to update the API to POST /:spaceId/members/:memberId/resend and use id to fetch and update, that will simplify this query

Comment on lines +109 to +115
const isAdmin = members.some((member) => {
return (
member.user.id === userId &&
member.status === 'ACTIVE' &&
member.role === 'ADMIN'
);
});
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.

findAuthorizedMembersOrFail already fetches the caller's row inside findActiveOrInvitedMemberOrFail for the authz check, then discards it. We then re-scan the bulk list here to recompute isAdmin, which is the exact info that row already had.

Could we maybe surface the caller's row from the repository and read isAdmin directly off it instead of .some()-ing the list? Or maybe you have other ideas?

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.

2 participants