434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
/**
|
|
* 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<TeamMember> {
|
|
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<TeamMember> {
|
|
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<boolean> {
|
|
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<TeamMemberWithUser[]> {
|
|
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<TeamMemberWithUser | null> {
|
|
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<number> {
|
|
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<CreateInvitationResult> {
|
|
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<AcceptInvitationResult> {
|
|
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<ResendInvitationResult> {
|
|
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<boolean> {
|
|
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<InvitationWithDetails[]> {
|
|
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<InvitationWithDetails | null> {
|
|
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<InvitationWithDetails | null> {
|
|
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<number> {
|
|
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<string, unknown>): 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<string, unknown>) || {},
|
|
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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();
|