Skip to main content

Team Member Management

Manage your company's team members through a secure invitation system with role assignment, token-based acceptance, and resend capabilities.

Invitation Lifecycle

Team member invitations follow a defined status lifecycle. Each invitation generates a unique token sent via email; resending generates a fresh token and resets the status to PENDING.

Accepted Invitations Cannot Be Resent

Once an invitation reaches ACCEPTED status, the resend mutation returns a BadRequestException. The user is already a company member at that point.

Re-inviting After Removal

A previously accepted member can be invited again after being removed. The duplicate-invitation pre-check on inviteUserToCompany is scoped to status: 'PENDING' only, so historical ACCEPTED / CANCELLED / REJECTED rows never block a fresh invite. Removing a member also cascades the linked invitation to CANCELLED inside a $transaction so the roster stays clean. See SUP-00063 below.

Roles & Permissions

Who Can Invite and Resend

The same roles that can send new invitations can also resend them. Authorization is enforced by @HasRole in the company resolver and service-level checks:

RoleTierCan InviteCan ResendCan RemoveCan Transfer Ownership
Super AdminAdminYesYesYesYes (override)
Company AdminAdminYesYesYesOnly if billingOwnerId
Internal SalesMemberYesYesNoNo
Recruiter/HRMemberNoNoNoNo
External SalesViewerNoNoNoNo
EconomyViewerNoNoNoNo
Auditor/ViewerViewerNoNoNoNo
supportSpecialNoNoNoNo
Removing the billing-owner UCR is blocked

The removeUserCompanyRole resolver refuses to delete the UCR that anchors Company.billingOwnerId, regardless of who calls it (only a super admin can override). This protects subscription/plan resolution — see Company Ownership Transfer below for the supported handoff path.

See Roles & Permissions for the full role-permission matrix and authorization details.

No Plan Limit on Resend

Resending does not consume an additional maxUsersPerCompany plan limit slot. The invitation already exists — resend simply refreshes the token and re-sends the email.

Resend Feature

What Happens on Resend

  1. New token generated — The old acceptance link becomes invalid immediately
  2. Status reset to PENDING — Regardless of whether it was PENDING, REJECTED, or CANCELLED
  3. Email re-sent — The invitee receives a fresh invitation email with the new link
  4. Any admin can resend — Not restricted to the original inviter; any authorized role in the company can resend

Use Cases

  • Reminder — Invitation still PENDING after several days, recipient may have missed the email
  • Re-attempt after rejection — Recipient previously declined but has changed their mind
  • Re-attempt after cancellation — Admin cancelled the invitation but now wants to re-invite without deleting and recreating

GraphQL API

Send New Invitation

Creates a new invitation record, generates a token, and sends the invitation email.

mutation InviteToCompany($input: InviteToCompanyInput!) {
inviteToCompany(input: $input) {
success
message
}
}

Variables:

{
"input": {
"email": "newmember@example.com",
"companyId": "7659fee2-6b88-4fdf-b2f5-7495c764c8fb",
"roleId": "role-uuid-here"
}
}

Response:

{
"data": {
"inviteToCompany": {
"success": true,
"message": "Invitation sent"
}
}
}
Guards

This mutation uses JwtAuthGuard, RoleGuard, and PlanLimitGuard (maxUsersPerCompany). The caller must be authenticated, have an authorized role, and the company must not have exceeded its plan limit.

Duplicate Pre-Check Is PENDING-only

Before creating the row, the service runs invitation.findFirst({ where: { email, companyId, status: 'PENDING' } }). Any match throws BadRequestException('User has already been invited to this company.'). Historical ACCEPTED / CANCELLED / REJECTED rows are ignored — so re-inviting a previously-removed member works without needing to delete old invitation records. (Regression fix for SUP-00063.)

Resend Invitation

Generates a new token, resets the status to PENDING, and re-sends the invitation email. Works on PENDING, REJECTED, and CANCELLED invitations.

mutation ResendTeamMemberInvitation($input: ResendTeamMemberInvitationInput!) {
resendTeamMemberInvitation(input: $input) {
success
message
}
}

Variables:

{
"input": {
"invitationId": "invitation-uuid-here"
}
}

Response (success):

{
"data": {
"resendTeamMemberInvitation": {
"success": true,
"message": "Invitation resent successfully"
}
}
}

Response (error — already accepted):

{
"errors": [
{
"message": "Cannot resend an invitation that has already been accepted.",
"extensions": {
"code": "BAD_REQUEST",
"statusCode": 400
}
}
]
}

Response (error — unauthorized role):

{
"errors": [
{
"message": "You do not have permission to resend invitations. Required role: Company Admin, Super Admin, or Internal Sales. Your role: Recruiter/HR",
"extensions": {
"code": "BAD_REQUEST",
"statusCode": 400
}
}
]
}
Authorization

