erp-construccion-backend-v2/src/modules/projects/services/project-member.service.ts
Adrian Flores Cortes 8f8843cd10 [ERP-CONSTRUCCION] feat: Implement 6 core business modules
Branches:
- 5 services: branch, schedule, inventory-settings, payment-terminal, user-assignment
- Hierarchical management, schedules, terminals

Products:
- 6 services: category, product, price, supplier, attribute, variant
- Hierarchical categories, multi-pricing, variants

Projects:
- 6 services: project, task, timesheet, milestone, member, stage
- Kanban support, timesheet approval workflow

Sales:
- 2 services: quotation, sales-order
- Full sales workflow with quotation-to-order conversion

Invoices:
- 2 services: invoice, payment
- Complete invoicing with payment allocation

Notifications:
- 5 services: notification, preference, template, channel, in-app
- Multi-channel support (email, sms, push, whatsapp, in-app)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:24:00 -06:00

264 lines
6.0 KiB
TypeScript

/**
* ProjectMemberService - Project Team Member Management Service
*
* Provides team member management for projects.
* Supports multi-tenant architecture with ServiceContext pattern.
*
* @module Projects
*/
import { DataSource, Repository } from 'typeorm';
import { ProjectMemberEntity, ProjectMemberRole } from '../entities/project-member.entity';
import { ServiceContext, PaginatedResult } from './project.service';
export interface AddMemberDto {
projectId: string;
userId: string;
role?: string;
}
export interface UpdateMemberRoleDto {
role: string;
}
export interface ProjectMemberFilters {
projectId?: string;
userId?: string;
role?: string;
}
export class ProjectMemberService {
private repository: Repository<ProjectMemberEntity>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(ProjectMemberEntity);
}
/**
* Find member by ID
*/
async findById(ctx: ServiceContext, id: string): Promise<ProjectMemberEntity | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
},
});
}
/**
* Find member by project and user
*/
async findByProjectAndUser(
ctx: ServiceContext,
projectId: string,
userId: string
): Promise<ProjectMemberEntity | null> {
return this.repository.findOne({
where: {
projectId,
userId,
tenantId: ctx.tenantId,
},
});
}
/**
* Find all members with pagination and filters
*/
async findAll(
ctx: ServiceContext,
filters: ProjectMemberFilters = {},
page = 1,
limit = 20
): Promise<PaginatedResult<ProjectMemberEntity>> {
const skip = (page - 1) * limit;
const qb = this.repository
.createQueryBuilder('pm')
.where('pm.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.projectId) {
qb.andWhere('pm.project_id = :projectId', { projectId: filters.projectId });
}
if (filters.userId) {
qb.andWhere('pm.user_id = :userId', { userId: filters.userId });
}
if (filters.role) {
qb.andWhere('pm.role = :role', { role: filters.role });
}
qb.orderBy('pm.created_at', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Add a member to a project
*/
async addMember(ctx: ServiceContext, dto: AddMemberDto): Promise<ProjectMemberEntity> {
// Check if already a member
const existing = await this.findByProjectAndUser(ctx, dto.projectId, dto.userId);
if (existing) {
throw new Error('User is already a member of this project');
}
const entity = this.repository.create({
tenantId: ctx.tenantId,
projectId: dto.projectId,
userId: dto.userId,
role: dto.role || ProjectMemberRole.MEMBER,
});
return this.repository.save(entity);
}
/**
* Update member role
*/
async updateRole(
ctx: ServiceContext,
id: string,
dto: UpdateMemberRoleDto
): Promise<ProjectMemberEntity | null> {
const entity = await this.findById(ctx, id);
if (!entity) {
return null;
}
entity.role = dto.role;
return this.repository.save(entity);
}
/**
* Remove a member from a project
*/
async removeMember(ctx: ServiceContext, id: string): Promise<boolean> {
const entity = await this.findById(ctx, id);
if (!entity) {
return false;
}
await this.repository.delete(id);
return true;
}
/**
* Remove a member by project and user
*/
async removeMemberByProjectAndUser(
ctx: ServiceContext,
projectId: string,
userId: string
): Promise<boolean> {
const entity = await this.findByProjectAndUser(ctx, projectId, userId);
if (!entity) {
return false;
}
await this.repository.delete(entity.id);
return true;
}
/**
* Find members by project
*/
async findByProject(
ctx: ServiceContext,
projectId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<ProjectMemberEntity>> {
return this.findAll(ctx, { projectId }, page, limit);
}
/**
* Find projects where user is a member
*/
async findProjectsByUser(
ctx: ServiceContext,
userId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<ProjectMemberEntity>> {
return this.findAll(ctx, { userId }, page, limit);
}
/**
* Check if user is a member of a project
*/
async isMember(ctx: ServiceContext, projectId: string, userId: string): Promise<boolean> {
const member = await this.findByProjectAndUser(ctx, projectId, userId);
return member !== null;
}
/**
* Get user role in a project
*/
async getUserRole(
ctx: ServiceContext,
projectId: string,
userId: string
): Promise<string | null> {
const member = await this.findByProjectAndUser(ctx, projectId, userId);
return member?.role || null;
}
/**
* Count members in a project
*/
async countMembers(ctx: ServiceContext, projectId: string): Promise<number> {
return this.repository.count({
where: { projectId, tenantId: ctx.tenantId },
});
}
/**
* Add multiple members to a project
*/
async addMembers(
ctx: ServiceContext,
projectId: string,
userIds: string[],
role?: string
): Promise<ProjectMemberEntity[]> {
const added: ProjectMemberEntity[] = [];
for (const userId of userIds) {
try {
const member = await this.addMember(ctx, {
projectId,
userId,
role: role || ProjectMemberRole.MEMBER,
});
added.push(member);
} catch {
// Skip if already a member
}
}
return added;
}
/**
* Remove all members from a project
*/
async removeAllMembers(ctx: ServiceContext, projectId: string): Promise<number> {
const result = await this.repository.delete({
projectId,
tenantId: ctx.tenantId,
});
return result.affected || 0;
}
}