Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
28 changes: 25 additions & 3 deletions src/cohort/cohort.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Post,
Body,
Put,
Patch,
Param,
UseInterceptors,
SerializeOptions,
Expand All @@ -41,6 +42,7 @@ import { Response } from "express";
import { CohortService } from "./cohort.service";
import { CohortCreateDto } from "./dto/cohort-create.dto";
import { CohortUpdateDto } from "./dto/cohort-update.dto";
import { CohortStatusUpdateDto } from "./dto/cohort-status-update.dto";
import { GeographicalHierarchySearchDto } from "./dto/geographical-hierarchy-search.dto";
import { JwtAuthGuard } from "src/common/guards/keycloak.guard";
import { AllExceptionsFilter } from "src/common/filters/exception.filter";
Expand All @@ -54,7 +56,7 @@ import { GetUserId } from "src/common/decorators/getUserId.decorator";
@Controller("cohort")
@UseGuards(JwtAuthGuard)
export class CohortController {
constructor(private readonly cohortService: CohortService) {}
constructor(private readonly cohortService: CohortService) { }

@UseFilters(new AllExceptionsFilter(APIID.COHORT_READ))
@Get("/cohortHierarchy/:cohortId")
Expand Down Expand Up @@ -106,9 +108,9 @@ export class CohortController {
@Body() cohortCreateDto: CohortCreateDto,
@UploadedFile() image,
@Res() response: Response,
@GetUserId("userId", ParseUUIDPipe) userId: string
@GetUserId("userId", ParseUUIDPipe) userId: string
) {

const tenantId = headers["tenantid"];
const academicYearId = headers["academicyearid"];
if (!tenantId || !isUUID(tenantId)) {
Expand Down Expand Up @@ -191,6 +193,26 @@ export class CohortController {
.updateCohort(cohortId, cohortUpdateDto, response);
}

@UseFilters(new AllExceptionsFilter(APIID.COHORT_STATUS_UPDATE))
@Patch("/updateStatus")
@ApiBody({ type: CohortStatusUpdateDto })
@ApiOkResponse({ description: "Cohort statuses updated successfully" })
@ApiBadRequestResponse({ description: "Bad request." })
@ApiInternalServerErrorResponse({ description: "Internal Server Error." })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
public async updateCohortStatuses(
@Body() dto: CohortStatusUpdateDto,
@Res() response: Response,
@GetUserId("userId", ParseUUIDPipe) userId: string
) {
return await this.cohortService.updateCohortStatuses(
dto.cohortIds,
dto.status,
userId,
response
);
}

@UseFilters(new AllExceptionsFilter(APIID.COHORT_DELETE))
@Delete("/delete/:cohortId")
@ApiOkResponse({ description: "Cohort has been deleted successfully." })
Expand Down
76 changes: 64 additions & 12 deletions src/cohort/cohort.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,58 @@ export class CohortService {
}
}

public async updateCohortStatuses(
cohortIds: string[],
status: string,
updatedBy: string,
res
) {
const apiId = APIID.COHORT_STATUS_UPDATE;
try {
const uniqueCohortIds = [...new Set(cohortIds)];

const existingCohortsCount = await this.cohortRepository.count({
where: { cohortId: In(uniqueCohortIds) },
});

if (existingCohortsCount !== uniqueCohortIds.length) {
return APIResponse.error(
res,
apiId,
"One or more cohort IDs do not exist",
"Invalid cohortId",
HttpStatus.NOT_FOUND
);
}

const result = await this.cohortRepository.update(
Comment thread
sourav-dev-hub marked this conversation as resolved.
{ cohortId: In(uniqueCohortIds) },
{ status, updatedBy }
);
LoggerUtil.log(`Cohort statuses updated: ${result.affected} rows`);
return APIResponse.success(
res,
apiId,
{ affected: result.affected },
HttpStatus.OK,
"Cohort statuses updated successfully"
);
} catch (error) {
LoggerUtil.error(
`${API_RESPONSES.SERVER_ERROR}`,
`Error: ${error.message}`,
apiId
);
return APIResponse.error(
res,
apiId,
API_RESPONSES.SERVER_ERROR,
error.message || API_RESPONSES.SERVER_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}

public async updateCohort(
cohortId: string,
cohortUpdateDto: CohortUpdateDto,
Expand Down Expand Up @@ -740,11 +792,11 @@ export class CohortService {

const whereClause = {};
const searchCustomFields = {};

// SECURITY FIX: Always enforce tenantId from headers, not from filters
// This prevents users from querying cohorts from other tenants
whereClause["tenantId"] = tenantId;

if (academicYearId) {
// check if the tenantId and academic year exist together
cohortsByAcademicYear =
Expand All @@ -768,7 +820,7 @@ export class CohortService {
const cleanedFilters = Object.fromEntries(
Object.entries(filters).filter(([_, value]) => value !== undefined && value !== null)
);

if (cleanedFilters?.customFieldsName) {
Object.entries(cleanedFilters.customFieldsName).forEach(([key, value]) => {
if (customFieldsKeys.includes(key)) {
Expand All @@ -782,7 +834,7 @@ export class CohortService {
if (key === "tenantId") {
return; // Ignore tenantId from request body filters
}

if (!allowedKeys.includes(key) && key !== "customFieldsName") {
return APIResponse.error(
response,
Expand Down Expand Up @@ -1143,7 +1195,7 @@ export class CohortService {
let findCohortId;
if (checkAutomaticMember) {
findCohortId = await this.automaticMemberCohortHierarchy(checkAutomaticMember, requiredData?.academicYearId);

} else {
findCohortId = await this.findCohortName(requiredData.userId, requiredData?.academicYearId);
if (!findCohortId.length) {
Expand Down Expand Up @@ -1304,7 +1356,7 @@ export class CohortService {

try {
const { userId, academicYearId, filters, limit, offset } = requiredData;

// Validate userId
if (!userId || !isUUID(userId)) {
return APIResponse.error(
Expand All @@ -1315,10 +1367,10 @@ export class CohortService {
HttpStatus.NOT_FOUND
);
}

const paginationLimit = limit || 100;
const paginationOffset = offset || 0;

LoggerUtil.log(
`Getting geographical hierarchy for userId: ${userId}, academicYearId: ${academicYearId}`,
apiId
Expand Down Expand Up @@ -1399,7 +1451,7 @@ export class CohortService {

for (const cohort of userCohorts) {
let centerId: string;

if (cohort.type?.toLowerCase() === 'batch' && cohort.parentId) {
// Batch: get parent center
centerId = cohort.parentId;
Expand All @@ -1419,7 +1471,7 @@ export class CohortService {
} else {
continue;
}

centerIds.add(centerId);
}

Expand Down Expand Up @@ -1456,7 +1508,7 @@ export class CohortService {
center.cohortId,
'Cohort'
);

const geoData: any = {
stateId: null,
stateName: null,
Expand All @@ -1472,7 +1524,7 @@ export class CohortService {
for (const field of customFields) {
const fieldLabel = field.label?.toLowerCase();
const fieldName = field.name?.toLowerCase();

// Check both label and name (as done in other services)
if ((fieldLabel === 'state' || fieldName === 'state') && field.selectedValues?.[0]) {
const val = field.selectedValues[0];
Expand Down
32 changes: 32 additions & 0 deletions src/cohort/dto/cohort-status-update.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsEnum, IsNotEmpty, IsUUID } from "class-validator";

export enum CohortStatus {
ACTIVE = "active",
INACTIVE = "inactive",
ARCHIVED = "archived",
PENDING = "pending",
}

export class CohortStatusUpdateDto {
@ApiProperty({
type: [String],
description: "Array of cohort UUIDs to update",
example: ["uuid-1", "uuid-2"],
})
@IsArray()
@IsUUID("4", { each: true })
@IsNotEmpty()
cohortIds: string[];

@ApiProperty({
enum: CohortStatus,
description: "New status to set for the cohorts",
example: CohortStatus.ACTIVE,
})
@IsEnum(CohortStatus, {
message: "Status must be one of: active, inactive, archived, pending",
})
@IsNotEmpty()
status: CohortStatus;
}
12 changes: 6 additions & 6 deletions src/cohortMembers/cohortMembers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { UserService } from "src/user/user.service";
import { isValid } from "date-fns";
import { FieldValuesOptionDto } from "src/user/dto/user-create.dto";
import { KafkaService } from "src/kafka/kafka.service";
import { BulkCohortMember } from "src/cohortMembers/dto/bulkMember-create.dto";

@Injectable()
export class CohortMembersService {
Expand Down Expand Up @@ -923,24 +924,23 @@ ${whereCase}`;

public async createBulkCohortMembers(
loginUser: any,
cohortMembersDto: {
userId: string[];
cohortId: string[];
removeCohortId?: string[];
},
cohortMembersDto: BulkCohortMember,
response: Response,
tenantId: string,
academicyearId: string
) {
const apiId = APIID.COHORT_MEMBER_CREATE;
const results = [];
const errors = [];
const cohortMembersBase = {
const cohortMembersBase: any = {
createdBy: loginUser,
updatedBy: loginUser,
tenantId: tenantId,
// cohortAcademicYearId: academicyearId
};
if (cohortMembersDto.status) {
cohortMembersBase.status = cohortMembersDto.status;
}

// Track users that were successfully added to cohorts for Kafka event publishing
const affectedUsers = new Set<string>();
Expand Down
11 changes: 11 additions & 0 deletions src/cohortMembers/dto/bulkMember-create.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
IsOptional,
IsNotEmpty,
ArrayMaxSize,
IsEnum,
} from "class-validator";
import { MemberStatus } from "../entities/cohort-member.entity";

export class BulkCohortMember {
@ApiProperty({
Expand Down Expand Up @@ -42,6 +44,15 @@ export class BulkCohortMember {
@ArrayMaxSize(1000)
removeCohortId: string[];

@ApiProperty({
enum: MemberStatus,
description: "Member status (e.g. pending, active)",
required: false,
})
@IsOptional()
@IsEnum(MemberStatus)
status?: MemberStatus;

constructor(obj: any) {
Object.assign(this, obj);
}
Expand Down
13 changes: 12 additions & 1 deletion src/cohortMembers/dto/cohortMembers.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Expose } from "class-transformer";
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsUUID } from "class-validator";
import { IsNotEmpty, IsOptional, IsUUID, IsEnum } from "class-validator";
import { MemberStatus } from "../entities/cohort-member.entity";

export class CohortMembersDto {
//generated fields
Expand Down Expand Up @@ -49,6 +50,16 @@ export class CohortMembersDto {
@IsUUID(undefined, { message: "User Id must be a valid UUID" })
userId: string;

@ApiProperty({
enum: MemberStatus,
description: "Member status (e.g. pending, active)",
required: false,
})
@Expose()
@IsOptional()
@IsEnum(MemberStatus)
status?: MemberStatus;

constructor(obj: any) {
Object.assign(this, obj);
}
Expand Down
1 change: 1 addition & 0 deletions src/cohortMembers/entities/cohort-member.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum MemberStatus {
DROPOUT = "dropout",
ARCHIVED = "archived",
REASSIGNED = "reassigned",
PENDING = "pending",
}

@Entity("CohortMembers")
Expand Down
2 changes: 2 additions & 0 deletions src/common/utils/api-id.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const APIID = {
PRIVILEGE_CREATE: "api.privilege.create",
PRIVILEGE_DELETE: "api.privilege.delete",
USERROLE_CREATE: "api.userRole.create",
USERROLE_BULK_UPDATE: "api.userRole.bulkUpdate",
USERROLE_GET: "api.userRole.get",
USERROLE_DELETE: "api.userRole.delete",
COHORT_MEMBER_GET: "api.cohortmember.get",
Expand All @@ -33,6 +34,7 @@ export const APIID = {
COHORT_READ: "api.cohort.read",
COHORT_UPDATE: "api.cohort.update",
COHORT_DELETE: "api.cohort.delete",
COHORT_STATUS_UPDATE: "api.cohort.statusUpdate",
ASSIGN_TENANT_CREATE: "api.assigntenant.create",
ASSIGN_TENANT_UPDATE_STATUS: "api.assigntenant.updatestatus",
FIELDS_CREATE: "api.fields.create",
Expand Down
Loading