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;