mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- Team module: POST /api/team/members (in-place employee creation with temp password + Redis cache), PUT /api/team/members/:id, GET temp password endpoint. Uses signUpInWorkspace — no email invites. - Dockerfile: rewritten as multi-stage build (builder + runtime) so native modules compile for target arch. Fixes darwin→linux crash. - .dockerignore: exclude dist, node_modules, .env, .git, data/ - package-lock.json: regenerated against public npmjs.org (was pointing at localhost:4873 Verdaccio — broke docker builds) - Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors helper for visit-slot-aware queries across 6 consumers - AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped setup-state with workspace ID isolation, AI prompt defaults overhaul - Agent config: camelCase field fix for SDK-synced workspaces - Session service: workspace-scoped Redis key prefixing for setup state - Recordings/supervisor/widget services: updated to use doctor-utils shared fragments instead of inline visitingHours queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
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<CreatedMember> {
|
|
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<string | null> {
|
|
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;
|
|
}
|
|
}
|