/** * Team Service - Team management operations */ import { getPool } from '../config'; import { logger } from '../utils/logger'; import { TeamMember, TeamMemberWithUser, Invitation, InvitationWithDetails, AddTeamMemberInput, UpdateTeamMemberInput, RemoveTeamMemberInput, ListTeamMembersInput, CreateInvitationInput, CreateInvitationResult, AcceptInvitationInput, AcceptInvitationResult, ResendInvitationInput, ResendInvitationResult, RevokeInvitationInput, ListInvitationsInput, GetInvitationByTokenInput, } from '../types/team.types'; class TeamService { // ================== Team Members ================== /** * Add a team member */ async addTeamMember(input: AddTeamMemberInput): Promise { const pool = getPool(); logger.info('Adding team member', { tenantId: input.tenantId, userId: input.userId, }); const result = await pool.query( `SELECT * FROM teams.add_team_member($1, $2, $3, $4, NULL, $5, $6)`, [ input.tenantId, input.userId, input.memberRole || 'member', input.addedBy, input.department, input.jobTitle, ] ); // Get the created member const memberResult = await pool.query( `SELECT * FROM teams.team_members WHERE tenant_id = $1 AND user_id = $2 AND removed_at IS NULL`, [input.tenantId, input.userId] ); return this.mapTeamMemberRow(memberResult.rows[0]); } /** * Update a team member */ async updateTeamMember(input: UpdateTeamMemberInput): Promise { const pool = getPool(); logger.info('Updating team member', { tenantId: input.tenantId, userId: input.userId, }); const updates: string[] = []; const values: unknown[] = []; let paramIndex = 1; if (input.memberRole !== undefined) { updates.push(`member_role = $${paramIndex++}`); values.push(input.memberRole); } if (input.department !== undefined) { updates.push(`department = $${paramIndex++}`); values.push(input.department); } if (input.jobTitle !== undefined) { updates.push(`job_title = $${paramIndex++}`); values.push(input.jobTitle); } if (input.status !== undefined) { updates.push(`status = $${paramIndex++}`); values.push(input.status); } updates.push(`updated_at = CURRENT_TIMESTAMP`); values.push(input.tenantId); values.push(input.userId); const result = await pool.query( `UPDATE teams.team_members SET ${updates.join(', ')} WHERE tenant_id = $${paramIndex++} AND user_id = $${paramIndex++} AND removed_at IS NULL RETURNING *`, values ); if (result.rowCount === 0) { throw new Error('Team member not found'); } return this.mapTeamMemberRow(result.rows[0]); } /** * Remove a team member */ async removeTeamMember(input: RemoveTeamMemberInput): Promise { const pool = getPool(); logger.info('Removing team member', { tenantId: input.tenantId, userId: input.userId, }); await pool.query(`SELECT teams.remove_team_member($1, $2, $3)`, [ input.tenantId, input.userId, input.removedBy, ]); return true; } /** * List team members */ async listTeamMembers(input: ListTeamMembersInput): Promise { const pool = getPool(); let query = `SELECT * FROM teams.v_team_members WHERE tenant_id = $1`; const values: unknown[] = [input.tenantId]; let paramIndex = 2; if (input.status) { query += ` AND status = $${paramIndex++}`; values.push(input.status); } if (input.memberRole) { query += ` AND member_role = $${paramIndex++}`; values.push(input.memberRole); } if (input.department) { query += ` AND department = $${paramIndex++}`; values.push(input.department); } query += ` ORDER BY joined_at DESC`; const result = await pool.query(query, values); return result.rows.map((row) => this.mapTeamMemberWithUserRow(row)); } /** * Get team member by user ID */ async getTeamMember(tenantId: string, userId: string): Promise { const pool = getPool(); const result = await pool.query( `SELECT * FROM teams.v_team_members WHERE tenant_id = $1 AND user_id = $2`, [tenantId, userId] ); if (result.rowCount === 0) { return null; } return this.mapTeamMemberWithUserRow(result.rows[0]); } /** * Get team member count */ async getTeamMemberCount(tenantId: string): Promise { const pool = getPool(); const result = await pool.query( `SELECT COUNT(*) FROM teams.team_members WHERE tenant_id = $1 AND removed_at IS NULL AND status = 'active'`, [tenantId] ); return parseInt(result.rows[0].count, 10); } // ================== Invitations ================== /** * Create an invitation */ async createInvitation(input: CreateInvitationInput): Promise { const pool = getPool(); logger.info('Creating invitation', { tenantId: input.tenantId, email: input.email, }); const result = await pool.query( `SELECT * FROM teams.create_invitation($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ input.tenantId, input.email, input.invitedBy, input.roleId || null, input.firstName || null, input.lastName || null, input.department || null, input.jobTitle || null, input.personalMessage || null, input.expiresInDays || 7, ] ); return { invitationId: result.rows[0].invitation_id, token: result.rows[0].token, }; } /** * Accept an invitation */ async acceptInvitation(input: AcceptInvitationInput): Promise { const pool = getPool(); logger.info('Accepting invitation', { userId: input.userId }); const result = await pool.query(`SELECT * FROM teams.accept_invitation($1, $2)`, [ input.token, input.userId, ]); const row = result.rows[0]; return { success: row.success, tenantId: row.tenant_id, roleId: row.role_id, message: row.message, }; } /** * Resend an invitation */ async resendInvitation(input: ResendInvitationInput): Promise { const pool = getPool(); logger.info('Resending invitation', { invitationId: input.invitationId }); const result = await pool.query(`SELECT * FROM teams.resend_invitation($1, $2)`, [ input.invitationId, input.resentBy, ]); const row = result.rows[0]; return { success: row.success, token: row.token, message: row.message, }; } /** * Revoke an invitation */ async revokeInvitation(input: RevokeInvitationInput): Promise { const pool = getPool(); logger.info('Revoking invitation', { invitationId: input.invitationId }); const result = await pool.query( `UPDATE teams.invitations SET status = 'revoked', revoked_at = CURRENT_TIMESTAMP, revoked_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND status = 'pending'`, [input.invitationId, input.revokedBy] ); return result.rowCount !== null && result.rowCount > 0; } /** * List invitations */ async listInvitations(input: ListInvitationsInput): Promise { const pool = getPool(); let query = `SELECT * FROM teams.v_invitations WHERE tenant_id = $1`; const values: unknown[] = [input.tenantId]; if (input.status) { query += ` AND status = $2`; values.push(input.status); } query += ` ORDER BY sent_at DESC`; const result = await pool.query(query, values); return result.rows.map((row) => this.mapInvitationWithDetailsRow(row)); } /** * Get invitation by ID */ async getInvitation(invitationId: string): Promise { const pool = getPool(); const result = await pool.query(`SELECT * FROM teams.v_invitations WHERE id = $1`, [ invitationId, ]); if (result.rowCount === 0) { return null; } return this.mapInvitationWithDetailsRow(result.rows[0]); } /** * Get invitation by token (for acceptance page) */ async getInvitationByToken(input: GetInvitationByTokenInput): Promise { const pool = getPool(); const result = await pool.query( `SELECT v.* FROM teams.v_invitations v JOIN teams.invitations i ON v.id = i.id WHERE i.token_hash = encode(sha256($1::bytea), 'hex')`, [input.token] ); if (result.rowCount === 0) { return null; } return this.mapInvitationWithDetailsRow(result.rows[0]); } /** * Expire old invitations (scheduled job) */ async expireOldInvitations(): Promise { const pool = getPool(); const result = await pool.query(`SELECT teams.expire_old_invitations()`); return result.rows[0].expire_old_invitations; } // ================== Row Mappers ================== private mapTeamMemberRow(row: Record): TeamMember { return { id: row.id as string, tenantId: row.tenant_id as string, userId: row.user_id as string, memberRole: row.member_role as TeamMember['memberRole'], status: row.status as TeamMember['status'], department: row.department as string | null, jobTitle: row.job_title as string | null, joinedViaInvitation: row.joined_via_invitation as string | null, joinedAt: new Date(row.joined_at as string), lastActiveAt: row.last_active_at ? new Date(row.last_active_at as string) : null, settings: (row.settings as Record) || {}, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), addedBy: row.added_by as string | null, removedAt: row.removed_at ? new Date(row.removed_at as string) : null, removedBy: row.removed_by as string | null, }; } private mapTeamMemberWithUserRow(row: Record): TeamMemberWithUser { return { ...this.mapTeamMemberRow(row), userEmail: row.user_email as string, userDisplayName: row.user_display_name as string | null, userFirstName: row.user_first_name as string | null, userLastName: row.user_last_name as string | null, userAvatarUrl: row.user_avatar_url as string | null, primaryRoleName: row.primary_role_name as string | null, }; } private mapInvitationRow(row: Record): Invitation { return { id: row.id as string, tenantId: row.tenant_id as string, email: row.email as string, firstName: row.first_name as string | null, lastName: row.last_name as string | null, roleId: row.role_id as string | null, department: row.department as string | null, jobTitle: row.job_title as string | null, personalMessage: row.personal_message as string | null, status: row.status as Invitation['status'], expiresAt: new Date(row.expires_at as string), sentAt: new Date(row.sent_at as string), resentCount: row.resent_count as number, lastResentAt: row.last_resent_at ? new Date(row.last_resent_at as string) : null, respondedAt: row.responded_at ? new Date(row.responded_at as string) : null, acceptedUserId: row.accepted_user_id as string | null, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), invitedBy: row.invited_by as string, revokedAt: row.revoked_at ? new Date(row.revoked_at as string) : null, revokedBy: row.revoked_by as string | null, }; } private mapInvitationWithDetailsRow(row: Record): InvitationWithDetails { return { ...this.mapInvitationRow(row), tenantName: row.tenant_name as string, roleName: row.role_name as string | null, invitedByEmail: row.invited_by_email as string, invitedByName: row.invited_by_name as string | null, isExpired: row.is_expired as boolean, }; } } export const teamService = new TeamService();