improvement: Nothing done is related to subscriptions!!!

This commit is contained in:
2026-09-05 20:44:16 +03:30
parent 5cd3436d0c
commit 1ca8e6178e
13 changed files with 431 additions and 57 deletions

View File

@@ -84,6 +84,7 @@ export const ErrorCode = {
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
PATIENT_MOBILE_UNAVAILABLE: 'PATIENT_MOBILE_UNAVAILABLE',
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
@@ -110,6 +111,8 @@ export const ErrorCode = {
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
STAFF_CANNOT_CLEAR_OWN_PASSWORD: 'STAFF_CANNOT_CLEAR_OWN_PASSWORD',
STAFF_PASSWORD_CLEAR_ACTIVE_ONLY: 'STAFF_PASSWORD_CLEAR_ACTIVE_ONLY',
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',

View File

@@ -341,12 +341,16 @@ export class AppointmentsService {
}
}
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
const patient = await this.prisma.patient.findUnique({
where: { id: patientId },
select: { id: true, isWalkIn: true },
private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: {
id: patientId,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
select: { id: true },
});
if (!patient || patient.isWalkIn) {
if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}

View File

@@ -25,16 +25,17 @@ export class PatientsController {
constructor(private readonly patientsService: PatientsService) {}
@Post()
@ApiOperation({ summary: 'Create or return existing global patient by mobile' })
@ApiOperation({ summary: 'Create or return this clinics patient by mobile' })
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.create(createPatientDto, organizationId);
}
@Get()
@ApiOperation({ summary: 'Search all patients globally' })
findAll(@Query() query: ListPatientsDto) {
return this.patientsService.findAll(query);
@ApiOperation({ summary: 'Search patients created by the current clinic' })
findAll(@Query() query: ListPatientsDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findAll(query, organizationId);
}
@Get(':id/appointments')
@@ -48,14 +49,20 @@ export class PatientsController {
}
@Get(':id')
@ApiOperation({ summary: 'Get one patient by id' })
findOne(@Param('id') id: string) {
return this.patientsService.findOne(id);
@ApiOperation({ summary: 'Get one patient created by the current clinic' })
findOne(@Param('id') id: string, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findOne(id, organizationId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update global patient record' })
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
return this.patientsService.update(id, updatePatientDto);
@ApiOperation({ summary: 'Update a patient created by the current clinic' })
update(
@Param('id') id: string,
@Body() updatePatientDto: UpdatePatientDto,
@Req() req,
) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.update(id, updatePatientDto, organizationId);
}
}

View File

@@ -21,7 +21,13 @@ export class PatientsService {
});
if (existing) {
return { success: true, data: existing, existing: true as const };
if (
!existing.isWalkIn &&
existing.createdByOrganizationId === organizationId
) {
return { success: true, data: existing, existing: true as const };
}
throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
}
const patient = await this.prisma.patient.create({
@@ -39,12 +45,13 @@ export class PatientsService {
return { success: true, data: patient, existing: false as const };
}
async findAll(query: ListPatientsDto) {
async findAll(query: ListPatientsDto, organizationId: string) {
const { page = 1, limit = 10, q } = query;
const skip = (page - 1) * limit;
const where = {
isWalkIn: false,
createdByOrganizationId: organizationId,
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
};
@@ -72,27 +79,13 @@ export class PatientsService {
};
}
async findOne(id: string) {
const patient = await this.prisma.patient.findUnique({
where: { id },
});
if (!patient || patient.isWalkIn) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
async findOne(id: string, organizationId: string) {
const patient = await this.findNamedPatientInOrg(id, organizationId);
return { success: true, data: patient };
}
async update(id: string, updatePatientDto: UpdatePatientDto) {
await this.ensurePatient(id);
const patient = await this.prisma.patient.findUnique({
where: { id },
select: { isWalkIn: true },
});
if (patient?.isWalkIn) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
await this.findNamedPatientInOrg(id, organizationId);
const data: {
firstName?: string;
@@ -110,7 +103,15 @@ export class PatientsService {
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
}
if (updatePatientDto.mobile !== undefined) {
data.mobile = this.resolveMobile(updatePatientDto.mobile);
const mobile = this.resolveMobile(updatePatientDto.mobile);
const taken = await this.prisma.patient.findUnique({
where: { mobile },
select: { id: true, createdByOrganizationId: true, isWalkIn: true },
});
if (taken && taken.id !== id) {
throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
}
data.mobile = mobile;
}
if (updatePatientDto.email !== undefined) {
data.email = updatePatientDto.email?.trim() || null;
@@ -138,7 +139,7 @@ export class PatientsService {
actorUserId: string,
) {
await this.assertCanViewPatients(actorUserId, organizationId);
await this.ensurePatient(patientId);
await this.findNamedPatientInOrg(patientId, organizationId);
const items = await this.prisma.appointment.findMany({
where: { organizationId, patientId },
@@ -241,14 +242,18 @@ export class PatientsService {
}
}
private async ensurePatient(id: string) {
const patient = await this.prisma.patient.findUnique({
where: { id },
select: { id: true },
private async findNamedPatientInOrg(id: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: {
id,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
});
if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return patient;
}
}

View File

@@ -61,6 +61,20 @@ export class StaffController {
return this.staffService.invite(req.user.id, organizationId, dto);
}
@Post('members/:membershipId/clear-password')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary:
'Clear a staff member password and return a setup link (owner or TAB_STAFF_EDIT; active members only)',
})
clearPassword(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.clearPassword(req.user.id, organizationId, membershipId);
}
@Post('members/:membershipId/invitation-link')
@UseGuards(JwtAuthGuard)
@ApiOperation({

View File

@@ -49,7 +49,7 @@ export class StaffService {
this.prisma.membership.findMany({
where: { organizationId },
include: {
user: { select: { id: true, email: true, name: true } },
user: { select: { id: true, email: true, name: true, passwordHash: true } },
permissions: { include: { permission: true } },
invitations: {
orderBy: { createdAt: 'desc' },
@@ -80,6 +80,7 @@ export class StaffService {
isOwner: m.isOwner,
isActive: m.isOwner ? true : m.isActive,
invitationStatus: this.getInvitationStatus(m),
hasPassword: Boolean(m.user.passwordHash),
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
permissions: m.isOwner
@@ -310,6 +311,77 @@ export class StaffService {
organizationName: org.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
mode: invitation.membership.isActive ? 'password_setup' : 'join',
},
};
}
async clearPassword(
actorUserId: string,
organizationId: string,
membershipId: string,
) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const membership = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
user: { select: { id: true, email: true } },
},
});
if (!membership) {
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (membership.isOwner) {
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
}
if (membership.userId === actorUserId) {
throw new AppException(ErrorCode.STAFF_CANNOT_CLEAR_OWN_PASSWORD, HttpStatus.BAD_REQUEST);
}
if (!membership.isActive) {
throw new AppException(ErrorCode.STAFF_PASSWORD_CLEAR_ACTIVE_ONLY, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
const invitation = await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: membership.userId },
data: { passwordHash: null },
});
await tx.session.deleteMany({
where: { userId: membership.userId },
});
await tx.staffInvitation.updateMany({
where: {
membershipId: membership.id,
acceptedAt: null,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return tx.staffInvitation.create({
data: {
membershipId: membership.id,
invitedById: actorUserId,
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
});
return {
success: true,
data: {
membershipId: membership.id,
invitationId: invitation.id,
email: membership.user.email,
invitationUrl: this.buildInviteUrl(plainToken),
},
};
}

View File

@@ -222,14 +222,7 @@ export class TreatmentsService {
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
patientId = sentinel.id;
} else {
await this.ensurePatientExists(dto.patientId!);
const patient = await this.prisma.patient.findUnique({
where: { id: dto.patientId! },
select: { isWalkIn: true },
});
if (patient?.isWalkIn) {
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
}
await this.ensurePatientInOrg(dto.patientId!, organizationId);
patientId = dto.patientId!;
}
@@ -1670,6 +1663,20 @@ export class TreatmentsService {
}
}
private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: {
id: patientId,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
select: { id: true },
});
if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
private async ensureTreatmentProvider(
treatmentId: string,
organizationId: string,