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
16 changes: 14 additions & 2 deletions src/adapters/postgres/cohortMembers-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@
return results;
}

public async createCohortMembers(

Check failure on line 707 in src/adapters/postgres/cohortMembers-adapter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 37 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ5OOijXwB6YViKy9y2y&open=AZ5OOijXwB6YViKy9y2y&pullRequest=746
loginUser: any,
cohortMembers: CohortMembersDto,
res: Response,
Expand Down Expand Up @@ -779,10 +779,11 @@
);
}

// Validate Application End Date before creating cohort member (only if configured)
// Validate Application End Date before creating cohort member (only if configured and not admin)
const applicationEndDateFieldId = process.env.APPLICATION_END_FIELD_ID;
const callerIsAdmin = await this.isUserAdmin(loginUser, tenantId);

if (applicationEndDateFieldId) {
if (applicationEndDateFieldId && !callerIsAdmin) {
const cohortId = cohortMembers.cohortId;

// Query FieldValues table for the Application End Date field
Expand Down Expand Up @@ -6881,4 +6882,15 @@
return { condition: '', params: [] };
}
}

private async isUserAdmin(userId: string, tenantId: string): Promise<boolean> {
const result = await this.usersRepository.query(
`SELECT 1 FROM "UserRolesMapping" URM
JOIN "Roles" R ON R."roleId" = URM."roleId"
WHERE URM."userId" = $1 AND URM."tenantId" = $2 AND R."code" = 'admin'
LIMIT 1`,
[userId, tenantId]
);
return result.length > 0;
}
}
2 changes: 1 addition & 1 deletion src/referrals/dto/create-referral-entity.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '../referrals.types';

