Document that product specs need /technical-info for labels, auto-verify admin-created business owners, and expand category color presets with Farsi aliases. Co-authored-by: Cursor <cursoragent@cursor.com>
530 lines
15 KiB
TypeScript
530 lines
15 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import * as bcrypt from 'bcrypt';
|
|
import {
|
|
ASSIGNABLE_TEAM_ROLES,
|
|
AssignableTeamRole,
|
|
AuthUser,
|
|
} from '../auth/auth.types';
|
|
import { PermissionsService } from '../auth/permissions.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AddTeamMemberDto } from './dto/add-team-member.dto';
|
|
import { AssignTeamAccessDto } from './dto/assign-team-access.dto';
|
|
import { UpdateTeamMemberDto } from './dto/update-team-member.dto';
|
|
|
|
type TeamMembership = {
|
|
id: bigint;
|
|
userId: bigint;
|
|
isOwner: boolean;
|
|
role: { slug: string } | null;
|
|
};
|
|
|
|
@Injectable()
|
|
export class BusinessTeamService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionsService,
|
|
) {}
|
|
|
|
async list(businessId: bigint, actor: AuthUser) {
|
|
await this.ensureCanRead(businessId, actor.id);
|
|
|
|
const members = await this.prisma.businessUser.findMany({
|
|
where: { businessId },
|
|
include: {
|
|
user: true,
|
|
role: true,
|
|
inviter: { select: { id: true, firstName: true, lastName: true } },
|
|
},
|
|
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
|
});
|
|
|
|
return {
|
|
members: await Promise.all(
|
|
members.map(async (m) => ({
|
|
id: m.id,
|
|
userId: m.user.id,
|
|
cellNumber: m.user.cellNumber,
|
|
firstName: m.user.firstName,
|
|
lastName: m.user.lastName,
|
|
email: m.user.email,
|
|
isOwner: m.isOwner,
|
|
teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null,
|
|
permissions: await this.permissions.getPermissionsForBusiness(
|
|
m.user.id,
|
|
businessId,
|
|
),
|
|
invitedBy: m.inviter,
|
|
createdAt: m.createdAt,
|
|
})),
|
|
),
|
|
};
|
|
}
|
|
|
|
async add(businessId: bigint, actor: AuthUser, dto: AddTeamMemberDto) {
|
|
const canInvite = await this.permissions.hasBusinessPermission(
|
|
actor.id,
|
|
businessId,
|
|
'business.team.invite',
|
|
);
|
|
if (!canInvite) {
|
|
throw new ForbiddenException('You cannot invite team members');
|
|
}
|
|
|
|
this.assertAssignableRole(dto.roleSlug);
|
|
await this.assertCanAssignTeamRole(actor, businessId, null, dto.roleSlug);
|
|
|
|
const business = await this.prisma.business.findUnique({
|
|
where: { id: businessId },
|
|
});
|
|
if (!business?.isActive) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const staffRole = await this.prisma.role.findUnique({
|
|
where: { slug: dto.roleSlug },
|
|
});
|
|
if (!staffRole) {
|
|
throw new BadRequestException('Invalid team role');
|
|
}
|
|
|
|
const businessStaffRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'business_staff' },
|
|
});
|
|
if (!businessStaffRole) {
|
|
throw new Error('business_staff role is missing. Run migrations.');
|
|
}
|
|
|
|
const existingUser = await this.prisma.user.findUnique({
|
|
where: { cellNumber: dto.cellNumber },
|
|
});
|
|
|
|
if (existingUser) {
|
|
const existingMembership = await this.prisma.businessUser.findUnique({
|
|
where: {
|
|
businessId_userId: { businessId, userId: existingUser.id },
|
|
},
|
|
});
|
|
if (existingMembership) {
|
|
throw new ConflictException('User is already a member of this business');
|
|
}
|
|
}
|
|
|
|
if (!existingUser && !dto.password) {
|
|
throw new BadRequestException('password is required for new users');
|
|
}
|
|
|
|
const passwordHash = existingUser
|
|
? existingUser.passwordHash
|
|
: await bcrypt.hash(dto.password!, 10);
|
|
|
|
const member = await this.prisma.$transaction(async (tx) => {
|
|
const user =
|
|
existingUser ??
|
|
(await tx.user.create({
|
|
data: {
|
|
cellNumber: dto.cellNumber,
|
|
passwordHash,
|
|
email: dto.email,
|
|
firstName: dto.firstName,
|
|
lastName: dto.lastName,
|
|
cellVerifiedAt: new Date(),
|
|
},
|
|
}));
|
|
|
|
if (existingUser && !existingUser.cellVerifiedAt) {
|
|
await tx.user.update({
|
|
where: { id: user.id },
|
|
data: { cellVerifiedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
const businessUser = await tx.businessUser.create({
|
|
data: {
|
|
businessId,
|
|
userId: user.id,
|
|
isOwner: false,
|
|
roleId: staffRole.id,
|
|
invitedBy: actor.id,
|
|
},
|
|
include: { user: true, role: true },
|
|
});
|
|
|
|
const hasStaffGlobalRole = await tx.userRole.findUnique({
|
|
where: {
|
|
userId_roleId: {
|
|
userId: user.id,
|
|
roleId: businessStaffRole.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!hasStaffGlobalRole) {
|
|
await tx.userRole.create({
|
|
data: { userId: user.id, roleId: businessStaffRole.id },
|
|
});
|
|
}
|
|
|
|
return businessUser;
|
|
});
|
|
|
|
return {
|
|
message: 'Team member added',
|
|
member: {
|
|
id: member.id,
|
|
userId: member.user.id,
|
|
cellNumber: member.user.cellNumber,
|
|
firstName: member.user.firstName,
|
|
lastName: member.user.lastName,
|
|
isOwner: false,
|
|
teamRole: member.role?.slug ?? dto.roleSlug,
|
|
permissions: await this.permissions.getPermissionsForBusiness(
|
|
member.user.id,
|
|
businessId,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Super-admin / owner path: set a user as customer or staff (admin/editor/viewer)
|
|
* for one business. Owners cannot be changed.
|
|
*/
|
|
async assignAccess(
|
|
businessId: bigint,
|
|
actor: AuthUser,
|
|
dto: AssignTeamAccessDto,
|
|
) {
|
|
const userId = BigInt(dto.userId);
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user || !user.isActive) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
const business = await this.prisma.business.findUnique({
|
|
where: { id: businessId },
|
|
});
|
|
if (!business?.isActive) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const membership = await this.prisma.businessUser.findUnique({
|
|
where: { businessId_userId: { businessId, userId } },
|
|
include: { role: true },
|
|
});
|
|
|
|
if (membership?.isOwner) {
|
|
throw new ForbiddenException('Cannot change the role of a business owner');
|
|
}
|
|
|
|
if (dto.access === 'staff') {
|
|
if (!dto.roleSlug) {
|
|
throw new BadRequestException('roleSlug is required for staff access');
|
|
}
|
|
this.assertAssignableRole(dto.roleSlug);
|
|
|
|
const requiredPermission = membership
|
|
? 'business.team.update'
|
|
: 'business.team.invite';
|
|
const allowed = await this.permissions.hasBusinessPermission(
|
|
actor.id,
|
|
businessId,
|
|
requiredPermission,
|
|
);
|
|
if (!allowed) {
|
|
throw new ForbiddenException('You cannot assign team access');
|
|
}
|
|
|
|
await this.assertCanAssignTeamRole(
|
|
actor,
|
|
businessId,
|
|
membership,
|
|
dto.roleSlug,
|
|
);
|
|
|
|
const staffRole = await this.prisma.role.findUnique({
|
|
where: { slug: dto.roleSlug },
|
|
});
|
|
if (!staffRole) {
|
|
throw new BadRequestException('Invalid team role');
|
|
}
|
|
|
|
const businessStaffRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'business_staff' },
|
|
});
|
|
if (!businessStaffRole) {
|
|
throw new Error('business_staff role is missing. Run migrations.');
|
|
}
|
|
|
|
const member = await this.prisma.$transaction(async (tx) => {
|
|
const businessUser = membership
|
|
? await tx.businessUser.update({
|
|
where: { id: membership.id },
|
|
data: { roleId: staffRole.id },
|
|
include: { user: true, role: true },
|
|
})
|
|
: await tx.businessUser.create({
|
|
data: {
|
|
businessId,
|
|
userId,
|
|
isOwner: false,
|
|
roleId: staffRole.id,
|
|
invitedBy: actor.id,
|
|
},
|
|
include: { user: true, role: true },
|
|
});
|
|
|
|
const hasStaffGlobalRole = await tx.userRole.findUnique({
|
|
where: {
|
|
userId_roleId: {
|
|
userId,
|
|
roleId: businessStaffRole.id,
|
|
},
|
|
},
|
|
});
|
|
if (!hasStaffGlobalRole) {
|
|
await tx.userRole.create({
|
|
data: { userId, roleId: businessStaffRole.id },
|
|
});
|
|
}
|
|
|
|
return businessUser;
|
|
});
|
|
|
|
return {
|
|
message: 'Team access updated',
|
|
access: 'staff' as const,
|
|
member: {
|
|
id: member.id,
|
|
userId: member.user.id,
|
|
isOwner: false,
|
|
teamRole: member.role?.slug ?? dto.roleSlug,
|
|
permissions: await this.permissions.getPermissionsForBusiness(
|
|
member.user.id,
|
|
businessId,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
const canRemove = await this.permissions.hasBusinessPermission(
|
|
actor.id,
|
|
businessId,
|
|
membership ? 'business.team.remove' : 'business.team.update',
|
|
);
|
|
if (!canRemove) {
|
|
throw new ForbiddenException('You cannot demote team members to customer');
|
|
}
|
|
|
|
await this.assertCanAssignTeamRole(actor, businessId, membership, null);
|
|
|
|
const customerRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'customer' },
|
|
});
|
|
if (!customerRole) {
|
|
throw new Error('customer role is missing. Run migrations.');
|
|
}
|
|
|
|
const businessStaffRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'business_staff' },
|
|
});
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
if (membership) {
|
|
await tx.businessUser.delete({ where: { id: membership.id } });
|
|
}
|
|
|
|
const existingCustomer = await tx.businessCustomer.findUnique({
|
|
where: { businessId_userId: { businessId, userId } },
|
|
});
|
|
if (!existingCustomer) {
|
|
await tx.businessCustomer.create({
|
|
data: { businessId, userId },
|
|
});
|
|
}
|
|
|
|
const hasCustomerRole = await tx.userRole.findUnique({
|
|
where: {
|
|
userId_roleId: { userId, roleId: customerRole.id },
|
|
},
|
|
});
|
|
if (!hasCustomerRole) {
|
|
await tx.userRole.create({
|
|
data: { userId, roleId: customerRole.id },
|
|
});
|
|
}
|
|
|
|
if (businessStaffRole) {
|
|
const remainingStaff = await tx.businessUser.count({
|
|
where: { userId, isOwner: false },
|
|
});
|
|
if (remainingStaff === 0) {
|
|
await tx.userRole.deleteMany({
|
|
where: { userId, roleId: businessStaffRole.id },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
message: 'User set as customer for this business',
|
|
access: 'customer' as const,
|
|
member: null,
|
|
};
|
|
}
|
|
|
|
async update(
|
|
businessId: bigint,
|
|
memberId: bigint,
|
|
actor: AuthUser,
|
|
dto: UpdateTeamMemberDto,
|
|
) {
|
|
const canUpdate = await this.permissions.hasBusinessPermission(
|
|
actor.id,
|
|
businessId,
|
|
'business.team.update',
|
|
);
|
|
if (!canUpdate) {
|
|
throw new ForbiddenException('You cannot update team members');
|
|
}
|
|
|
|
this.assertAssignableRole(dto.roleSlug);
|
|
|
|
const member = await this.prisma.businessUser.findFirst({
|
|
where: { id: memberId, businessId },
|
|
include: { user: true, role: true },
|
|
});
|
|
|
|
if (!member) {
|
|
throw new NotFoundException('Team member not found');
|
|
}
|
|
|
|
if (member.isOwner) {
|
|
throw new ForbiddenException('Cannot change the role of a business owner');
|
|
}
|
|
|
|
await this.assertCanAssignTeamRole(actor, businessId, member, dto.roleSlug);
|
|
|
|
const staffRole = await this.prisma.role.findUnique({
|
|
where: { slug: dto.roleSlug },
|
|
});
|
|
if (!staffRole) {
|
|
throw new BadRequestException('Invalid team role');
|
|
}
|
|
|
|
const updated = await this.prisma.businessUser.update({
|
|
where: { id: memberId },
|
|
data: { roleId: staffRole.id },
|
|
include: { user: true, role: true },
|
|
});
|
|
|
|
return {
|
|
message: 'Team member role updated',
|
|
member: {
|
|
id: updated.id,
|
|
userId: updated.user.id,
|
|
teamRole: updated.role?.slug ?? dto.roleSlug,
|
|
permissions: await this.permissions.getPermissionsForBusiness(
|
|
updated.user.id,
|
|
businessId,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
async remove(businessId: bigint, memberId: bigint, actor: AuthUser) {
|
|
const canRemove = await this.permissions.hasBusinessPermission(
|
|
actor.id,
|
|
businessId,
|
|
'business.team.remove',
|
|
);
|
|
if (!canRemove) {
|
|
throw new ForbiddenException('You cannot remove team members');
|
|
}
|
|
|
|
const member = await this.prisma.businessUser.findFirst({
|
|
where: { id: memberId, businessId },
|
|
include: { role: true },
|
|
});
|
|
|
|
if (!member) {
|
|
throw new NotFoundException('Team member not found');
|
|
}
|
|
|
|
if (member.isOwner) {
|
|
throw new ForbiddenException('Cannot remove a business owner');
|
|
}
|
|
|
|
if (member.userId === actor.id) {
|
|
throw new ForbiddenException('You cannot remove yourself');
|
|
}
|
|
|
|
await this.assertCanAssignTeamRole(actor, businessId, member, null);
|
|
|
|
await this.prisma.businessUser.delete({ where: { id: memberId } });
|
|
|
|
const businessStaffRole = await this.prisma.role.findUnique({
|
|
where: { slug: 'business_staff' },
|
|
});
|
|
if (businessStaffRole) {
|
|
const remainingStaff = await this.prisma.businessUser.count({
|
|
where: { userId: member.userId, isOwner: false },
|
|
});
|
|
if (remainingStaff === 0) {
|
|
await this.prisma.userRole.deleteMany({
|
|
where: { userId: member.userId, roleId: businessStaffRole.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
return { message: 'Team member removed' };
|
|
}
|
|
|
|
private async ensureCanRead(businessId: bigint, userId: bigint) {
|
|
const canRead = await this.permissions.hasBusinessPermission(
|
|
userId,
|
|
businessId,
|
|
'business.team.read',
|
|
);
|
|
if (!canRead) {
|
|
throw new ForbiddenException('You cannot view this business team');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Only platform super admins may assign or change the `admin` team role.
|
|
* Owners and team admins may manage editor/viewer (and demote those to customer).
|
|
*/
|
|
private async assertCanAssignTeamRole(
|
|
actor: AuthUser,
|
|
businessId: bigint,
|
|
target: TeamMembership | null,
|
|
nextRoleSlug: string | null,
|
|
) {
|
|
if (await this.permissions.isSuperAdmin(actor.id)) {
|
|
return;
|
|
}
|
|
|
|
if (target?.role?.slug === 'admin') {
|
|
throw new ForbiddenException('Cannot change the role of an admin');
|
|
}
|
|
|
|
if (nextRoleSlug === 'admin') {
|
|
throw new ForbiddenException('Only a super admin can assign the admin role');
|
|
}
|
|
}
|
|
|
|
private assertAssignableRole(roleSlug: string): asserts roleSlug is AssignableTeamRole {
|
|
if (!ASSIGNABLE_TEAM_ROLES.includes(roleSlug as AssignableTeamRole)) {
|
|
throw new BadRequestException(
|
|
`roleSlug must be one of: ${ASSIGNABLE_TEAM_ROLES.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
}
|