diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index b89c50b8..5130e37f 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -779,10 +779,11 @@ export class PostgresCohortMembersService { ); } - // 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 @@ -6881,4 +6882,15 @@ export class PostgresCohortMembersService { return { condition: '', params: [] }; } } + + private async isUserAdmin(userId: string, tenantId: string): Promise { + 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; + } } \ No newline at end of file diff --git a/src/referrals/dto/create-referral-entity.dto.ts b/src/referrals/dto/create-referral-entity.dto.ts index e1d431bc..92f19df4 100644 --- a/src/referrals/dto/create-referral-entity.dto.ts +++ b/src/referrals/dto/create-referral-entity.dto.ts @@ -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) diff --git a/src/referrals/dto/update-referral-slug.dto.ts b/src/referrals/dto/update-referral-slug.dto.ts index e98f9232..2ed18dbc 100644 --- a/src/referrals/dto/update-referral-slug.dto.ts +++ b/src/referrals/dto/update-referral-slug.dto.ts @@ -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) diff --git a/src/referrals/referrals.service.ts b/src/referrals/referrals.service.ts index 3f98e2fd..34771ba6 100644 --- a/src/referrals/referrals.service.ts +++ b/src/referrals/referrals.service.ts @@ -15,7 +15,8 @@ import { ReferralEntitySubType, ReferralEntityType } from './referrals.types'; import { buildReferLink, generateReferralSlug, - isValidStandardSlug, + isValidUserProvidedSlug, + normalizeUserProvidedSlug, standardizeSlugInput, } from './utils/referral-slug.util'; @@ -66,9 +67,9 @@ export class ReferralsService { }); 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) { @@ -247,16 +248,13 @@ export class ReferralsService { // ── 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; } } diff --git a/src/referrals/utils/referral-slug.util.ts b/src/referrals/utils/referral-slug.util.ts index 61336274..8af21214 100644 --- a/src/referrals/utils/referral-slug.util.ts +++ b/src/referrals/utils/referral-slug.util.ts @@ -22,22 +22,39 @@ function randomBase36(length: number): string { 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\-_\.~]+$/; + +// 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; } @@ -51,11 +68,9 @@ export function generateReferralSlug(params: { 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}`; @@ -64,7 +79,6 @@ export function generateReferralSlug(params: { 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); }