export class CreateReferralEntityDto {
@ApiPropertyOptional({ description: 'Custom slug (any format accepted; will be normalized to lowercase a-z0-9_). If omitted, auto-generated.' })
@ApiPropertyOptional({ description: 'Custom slug. Allowed characters: letters (A-Z, a-z), digits, hyphens (-), underscores (_), dots (.) and tildes (~). Input is lowercased before storage. If omitted, auto-generated.' })
@IsOptional()
@IsString()
@MaxLength(100)
Expand Down
2 changes: 1 addition & 1 deletion src/referrals/dto/update-referral-slug.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { IsArray, IsEmail, IsEnum, IsOptional, IsString, MaxLength } from 'class
import { ReferralEntityStatus, ReferralEntitySubType, ReferralEntityType } from '../referrals.types';

export class UpdateReferralSlugDto {
@ApiPropertyOptional({ description: 'New slug (any format accepted; will be normalized to lowercase a-z0-9_)' })
@ApiPropertyOptional({ description: 'New slug. Allowed characters: letters (A-Z, a-z), digits, hyphens (-), underscores (_), dots (.) and tildes (~). Input is lowercased before storage.' })
@IsOptional()
@IsString()
@MaxLength(100)
Expand Down
24 changes: 11 additions & 13 deletions src/referrals/referrals.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
import {
buildReferLink,
generateReferralSlug,
isValidStandardSlug,
isValidUserProvidedSlug,
normalizeUserProvidedSlug,
standardizeSlugInput,
} from './utils/referral-slug.util';

Expand All @@ -34,7 +35,7 @@
private readonly dataSource: DataSource,
) {}

async createReferralEntity(dto: CreateReferralEntityDto, createdBy?: string) {

Check failure on line 38 in src/referrals/referrals.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ5jzb0acVnEFZITrfLa&open=AZ5jzb0acVnEFZITrfLa&pullRequest=746
let resolvedLinkedEntityId: string | null = dto.linkedEntityId ?? null;

if (dto.contactEmail) {
Expand Down Expand Up @@ -66,9 +67,9 @@
});

if (dto.slug) {
const normalizedSlug = standardizeSlugInput(dto.slug);
if (!normalizedSlug) {
throw new BadRequestException('Provided slug is invalid after normalization');
const normalizedSlug = normalizeUserProvidedSlug(dto.slug);
if (!normalizedSlug || !isValidUserProvidedSlug(normalizedSlug)) {
throw new BadRequestException('Slug may only contain letters, digits, hyphens (-), underscores (_), dots (.) and tildes (~)');
}
const slugExists = await this.slugExistsAnywhere(normalizedSlug);
if (slugExists) {
Expand Down Expand Up @@ -202,7 +203,7 @@
};
}

async updateSlug(referralEntityId: string, dto: UpdateReferralSlugDto, changedBy?: string) {

Check failure on line 206 in src/referrals/referrals.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 30 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ5jzb0acVnEFZITrfLb&open=AZ5jzb0acVnEFZITrfLb&pullRequest=746
const entity = await this.referralRepo.findOne({ where: { id: referralEntityId } });
if (!entity) {
throw new NotFoundException('Referral entity not found');
Expand Down Expand Up @@ -247,16 +248,13 @@
// ── Slug: normalize any format, check uniqueness, preserve history ───────
let pendingSlug: string | null = null;
if (dto.slug !== undefined) {
const newSlug = standardizeSlugInput(dto.slug);
if (!newSlug) {
throw new BadRequestException('Provided slug is invalid after normalization');
const normalizedSlug = normalizeUserProvidedSlug(dto.slug);
if (!normalizedSlug || !isValidUserProvidedSlug(normalizedSlug)) {
throw new BadRequestException('Slug may only contain letters, digits, hyphens (-), underscores (_), dots (.) and tildes (~)');
}
if (!isValidStandardSlug(newSlug)) {
throw new BadRequestException('Slug must contain only lowercase a-z, 0-9, and _');
}
if (newSlug !== entity.slug) {
await this.assertSlugUnique(newSlug);
pendingSlug = newSlug;
if (normalizedSlug !== entity.slug) {
await this.assertSlugUnique(normalizedSlug);
pendingSlug = normalizedSlug;
}
}

Expand Down
40 changes: 27 additions & 13 deletions src/referrals/utils/referral-slug.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,39 @@
return asBase36.padStart(length, '0').slice(0, length);
}

export function standardizeSlugInput(input: string): string {
// Lowercase, remove accents, keep [a-z0-9_], collapse spaces to nothing.
// Use NFKD so accents become combining marks; then strip them.
const s = String(input ?? '')
// Allowed chars after lowercasing: digits, a-z, -, _, ., ~
// Input may contain uppercase letters \u2014 they are lowercased before validation and storage.
const USER_SLUG_PATTERN = /^[a-z0-9\-_\.~]+$/;

Check warning on line 27 in src/referrals/utils/referral-slug.util.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unnecessary escape character: \..

See more on https://sonarcloud.io/project/issues?id=tekdi_user-microservice&issues=AZ5j8Gvb7xQDFwfqH_Eu&open=AZ5j8Gvb7xQDFwfqH_Eu&pullRequest=746

// Normalize a user-provided slug: trim whitespace and lowercase.
export function normalizeUserProvidedSlug(slug: string): string {
return String(slug ?? '').trim().toLowerCase();
}

// Returns true when the slug (already normalized) contains only allowed chars.
export function isValidUserProvidedSlug(slug: string): boolean {
return USER_SLUG_PATTERN.test(slug);
}

// Used only for name-based auto-generation (strips to safe base36-compatible chars)
function normalizeNamePart(input: string): string {
return String(input ?? '')
.trim()
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') // diacritics
.replace(/\s+/g, '') // remove spaces
.replace(/[^a-z0-9_]/g, ''); // remove special chars
return s;
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '')
.replace(/[^a-z0-9_]/g, '');
}

// Trim + lowercase: used by resolveSlug for consistent lookup against stored slugs.
export function standardizeSlugInput(input: string): string {
return String(input ?? '').trim().toLowerCase();
}

export function standardizeNameForSlug(firstName: string, lastName?: string) {
const a = standardizeSlugInput(firstName);
const b = lastName ? standardizeSlugInput(lastName) : '';
const a = normalizeNamePart(firstName);
const b = lastName ? normalizeNamePart(lastName) : '';
if (a && b) return `${a}_${b}`;
return a || b;
}
Expand All @@ -51,11 +68,9 @@
const { type, subType, firstName, lastName } = params;

if (type === ReferralEntityType.INTERNAL && subType === ReferralEntitySubType.ALUMNI) {
// Internal alumni: random, non-identifiable
return randomBase36(8);
}

// External: human-readable name + random suffix
const base = standardizeNameForSlug(firstName, lastName ?? undefined);
const suffix = randomBase36(6);
return `${base}_${suffix}`;
Expand All @@ -64,7 +79,6 @@
export function isValidStandardSlug(slug: string): boolean {
const s = String(slug || '').trim();
if (!s) return false;
if (s !== s.toLowerCase()) return false;
return /^[a-z0-9_]+$/.test(s);
}