diff --git a/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.html b/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.html index 8eaf70d6..41324589 100644 --- a/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.html +++ b/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.html @@ -45,47 +45,19 @@ - +
- - Organization + - @if (form().errors?.['organizationRequired'] && form().get('organization')?.touched) { -

Organization name is required when organization URL is provided

- } -
- - -
- - - @if (form().get('organization_url')?.errors?.['pattern'] && form().get('organization_url')?.touched) { -

Please enter a valid URL

- } - @if (form().errors?.['organizationUrlRequired'] && form().get('organization_url')?.touched) { -

Organization URL is required when organization name is provided

- } + nameControl="organization" + domainControl="organization_url" + placeholder="Search for organization..." + styleClass="w-full" + inputStyleClass="text-sm w-full" + panelStyleClass="text-sm w-full" + dataTestId="member-form-organization-search"> +
diff --git a/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.ts b/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.ts index 78518d15..7abb0140 100644 --- a/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.ts +++ b/apps/lfx-one/src/app/modules/project/committees/components/member-form/member-form.component.ts @@ -3,10 +3,11 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, signal } from '@angular/core'; -import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; import { CalendarComponent } from '@components/calendar/calendar.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; +import { OrganizationSearchComponent } from '@components/organization-search/organization-search.component'; import { SelectComponent } from '@components/select/select.component'; import { MEMBER_ROLES, VOTING_STATUSES } from '@lfx-one/shared/constants'; import { CreateCommitteeMemberRequest } from '@lfx-one/shared/interfaces'; @@ -18,7 +19,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; @Component({ selector: 'lfx-member-form', standalone: true, - imports: [CommonModule, ReactiveFormsModule, ButtonComponent, SelectComponent, InputTextComponent, CalendarComponent], + imports: [CommonModule, ReactiveFormsModule, ButtonComponent, SelectComponent, InputTextComponent, CalendarComponent, OrganizationSearchComponent], templateUrl: './member-form.component.html', styleUrl: './member-form.component.scss', }) @@ -154,55 +155,20 @@ export class MemberFormComponent { } private createMemberFormGroup(): FormGroup { - return new FormGroup( - { - first_name: new FormControl('', [Validators.required]), - last_name: new FormControl('', [Validators.required]), - email: new FormControl('', [Validators.required, Validators.email]), - job_title: new FormControl(''), - organization: new FormControl(''), - organization_url: new FormControl('', [Validators.pattern('^https?://.+')]), - role: new FormControl(''), - voting_status: new FormControl(''), - appointed_by: new FormControl(''), - role_start: new FormControl(null), - role_end: new FormControl(null), - voting_status_start: new FormControl(null), - voting_status_end: new FormControl(null), - }, - { validators: this.organizationCrossFieldValidator() } - ); - } - - /** - * Custom validator that ensures if organization is provided, organization_url is also required and vice versa - */ - private organizationCrossFieldValidator(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (!control) { - return null; - } - - const organization = control.get('organization')?.value; - const organizationUrl = control.get('organization_url')?.value; - - // If both are empty, that's valid - if (!organization && !organizationUrl) { - return null; - } - - // If organization is provided but URL is missing - if (organization && !organizationUrl) { - return { organizationUrlRequired: true }; - } - - // If URL is provided but organization is missing - if (organizationUrl && !organization) { - return { organizationRequired: true }; - } - - // Both provided, validation passes - return null; - }; + return new FormGroup({ + first_name: new FormControl('', [Validators.required]), + last_name: new FormControl('', [Validators.required]), + email: new FormControl('', [Validators.required, Validators.email]), + job_title: new FormControl(''), + organization: new FormControl(''), + organization_url: new FormControl(''), + role: new FormControl(''), + voting_status: new FormControl(''), + appointed_by: new FormControl(''), + role_start: new FormControl(null), + role_end: new FormControl(null), + voting_status_start: new FormControl(null), + voting_status_end: new FormControl(null), + }); } } diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.html index ffd5a571..57ee3539 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.html +++ b/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.html @@ -66,14 +66,15 @@
- - + nameControl="org_name" + placeholder="Search organizations..." + styleClass="w-full" + inputStyleClass="text-sm w-full" + panelStyleClass="text-sm w-full" + dataTestId="registrant-form-organization-input"> +
diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.ts index 6fedd170..59af1b1a 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/components/registrant-form/registrant-form.component.ts @@ -6,12 +6,13 @@ import { Component, input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { CheckboxComponent } from '@components/checkbox/checkbox.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; +import { OrganizationSearchComponent } from '@components/organization-search/organization-search.component'; import { MeetingRegistrant } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-registrant-form', standalone: true, - imports: [CommonModule, ReactiveFormsModule, InputTextComponent, CheckboxComponent], + imports: [CommonModule, ReactiveFormsModule, InputTextComponent, CheckboxComponent, OrganizationSearchComponent], templateUrl: './registrant-form.component.html', }) export class RegistrantFormComponent { diff --git a/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.html b/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.html index 876c4ceb..4634237c 100644 --- a/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.html +++ b/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.html @@ -19,6 +19,11 @@ [autoOptionFocus]="autoOptionFocus()" [completeOnFocus]="completeOnFocus()" [panelStyleClass]="panelStyleClass()" - [autoHighlight]="autoHighlight()"> + [autoHighlight]="autoHighlight()" + [appendTo]="appendTo()"> + + + + diff --git a/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.ts b/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.ts index 0295d667..3a66fc90 100644 --- a/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.ts +++ b/apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.ts @@ -1,16 +1,19 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, ContentChild, input, output, TemplateRef } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { AutoCompleteCompleteEvent, AutoCompleteModule, AutoCompleteSelectEvent } from 'primeng/autocomplete'; @Component({ selector: 'lfx-autocomplete', - imports: [AutoCompleteModule, ReactiveFormsModule], + imports: [CommonModule, AutoCompleteModule, ReactiveFormsModule], templateUrl: './autocomplete.component.html', }) export class AutocompleteComponent { + // Template reference for content projection + @ContentChild('empty', { static: false, descendants: false }) public emptyTemplate?: TemplateRef; public form = input.required(); public control = input.required(); public placeholder = input(); @@ -26,6 +29,7 @@ export class AutocompleteComponent { public autoOptionFocus = input(false); public completeOnFocus = input(false); public autoHighlight = input(false); + public appendTo = input(undefined); public readonly completeMethod = output(); public readonly onSelect = output(); diff --git a/apps/lfx-one/src/app/shared/components/organization-search/organization-search.component.html b/apps/lfx-one/src/app/shared/components/organization-search/organization-search.component.html new file mode 100644 index 00000000..1fba5df3 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/organization-search/organization-search.component.html @@ -0,0 +1,48 @@ + + + +
+ @if (!manualMode()) { + + + +
+

No organizations found

+ +
+
+
+ } @else { + +
+ @if (nameControl()) { + + } + + @if (domainControl()) { + + } + + +
+ } +
diff --git a/apps/lfx-one/src/app/shared/components/organization-search/organization-search.component.ts b/apps/lfx-one/src/app/shared/components/organization-search/organization-search.component.ts new file mode 100644 index 00000000..b9f2e555 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/organization-search/organization-search.component.ts @@ -0,0 +1,160 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, effect, inject, input, output, signal, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { normalizeToUrl, OrganizationSuggestion } from '@lfx-one/shared'; +import { AutoCompleteCompleteEvent, AutoCompleteSelectEvent } from 'primeng/autocomplete'; +import { catchError, distinctUntilChanged, of, startWith, switchMap } from 'rxjs'; + +import { OrganizationService } from '../../services/organization.service'; +import { AutocompleteComponent } from '../autocomplete/autocomplete.component'; +import { InputTextComponent } from '../input-text/input-text.component'; + +@Component({ + selector: 'lfx-organization-search', + imports: [AutocompleteComponent, ReactiveFormsModule, InputTextComponent, CommonModule], + templateUrl: './organization-search.component.html', +}) +export class OrganizationSearchComponent { + private readonly organizationService = inject(OrganizationService); + + public form = input.required(); + public nameControl = input(); + public domainControl = input(); + public placeholder = input('Search organizations...'); + public styleClass = input(); + public inputStyleClass = input(); + public panelStyleClass = input(); + public dataTestId = input('organization-search'); + public disabled = input(false); + + public readonly onOrganizationSelect = output(); + + // Track manual mode state + public manualMode = signal(false); + + // Internal form for the search input + protected readonly organizationForm = new FormGroup({ + organizationSearch: new FormControl(''), + }); + + // Initialize suggestions as a signal based on search query changes + protected suggestions: Signal; + + public constructor() { + // Initialize suggestions signal that reacts to search query changes + const searchResults$ = this.organizationForm.get('organizationSearch')!.valueChanges.pipe( + startWith(''), + distinctUntilChanged(), + switchMap((searchTerm: string | null) => { + const trimmedTerm = searchTerm?.trim() || ''; + + // Only fetch suggestions when user types something + if (!trimmedTerm) { + return of([]); + } + + return this.organizationService.searchOrganizations(trimmedTerm); + }), + catchError((error) => { + console.error('Error searching organizations:', error); + return of([]); + }) + ); + + this.suggestions = toSignal(searchResults$, { + initialValue: [], + }); + + // Effect to sync the search input with the parent form's name control + effect(() => { + const parentForm = this.form(); + const nameControlName = this.nameControl(); + + if (parentForm && nameControlName) { + const nameControlValue = parentForm.get(nameControlName)?.value; + + if (nameControlValue && nameControlValue.trim()) { + this.organizationForm.get('organizationSearch')?.setValue(nameControlValue, { emitEvent: false }); + } + } + }); + } + + public onSearchComplete(event: AutoCompleteCompleteEvent): void { + // Update the search form value which will trigger the observable + this.organizationForm.get('organizationSearch')?.setValue(event.query); + } + + public onOrganizationSelected(event: AutoCompleteSelectEvent): void { + const selectedOrganization = event.value as OrganizationSuggestion; + + // Update form controls if they are specified + const parentForm = this.form(); + const nameControlName = this.nameControl(); + const domainControlName = this.domainControl(); + + if (nameControlName && parentForm.get(nameControlName)) { + parentForm.get(nameControlName)?.setValue(selectedOrganization.name); + } + + // Only update domain control if it's specified (optional for forms that only need org name) + if (domainControlName && parentForm.get(domainControlName)) { + // Convert domain to full URL using the normalizeToUrl utility + const normalizedUrl = normalizeToUrl(selectedOrganization.domain); + parentForm.get(domainControlName)?.setValue(normalizedUrl); + } + + this.onOrganizationSelect.emit(selectedOrganization); + } + + public onSearchClear(): void { + this.organizationForm.get('organizationSearch')?.setValue(''); + + // Clear form controls if they are specified + const parentForm = this.form(); + const nameControlName = this.nameControl(); + const domainControlName = this.domainControl(); + + if (nameControlName && parentForm.get(nameControlName)) { + parentForm.get(nameControlName)?.setValue(null); + } + + // Only clear domain control if it's specified (optional for forms that only need org name) + if (domainControlName && parentForm.get(domainControlName)) { + parentForm.get(domainControlName)?.setValue(null); + } + } + + public switchToManualMode(): void { + this.manualMode.set(true); + + const nameControlName = this.nameControl(); + + if (nameControlName && this.form().get(nameControlName)) { + this.form().get(nameControlName)?.setValue(this.organizationForm.get('organizationSearch')?.value); + } + + // Clear search field when switching to manual + this.organizationForm.get('organizationSearch')?.setValue(''); + } + + public switchToSearchMode(): void { + this.manualMode.set(false); + // Clear any touched state on the form controls + const parentForm = this.form(); + const nameControlName = this.nameControl(); + const domainControlName = this.domainControl(); + + if (nameControlName && parentForm.get(nameControlName)) { + parentForm.get(nameControlName)?.markAsUntouched(); + } + + if (domainControlName && parentForm.get(domainControlName)) { + parentForm.get(domainControlName)?.markAsUntouched(); + } + } +} diff --git a/apps/lfx-one/src/app/shared/pipes/normalize-url.pipe.ts b/apps/lfx-one/src/app/shared/pipes/normalize-url.pipe.ts new file mode 100644 index 00000000..e255e814 --- /dev/null +++ b/apps/lfx-one/src/app/shared/pipes/normalize-url.pipe.ts @@ -0,0 +1,24 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Pipe, PipeTransform } from '@angular/core'; +import { normalizeToUrl } from '@lfx-one/shared'; + +@Pipe({ + name: 'normalizeUrl', + standalone: true, +}) +export class NormalizeUrlPipe implements PipeTransform { + /** + * Transforms a domain or URL string into a valid URL + * @param value - The domain or URL string to transform + * @returns A valid URL string or null if invalid + */ + public transform(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + return normalizeToUrl(value); + } +} diff --git a/apps/lfx-one/src/app/shared/services/organization.service.ts b/apps/lfx-one/src/app/shared/services/organization.service.ts new file mode 100644 index 00000000..1d8620c5 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/organization.service.ts @@ -0,0 +1,38 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { OrganizationSuggestion, OrganizationSuggestionsResponse } from '@lfx-one/shared'; +import { catchError, map, Observable, of } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class OrganizationService { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/organizations'; + + /** + * Search for organizations by name + * @param searchTerm - The search term to look for + * @returns Observable of organization suggestions + */ + public searchOrganizations(searchTerm: string): Observable { + if (!searchTerm || searchTerm.length < 2) { + return of([]); + } + + return this.http + .get(`${this.baseUrl}/search`, { + params: { query: searchTerm.trim() }, + }) + .pipe( + map((response) => response.suggestions || []), + catchError((error) => { + console.error('Error searching organizations:', error); + return of([]); + }) + ); + } +} diff --git a/apps/lfx-one/src/server/controllers/organization.controller.ts b/apps/lfx-one/src/server/controllers/organization.controller.ts new file mode 100644 index 00000000..47196e65 --- /dev/null +++ b/apps/lfx-one/src/server/controllers/organization.controller.ts @@ -0,0 +1,59 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { NextFunction, Request, Response } from 'express'; + +import { ServiceValidationError } from '../errors'; +import { Logger } from '../helpers/logger'; +import { OrganizationService } from '../services/organization.service'; + +/** + * Controller for handling organization HTTP requests + */ +export class OrganizationController { + private organizationService: OrganizationService = new OrganizationService(); + + /** + * GET /organizations/search + */ + public async searchOrganizations(req: Request, res: Response, next: NextFunction): Promise { + const { query } = req.query; + const startTime = Logger.start(req, 'search_organizations', { + has_query: !!query, + }); + + try { + // Check if the search query is provided and is a string + if (!query || typeof query !== 'string') { + Logger.error(req, 'search_organizations', startTime, new Error('Missing or invalid search query'), { + query_type: typeof query, + }); + + // Create a validation error + const validationError = ServiceValidationError.forField('query', 'Search query is required and must be a string', { + operation: 'search_organizations', + service: 'organization_controller', + path: req.path, + }); + + next(validationError); + return; + } + + // Search for organizations + const suggestions = await this.organizationService.searchOrganizations(req, query); + + // Log the success + Logger.success(req, 'search_organizations', startTime, { + result_count: suggestions.length, + }); + + // Send the results to the client + res.json({ suggestions }); + } catch (error) { + // Log the error + Logger.error(req, 'search_organizations', startTime, error); + next(error); + } + } +} diff --git a/apps/lfx-one/src/server/routes/organizations.route.ts b/apps/lfx-one/src/server/routes/organizations.route.ts new file mode 100644 index 00000000..c6f245c8 --- /dev/null +++ b/apps/lfx-one/src/server/routes/organizations.route.ts @@ -0,0 +1,14 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Router } from 'express'; + +import { OrganizationController } from '../controllers/organization.controller'; + +const router = Router(); +const organizationController = new OrganizationController(); + +// GET /api/organizations/search - Search for organizations +router.get('/search', organizationController.searchOrganizations.bind(organizationController)); + +export default router; diff --git a/apps/lfx-one/src/server/server.ts b/apps/lfx-one/src/server/server.ts index 334acc8e..c76b01fe 100644 --- a/apps/lfx-one/src/server/server.ts +++ b/apps/lfx-one/src/server/server.ts @@ -19,6 +19,7 @@ import { authMiddleware } from './middleware/auth.middleware'; import { apiErrorHandler } from './middleware/error-handler.middleware'; import committeesRouter from './routes/committees.route'; import meetingsRouter from './routes/meetings.route'; +import organizationsRouter from './routes/organizations.route'; import pastMeetingsRouter from './routes/past-meetings.route'; import profileRouter from './routes/profile.route'; import projectsRouter from './routes/projects.route'; @@ -199,6 +200,7 @@ app.use('/public/api/meetings', publicMeetingsRouter); app.use('/api/projects', projectsRouter); app.use('/api/committees', committeesRouter); app.use('/api/meetings', meetingsRouter); +app.use('/api/organizations', organizationsRouter); app.use('/api/past-meetings', pastMeetingsRouter); app.use('/api/profile', profileRouter); diff --git a/apps/lfx-one/src/server/services/organization.service.ts b/apps/lfx-one/src/server/services/organization.service.ts new file mode 100644 index 00000000..78fce264 --- /dev/null +++ b/apps/lfx-one/src/server/services/organization.service.ts @@ -0,0 +1,32 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { OrganizationSuggestion, OrganizationSuggestionsResponse } from '@lfx-one/shared'; +import { Request } from 'express'; + +import { MicroserviceProxyService } from './microservice-proxy.service'; + +export class OrganizationService { + private microserviceProxy: MicroserviceProxyService; + + public constructor() { + this.microserviceProxy = new MicroserviceProxyService(); + } + + /** + * Search for organizations using the microservice proxy + * @param req - Express request object (needed for authentication) + * @param query - The search query + * @returns Promise of organization suggestions + */ + public async searchOrganizations(req: Request, query: string): Promise { + const params = { + v: 1, + query, + }; + + const response = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', '/query/orgs/suggest', 'GET', params); + + return response.suggestions || []; + } +} diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 577ad5e9..f2feeaa2 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -10,6 +10,9 @@ export * from './committee.interface'; // Member interfaces export * from './member.interface'; +// Organization interfaces +export * from './organization.interface'; + // Component interfaces export * from './components.interface'; diff --git a/packages/shared/src/interfaces/organization.interface.ts b/packages/shared/src/interfaces/organization.interface.ts new file mode 100644 index 00000000..d843b07b --- /dev/null +++ b/packages/shared/src/interfaces/organization.interface.ts @@ -0,0 +1,22 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Organization suggestion from search results + * @description Individual organization entry returned from typeahead search + */ +export interface OrganizationSuggestion { + /** Organization display name */ + name: string; + /** Organization domain name */ + domain: string; +} + +/** + * Response containing organization suggestions + * @description API response format for organization typeahead search + */ +export interface OrganizationSuggestionsResponse { + /** Array of organization suggestions */ + suggestions: OrganizationSuggestion[]; +} diff --git a/packages/shared/src/utils/url.utils.ts b/packages/shared/src/utils/url.utils.ts index a7bf9c2b..9908cc1c 100644 --- a/packages/shared/src/utils/url.utils.ts +++ b/packages/shared/src/utils/url.utils.ts @@ -117,7 +117,71 @@ export function extractUrlsWithDomains(text: string): { url: string; domain: str } /** - * URL regex pattern for linkification (kept for backward compatibility) + * Checks if a string is a valid domain by attempting to create a URL + * @param domain - The domain string to validate + * @returns true if the domain is valid + */ +export function isValidDomain(domain: string): boolean { + if (!domain || typeof domain !== 'string') { + return false; + } + + const trimmedDomain = domain.trim(); + if (!trimmedDomain || trimmedDomain.length < 3) { + return false; + } + + // Case-insensitive protocol detection + const hasProtocol = /^https?:\/\//i.test(trimmedDomain); + + let candidateUrl: string; + if (hasProtocol) { + // Already has protocol, use as-is + candidateUrl = trimmedDomain; + } else { + // No protocol, build HTTPS candidate from host (with optional path) + candidateUrl = `https://${trimmedDomain}`; + } + + // Try to create and validate the URL + try { + const url = new URL(candidateUrl); + + // Check if hostname is valid and contains at least one dot (for TLD) + return url.hostname.length > 0 && url.hostname.includes('.'); + } catch { + return false; + } +} + +/** + * Converts a domain to a full URL or validates an existing URL + * @param input - The domain or URL string to process + * @returns A valid URL string or null if invalid + */ +export function normalizeToUrl(input: string): string | null { + if (!input || typeof input !== 'string') { + return null; + } + + const trimmedInput = input.trim(); + + // If it already looks like a URL, validate it using existing function + if (trimmedInput.startsWith('http://') || trimmedInput.startsWith('https://')) { + return isValidUrl(trimmedInput) ? trimmedInput : null; + } + + // If it's a valid domain, convert to HTTPS URL (preserve www if present) + if (isValidDomain(trimmedInput)) { + const url = `https://${trimmedInput}`; + return isValidUrl(url) ? url : null; + } + + return null; +} + +/** + * URL regex pattern for link creation (kept for backward compatibility) * @deprecated Use extractUrls() function instead for safer URL detection */ export const URL_REGEX = URL_DETECTION_REGEX;