mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-12 02:18:18 +00:00
feat: team module, multi-stage Dockerfile, doctor utils, AI config overhaul
- 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>
This commit is contained in:
334
src/team/team.service.ts
Normal file
334
src/team/team.service.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user