This mutation uses JwtAuthGuard only (no RoleGuard or PlanLimitGuard). The companyId is not in the input — it is resolved from the invitation record. Role authorization is validated inside the service by checking the caller's UserCompanyRole for the invitation's company.

Remove Invitation

Permanently deletes a pending invitation record (used when an admin wants to rescind an invite that hasn't been accepted yet).

Removing an Accepted Member

For a member who already accepted the invitation, use removeUserCompanyRole(id) — that mutation hard-deletes the UserCompanyRole and, in the same $transaction, flips the linked Invitation.status to CANCELLED. This keeps historical audit trail while freeing the (email, companyId) pair for a fresh invite. See Re-invite after removal.

mutation RemoveInvitation($inviteId: String!) {
removeInvitation(inviteId: $inviteId) {
success
message
}
}

Variables:

{
"input": {
"inviteId": "invitation-uuid-here"
}
}

Response:

{
"data": {
"removeInvitation": {
"success": true,
"message": "Invitation removed successfully"
}
}
}

Query Invitations

Invitations are available as a field resolver on the Company type. The resolver filters to status: 'PENDING'ACCEPTED / CANCELLED / REJECTED rows are excluded so the field can be rendered directly as the team's pending-invitations list without extra client-side filtering:

query CompanyInvitations($id: ID!) {
company(id: $id) {
id
companyName
invitations {
id
email
status
createdAt
updatedAt
role {
id
name
}
invitedBy {
firstName
lastName
email
}
}
}
}

Response:

{
"data": {
"company": {
"id": "7659fee2-6b88-4fdf-b2f5-7495c764c8fb",
"companyName": "AIQLick",
"invitations": [
{
"id": "inv-abc-123",
"email": "newmember@example.com",
"status": "PENDING",
"createdAt": "2026-03-10T10:00:00.000Z",
"updatedAt": "2026-03-10T12:30:00.000Z",
"role": {
"id": "role-uuid",
"name": "Recruiter/HR"
},
"invitedBy": {
"firstName": "Reza",
"lastName": "Zeraat",
"email": "rezdev97@gmail.com"
}
}
]
}
}
}

Security

Token Rotation

Every resend generates a new cryptographically random token (crypto.randomBytes(32)). The previous token is overwritten in the database — old acceptance links become immediately invalid. This prevents:

  • Replay attacks using leaked/expired links
  • Multiple people accepting the same invitation
  • Stale tokens remaining valid after an admin resends

Invitation emails contain a link to the frontend message flow:

https://www.aiqlick.com/message/accept?type=team-member&token={token}&invitedEmail={email}
ParameterDescription
token64-character hex string (32 random bytes)
typeAlways team-member for team invitations
invitedEmailURL-encoded email of the invitee
Backward Compatibility

Old invitation links using /auth/accept?token=... are automatically redirected to the new /message/accept flow. Already-delivered emails continue to work.

Acceptance Flow

The email link is the entry point; the UI does the rest. New users complete account creation in one step (name + password) and land directly in the team workspace. The mutation is public (no JWT required) and synchronous — all database writes complete inside a single prisma.$transaction before the response returns.

One-step team-member acceptance (new user)

Already-logged-in fast path

When the invitee is authenticated (or checkUserExists returns true), the original acceptInvitation mutation is still used. It accepts the invite and refreshes user state, then routes the user to /welcome/team-member. No password entry is shown.

GraphQL — one-step new-user accept

mutation AcceptTeamInviteWithPassword($input: AcceptInvitationWithPasswordInput!) {
acceptInvitationWithPassword(input: $input) {
message
token
}
}

Variables:

{
"input": {
"token": "64-char-hex-from-email",
"fullName": "Liridona Hoxha",
"password": "Strong1Pass",
"confirmPassword": "Strong1Pass"
}
}

The returned token is a signed JWT (sub, email) intended for first-login. The frontend calls login(token) and skips the manual sign-in step. Existing users receive the standard acceptInvitation(input: { token }) mutation instead.

Key behaviors:

  • Transaction safety — All database writes (user creation, role assignment, status update, invitation flip) are wrapped in prisma.$transaction. Any failure rolls everything back.
  • selectedCompanyId set on the new user — The new account is pre-pointed at the invited company, so AuthGuard doesn't redirect to /onboarding/company (which is the owner-creation wizard, not the team-member experience).
  • Password rules enforced at the service — Length ≥ 8 plus at least one uppercase, lowercase, and digit, even if GraphQL DTO validation is bypassed. Passwords are stored as bcrypt hashes.
  • No second password-setup email — The previous flow created the user with a random password and sent a sendPasswordSetupEmail after the transaction committed. The new flow takes the password up front, so no second email is sent and there is no manual /auth/setpassword?context=invite step.
  • Existing-account guard — If findFirst({ email, mode: 'insensitive' }) returns a row, the mutation returns 400 An account already exists for this email. Please sign in to accept the invitation. and the frontend stays on the sign-in path.

Resend Authorization Flow

Database Model

Team invitations are stored in the Invitation table:

model Invitation {
id String @id @default(uuid())
email String
companyId String @db.Uuid
invitedById String @db.Uuid
roleId String @db.Uuid
token String @unique
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Company Company @relation(fields: [companyId], references: [id])
User User @relation(fields: [invitedById], references: [id])
Role Role @relation(fields: [roleId], references: [id])
}
Finding the Invitation ID

The invitation ID is available from the company.invitations field resolver. The frontend prefixes invitation IDs with invite: for UI purposes — strip this prefix before passing to the resendTeamMemberInvitation mutation.

Re-invite after removal (SUP-00063)

Before the fix, inviting any email that had ever been associated with the company failed with User has already been invited to this company. The root cause was a stateless duplicate-check — the pre-check matched on (email, companyId) alone, so stale ACCEPTED rows from members who had since been removed blocked every future invite.

Current behavior:

  1. Pre-check is PENDING-scopedinvitation.findFirst({ where: { email, companyId, status: 'PENDING' } }). Only in-flight pending invitations block a new invite.
  2. Member removal cascadesUserCompanyRoleService.remove() wraps the delete in a $transaction; if the UCR has an invitationId, the linked Invitation.status is flipped to CANCELLED before the row is deleted. The original ACCEPTED state is preserved in the audit log but no longer blocks future invites.
  3. Field resolver stays cleanCompany.invitations returns only status: 'PENDING' rows, so the frontend team roster never shows ghost entries from past members.
  4. Frontend defense-in-depthuseMemberData filters merged invite rows to status === 'PENDING' in case any other caller of the field ever relaxes the backend filter.

Retest: invite a team member → have them accept → remove them → invite the same email again. The second invite should succeed without the duplicate-invitation error.

Cross-company UCR scoping (SUP-00163)

Before the fix, a user with multi-company memberships could be silently removed from the wrong company. Two issues compounded:

  1. The frontend team-members action menu resolved the UCR id as target.userCompanyRoles[0].id with a fallback to whichever UCR happened to come back first. For a user who was a member of multiple companies, that could point at a UCR for a different company than the one the admin was viewing.
  2. removeUserCompanyRole had no caller-side permission gate at the resolver — JwtAuthGuard only. Any authenticated user could remove any UCR they had a valid id for.

Current behavior:

  1. Server-side per-company filterUser.userCompanyRoles(companyId: ID) accepts an optional companyId arg. When present, the resolver returns only the UCR row(s) where companyId matches. With no arg, behaviour is unchanged (returns all UCRs for the user).
  2. Caller permission gateremoveUserCompanyRole(id) now requires the caller to be Company Admin of the target UCR's company OR a Super Admin. Non-admin callers receive 403 You do not have permission to remove members of this company. Self-deletion guard is preserved (a user cannot remove their own UCR).
  3. Frontend strict matchapp/(shared)/userprofile/members/memberPage.tsx and app/company/team/members/page.tsx strict-match by companyId === current before sending the mutation. The [0] foreign-UCR fallback is gone; if no matching UCR is found the mutation is not sent.
  4. Cascade to invitations preserved — the $transaction from SUP-00063 still flips the linked Invitation.status to CANCELLED when applicable; only the access boundary changed.

Retest: sign in as a user with memberships in two companies. In a company where the user is not admin, attempt to remove someone — expect a clean 403 with no row vanishing in either company. In a company where the user is admin, remove a member — expect that company's UCR to be deleted while the same target's UCR in the other company is untouched.

In-app notification on team invite (SUP-00170)

Before the fix, inviting an already-registered AIQLick user via the Teams flow only sent an email — no in-app notification appeared on the invitee's bell. The collaboration-invite path (used by external collaborators) had been emitting invitation.received since launch and CollaborationNotificationListener.handleInvitationReceived was already creating an INVITATION_RECEIVED notification from it; the team-member invite path was simply missing the emit.

Current behavior:

  1. inviteUserToCompany emits invitation.received for registered invitees — after sending the email, the service looks up the invitee by email; if a User row exists, it emits invitation.received with invitedUserId, companyId, roleName, invitedByUserId, invitationLink, and token. The existing CollaborationNotificationListener then creates the INVITATION_RECEIVED notification via NotificationService.createFromTemplate.
  2. resendTeamMemberInvitation mirrors the same emit — resending also emits invitation.received so a registered invitee gets an in-app notification on every resend, not just the initial invite.
  3. Unregistered invitees skip the event — if the user lookup returns null (the invitee hasn't signed up yet), no event is emitted. The email is the only delivery channel for them; there's no userId to attach an in-app notification to.
  4. Errors on emit are swallowed — both call sites wrap the eventEmitter.emit in try/catch and log without rethrowing. The email already went out, so a notification-system hiccup must never break the invite flow.
  5. Email lookup is case-insensitive (SUP-00170 follow-up) — the invitee's email is normalised at the top of inviteUserToCompany (toLowerCase().trim()) and the user-existence check uses Prisma findFirst with email: { equals: ..., mode: 'insensitive' }. The same case-insensitive findFirst is used in resendTeamMemberInvitation against the stored Invitation.email. This guarantees the in-app notification still fires when the invitation casing differs from how the user originally signed up (e.g. admin types liridona@example.com but the user is stored as Liridona@example.com). Pre-fix, findUnique was case-sensitive and silently missed those users — Copilot caught this on PR #171's review and PR #173 closed the gap. The duplicate-PENDING-invitation check is also case-insensitive, and Invitation.email is now stored in the normalised form so downstream consumers see a canonical value.

Retest: invite a registered AIQLick user via Teams → sign in as that user → check the bell. Expect a fresh INVITATION_RECEIVED notification (status UNREAD) titled "Invitation to Join <company>" with a body like "<Inviter> from <company> has invited you to join as <Role>." On the resend path, the same notification is created again with a new token in the metadata.

Company Ownership Transfer

Company.billingOwnerId is the user whose Subscription resolves all plan limits and credit allocations for the company. Removing or orphaning this user would break every billing lookup — see Plan Limits → Owner-anchored resolution. Two guarantees keep the field consistent:

  1. removeUserCompanyRole refuses to delete the billing-owner UCR. Non-super-admin callers get HTTP 403 with You cannot remove the company owner. Transfer ownership first. Super admins can override (e.g. when winding down a company), but the typical path is the transfer mutation below.
  2. transferCompanyOwnership is the supported self-serve handoff. Only the current owner — or a super admin — can call it. The mutation moves billingOwnerId to a new user atomically; if the new owner doesn't yet have a UserCompanyRole for the company, the service creates one with role Company Admin so the new owner can manage members immediately.

GraphQL API

mutation TransferCompanyOwnership($input: TransferCompanyOwnershipInput!) {
transferCompanyOwnership(input: $input) {
success
message
}
}

Variables:

{
"input": {
"companyId": "7659fee2-6b88-4fdf-b2f5-7495c764c8fb",
"newOwnerId": "11111111-2222-3333-4444-555555555555"
}
}

Response (success):

{
"data": {
"transferCompanyOwnership": {
"success": true,
"message": "Ownership transferred"
}
}
}

Response (caller is not owner):

{
"errors": [
{
"message": "Only the current company owner or a super admin can transfer ownership.",
"extensions": { "code": "FORBIDDEN", "statusCode": 403 }
}
]
}
Subscription anchor

The transfer flips Company.billingOwnerId only — the underlying Subscription.userId is not migrated. Because plan resolution traverses Company.billingOwnerId → User → Subscription, the new owner's own active subscription becomes the company's plan immediately after the transfer. If the new owner has no active subscription, the company falls back to the unsubscribed state until they purchase one. Migrate the Stripe subscription separately (admin-side) if the intent is to keep the same plan under the new owner.

Frontend (Team page)

app/company/team/members/page.tsx queries GET_COMPANY_OWNER_ID with fetchPolicy: cache-and-network so the owner badge revalidates on every mount of /company/team/members. The cache-and-network policy was chosen specifically so that, after transferCompanyOwnership succeeds, refetchMembers() plus the next route revisit refresh the owner badge — cache-first would have left a stale crown on the previous owner.

The "Transfer Ownership" affordance only appears when:

  • viewer.user.id === company.billingOwnerId, OR
  • viewer.user.isSuperAdmin === true.

The delete affordance on the owner row is hidden for everyone (the bulk-delete short-circuit drops the owner row before issuing removeUserCompanyRole); for super admins it's intentionally still shown — the frontend defers to the server, which permits super-admin removal of the owner only after the gate above. The Apollo cache invalidation hook calls client.resetStore() (not clearStore()) on company switch so any active queries — including the owner badge — re-fire under the new context.

Retest

  1. Sign in as the current company owner. Click "Transfer Ownership", pick another member, confirm. Without a hard refresh, the team table should re-render with the crown on the new owner.
  2. Sign in as a Company Admin who is NOT the owner. The "Transfer Ownership" button should not be visible. Attempting the mutation directly returns 403.
  3. Try to remove the owner row from the bulk-delete checklist as a non-super-admin. The mutation should not fire (frontend short-circuit). Submitting it directly returns 403 with You cannot remove the company owner. Transfer ownership first.