Files
helix-engage-server/src/team/team.service.ts
saridsa2 695f119c2b 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>
2026-04-10 08:37:58 +05:30

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;
}
}