improvement: a flow added for owner users to make it possible for them to participate in treatments or tasks.

This commit is contained in:
2026-07-12 15:11:50 +03:30
parent 4208d15efb
commit 39935688ad
20 changed files with 1122 additions and 154 deletions

View File

@@ -0,0 +1,96 @@
import {
ownerPermissionsForOrgType,
type OrganizationTypeName,
} from './organization-type';
import { normalizeTabPermissions } from './permissions';
export const CLINIC_PARTICIPATION_PERMISSIONS = [
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
] as const;
export const LAB_PARTICIPATION_PERMISSIONS = [
'TAB_TASKS_READ',
'TAB_TASKS_EDIT',
] as const;
const CLINIC_PARTICIPATION_SET = new Set<string>(CLINIC_PARTICIPATION_PERMISSIONS);
const LAB_PARTICIPATION_SET = new Set<string>(LAB_PARTICIPATION_PERMISSIONS);
export type MembershipWithPermissions = {
isOwner: boolean;
organization: {
plan?: { name: string; maxUsers?: number; price?: number } | null;
planId?: string | null;
type?: { name: string };
};
permissions?: Array<{ permission: { name: string } }>;
};
export function getOrgTypeFromMembership(
membership: MembershipWithPermissions,
): OrganizationTypeName {
return membership.organization.type?.name === 'LAB' ? 'LAB' : 'CLINIC';
}
export function hasActivePlan(membership: MembershipWithPermissions): boolean {
if (membership.organization.plan != null) {
return true;
}
return Boolean(membership.organization.planId);
}
export function getStoredPermissionNames(
membership: MembershipWithPermissions,
): string[] {
return membership.permissions?.map((p) => p.permission.name) ?? [];
}
export function getEffectivePermissionNames(
membership: MembershipWithPermissions,
): string[] {
const stored = getStoredPermissionNames(membership);
if (!membership.isOwner) {
return normalizeTabPermissions(stored);
}
const orgType = getOrgTypeFromMembership(membership);
const base = ownerPermissionsForOrgType(orgType, hasActivePlan(membership));
return normalizeTabPermissions([...base, ...stored]);
}
export function hasEffectivePermission(
membership: MembershipWithPermissions,
permission: string,
): boolean {
return getEffectivePermissionNames(membership).includes(permission);
}
export function participationPermissionsForOrgType(
orgType: OrganizationTypeName,
): readonly string[] {
return orgType === 'LAB'
? LAB_PARTICIPATION_PERMISSIONS
: CLINIC_PARTICIPATION_PERMISSIONS;
}
export function participatesInTreatments(
membership: MembershipWithPermissions,
): boolean {
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
return false;
}
return getStoredPermissionNames(membership).includes('TAB_TREATMENT_EDIT');
}
export function participatesInTasks(membership: MembershipWithPermissions): boolean {
if (getOrgTypeFromMembership(membership) !== 'LAB') {
return false;
}
return getStoredPermissionNames(membership).includes('TAB_TASKS_EDIT');
}
export function isParticipationPermission(name: string): boolean {
return CLINIC_PARTICIPATION_SET.has(name) || LAB_PARTICIPATION_SET.has(name);
}

View File

