import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { SessionService } from '../auth/session.service'; // Recently-created temp passwords are cached in Redis under this prefix // for 24 hours so the right pane's copy icon keeps working after a // browser refresh. The plaintext expires automatically — the assumption // is the employee logs in within a day, at which point the password // loses value anyway. const TEMP_PASSWORD_KEY_PREFIX = 'team:tempPassword:'; const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60; const tempPasswordKey = (memberId: string) => `${TEMP_PASSWORD_KEY_PREFIX}${memberId}`; // In-place employee creation. The platform's sendInvitations flow is // deliberately NOT used — hospital admins create employees from the // portal and hand out credentials directly (see feedback-no-invites in // memory). // // Chain: // 1. Fetch workspace invite hash (workspace-level setting) so // signUpInWorkspace accepts our call — this is the same hash the // public invite link uses but we consume it server-side. // 2. signUpInWorkspace(email, password, workspaceId, workspaceInviteHash) // — creates the core.user row + the workspaceMember row. Returns // a loginToken we throw away (admin has their own session). // 3. Look up the workspaceMember we just created, filtering by // userEmail (the only field we have to go on). // 4. updateWorkspaceMember to set firstName / lastName. // 5. updateWorkspaceMemberRole to assign the role the admin picked. // 6. (optional) updateAgent to link the new member to a SIP seat if // they're a CC agent. // // Errors from any step bubble up as a BadRequestException — the admin // sees the real GraphQL error message, which usually tells them // exactly what went wrong (email already exists, role not assignable, // etc). export type CreateMemberInput = { firstName: string; lastName: string; email: string; password: string; roleId: string; // Optional SIP seat link — set when the role is HelixEngage User // (CC agent). Ignored otherwise. agentId?: string | null; }; export type CreatedMember = { id: string; userId: string; userEmail: string; firstName: string; lastName: string; roleId: string; agentId: string | null; }; // Update payload — name + role only. Email and password are not // touched (they need separate flows). SIP seat reassignment goes // through the Telephony step's updateAgent path, not here. export type UpdateMemberInput = { firstName: string; lastName: string; roleId: string; }; @Injectable() export class TeamService { private readonly logger = new Logger(TeamService.name); // Workspace invite hash is stable for the lifetime of the workspace // — cache it after first fetch so subsequent creates skip the // extra round-trip. private cachedInviteHash: { workspaceId: string; inviteHash: string } | null = null; constructor( private platform: PlatformGraphqlService, private session: SessionService, ) {} async createMember(input: CreateMemberInput): Promise { const email = input.email.trim().toLowerCase(); const firstName = input.firstName.trim(); const lastName = input.lastName.trim(); if (!email || !firstName || !input.password || !input.roleId) { throw new BadRequestException('email, firstName, password and roleId are required'); } // Step 1 — fetch workspace id + invite hash const ws = await this.getWorkspaceContext(); // Step 2 — create the user + workspace member via signUpInWorkspace try { await this.platform.query( `mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) { signUpInWorkspace( email: $email, password: $password, workspaceId: $workspaceId, workspaceInviteHash: $workspaceInviteHash, ) { workspace { id } } }`, { email, password: input.password, workspaceId: ws.workspaceId, workspaceInviteHash: ws.inviteHash, }, ); } catch (err) { this.logger.warn(`signUpInWorkspace failed for ${email}: ${err}`); throw new BadRequestException(this.extractGraphqlMessage(err)); } // Step 3 — find the workspaceMember that just got created. We // filter by userEmail since that's the only handle we have. // Plural query + client-side pick so we don't rely on a // specific filter shape. const membersData = await this.platform.query<{ workspaceMembers: { edges: { node: { id: string; userId: string; userEmail: string } }[] }; }>( `{ workspaceMembers { edges { node { id userId userEmail } } } }`, ); const member = membersData.workspaceMembers.edges .map((e) => e.node) .find((m) => (m.userEmail ?? '').toLowerCase() === email); if (!member) { throw new BadRequestException( 'Workspace member was created but could not be located — retry in a few seconds', ); } // Step 4 — set their name. Note: the platform's // updateWorkspaceMember mutation declares its `id` arg as // `UUID!` (not `ID!`), and GraphQL refuses to coerce between // those scalars even though both hold the same string value. // Same applies to updateAgent below — verified via __schema // introspection. Pre-existing code in platform-graphql.service // still uses `ID!` for updateLead; that's a separate latent // bug that's untouched here so the diff stays focused on the // team-create failure. try { await this.platform.query( `mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) { updateWorkspaceMember(id: $id, data: $data) { id } }`, { id: member.id, data: { name: { firstName, lastName }, }, }, ); } catch (err) { this.logger.warn(`updateWorkspaceMember name failed for ${member.id}: ${err}`); // Non-fatal — the account exists, just unnamed. Surface it // anyway so the admin can fix in settings. throw new BadRequestException(this.extractGraphqlMessage(err)); } // Step 5 — assign role try { await this.platform.query( `mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) { updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) { id } }`, { workspaceMemberId: member.id, roleId: input.roleId }, ); } catch (err) { this.logger.warn(`updateWorkspaceMemberRole failed for ${member.id}: ${err}`); throw new BadRequestException(this.extractGraphqlMessage(err)); } // Step 6 — (optional) link SIP seat if (input.agentId) { try { await this.platform.query( `mutation LinkAgent($id: UUID!, $data: AgentUpdateInput!) { updateAgent(id: $id, data: $data) { id workspaceMemberId } }`, { id: input.agentId, data: { workspaceMemberId: member.id }, }, ); } catch (err) { this.logger.warn(`updateAgent link failed for agent ${input.agentId}: ${err}`); throw new BadRequestException(this.extractGraphqlMessage(err)); } } // Cache the plaintext temp password in Redis (24h TTL) so the // wizard's right-pane copy icon keeps working after a browser // refresh. The password is also stored hashed on the platform // (used for actual login auth) — this Redis copy exists ONLY // so the admin can recover the plaintext to share with the // employee. Expires automatically; no plaintext persists past // 24h. Trade-off accepted because the plan is to force a // password reset on first login (defense in depth). try { await this.session.setCache( tempPasswordKey(member.id), input.password, TEMP_PASSWORD_TTL_SECONDS, ); } catch (err) { this.logger.warn(`Failed to cache temp password for ${member.id}: ${err}`); // Non-fatal — admin can still copy from session memory // before page reload. We just lose the post-reload // recovery path for this one member. } this.logger.log( `Created member ${email} (id=${member.id}) role=${input.roleId} agent=${input.agentId ?? 'none'}`, ); return { id: member.id, userId: member.userId, userEmail: email, firstName, lastName, roleId: input.roleId, agentId: input.agentId ?? null, }; } // Read the cached temp password for a member, if it's still // within its 24h TTL. Returns null on miss (cache expired, member // never created via this endpoint, or Redis unreachable). The // wizard's copy icon falls back to this when the in-browser // memory was wiped by a page reload. async getTempPassword(memberId: string): Promise { if (!memberId) return null; try { return await this.session.getCache(tempPasswordKey(memberId)); } catch (err) { this.logger.warn(`Failed to read temp password cache for ${memberId}: ${err}`); return null; } } // Update an existing workspace member — name + role only. // Mirrors the create path's mutation chain but skips signUp, // member lookup, and the SIP seat link. Errors bubble up as // BadRequestException so the admin sees the real GraphQL message. async updateMember(memberId: string, input: UpdateMemberInput): Promise<{ id: string }> { const firstName = input.firstName.trim(); const lastName = input.lastName.trim(); if (!memberId || !firstName || !input.roleId) { throw new BadRequestException('memberId, firstName and roleId are required'); } // Step 1 — set their name try { await this.platform.query( `mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) { updateWorkspaceMember(id: $id, data: $data) { id } }`, { id: memberId, data: { name: { firstName, lastName }, }, }, ); } catch (err) { this.logger.warn(`updateWorkspaceMember name failed for ${memberId}: ${err}`); throw new BadRequestException(this.extractGraphqlMessage(err)); } // Step 2 — assign role (idempotent — same call as the create // path so changing role from X to X is a no-op). try { await this.platform.query( `mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) { updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) { id } }`, { workspaceMemberId: memberId, roleId: input.roleId }, ); } catch (err) { this.logger.warn(`updateWorkspaceMemberRole failed for ${memberId}: ${err}`); throw new BadRequestException(this.extractGraphqlMessage(err)); } this.logger.log(`Updated member ${memberId} (name="${firstName} ${lastName}", role=${input.roleId})`); return { id: memberId }; } private async getWorkspaceContext(): Promise<{ workspaceId: string; inviteHash: string }> { if (this.cachedInviteHash) return this.cachedInviteHash; const data = await this.platform.query<{ currentWorkspace: { id: string; inviteHash: string; isPublicInviteLinkEnabled: boolean; }; }>(`{ currentWorkspace { id inviteHash isPublicInviteLinkEnabled } }`); const ws = data.currentWorkspace; if (!ws?.id || !ws?.inviteHash) { throw new BadRequestException( 'Workspace is missing id/inviteHash — cannot create employees in-place', ); } if (!ws.isPublicInviteLinkEnabled) { // signUpInWorkspace will reject us without this flag set. // Surface a clear error instead of the platform's opaque // "FORBIDDEN" response. throw new BadRequestException( 'Workspace public invite link is disabled — enable it in workspace settings so the server can mint user accounts directly', ); } this.cachedInviteHash = { workspaceId: ws.id, inviteHash: ws.inviteHash }; return this.cachedInviteHash; } private extractGraphqlMessage(err: unknown): string { const msg = (err as Error)?.message ?? 'Unknown error'; // PlatformGraphqlService wraps errors as `GraphQL error: [{...}]`. // Pull out the first message so the admin sees something // meaningful in the toast. const match = msg.match(/"message":"([^"]+)"/); return match ? match[1] : msg; } }