Initial commit: Meshkee CMS API

NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
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 { UpdateTeamMemberDto } from './dto/update-team-member.dto';
@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);
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(),
},
}));
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,
),
},
};
}
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');
}
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 },
});
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.prisma.businessUser.delete({ where: { id: memberId } });
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');
}
}
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(', ')}`,
);
}
}
}