@@ -49,18 +49,27 @@ export function filterPermissionsForOrgType(
return normalizeTabPermissions(names.filter((n) => allowed.has(n))); return normalizeTabPermissions(names.filter((n) => allowed.has(n)));
} }
/** Owner opt-in permissions — granted via MembershipPermission when owner chooses to participate. */
const OWNER_OPT_IN_CLINIC = new Set<string>(['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT']);
const OWNER_OPT_IN_LAB = new Set<string>(['TAB_TASKS_READ', 'TAB_TASKS_EDIT']);
function ownerBasePermissions(orgType: OrganizationTypeName): readonly string[] {
const all = orgType === 'LAB' ? LAB_TAB_PERMISSIONS : CLINIC_TAB_PERMISSIONS;
const optIn = orgType === 'LAB' ? OWNER_OPT_IN_LAB : OWNER_OPT_IN_CLINIC;
return all.filter((p) => !optIn.has(p));
}
export function ownerPermissionsForOrgType( export function ownerPermissionsForOrgType(
orgType: OrganizationTypeName, orgType: OrganizationTypeName,
hasActivePlan: boolean, hasActivePlan: boolean,
): string[] { ): string[] {
const base = ownerBasePermissions(orgType);
if (hasActivePlan) { if (hasActivePlan) {
return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS]; return [...base];
} }
const readOnly = (perms: readonly string[]) => return normalizeTabPermissions(base.filter((p) => p.endsWith('_READ')));
normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ')));
return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS);
} }
export async function getOrganizationTypeName( export async function getOrganizationTypeName(

View File

@@ -15,6 +15,7 @@ import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
const MS_PER_DAY = 86_400_000; const MS_PER_DAY = 86_400_000;
@@ -39,8 +40,7 @@ export class AppointmentsService {
const members = await this.prisma.membership.findMany({ const members = await this.prisma.membership.findMany({
where: { where: {
organizationId, organizationId,
isOwner: false, OR: [{ isOwner: true }, { isActive: true }],
isActive: true,
permissions: { permissions: {
some: { some: {
permission: { permission: {
@@ -308,16 +308,10 @@ export class AppointmentsService {
if (!m) { if (!m) {
throw new BadRequestException('Provider is not a member of this organization'); throw new BadRequestException('Provider is not a member of this organization');
} }
if (m.isOwner) { if (!m.isOwner && !m.isActive) {
throw new BadRequestException(
'Appointments must be assigned to staff with treatment access, not the organization owner',
);
}
if (!m.isActive) {
throw new BadRequestException('Provider is not an active staff member'); throw new BadRequestException('Provider is not an active staff member');
} }
const names = m.permissions.map((p) => p.permission.name); if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
if (!names.includes('TAB_TREATMENT_EDIT')) {
throw new BadRequestException('Provider does not have treatment edit access'); throw new BadRequestException('Provider does not have treatment edit access');
} }
} }
@@ -375,8 +369,15 @@ export class AppointmentsService {
private async getMembership(userId: string, organizationId: string) { private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId }, where: {
include: { permissions: { include: { permission: true } } }, userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
}); });
} }
} }

View File

@@ -11,6 +11,7 @@ import {
HttpStatus, HttpStatus,
Get, Get,
Patch, Patch,
Put,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
@@ -36,6 +37,8 @@ import {
ForgotPasswordVerifyDto, ForgotPasswordVerifyDto,
} from './dto/forgot-password.dto'; } from './dto/forgot-password.dto';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { UpdateParticipationDto } from './dto/update-participation.dto';
import { UpsertWorkingHoursDto } from '../staff/dto/upsert-working-hours.dto';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -200,6 +203,46 @@ export class AuthController {
return result; return result;
} }
@Get('profile/participation')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get owner participation settings for current organization' })
async getParticipation(@Req() req) {
return this.authService.getParticipation(req.user.id, req.user.organizationId);
}
@Patch('profile/participation')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Enable or disable owner participation in treatments/tasks' })
async updateParticipation(@Req() req, @Body() dto: UpdateParticipationDto) {
return this.authService.updateParticipation(
req.user.id,
req.user.organizationId,
dto,
);
}
@Get('profile/working-hours')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get working hours for participating clinic owner' })
async getMyWorkingHours(@Req() req) {
return this.authService.getMyWorkingHours(req.user.id, req.user.organizationId);
}
@Put('profile/working-hours')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Save working hours for participating clinic owner' })
async upsertMyWorkingHours(@Req() req, @Body() dto: UpsertWorkingHoursDto) {
return this.authService.upsertMyWorkingHours(
req.user.id,
req.user.organizationId,
dto,
);
}
@Post('forgot-password/send-code') @Post('forgot-password/send-code')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send forgot-password SMS verification code' }) @ApiOperation({ summary: 'Send forgot-password SMS verification code' })

View File

@@ -9,11 +9,13 @@ import { PrismaService } from '../../../prisma/prisma.service';
import { LocalStrategy } from './strategies/local.strategy'; import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
import { StaffModule } from '../staff/staff.module';
@Module({ @Module({
imports: [ imports: [
PassportModule, PassportModule,
SmsModule, SmsModule,
StaffModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({

View File

@@ -21,6 +21,11 @@ import {
} from './dto/update-language.dto'; } from './dto/update-language.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface';
import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type'; import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type';
import { getEffectivePermissionNames, getOrgTypeFromMembership, hasActivePlan, participatesInTasks, participatesInTreatments, participationPermissionsForOrgType } from '../../common/membership-permissions';
import { assertClinicOrganization, assertLabOrganization } from '../../common/organization-type';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { UpsertWorkingHoursDto } from '../staff/dto/upsert-working-hours.dto';
import { UpdateParticipationDto } from './dto/update-participation.dto';
import { SmsService } from '../sms/sms.service'; import { SmsService } from '../sms/sms.service';
import { import {
ForgotPasswordSendCodeDto, ForgotPasswordSendCodeDto,
@@ -75,6 +80,7 @@ export class AuthService {
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
private smsService: SmsService, private smsService: SmsService,
private staffWorkingHoursService: StaffWorkingHoursService,
) { } ) { }
private accessJwtSignOptions(): JwtSignOptions { private accessJwtSignOptions(): JwtSignOptions {
@@ -999,17 +1005,12 @@ export class AuthService {
isOwner: boolean; isOwner: boolean;
organization: { organization: {
plan?: { name: string; maxUsers: number; price: number } | null; plan?: { name: string; maxUsers: number; price: number } | null;
planId?: string | null;
type?: { name: string }; type?: { name: string };
}; };
permissions?: Array<{ permission: { name: string } }>; permissions?: Array<{ permission: { name: string } }>;
}): string[] { }): string[] {
if (membership.isOwner) { return getEffectivePermissionNames(membership);
const orgType = (membership.organization.type?.name === 'LAB'
? 'LAB'
: 'CLINIC') as OrganizationTypeName;
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.plan));
}
return membership.permissions?.map((p) => p.permission.name) || [];
} }
/** /**
@@ -1161,4 +1162,204 @@ export class AuthService {
mobile: user.mobile ?? null, mobile: user.mobile ?? null,
}; };
} }
private async getOwnerMembership(userId: string, organizationId: string) {
if (!organizationId) {
throw new BadRequestException('Organization is not selected');
}
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isOwner: true },
include: {
organization: {
include: { type: true, plan: true },
},
permissions: { include: { permission: true } },
},
});
if (!membership) {
throw new ForbiddenException('Only organization owners can manage participation');
}
return membership;
}
async getParticipation(userId: string, organizationId: string) {
const membership = await this.getOwnerMembership(userId, organizationId);
const orgType = getOrgTypeFromMembership(membership);
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: { blocks: true },
});
return {
success: true,
data: {
membershipId: membership.id,
orgType,
participatesInTreatments: participatesInTreatments(membership),
participatesInTasks: participatesInTasks(membership),
hasWorkingHours: (schedule?.blocks.length ?? 0) > 0,
},
};
}
async updateParticipation(
userId: string,
organizationId: string,
dto: UpdateParticipationDto,
) {
const membership = await this.getOwnerMembership(userId, organizationId);
const orgType = getOrgTypeFromMembership(membership);
if (!hasActivePlan(membership)) {
throw new ForbiddenException(
'An active subscription is required to participate in treatments or tasks',
);
}
if (dto.participate) {
await this.grantOwnerParticipation(membership.id, orgType);
} else {
await this.revokeOwnerParticipation(membership, orgType);
}
const updated = await this.prisma.membership.findFirst({
where: { id: membership.id },
include: {
organization: { include: { type: true, plan: true } },
permissions: { include: { permission: true } },
},
});
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: { blocks: true },
});
return {
success: true,
data: {
membershipId: membership.id,
orgType,
participatesInTreatments: participatesInTreatments(updated!),
participatesInTasks: participatesInTasks(updated!),
hasWorkingHours: (schedule?.blocks.length ?? 0) > 0,
permissions: getEffectivePermissionNames(updated!),
},
};
}
async getMyWorkingHours(userId: string, organizationId: string) {
const membership = await this.getOwnerMembership(userId, organizationId);
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
throw new BadRequestException('Working hours are only available for clinic organizations');
}
return this.staffWorkingHoursService.getMyWorkingHours(userId, organizationId);
}
async upsertMyWorkingHours(
userId: string,
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const membership = await this.getOwnerMembership(userId, organizationId);
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
throw new BadRequestException('Working hours are only available for clinic organizations');
}
if (!participatesInTreatments(membership)) {
throw new ForbiddenException(
'Enable treatment participation before setting working hours',
);
}
return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto);
}
private async grantOwnerParticipation(
membershipId: string,
orgType: OrganizationTypeName,
) {
const participationNames = participationPermissionsForOrgType(orgType).filter((p) =>
p.endsWith('_EDIT'),
);
const permissionRows = await this.prisma.permission.findMany({
where: { name: { in: [...participationNames] } },
});
if (permissionRows.length === 0) {
throw new InternalServerErrorException('Participation permissions are not configured');
}
const participationIds = (
await this.prisma.permission.findMany({
where: { name: { in: [...participationPermissionsForOrgType(orgType)] } },
select: { id: true },
})
).map((p) => p.id);
await this.prisma.$transaction(async (tx) => {
await tx.membershipPermission.deleteMany({
where: {
membershipId,
permissionId: { in: participationIds },
},
});
await tx.membershipPermission.createMany({
data: permissionRows.map((p) => ({
membershipId,
permissionId: p.id,
})),
skipDuplicates: true,
});
});
}
private async revokeOwnerParticipation(
membership: {
id: string;
userId: string;
organizationId: string;
organization: { id: string };
},
orgType: OrganizationTypeName,
) {
if (orgType === 'CLINIC') {
await assertClinicOrganization(this.prisma, membership.organizationId);
const futureAppointment = await this.prisma.appointment.findFirst({
where: {
organizationId: membership.organizationId,
providerUserId: membership.userId,
startAt: { gte: new Date() },
},
select: { id: true },
});
if (futureAppointment) {
throw new ConflictException(
'You cannot stop participating in treatments while you have future appointments assigned. Reassign or cancel those appointments first.',
);
}
} else {
await assertLabOrganization(this.prisma, membership.organizationId);
}
const participationIds = (
await this.prisma.permission.findMany({
where: { name: { in: [...participationPermissionsForOrgType(orgType)] } },
select: { id: true },
})
).map((p) => p.id);
await this.prisma.membershipPermission.deleteMany({
where: {
membershipId: membership.id,
permissionId: { in: participationIds },
},
});
}
} }

View File

@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateParticipationDto {
@IsBoolean()
participate: boolean;
}

View File

@@ -6,6 +6,7 @@ import {
import { LabCaseCommentSide, Prisma } from '@prisma/client'; import { LabCaseCommentSide, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
const commentInclude = { const commentInclude = {
authorUser: { select: { id: true, name: true } }, authorUser: { select: { id: true, name: true } },
@@ -206,15 +207,20 @@ export class LabCaseCommentsService {
} }
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true }, where: {
include: { permissions: { include: { permission: true } } }, userId: actorUserId,
organizationId: labOrganizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
}); });
if (!membership) { if (!membership) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (membership.isOwner) return; if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
const names = membership.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments'); throw new ForbiddenException('You do not have access to task comments');
} }
} }
@@ -244,15 +250,23 @@ export class LabCaseCommentsService {
) { ) {
await this.assertClinicOwnsCase(caseId, clinicOrganizationId); await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true }, where: {
include: { permissions: { include: { permission: true } } }, userId: actorUserId,
organizationId: clinicOrganizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
}); });
if (!membership) { if (!membership) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (membership.isOwner) return; if (
const names = membership.permissions.map((p) => p.permission.name); hasEffectivePermission(membership, 'TAB_TREATMENT_READ') ||
if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')
) {
return; return;
} }
throw new ForbiddenException('You do not have access to treatment cases'); throw new ForbiddenException('You do not have access to treatment cases');

View File

@@ -13,6 +13,9 @@ import {
type WorkingHoursBlockInput, type WorkingHoursBlockInput,
} from '../../common/working-hours'; } from '../../common/working-hours';
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto';
import {
hasEffectivePermission,
} from '../../common/membership-permissions';
@Injectable() @Injectable()
export class StaffWorkingHoursService { export class StaffWorkingHoursService {
@@ -21,7 +24,7 @@ export class StaffWorkingHoursService {
async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) { async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) {
await this.assertCanViewStaff(actorUserId, organizationId); await this.assertCanViewStaff(actorUserId, organizationId);
const membership = await this.findMembership(membershipId, organizationId); const membership = await this.findStaffMembership(membershipId, organizationId);
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id }, where: { membershipId: membership.id },
include: { include: {
@@ -63,7 +66,62 @@ export class StaffWorkingHoursService {
) { ) {
await this.assertCanEditStaff(actorUserId, organizationId); await this.assertCanEditStaff(actorUserId, organizationId);
const membership = await this.findMembership(membershipId, organizationId); const membership = await this.findStaffMembership(membershipId, organizationId);
return this.persistWorkingHours(membership, organizationId, dto);
}
async getMyWorkingHours(userId: string, organizationId: string) {
const membership = await this.findOwnerMembership(userId, organizationId);
await this.assertOwnerCanManageWorkingHours(membership);
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: {
blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] },
},
});
if (!schedule) {
return {
success: true,
data: {
autoRepeatWeekly: true,
blocks: [],
hasWorkingHours: false,
},
};
}
return {
success: true,
data: {
autoRepeatWeekly: schedule.autoRepeatWeekly,
blocks: schedule.blocks.map((b) => ({
dayOfWeek: b.dayOfWeek,
startMinute: b.startMinute,
endMinute: b.endMinute,
sortOrder: b.sortOrder,
})),
hasWorkingHours: schedule.blocks.length > 0,
},
};
}
async upsertMyWorkingHours(
userId: string,
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const membership = await this.findOwnerMembership(userId, organizationId);
await this.assertOwnerCanManageWorkingHours(membership);
return this.persistWorkingHours(membership, organizationId, dto);
}
private async persistWorkingHours(
membership: { id: string; userId: string },
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const validationError = validateWorkingHoursBlocks(dto.blocks); const validationError = validateWorkingHoursBlocks(dto.blocks);
if (validationError) { if (validationError) {
throw new BadRequestException(validationError); throw new BadRequestException(validationError);
@@ -205,7 +263,7 @@ export class StaffWorkingHoursService {
); );
} }
private async findMembership(membershipId: string, organizationId: string) { private async findStaffMembership(membershipId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId }, where: { id: membershipId, organizationId },
select: { id: true, isOwner: true, userId: true }, select: { id: true, isOwner: true, userId: true },
@@ -219,6 +277,32 @@ export class StaffWorkingHoursService {
return membership; return membership;
} }
private async findOwnerMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isOwner: true },
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
if (!membership) {
throw new ForbiddenException('Only organization owners can manage their working hours');
}
return membership;
}
private async assertOwnerCanManageWorkingHours(membership: {
isOwner: boolean;
permissions: { permission: { name: string } }[];
organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null };
}) {
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
throw new ForbiddenException(
'Enable treatment participation before setting working hours',
);
}
}
private async assertCanViewStaff(userId: string, organizationId: string) { private async assertCanViewStaff(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId); const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canViewStaff(actor)) { if (!actor || !this.canViewStaff(actor)) {

View File

@@ -13,6 +13,7 @@ import {
} from '../catalog/catalog-label.service'; } from '../catalog/catalog-label.service';
import { normalizeTaskTeeth } from '../cases/lab-case-task.util'; import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
const taskListInclude = { const taskListInclude = {
lastStatusChangedBy: { select: { id: true, name: true } }, lastStatusChangedBy: { select: { id: true, name: true } },
@@ -288,9 +289,10 @@ export class TasksService {
if (!m) { if (!m) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (m.isOwner) return; if (
const names = m.permissions.map((p) => p.permission.name); hasEffectivePermission(m, 'TAB_TASKS_READ') ||
if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) { hasEffectivePermission(m, 'TAB_TASKS_EDIT')
) {
return; return;
} }
throw new ForbiddenException('You do not have access to tasks'); throw new ForbiddenException('You do not have access to tasks');
@@ -301,9 +303,7 @@ export class TasksService {
if (!m) { if (!m) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (m.isOwner) return; if (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) {
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_TASKS_EDIT')) {
return; return;
} }
throw new ForbiddenException('You cannot update tasks'); throw new ForbiddenException('You cannot update tasks');
@@ -311,8 +311,15 @@ export class TasksService {
private async getMembership(userId: string, organizationId: string) { private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true }, where: {
include: { permissions: { include: { permission: true } } }, userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
}); });
} }
} }

View File

@@ -7,12 +7,11 @@ import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { import {
isUnlimitedSeats, isUnlimitedSeats,
normalizeTabPermissions,
} from '../../common/permissions'; } from '../../common/permissions';
import { import {
OrganizationTypeName, OrganizationTypeName,
ownerPermissionsForOrgType,
} from '../../common/organization-type'; } from '../../common/organization-type';
import { getEffectivePermissionNames } from '../../common/membership-permissions';
import { import {
CatalogLabelService, CatalogLabelService,
normalizeCatalogLocale, normalizeCatalogLocale,
@@ -565,16 +564,10 @@ export class TodayService {
const members = await this.prisma.membership.findMany({ const members = await this.prisma.membership.findMany({
where: { where: {
organizationId, organizationId,
isActive: true, OR: [{ isOwner: true }, { isActive: true }],
OR: [ permissions: {
{ isOwner: true }, some: { permission: { name: editPermission } },
{ },
isOwner: false,
permissions: {
some: { permission: { name: editPermission } },
},
},
],
}, },
select: { userId: true, isOwner: true }, select: { userId: true, isOwner: true },
}); });
@@ -605,10 +598,7 @@ export class TodayService {
code: member.userId, code: member.userId,
label: nameById.get(member.userId) ?? member.userId, label: nameById.get(member.userId) ?? member.userId,
count: countsByUser.get(member.userId) ?? 0, count: countsByUser.get(member.userId) ?? 0,
isOwner: member.isOwner,
})) }))
.filter((row) => !row.isOwner || row.count > 0)
.map(({ code, label, count }) => ({ code, label, count }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
return rows.length >= 2 ? rows : undefined; return rows.length >= 2 ? rows : undefined;
@@ -834,8 +824,7 @@ export class TodayService {
const members = await this.prisma.membership.findMany({ const members = await this.prisma.membership.findMany({
where: { where: {
organizationId, organizationId,
isOwner: false, OR: [{ isOwner: true }, { isActive: true }],
isActive: true,
permissions: { permissions: {
some: { some: {
permission: { name: 'TAB_TREATMENT_EDIT' }, permission: { name: 'TAB_TREATMENT_EDIT' },
@@ -1184,19 +1173,12 @@ export class TodayService {
isOwner: boolean; isOwner: boolean;
organization: { organization: {
planId: string | null; planId: string | null;
plan?: { name: string } | null;
type: { name: string }; type: { name: string };
}; };
permissions: { permission: { name: string } }[]; permissions: { permission: { name: string } }[];
}): string[] { }): string[] {
if (membership.isOwner) { return getEffectivePermissionNames(membership);
const orgType = (membership.organization.type.name === 'LAB'
? 'LAB'
: 'CLINIC') as OrganizationTypeName;
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
}
return normalizeTabPermissions(
membership.permissions.map((p) => p.permission.name),
);
} }
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) { private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
@@ -1220,15 +1202,13 @@ export class TodayService {
); );
} }
private canViewTreatment(isOwner: boolean, names: string[]): boolean { private canViewTreatment(_isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) => return names.some((p) =>
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p), ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
); );
} }
private canViewMyAppointmentsWeekChart(isOwner: boolean, names: string[]): boolean { private canViewMyAppointmentsWeekChart(_isOwner: boolean, names: string[]): boolean {
if (isOwner) return false;
return names.includes('TAB_TREATMENT_EDIT'); return names.includes('TAB_TREATMENT_EDIT');
} }
@@ -1239,15 +1219,13 @@ export class TodayService {
); );
} }
private canViewTasks(isOwner: boolean, names: string[]): boolean { private canViewTasks(_isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) => return names.some((p) =>
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p), ['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
); );
} }
private canEditTreatment(isOwner: boolean, names: string[]): boolean { private canEditTreatment(_isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.includes('TAB_TREATMENT_EDIT'); return names.includes('TAB_TREATMENT_EDIT');
} }

View File

@@ -21,6 +21,7 @@ import {
normalizeTeeth, normalizeTeeth,
} from './treatment.utils'; } from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
import { hasEffectivePermission } from '../../common/membership-permissions';
const treatmentInclude = { const treatmentInclude = {
details: { details: {
@@ -923,9 +924,10 @@ export class TreatmentsService {
if (!m) { if (!m) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (m.isOwner) return; if (
const names = m.permissions.map((p) => p.permission.name); hasEffectivePermission(m, 'TAB_TREATMENT_READ') ||
if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')
) {
return; return;
} }
throw new ForbiddenException('You do not have access to treatments'); throw new ForbiddenException('You do not have access to treatments');
@@ -936,9 +938,7 @@ export class TreatmentsService {
if (!m) { if (!m) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (m.isOwner) return; if (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_TREATMENT_EDIT')) {
return; return;
} }
throw new ForbiddenException('You cannot edit treatments'); throw new ForbiddenException('You cannot edit treatments');
@@ -946,8 +946,15 @@ export class TreatmentsService {
private async getMembership(userId: string, organizationId: string) { private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true }, where: {
include: { permissions: { include: { permission: true } } }, userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
}); });
} }
} }

View File

@@ -756,7 +756,23 @@
"settings": { "settings": {
"accountTitle": "Account", "accountTitle": "Account",
"accountSubtitle": "Profile and security settings for your login.", "accountSubtitle": "Profile and security settings for your login.",
"accountPlaceholder": "Password change and profile editing will be wired here next (e.g. invite flow, reset password).", "participationSectionTitle": "Clinical participation",
"participationSectionSubtitle": "Choose whether you personally take part in clinical or lab work for this organization.",
"participateInTreatments": "I want to participate in treatments",
"participateInTasks": "I want to participate in tasks",
"participateWorkingHoursTitle": "Working hours",
"participateWorkingHoursSubtitle": "Set your schedule so appointments can be booked for you.",
"participateSaveHours": "Save and participate",
"participateEnabledTreatments": "You are now participating in treatments.",
"participateEnabledTasks": "You are now participating in tasks.",
"participateDisabledTreatments": "You are no longer participating in treatments.",
"participateDisabledTasks": "You are no longer participating in tasks.",
"participateUpdateFailed": "Could not update participation settings. Please try again.",
"participateConfirmRevokeTitle": "Stop participating?",
"participateConfirmRevokeBodyTreatments": "You will lose treatment access and be removed from the appointments schedule. Your saved working hours will be kept.",
"participateConfirmRevokeBodyTasks": "You will lose task edit access and be removed from the efficiency report.",
"participateConfirmRevokeConfirm": "Stop participating",
"changePasswordOption": "Change password",
"changePasswordTitle": "Change password", "changePasswordTitle": "Change password",
"resetPasswordTitle": "Set a new password", "resetPasswordTitle": "Set a new password",
"resetPasswordSubtitle": "Your mobile was verified. Choose a new password for your account.", "resetPasswordSubtitle": "Your mobile was verified. Choose a new password for your account.",

View File

@@ -757,7 +757,23 @@
"settings": { "settings": {
"accountTitle": "حساب کاربری", "accountTitle": "حساب کاربری",
"accountSubtitle": "تنظیمات پروفایل و امنیت برای ورود شما.", "accountSubtitle": "تنظیمات پروفایل و امنیت برای ورود شما.",
"accountPlaceholder": "تغییر رمز عبور و ویرایش پروفایل در مرحله بعدی در اینجا قرار می‌گیرند (مثلاً فرآیند دعوت، بازنشانی رمز عبور).", "participationSectionTitle": "مشارکت بالینی",
"participationSectionSubtitle": "مشخص کنید آیا شخصاً در درمان‌ها یا کارهای آزمایشگاهی این سازمان شرکت می‌کنید.",
"participateInTreatments": "می‌خواهم در درمان‌ها شرکت کنم",
"participateInTasks": "می‌خواهم در وظایف شرکت کنم",
"participateWorkingHoursTitle": "ساعات کاری",
"participateWorkingHoursSubtitle": "برنامه خود را تنظیم کنید تا نوبت‌ها برای شما رزرو شوند.",
"participateSaveHours": "ذخیره و مشارکت",
"participateEnabledTreatments": "اکنون در درمان‌ها شرکت می‌کنید.",
"participateEnabledTasks": "اکنون در وظایف شرکت می‌کنید.",
"participateDisabledTreatments": "دیگر در درمان‌ها شرکت نمی‌کنید.",
"participateDisabledTasks": "دیگر در وظایف شرکت نمی‌کنید.",
"participateUpdateFailed": "به‌روزرسانی تنظیمات مشارکت ممکن نشد. دوباره تلاش کنید.",
"participateConfirmRevokeTitle": "توقف مشارکت؟",
"participateConfirmRevokeBodyTreatments": "دسترسی درمان را از دست می‌دهید و از برنامه نوبت‌ها حذف می‌شوید. ساعات کاری ذخیره‌شده حفظ می‌شود.",
"participateConfirmRevokeBodyTasks": "دسترسی ویرایش وظایف را از دست می‌دهید و از گزارش کارایی حذف می‌شوید.",
"participateConfirmRevokeConfirm": "توقف مشارکت",
"changePasswordOption": "تغییر رمز عبور",
"changePasswordTitle": "تغییر رمز عبور", "changePasswordTitle": "تغییر رمز عبور",
"resetPasswordTitle": "تنظیم رمز عبور جدید", "resetPasswordTitle": "تنظیم رمز عبور جدید",
"resetPasswordSubtitle": "موبایل شما تأیید شد. رمز عبور جدید برای حساب خود انتخاب کنید.", "resetPasswordSubtitle": "موبایل شما تأیید شد. رمز عبور جدید برای حساب خود انتخاب کنید.",

View File

@@ -757,7 +757,23 @@
"settings": { "settings": {
"accountTitle": "Account", "accountTitle": "Account",
"accountSubtitle": "Profiel- en beveiligingsinstellingen voor uw login.", "accountSubtitle": "Profiel- en beveiligingsinstellingen voor uw login.",
"accountPlaceholder": "Wachtwoordwijziging en profielbewerking worden hierna hier aangesloten (bijv. uitnodigingsflow, wachtwoord herstellen).", "participationSectionTitle": "Klinische deelname",
"participationSectionSubtitle": "Kies of u persoonlijk deelneemt aan behandelingen of labtaken voor deze organisatie.",
"participateInTreatments": "Ik wil deelnemen aan behandelingen",
"participateInTasks": "Ik wil deelnemen aan taken",
"participateWorkingHoursTitle": "Werkuren",
"participateWorkingHoursSubtitle": "Stel uw rooster in zodat afspraken voor u geboekt kunnen worden.",
"participateSaveHours": "Opslaan en deelnemen",
"participateEnabledTreatments": "U neemt nu deel aan behandelingen.",
"participateEnabledTasks": "U neemt nu deel aan taken.",
"participateDisabledTreatments": "U neemt niet langer deel aan behandelingen.",
"participateDisabledTasks": "U neemt niet langer deel aan taken.",
"participateUpdateFailed": "Deelname-instellingen konden niet worden bijgewerkt. Probeer het opnieuw.",
"participateConfirmRevokeTitle": "Deelname stoppen?",
"participateConfirmRevokeBodyTreatments": "U verliest toegang tot behandelingen en wordt uit het afsprakenrooster verwijderd. Opgeslagen werkuren blijven bewaard.",
"participateConfirmRevokeBodyTasks": "U verliest bewerkingstoegang tot taken en wordt uit het efficiëntierapport verwijderd.",
"participateConfirmRevokeConfirm": "Deelname stoppen",
"changePasswordOption": "Wachtwoord wijzigen",
"changePasswordTitle": "Wachtwoord wijzigen", "changePasswordTitle": "Wachtwoord wijzigen",
"resetPasswordTitle": "Nieuw wachtwoord instellen", "resetPasswordTitle": "Nieuw wachtwoord instellen",
"resetPasswordSubtitle": "Uw mobiel is geverifieerd. Kies een nieuw wachtwoord voor uw account.", "resetPasswordSubtitle": "Uw mobiel is geverifieerd. Kies een nieuw wachtwoord voor uw account.",

View File

@@ -1,18 +1,22 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Lock } from 'lucide-react'; import { ChevronDown, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import { accountApi } from '@/lib/api/account';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import { Toast } from '@/components/ui/shared/Toast'; import { Toast } from '@/components/ui/shared/Toast';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
type PasswordForm = { type PasswordForm = {
currentPassword: string; currentPassword: string;
@@ -25,13 +29,27 @@ export default function AccountSettingsPage() {
const tAuth = useTranslations('auth'); const tAuth = useTranslations('auth');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tValidation = useTranslations('validation'); const tValidation = useTranslations('validation');
const { user, isAuthReady } = useAuth(); const { user, currentOrganization, isAuthReady, refreshSession } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isResetFlow = searchParams.get('reset') === '1'; const isResetFlow = searchParams.get('reset') === '1';
const isOwner = Boolean(currentOrganization?.isOwner);
const orgType = currentOrganization?.type;
const showClinicParticipation = isOwner && orgType === 'CLINIC';
const showLabParticipation = isOwner && orgType === 'LAB';
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow);
const [participationLoading, setParticipationLoading] = useState(false);
const [participatesInTreatments, setParticipatesInTreatments] = useState(false);
const [participatesInTasks, setParticipatesInTasks] = useState(false);
const [workingHoursOpen, setWorkingHoursOpen] = useState(false);
const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false);
const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null);
const passwordSchema = useMemo( const passwordSchema = useMemo(
() => () =>
@@ -75,12 +93,147 @@ export default function AccountSettingsPage() {
}, },
}); });
const loadParticipation = useCallback(async () => {
if (!isOwner) return;
try {
const res = await accountApi.getParticipation();
setParticipatesInTreatments(res.data.participatesInTreatments);
setParticipatesInTasks(res.data.participatesInTasks);
} catch {
/* non-owners or missing org context */
}
}, [isOwner]);
useEffect(() => { useEffect(() => {
if (isAuthReady && !user) { if (isAuthReady && !user) {
router.replace('/login'); router.replace('/login');
} }
}, [isAuthReady, user, router]); }, [isAuthReady, user, router]);
useEffect(() => {
if (isResetFlow) {
setPasswordExpanded(true);
}
}, [isResetFlow]);
useEffect(() => {
void loadParticipation();
}, [loadParticipation, currentOrganization?.id]);
const syncSessionAfterParticipationChange = useCallback(async () => {
await refreshSession();
}, [refreshSession]);
const enableClinicParticipation = useCallback(
async (options: {
skipHours: boolean;
hoursPayload?: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
}) => {
await accountApi.updateParticipation(true);
if (!options.skipHours && options.hoursPayload) {
await accountApi.upsertMyWorkingHours(options.hoursPayload);
}
setParticipatesInTreatments(true);
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTreatments'));
},
[syncSessionAfterParticipationChange, t],
);
const enableLabParticipation = async () => {
setParticipationLoading(true);
setError(null);
try {
await accountApi.updateParticipation(true);
setParticipatesInTasks(true);
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTasks'));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setParticipatesInTasks(false);
} finally {
setParticipationLoading(false);
}
};
const confirmRevokeParticipation = async () => {
if (!pendingRevokeType) return;
setParticipationLoading(true);
setError(null);
try {
await accountApi.updateParticipation(false);
if (pendingRevokeType === 'CLINIC') {
setParticipatesInTreatments(false);
} else {
setParticipatesInTasks(false);
}
await syncSessionAfterParticipationChange();
setSuccessMessage(
pendingRevokeType === 'CLINIC'
? t('participateDisabledTreatments')
: t('participateDisabledTasks'),
);
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
} finally {
setParticipationLoading(false);
}
};
const handleClinicParticipationChange = (checked: boolean) => {
setError(null);
if (checked) {
setWorkingHoursOpen(true);
return;
}
setPendingRevokeType('CLINIC');
setRevokeConfirmOpen(true);
};
const handleWorkingHoursClose = useCallback(() => {
setWorkingHoursOpen(false);
}, []);
const handleWorkingHoursComplete = useCallback(
async (options: {
skipHours: boolean;
hoursPayload?: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
}) => {
setParticipationLoading(true);
setError(null);
try {
await enableClinicParticipation(options);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
throw err;
} finally {
setParticipationLoading(false);
}
},
[enableClinicParticipation, t],
);
const handleLabParticipationChange = (checked: boolean) => {
setError(null);
if (checked) {
void enableLabParticipation();
return;
}
setPendingRevokeType('LAB');
setRevokeConfirmOpen(true);
};
const onSubmit = async (data: PasswordForm) => { const onSubmit = async (data: PasswordForm) => {
try { try {
setError(null); setError(null);
@@ -121,65 +274,178 @@ export default function AccountSettingsPage() {
{tCommon('backToApp')} {tCommon('backToApp')}
</Link> </Link>
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary mt-4">{t('accountTitle')}</h1> <h1 className="text-xl sm:text-2xl font-semibold text-text-primary mt-4">{t('accountTitle')}</h1>
<p className="text-text-secondary text-sm mt-2"> <p className="text-text-secondary text-sm mt-2">{t('accountSubtitle')}</p>
{isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')}
</p>
</div> </div>
<div className="surface-card p-4 sm:p-6 max-w-lg"> {(showClinicParticipation || showLabParticipation) && (
<h2 className="text-lg font-medium text-text-primary mb-1"> <div className="surface-card p-4 sm:p-6 max-w-lg space-y-4">
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} <div>
</h2> <h2 className="text-lg font-medium text-text-primary">{t('participationSectionTitle')}</h2>
<p className="text-sm text-text-secondary mb-6"> <p className="text-sm text-text-secondary mt-1">{t('participationSectionSubtitle')}</p>
{user.email} </div>
{user.mobile ? ` · ${user.mobile}` : ''}
</p>
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}> {showClinicParticipation && (
{!isResetFlow && ( <Checkbox
<Input checked={participatesInTreatments}
label={t('currentPassword')} onChange={handleClinicParticipationChange}
{...register('currentPassword')} disabled={participationLoading}
type="password" label={t('participateInTreatments')}
placeholder={tAuth('passwordPlaceholder')}
error={errors.currentPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/> />
)} )}
<Input {showLabParticipation && (
label={t('newPassword')} <Checkbox
{...register('newPassword')} checked={participatesInTasks}
type="password" onChange={handleLabParticipationChange}
placeholder={tAuth('passwordPlaceholder')} disabled={participationLoading}
error={errors.newPassword?.message} label={t('participateInTasks')}
icon={<Lock className="h-5 w-5 icon-flat" />} />
passwordToggleLabels={passwordToggleLabels}
/>
<Input
label={t('confirmNewPassword')}
{...register('confirmPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)} )}
</div>
)}
<Button type="submit" variant="primary" isLoading={isSubmitting}> <div className="surface-card max-w-lg overflow-hidden">
{isResetFlow ? t('setNewPassword') : t('updatePassword')} <button
</Button> type="button"
</form> className="w-full flex items-center justify-between gap-3 p-4 sm:p-6 text-left hover:bg-background-card/40 transition-colors"
onClick={() => setPasswordExpanded((open) => !open)}
aria-expanded={passwordExpanded}
>
<div className="flex items-center gap-3 min-w-0">
<Lock className="h-5 w-5 icon-flat shrink-0" />
<div>
<p className="text-base font-medium text-text-primary">{t('changePasswordOption')}</p>
<p className="text-sm text-text-secondary truncate">
{user.email}
{user.mobile ? ` · ${user.mobile}` : ''}
</p>
</div>
</div>
<ChevronDown
className={`h-5 w-5 text-text-muted shrink-0 transition-transform ${
passwordExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{passwordExpanded && (
<div className="border-t border-border px-4 sm:px-6 pb-4 sm:pb-6 pt-4">
<h2 className="text-lg font-medium text-text-primary mb-1">
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')}
</h2>
{isResetFlow && (
<p className="text-sm text-text-secondary mb-4">{t('resetPasswordSubtitle')}</p>
)}
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{!isResetFlow && (
<Input
label={t('currentPassword')}
{...register('currentPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.currentPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
)}
<Input
label={t('newPassword')}
{...register('newPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.newPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
<Input
label={t('confirmNewPassword')}
{...register('confirmPassword')}
type="password"
placeholder={tAuth('passwordPlaceholder')}
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels}
/>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<Button type="submit" variant="primary" isLoading={isSubmitting}>
{isResetFlow ? t('setNewPassword') : t('updatePassword')}
</Button>
</form>
</div>
)}
</div> </div>
{error && !passwordExpanded && (
<div className="max-w-lg p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<OwnerWorkingHoursDialog
open={workingHoursOpen}
onClose={handleWorkingHoursClose}
onComplete={handleWorkingHoursComplete}
/>
{revokeConfirmOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
<div
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
role="dialog"
aria-modal="true"
aria-labelledby="revoke-participation-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="revoke-participation-title" className="text-lg font-semibold text-text-primary pr-2">
{t('participateConfirmRevokeTitle')}
</h2>
<DialogCloseButton
onClick={() => {
if (participationLoading) return;
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
{pendingRevokeType === 'CLINIC'
? t('participateConfirmRevokeBodyTreatments')
: t('participateConfirmRevokeBodyTasks')}
</p>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={participationLoading}
onClick={() => {
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
}}
>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="primary"
isLoading={participationLoading}
onClick={() => void confirmRevokeParticipation()}
>
{t('participateConfirmRevokeConfirm')}
</Button>
</div>
</div>
</div>
)}
{successMessage && ( {successMessage && (
<Toast variant="success">{successMessage}</Toast> <Toast variant="success">{successMessage}</Toast>
)} )}

View File

@@ -0,0 +1,161 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
StaffWorkingHoursStep,
createDefaultWorkingHoursState,
useWorkingHoursForm,
workingHoursPayloadFromState,
workingHoursStateFromApi,
} from '@/components/staff/StaffWorkingHoursStep';
import { accountApi } from '@/lib/api/account';
type OwnerWorkingHoursDialogProps = {
open: boolean;
onClose: () => void;
onComplete: (options: {
skipHours: boolean;
hoursPayload?: ReturnType<typeof workingHoursPayloadFromState>;
}) => Promise<void>;
};
export function OwnerWorkingHoursDialog({
open,
onClose,
onComplete,
}: OwnerWorkingHoursDialogProps) {
const t = useTranslations('settings');
const tStaff = useTranslations('staff');
const tCommon = useTranslations('common');
const {
days,
setDays,
autoRepeatWeekly,
setAutoRepeatWeekly,
validationError,
setValidationError,
reset,
} = useWorkingHoursForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) {
setLoading(false);
return;
}
let cancelled = false;
reset(createDefaultWorkingHoursState());
void accountApi
.getMyWorkingHours()
.then((res) => {
if (cancelled) return;
reset(workingHoursStateFromApi(res.data));
})
.catch(() => {
/* keep defaults for first-time opt-in */
});
return () => {
cancelled = true;
};
}, [open, reset]);
const handleValidationChange = useCallback(
(error: string | null) => {
setValidationError(error);
},
[setValidationError],
);
const handleClose = useCallback(() => {
if (loading) return;
onClose();
}, [loading, onClose]);
const handleSkip = async () => {
try {
setLoading(true);
await onComplete({ skipHours: true });
onClose();
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (validationError) return;
try {
setLoading(true);
await onComplete({
skipHours: false,
hoursPayload: workingHoursPayloadFromState({ days, autoRepeatWeekly }),
});
onClose();
} finally {
setLoading(false);
}
};
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
<div
className="w-full sm:max-w-lg max-h-[90dvh] overflow-y-auto rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-md)] border border-border bg-background-secondary p-4 sm:p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="owner-working-hours-title"
>
<div className="flex items-start justify-between gap-3">
<div>
<h2 id="owner-working-hours-title" className="text-lg font-semibold text-text-primary pr-2">
{t('participateWorkingHoursTitle')}
</h2>
<p className="text-xs text-text-muted mt-1">{t('participateWorkingHoursSubtitle')}</p>
</div>
<DialogCloseButton onClick={handleClose} />
</div>
<StaffWorkingHoursStep
days={days}
autoRepeatWeekly={autoRepeatWeekly}
onDaysChange={setDays}
onAutoRepeatWeeklyChange={setAutoRepeatWeekly}
onValidationChange={handleValidationChange}
disabled={loading}
/>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-2">
<Button variant="outline" type="button" disabled={loading} onClick={handleClose}>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="outline"
isLoading={loading}
onClick={() => void handleSkip()}
>
{tStaff('skipForNow')}
</Button>
<Button
type="button"
isLoading={loading}
disabled={Boolean(validationError)}
onClick={() => void handleSave()}
>
{t('participateSaveHours')}
</Button>
</div>
</div>
</div>
);
}
export { workingHoursPayloadFromState };

View File

@@ -142,15 +142,13 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
export function canEditTreatment(org: Organization | null): boolean { export function canEditTreatment(org: Organization | null): boolean {
if (!org) return false; if (!org) return false;
if (org.type !== 'CLINIC') return false; if (org.type !== 'CLINIC') return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TREATMENT_EDIT'); return hasPermission(org, 'TAB_TREATMENT_EDIT');
} }
/** Staff treatment editors only — personal schedule Today gadgets (not owners). */ /** Staff and participating owners — personal schedule Today gadgets */
export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean { export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean {
if (!org) return false; if (!org) return false;
if (org.type !== 'CLINIC') return false; if (org.type !== 'CLINIC') return false;
if (org.isOwner) return false;
return hasPermission(org, 'TAB_TREATMENT_EDIT'); return hasPermission(org, 'TAB_TREATMENT_EDIT');
} }
@@ -158,7 +156,6 @@ export function canViewMyAppointmentsWeekChart(org: Organization | null): boolea
export function canViewTreatment(org: Organization | null): boolean { export function canViewTreatment(org: Organization | null): boolean {
if (!org) return false; if (!org) return false;
if (org.type !== 'CLINIC') return false; if (org.type !== 'CLINIC') return false;
if (org.isOwner) return true;
return ( return (
hasPermission(org, 'TAB_TREATMENT_READ') || hasPermission(org, 'TAB_TREATMENT_READ') ||
hasPermission(org, 'TAB_TREATMENT_EDIT') hasPermission(org, 'TAB_TREATMENT_EDIT')
@@ -187,7 +184,6 @@ export function canEditCases(org: Organization | null): boolean {
export function canViewTasks(org: Organization | null): boolean { export function canViewTasks(org: Organization | null): boolean {
if (!org) return false; if (!org) return false;
if (org.type !== 'LAB') return false; if (org.type !== 'LAB') return false;
if (org.isOwner) return true;
return ( return (
hasPermission(org, 'TAB_TASKS_READ') || hasPermission(org, 'TAB_TASKS_READ') ||
hasPermission(org, 'TAB_TASKS_EDIT') hasPermission(org, 'TAB_TASKS_EDIT')
@@ -197,7 +193,6 @@ export function canViewTasks(org: Organization | null): boolean {
export function canEditTasks(org: Organization | null): boolean { export function canEditTasks(org: Organization | null): boolean {
if (!org) return false; if (!org) return false;
if (org.type !== 'LAB') return false; if (org.type !== 'LAB') return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TASKS_EDIT'); return hasPermission(org, 'TAB_TASKS_EDIT');
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor'; import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor';
import { import {
@@ -87,6 +87,12 @@ export function useWorkingHoursForm(initial?: {
const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true); const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const reset = useCallback((next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) => {
setDays(next?.days ?? emptyWorkingHoursEditorDays());
setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true);
setValidationError(null);
}, []);
return { return {
days, days,
setDays, setDays,
@@ -94,10 +100,6 @@ export function useWorkingHoursForm(initial?: {
setAutoRepeatWeekly, setAutoRepeatWeekly,
validationError, validationError,
setValidationError, setValidationError,
reset(next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) { reset,
setDays(next?.days ?? emptyWorkingHoursEditorDays());
setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true);
setValidationError(null);
},
}; };
} }

View File

@@ -0,0 +1,48 @@
import { apiClient } from './client';
export type WorkingHoursPayload = {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
};
export type ParticipationData = {
membershipId: string;
orgType: 'CLINIC' | 'LAB';
participatesInTreatments: boolean;
participatesInTasks: boolean;
hasWorkingHours: boolean;
};
export type ParticipationUpdateData = ParticipationData & {
permissions?: string[];
};
export const accountApi = {
getParticipation: async (): Promise<{ success: boolean; data: ParticipationData }> => {
const response = await apiClient.get('/auth/profile/participation');
return response.data;
},
updateParticipation: async (participate: boolean): Promise<{
success: boolean;
data: ParticipationUpdateData;
}> => {
const response = await apiClient.patch('/auth/profile/participation', { participate });
return response.data;
},
getMyWorkingHours: async (): Promise<{
success: boolean;
data: WorkingHoursPayload & { hasWorkingHours: boolean };
}> => {
const response = await apiClient.get('/auth/profile/working-hours');
return response.data;
},
upsertMyWorkingHours: async (
payload: WorkingHoursPayload,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.put('/auth/profile/working-hours', payload);
return response.data;
},
};