(null);
protected readonly selectedSearchQuery = signal('');
+ /** True when the active tab uses request-style filters (no role, no foundation, different statuses). */
+ protected readonly isRequestTab = computed(() => this.activeTab() === 'visa-letters' || this.activeTab() === 'travel-funding');
+
+ protected readonly currentStatusOptions = computed(() => (this.isRequestTab() ? VISA_REQUEST_STATUS_OPTIONS : MY_EVENT_STATUS_OPTIONS));
+
protected readonly foundationLabel: Signal = this.initFoundationLabel();
protected onFoundationChange(value: string | null): void {
@@ -52,8 +57,11 @@ export class MyEventsDashboardComponent {
protected onActiveTabChange(tab: EventTabId): void {
this.activeTab.set(tab);
- // Reset foundation filter when switching tabs — each tab has a different foundation list
+ // Reset all filters when switching tabs — each tab has different filter sets
this.selectedFoundation.set(null);
+ this.selectedRole.set(null);
+ this.selectedStatus.set(null);
+ this.selectedSearchQuery.set('');
}
private initFoundationLabel(): Signal {
diff --git a/apps/lfx-one/src/app/shared/pipes/event-request-status-severity.pipe.ts b/apps/lfx-one/src/app/shared/pipes/event-request-status-severity.pipe.ts
new file mode 100644
index 000000000..802adc0e1
--- /dev/null
+++ b/apps/lfx-one/src/app/shared/pipes/event-request-status-severity.pipe.ts
@@ -0,0 +1,21 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Pipe, PipeTransform } from '@angular/core';
+import { EVENT_REQUEST_STATUS_SEVERITY_MAP, TagSeverity } from '@lfx-one/shared';
+
+/**
+ * Transforms event request status to tag severity for consistent styling
+ * @description Maps visa/travel fund request status values to appropriate tag colors
+ * @example
+ *
+ * {{ request.status }}
+ */
+@Pipe({
+ name: 'eventRequestStatusSeverity',
+})
+export class EventRequestStatusSeverityPipe implements PipeTransform {
+ public transform(status: string): TagSeverity {
+ return EVENT_REQUEST_STATUS_SEVERITY_MAP[status] ?? 'info';
+ }
+}
diff --git a/apps/lfx-one/src/app/shared/services/events.service.ts b/apps/lfx-one/src/app/shared/services/events.service.ts
index 71a13dde3..24106421e 100644
--- a/apps/lfx-one/src/app/shared/services/events.service.ts
+++ b/apps/lfx-one/src/app/shared/services/events.service.ts
@@ -5,17 +5,24 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
-import { EMPTY_ORGANIZATIONS_RESPONSE } from '@lfx-one/shared/constants';
import {
EventsResponse,
GetCertificateParams,
GetEventOrganizationsParams,
+ GetEventRequestsParams,
GetEventsParams,
GetMyEventsParams,
+ GetUpcomingCountriesResponse,
MyEventOrganizationsResponse,
MyEventsResponse,
+ TravelFundApplication,
+ TravelFundApplicationResponse,
+ TravelFundRequestsResponse,
+ VisaRequestApplication,
+ VisaRequestApplicationResponse,
+ VisaRequestsResponse,
} from '@lfx-one/shared/interfaces';
-import { catchError, Observable, of } from 'rxjs';
+import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root',
@@ -36,6 +43,10 @@ export class EventsService {
if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize));
if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset));
if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder);
+ if (params.registeredOnly) httpParams = httpParams.set('registeredOnly', 'true');
+ if (params.startDateFrom) httpParams = httpParams.set('startDateFrom', params.startDateFrom);
+ if (params.startDateTo) httpParams = httpParams.set('startDateTo', params.startDateTo);
+ if (params.country) httpParams = httpParams.set('country', params.country);
return this.http.get('/api/events', { params: httpParams });
}
@@ -66,16 +77,52 @@ export class EventsService {
if (params.projectName) httpParams = httpParams.set('projectName', params.projectName);
if (params.isPast !== undefined) httpParams = httpParams.set('isPast', String(params.isPast));
- return this.http
- .get('/api/events/organizations', { params: httpParams })
- .pipe(catchError(() => of(EMPTY_ORGANIZATIONS_RESPONSE)));
+ return this.http.get('/api/events/organizations', { params: httpParams });
}
- public getCertificate(params: GetCertificateParams): Observable {
+ public getVisaRequests(params: GetEventRequestsParams = {}): Observable {
+ let httpParams = new HttpParams();
+
+ if (params.searchQuery) httpParams = httpParams.set('searchQuery', params.searchQuery);
+ if (params.status) httpParams = httpParams.set('status', params.status);
+ if (params.sortField) httpParams = httpParams.set('sortField', params.sortField);
+ if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize));
+ if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset));
+ if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder);
+
+ return this.http.get('/api/events/visa-requests', { params: httpParams });
+ }
+
+ public getTravelFundRequests(params: GetEventRequestsParams = {}): Observable {
+ let httpParams = new HttpParams();
+
+ if (params.searchQuery) httpParams = httpParams.set('searchQuery', params.searchQuery);
+ if (params.status) httpParams = httpParams.set('status', params.status);
+ if (params.sortField) httpParams = httpParams.set('sortField', params.sortField);
+ if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize));
+ if (params.offset !== undefined) httpParams = httpParams.set('offset', String(params.offset));
+ if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder);
+
+ return this.http.get('/api/events/travel-fund-requests', { params: httpParams });
+ }
+
+ public getUpcomingCountries(): Observable {
+ return this.http.get('/api/events/countries');
+ }
+
+ public submitVisaRequestApplication(payload: VisaRequestApplication): Observable {
+ return this.http.post('/api/events/visa-applications', payload);
+ }
+
+ public submitTravelFundApplication(payload: TravelFundApplication): Observable {
+ return this.http.post('/api/events/travel-fund-applications', payload);
+ }
+
+ public getCertificate(params: GetCertificateParams): Observable {
let httpParams = new HttpParams();
if (params.eventId) httpParams = httpParams.set('eventId', params.eventId);
- return this.http.get('/api/events/certificate', { params: httpParams, responseType: 'blob' }).pipe(catchError(() => of(null)));
+ return this.http.get('/api/events/certificate', { params: httpParams, responseType: 'blob' });
}
}
diff --git a/apps/lfx-one/src/server/controllers/events.controller.ts b/apps/lfx-one/src/server/controllers/events.controller.ts
index 47a9e271b..0b9b20f88 100644
--- a/apps/lfx-one/src/server/controllers/events.controller.ts
+++ b/apps/lfx-one/src/server/controllers/events.controller.ts
@@ -15,7 +15,18 @@ import {
VALID_EVENT_STATUS_VALUES,
VALID_MY_EVENT_STATUS_VALUES,
} from '@lfx-one/shared/constants';
-import { EventSortOrder, EventStatusFilter, GetEventOrganizationsOptions, GetEventsOptions } from '@lfx-one/shared/interfaces';
+import {
+ EventSortOrder,
+ EventStatusFilter,
+ GetEventOrganizationsOptions,
+ GetEventRequestsOptions,
+ GetEventsOptions,
+ GetUpcomingCountriesResponse,
+ TravelFundApplication,
+ TravelFundRequestsResponse,
+ VisaRequestApplication,
+ VisaRequestsResponse,
+} from '@lfx-one/shared/interfaces';
import { EventsService } from '../services/events.service';
import { PersonaDetectionService } from '../services/persona-detection.service';
import { getEffectiveEmail, getEffectiveName } from '../utils/auth-helper';
@@ -55,6 +66,10 @@ export class EventsController {
const rawMyEventStatus = req.query['status'] ? String(req.query['status']) : undefined;
const status = rawMyEventStatus && VALID_MY_EVENT_STATUS_VALUES.has(rawMyEventStatus) ? rawMyEventStatus : undefined;
const sortField = req.query['sortField'] ? String(req.query['sortField']) : undefined;
+ const registeredOnly = req.query['registeredOnly'] === 'true';
+ const startDateFrom = req.query['startDateFrom'] ? String(req.query['startDateFrom']) : undefined;
+ const startDateTo = req.query['startDateTo'] ? String(req.query['startDateTo']) : undefined;
+ const country = req.query['country'] ? String(req.query['country']) : undefined;
const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE;
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
@@ -85,6 +100,10 @@ export class EventsController {
pageSize,
offset,
sortOrder,
+ registeredOnly,
+ startDateFrom,
+ startDateTo,
+ country,
affiliatedProjectSlugs,
});
@@ -214,6 +233,46 @@ export class EventsController {
}
}
+ /**
+ * GET /api/events/visa-requests
+ * Get paginated visa letter requests for the authenticated user
+ * Query params: eventId (string), projectName (string), searchQuery (string), status (string),
+ * sortField (string), pageSize (number), offset (number), sortOrder (ASC|DESC)
+ */
+ public async getVisaRequests(req: Request, res: Response, next: NextFunction): Promise {
+ return this.handleEventRequestsEndpoint(req, res, next, 'get_visa_requests', (r, email, opts) => this.eventsService.getVisaRequests(r, email, opts));
+ }
+
+ /**
+ * GET /api/events/travel-fund-requests
+ * Get paginated travel fund requests for the authenticated user
+ * Query params: eventId (string), projectName (string), searchQuery (string), status (string),
+ * sortField (string), pageSize (number), offset (number), sortOrder (ASC|DESC)
+ */
+ public async getTravelFundRequests(req: Request, res: Response, next: NextFunction): Promise {
+ return this.handleEventRequestsEndpoint(req, res, next, 'get_travel_fund_requests', (r, email, opts) =>
+ this.eventsService.getTravelFundRequests(r, email, opts)
+ );
+ }
+
+ /**
+ * GET /api/events/countries
+ * Get distinct country names for upcoming events (for the location filter dropdown)
+ */
+ public async getUpcomingCountries(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'get_upcoming_countries', {});
+
+ try {
+ const response: GetUpcomingCountriesResponse = await this.eventsService.getUpcomingCountries(req);
+
+ logger.success(req, 'get_upcoming_countries', startTime, { result_count: response.data.length });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
+
/**
* GET /api/events/certificate
* Download attendance certificate as a PDF for the authenticated user
@@ -261,4 +320,138 @@ export class EventsController {
next(error);
}
}
+
+ /**
+ * POST /api/events/visa-applications
+ * Submit a visa letter application
+ * TODO: Wire to upstream microservice once available.
+ */
+ public async submitVisaRequestApplication(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'submit_visa_request_application', {});
+
+ try {
+ const userEmail = getEffectiveEmail(req);
+
+ if (!userEmail) {
+ throw new AuthenticationError('User authentication required', { operation: 'submit_visa_request_application' });
+ }
+
+ const payload = req.body as VisaRequestApplication;
+
+ if (!payload?.eventId) {
+ throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_visa_request_application' });
+ }
+
+ if (!payload?.applicantInfo) {
+ throw ServiceValidationError.forField('applicantInfo', 'applicantInfo is required', { operation: 'submit_visa_request_application' });
+ }
+
+ if (!payload?.termsAccepted) {
+ throw ServiceValidationError.forField('termsAccepted', 'termsAccepted must be true', { operation: 'submit_visa_request_application' });
+ }
+
+ // Overwrite client-provided email with session email for data integrity
+ payload.applicantInfo.email = userEmail;
+
+ const result = await this.eventsService.submitVisaRequestApplication(req, payload);
+ logger.success(req, 'submit_visa_request_application', startTime, { event_id: payload.eventId });
+ res.json(result);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * POST /api/events/travel-fund-applications
+ * Submit a travel fund application
+ * TODO: Wire to upstream microservice once available.
+ */
+ public async submitTravelFundApplication(req: Request, res: Response, next: NextFunction): Promise {
+ const startTime = logger.startOperation(req, 'submit_travel_fund_application', {});
+
+ try {
+ const userEmail = getEffectiveEmail(req);
+
+ if (!userEmail) {
+ throw new AuthenticationError('User authentication required', { operation: 'submit_travel_fund_application' });
+ }
+
+ const payload = req.body as TravelFundApplication;
+
+ if (!payload?.eventId) {
+ throw ServiceValidationError.forField('eventId', 'eventId is required', { operation: 'submit_travel_fund_application' });
+ }
+
+ if (!payload?.aboutMe) {
+ throw ServiceValidationError.forField('aboutMe', 'aboutMe is required', { operation: 'submit_travel_fund_application' });
+ }
+
+ if (!payload?.termsAccepted) {
+ throw ServiceValidationError.forField('termsAccepted', 'termsAccepted must be true', { operation: 'submit_travel_fund_application' });
+ }
+
+ if (!payload?.expenses) {
+ throw ServiceValidationError.forField('expenses', 'expenses is required', { operation: 'submit_travel_fund_application' });
+ }
+
+ // Overwrite client-provided email with session email for data integrity
+ payload.aboutMe.email = userEmail;
+
+ const result = await this.eventsService.submitTravelFundApplication(req, payload);
+ logger.success(req, 'submit_travel_fund_application', startTime, { event_id: payload.eventId });
+ res.json(result);
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ private async handleEventRequestsEndpoint(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ operationName: string,
+ fetchFn: (req: Request, userEmail: string, options: GetEventRequestsOptions) => Promise
+ ): Promise {
+ const startTime = logger.startOperation(req, operationName, {
+ has_query: Object.keys(req.query).length > 0,
+ });
+
+ try {
+ const userEmail = getEffectiveEmail(req);
+
+ if (!userEmail) {
+ throw new AuthenticationError('User authentication required', { operation: operationName });
+ }
+
+ const rawPageSize = parseInt(String(req.query['pageSize'] ?? DEFAULT_EVENTS_PAGE_SIZE), 10);
+ const rawOffset = parseInt(String(req.query['offset'] ?? 0), 10);
+ const rawSortOrder = String(req.query['sortOrder'] ?? 'DESC').toUpperCase() as EventSortOrder;
+
+ const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 && rawPageSize <= MAX_EVENTS_PAGE_SIZE ? rawPageSize : DEFAULT_EVENTS_PAGE_SIZE;
+ const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
+ const sortOrder: EventSortOrder = VALID_EVENT_SORT_ORDERS.includes(rawSortOrder) ? rawSortOrder : 'DESC';
+
+ const options: GetEventRequestsOptions = {
+ eventId: req.query['eventId'] ? String(req.query['eventId']) : undefined,
+ projectName: req.query['projectName'] ? String(req.query['projectName']) : undefined,
+ searchQuery: req.query['searchQuery'] ? String(req.query['searchQuery']).trim() : undefined,
+ status: req.query['status'] ? String(req.query['status']) : undefined,
+ sortField: req.query['sortField'] ? String(req.query['sortField']) : undefined,
+ pageSize,
+ offset,
+ sortOrder,
+ };
+
+ const response = await fetchFn(req, userEmail, options);
+
+ logger.success(req, operationName, startTime, {
+ result_count: response.data.length,
+ total: response.total,
+ });
+
+ res.json(response);
+ } catch (error) {
+ next(error);
+ }
+ }
}
diff --git a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html
index ae58d24d7..688aff4e2 100644
--- a/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html
+++ b/apps/lfx-one/src/server/pdf-templates/visa-letter-manual/template.html
@@ -92,7 +92,7 @@
| Event Location: |
- {{#if event.location }}{{ event.location }}, {{/if}} {{#if event.city }}{{ event.city }}, {{/if}} {{#if event.state}}{{ event.state }}, {{/if}}
+ {{#if event.location }}{{ event.location }}, {{/if}} {{#if event.city }}{{ event.city }}, {{/if}} {{#if event.state }}{{ event.state }}, {{/if}}
{{#if event.country }}{{ event.country }}{{/if}}
|
diff --git a/apps/lfx-one/src/server/routes/events.route.ts b/apps/lfx-one/src/server/routes/events.route.ts
index 37d0a8b0b..23931a45a 100644
--- a/apps/lfx-one/src/server/routes/events.route.ts
+++ b/apps/lfx-one/src/server/routes/events.route.ts
@@ -11,5 +11,10 @@ const eventsController = new EventsController();
router.get('/', (req, res, next) => eventsController.getMyEvents(req, res, next));
router.get('/all', (req, res, next) => eventsController.getEvents(req, res, next));
router.get('/organizations', (req, res, next) => eventsController.getEventOrganizations(req, res, next));
+router.get('/countries', (req, res, next) => eventsController.getUpcomingCountries(req, res, next));
+router.get('/visa-requests', (req, res, next) => eventsController.getVisaRequests(req, res, next));
+router.get('/travel-fund-requests', (req, res, next) => eventsController.getTravelFundRequests(req, res, next));
+router.post('/visa-applications', (req, res, next) => eventsController.submitVisaRequestApplication(req, res, next));
+router.post('/travel-fund-applications', (req, res, next) => eventsController.submitTravelFundApplication(req, res, next));
router.get('/certificate', (req, res, next) => eventsController.getCertificate(req, res, next));
export default router;
diff --git a/apps/lfx-one/src/server/services/events.service.ts b/apps/lfx-one/src/server/services/events.service.ts
index b740b6c2b..452413e45 100644
--- a/apps/lfx-one/src/server/services/events.service.ts
+++ b/apps/lfx-one/src/server/services/events.service.ts
@@ -3,19 +3,35 @@
// Generated with [Claude Code](https://claude.ai/code)
-import { COMING_SOON_SENTINEL, DEFAULT_EVENT_SORT_FIELD, VALID_EVENT_SORT_FIELDS } from '@lfx-one/shared/constants';
+import {
+ COMING_SOON_SENTINEL,
+ DEFAULT_EVENT_SORT_FIELD,
+ DEFAULT_VISA_REQUEST_SORT_FIELD,
+ VALID_EVENT_SORT_FIELDS,
+ VALID_VISA_REQUEST_SORT_FIELDS,
+} from '@lfx-one/shared/constants';
import {
EventRow,
EventSortOrder,
EventsResponse,
FoundationEvent,
GetEventOrganizationsOptions,
+ GetEventRequestsOptions,
GetEventsOptions,
GetMyEventsOptions,
+ GetUpcomingCountriesResponse,
MyEvent,
MyEventOrganizationsResponse,
MyEventRow,
MyEventsResponse,
+ TravelFundApplication,
+ TravelFundApplicationResponse,
+ TravelFundRequestsResponse,
+ VisaRequest,
+ VisaRequestApplication,
+ VisaRequestApplicationResponse,
+ VisaRequestRow,
+ VisaRequestsResponse,
} from '@lfx-one/shared/interfaces';
import { Request } from 'express';
@@ -30,7 +46,23 @@ export class EventsService {
}
public async getMyEvents(req: Request, userEmail: string, options: GetMyEventsOptions): Promise {
- const { isPast, eventId, projectName, searchQuery, role, status, sortField: rawSortField, pageSize, offset, sortOrder, affiliatedProjectSlugs } = options;
+ const {
+ isPast,
+ eventId,
+ projectName,
+ searchQuery,
+ role,
+ status,
+ sortField: rawSortField,
+ pageSize,
+ offset,
+ sortOrder,
+ registeredOnly,
+ startDateFrom,
+ startDateTo,
+ country,
+ affiliatedProjectSlugs,
+ } = options;
const sortField = rawSortField && VALID_EVENT_SORT_FIELDS.has(rawSortField) ? rawSortField : DEFAULT_EVENT_SORT_FIELD;
const normalizedSortOrder: EventSortOrder = sortOrder === 'DESC' ? 'DESC' : 'ASC';
const normalizedPageSize = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : 10;
@@ -60,6 +92,10 @@ export class EventsService {
const searchQueryFilter = searchQuery ? 'AND e.EVENT_NAME ILIKE ?' : '';
const roleFilterResult = role ? this.buildUpcomingRoleFilter(role) : { filter: '', binds: [] as string[] };
const statusFilterResult = status ? this.buildUpcomingStatusFilter(status) : { filter: '', binds: [] as string[] };
+ const startDateFromFilter = startDateFrom ? 'AND e.EVENT_START_DATE >= ?' : '';
+ const startDateToFilter = startDateTo ? 'AND e.EVENT_START_DATE <= ?' : '';
+ const countryFilter = country ? 'AND e.EVENT_COUNTRY = ?' : '';
+ const registeredOnlyFilter = registeredOnly ? "AND r.EVENT_ID IS NOT NULL AND r.REGISTRATION_STATUS = 'Accepted'" : '';
const slugs = affiliatedProjectSlugs ?? [];
const hasAffiliatedSlugs = slugs.length > 0;
@@ -166,6 +202,10 @@ export class EventsService {
${searchQueryFilter}
${roleFilterResult.filter}
${statusFilterResult.filter}
+ ${startDateFromFilter}
+ ${startDateToFilter}
+ ${countryFilter}
+ ${registeredOnlyFilter}
ORDER BY ${sortField} ${normalizedSortOrder}
LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset}
`;
@@ -428,6 +468,149 @@ export class EventsService {
return { data };
}
+ public async getUpcomingCountries(req: Request): Promise {
+ logger.debug(req, 'get_upcoming_countries', 'Fetching distinct countries for upcoming events');
+
+ const sql = `
+ SELECT DISTINCT EVENT_COUNTRY
+ FROM ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS
+ WHERE IS_PAST_EVENT = FALSE
+ AND EVENT_COUNTRY IS NOT NULL
+ ORDER BY EVENT_COUNTRY
+ `;
+
+ let result;
+ try {
+ result = await this.snowflakeService.execute<{ EVENT_COUNTRY: string }>(sql, []);
+ } catch (error) {
+ logger.warning(req, 'get_upcoming_countries', 'Snowflake query failed, returning empty countries', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return { data: [] };
+ }
+
+ const data = result.rows.map((row) => row.EVENT_COUNTRY);
+
+ logger.debug(req, 'get_upcoming_countries', 'Fetched countries', { count: data.length });
+
+ return { data };
+ }
+
+ public async getVisaRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise {
+ return this.executeEventRequestsQuery(req, userEmail, options, 'VL_REQUEST_STATUS', 'VL_APPLICATION_DATE', 'get_visa_requests');
+ }
+
+ public async getTravelFundRequests(req: Request, userEmail: string, options: GetEventRequestsOptions): Promise {
+ return this.executeEventRequestsQuery(req, userEmail, options, 'TF_REQUEST_STATUS', 'TF_APPLICATION_DATE', 'get_travel_fund_requests');
+ }
+
+ /**
+ * Stub: Submit a visa letter application.
+ * TODO: Replace with upstream microservice call once the API is available.
+ */
+ public async submitVisaRequestApplication(req: Request, payload: VisaRequestApplication): Promise {
+ logger.debug(req, 'submit_visa_request_application', 'Received visa letter application', {
+ event_id: payload.eventId,
+ event_name: payload.eventName,
+ });
+
+ return { success: true, message: 'Your visa letter application has been submitted successfully.' };
+ }
+
+ /**
+ * Stub: Submit a travel fund application.
+ * TODO: Replace with upstream microservice call once the API is available.
+ */
+ public async submitTravelFundApplication(req: Request, payload: TravelFundApplication): Promise {
+ logger.debug(req, 'submit_travel_fund_application', 'Received travel fund application', {
+ event_id: payload.eventId,
+ event_name: payload.eventName,
+ estimated_total: payload.expenses.estimatedTotal,
+ });
+
+ return { success: true, message: 'Your travel fund application has been submitted successfully.' };
+ }
+
+ private async executeEventRequestsQuery(
+ req: Request,
+ userEmail: string,
+ options: GetEventRequestsOptions,
+ statusColumn: string,
+ applicationDateColumn: string,
+ operationName: string
+ ): Promise {
+ const { eventId, projectName, searchQuery, status, sortField: rawSortField, pageSize, offset, sortOrder } = options;
+ const sortField = rawSortField && VALID_VISA_REQUEST_SORT_FIELDS.has(rawSortField) ? rawSortField : DEFAULT_VISA_REQUEST_SORT_FIELD;
+ const normalizedSortOrder: EventSortOrder = sortOrder === 'DESC' ? 'DESC' : 'ASC';
+ const normalizedPageSize = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : 10;
+ const normalizedOffset = Number.isInteger(offset) && offset >= 0 ? offset : 0;
+
+ logger.debug(req, operationName, 'Building event requests query', {
+ has_event_id: !!eventId,
+ has_project_name: !!projectName,
+ has_search_query: !!searchQuery,
+ status,
+ page_size: normalizedPageSize,
+ offset: normalizedOffset,
+ sort_order: normalizedSortOrder,
+ });
+
+ const eventIdFilter = eventId ? 'AND EVENT_ID = ?' : '';
+ const projectNameFilter = projectName ? 'AND PROJECT_NAME = ?' : '';
+ const searchQueryFilter = searchQuery ? 'AND EVENT_NAME ILIKE ?' : '';
+ const statusFilter = status ? `AND ${statusColumn} = ?` : '';
+
+ const sql = `
+ SELECT
+ EVENT_ID,
+ EVENT_NAME,
+ EVENT_URL,
+ EVENT_LOCATION,
+ EVENT_CITY,
+ EVENT_COUNTRY,
+ ${applicationDateColumn} AS APPLICATION_DATE,
+ ${statusColumn} AS REQUEST_STATUS,
+ COUNT(*) OVER() AS TOTAL_RECORDS
+ FROM ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS
+ WHERE ${statusColumn} IS NOT NULL
+ AND USER_EMAIL = ?
+ ${eventIdFilter}
+ ${projectNameFilter}
+ ${searchQueryFilter}
+ ${statusFilter}
+ -- sortField is validated against VALID_VISA_REQUEST_SORT_FIELDS allowlist above; safe to interpolate
+ ORDER BY ${sortField} ${normalizedSortOrder}
+ LIMIT ${normalizedPageSize} OFFSET ${normalizedOffset}
+ `;
+
+ const binds: string[] = [userEmail];
+ if (eventId) binds.push(eventId);
+ if (projectName) binds.push(projectName);
+ if (searchQuery) binds.push(`%${searchQuery}%`);
+ if (status) binds.push(status);
+
+ logger.debug(req, operationName, 'Executing event requests query', { bind_count: binds.length });
+
+ let result;
+ try {
+ result = await this.snowflakeService.execute(sql, binds);
+ } catch (error) {
+ logger.warning(req, operationName, 'Snowflake query failed, returning empty results', {
+ error: error instanceof Error ? error.message : String(error),
+ page_size: normalizedPageSize,
+ offset: normalizedOffset,
+ });
+ return { data: [], total: 0, pageSize: normalizedPageSize, offset: normalizedOffset };
+ }
+
+ const total = result.rows.length > 0 ? result.rows[0].TOTAL_RECORDS : 0;
+ const data = result.rows.map((row) => this.mapRowToVisaRequest(row));
+
+ logger.debug(req, operationName, 'Fetched event requests', { count: data.length, total });
+
+ return { data, total, pageSize: normalizedPageSize, offset: normalizedOffset };
+ }
+
/** Status filter for the past events query (unqualified column names, no IS NULL support). */
private buildStatusFilter(status: string): { filter: string; binds: string[] } {
switch (status) {
@@ -492,6 +675,19 @@ export class EventsService {
}
}
+ private mapRowToVisaRequest(row: VisaRequestRow): VisaRequest {
+ return {
+ id: row.EVENT_ID,
+ name: row.EVENT_NAME,
+ url: row.EVENT_URL ?? '',
+ location: this.formatLocation(row.EVENT_LOCATION, row.EVENT_CITY, row.EVENT_COUNTRY),
+ applicationDate: row.APPLICATION_DATE
+ ? new Date(row.APPLICATION_DATE).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ : '—',
+ status: row.REQUEST_STATUS,
+ };
+ }
+
private mapRowToFoundationEvent(row: EventRow): FoundationEvent {
return {
id: row.EVENT_ID,
@@ -527,6 +723,7 @@ export class EventsService {
url: row.EVENT_URL ?? '',
registrationUrl: row.EVENT_REGISTRATION_URL ?? null,
foundation: row.PROJECT_NAME,
+ startDate: new Date(row.EVENT_START_DATE).toISOString(),
date: this.formatDateRange(row.EVENT_START_DATE, row.EVENT_END_DATE),
location: this.formatLocation(row.EVENT_LOCATION, row.EVENT_CITY, row.EVENT_COUNTRY),
role: row.USER_ROLE ?? '',
diff --git a/packages/shared/src/constants/events.constants.ts b/packages/shared/src/constants/events.constants.ts
index 5f4c943e8..89ae4f433 100644
--- a/packages/shared/src/constants/events.constants.ts
+++ b/packages/shared/src/constants/events.constants.ts
@@ -2,7 +2,17 @@
// SPDX-License-Identifier: MIT
import { FoundationEventStatus } from '../enums';
-import { FilterOption, MyEventsResponse, EventsResponse, MyEventOrganizationsResponse, TagSeverity } from '../interfaces';
+import {
+ FilterOption,
+ MyEventsResponse,
+ EventsResponse,
+ MyEventOrganizationsResponse,
+ TagSeverity,
+ TravelFundRequestsResponse,
+ VisaRequestsResponse,
+ TravelFundStep,
+ VisaRequestStep,
+} from '../interfaces';
export const EVENT_ROLE_OPTIONS: FilterOption[] = [
{ label: 'All Roles', value: null },
@@ -19,6 +29,21 @@ export const MY_EVENT_STATUS_OPTIONS: FilterOption[] = [
{ label: 'Not Registered', value: 'not-registered' },
];
+export const VISA_REQUEST_STATUS_OPTIONS: FilterOption[] = [
+ { label: 'All Statuses', value: null },
+ { label: 'Submitted', value: 'Submitted' },
+ { label: 'Approved', value: 'Approved' },
+ { label: 'Denied', value: 'Denied' },
+ { label: 'Expired', value: 'Expired' },
+];
+
+export const EVENT_REQUEST_STATUS_SEVERITY_MAP: Partial> = {
+ Submitted: 'info',
+ Approved: 'success',
+ Denied: 'danger',
+ Expired: 'secondary',
+};
+
/**
* Status filter options for Foundation Lens events.
* Values are raw EVENT_STATUS DB values except 'coming-soon', which is a sentinel
@@ -44,6 +69,8 @@ export const FOUNDATION_EVENT_STATUS_SEVERITY_MAP: Partial = new Set(['EVENT_NAME', 'PROJECT_NAME', 'EVENT_START_DATE', 'EVENT_CITY']);
+export const VALID_VISA_REQUEST_SORT_FIELDS: ReadonlySet = new Set(['EVENT_NAME', 'EVENT_CITY', 'APPLICATION_DATE']);
+export const DEFAULT_VISA_REQUEST_SORT_FIELD = 'APPLICATION_DATE';
export const DEFAULT_EVENT_SORT_FIELD = 'EVENT_START_DATE';
export const VALID_EVENT_SORT_ORDERS: readonly string[] = ['ASC', 'DESC'];
export const DEFAULT_EVENTS_PAGE_SIZE = 10;
@@ -52,3 +79,14 @@ export const MAX_EVENTS_PAGE_SIZE = 100;
export const EMPTY_MY_EVENTS_RESPONSE: MyEventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 };
export const EMPTY_EVENTS_RESPONSE: EventsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 };
export const EMPTY_ORGANIZATIONS_RESPONSE: MyEventOrganizationsResponse = { data: [] };
+export const EMPTY_VISA_REQUESTS_RESPONSE: VisaRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 };
+export const EMPTY_TRAVEL_FUND_REQUESTS_RESPONSE: TravelFundRequestsResponse = { data: [], total: 0, pageSize: DEFAULT_EVENTS_PAGE_SIZE, offset: 0 };
+export const YES_NO_OPTIONS: FilterOption[] = [
+ { label: 'Yes', value: 'yes' },
+ { label: 'No', value: 'no' },
+];
+
+export const TRAVEL_FUND_STEP_ORDER: TravelFundStep[] = ['select-event', 'terms', 'about-me', 'expenses'];
+export const VIS_REQUEST_STEP_ORDER: VisaRequestStep[] = ['select-event', 'terms', 'apply'];
+
+export const EVENT_SELECTION_PAGE_SIZE = 12;
diff --git a/packages/shared/src/constants/pdf.constants.ts b/packages/shared/src/constants/pdf.constants.ts
index b50d31da7..b16a1bbf7 100644
--- a/packages/shared/src/constants/pdf.constants.ts
+++ b/packages/shared/src/constants/pdf.constants.ts
@@ -1,7 +1,7 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
-import { PDFTemplateDetails } from '../interfaces/my-event.interface';
+import { PDFTemplateDetails } from '../interfaces/events.interface';
export const DEFAULT_TEMPLATE: PDFTemplateDetails = {
link: 'https://www.linuxfoundation.org/',
diff --git a/packages/shared/src/interfaces/my-event.interface.ts b/packages/shared/src/interfaces/events.interface.ts
similarity index 59%
rename from packages/shared/src/interfaces/my-event.interface.ts
rename to packages/shared/src/interfaces/events.interface.ts
index 5dbc28f16..01645202c 100644
--- a/packages/shared/src/interfaces/my-event.interface.ts
+++ b/packages/shared/src/interfaces/events.interface.ts
@@ -25,6 +25,8 @@ export interface MyEvent {
registrationUrl: string | null;
/** Foundation short name (e.g. "CNCF", "OpenSSF") */
foundation: string;
+ /** ISO 8601 event start date string (e.g. "2026-11-10T00:00:00.000Z") — used for date-range filtering */
+ startDate: string;
/** Human-readable date string (e.g. "Nov 10–13, 2026") */
date: string;
/** Human-readable location string (e.g. "Salt Lake City, UT") */
@@ -177,6 +179,41 @@ export interface EventTab {
countKey?: 'upcoming' | 'past';
}
+/**
+ * Step identifiers for the visa letter application dialog
+ */
+export type VisaRequestStep = 'select-event' | 'terms' | 'apply';
+
+/**
+ * Step identifiers for the travel fund application dialog
+ */
+export type TravelFundStep = 'select-event' | 'terms' | 'about-me' | 'expenses';
+
+/**
+ * Represents the render state of a single step in a multi-step dialog's step indicator
+ */
+export interface DialogStepState {
+ id: string;
+ label: string;
+ number: number;
+ isActive: boolean;
+ isCompleted: boolean;
+}
+
+/**
+ * Parameters for fetching event requests (visa letters or travel fund) from the API
+ */
+export interface GetEventRequestsParams {
+ eventId?: string;
+ projectName?: string;
+ searchQuery?: string;
+ status?: string;
+ sortField?: string;
+ pageSize?: number;
+ offset?: number;
+ sortOrder?: 'ASC' | 'DESC';
+}
+
/**
* Parameters for fetching my events from the API
*/
@@ -191,6 +228,14 @@ export interface GetMyEventsParams {
pageSize?: number;
offset?: number;
sortOrder?: 'ASC' | 'DESC';
+ /** When true, only events the user has registered for are returned */
+ registeredOnly?: boolean;
+ /** ISO 8601 date string — include only events starting on or after this date */
+ startDateFrom?: string;
+ /** ISO 8601 date string — include only events starting on or before this date */
+ startDateTo?: string;
+ /** Filter events by country (e.g. "United States") */
+ country?: string;
}
/**
@@ -252,11 +297,75 @@ export interface CertificateData {
userName: string;
}
+/**
+ * Raw row returned from ANALYTICS.PLATINUM_LFX_ONE.EVENT_REGISTRATIONS for visa letter requests
+ */
+export interface VisaRequestRow {
+ EVENT_ID: string;
+ EVENT_NAME: string;
+ EVENT_URL: string | null;
+ EVENT_LOCATION: string | null;
+ EVENT_CITY: string | null;
+ EVENT_COUNTRY: string | null;
+ /** Date the visa letter was applied for */
+ APPLICATION_DATE: Date | string | null;
+ /** Visa letter request status. Actual Snowflake values: "Submitted", "Approved", "Denied", "Expired" */
+ REQUEST_STATUS: string;
+ TOTAL_RECORDS: number;
+}
+
+/**
+ * Visa letter request item for the My Events visa-letters tab
+ */
+export interface VisaRequest {
+ /** Unique event identifier */
+ id: string;
+ /** Event display name */
+ name: string;
+ /** External event URL */
+ url: string;
+ /** Human-readable location string (e.g. "Salt Lake City, UT") */
+ location: string;
+ /** Human-readable application date string (e.g. "Jan 15, 2026") */
+ applicationDate: string;
+ /** Visa letter request status (e.g. "Submitted", "Approved", "Denied", "Expired") */
+ status: string;
+}
+
+/**
+ * Paginated API response for visa letter requests
+ */
+export type VisaRequestsResponse = OffsetPaginatedResponse;
+
+/**
+ * Travel fund request item — identical shape to VisaRequest (event name, location, application date, status)
+ */
+export type TravelFundRequest = VisaRequest;
+
+/**
+ * Paginated API response for travel fund requests
+ */
+export type TravelFundRequestsResponse = OffsetPaginatedResponse;
+
/**
* Valid sort order values for event queries
*/
export type EventSortOrder = 'ASC' | 'DESC';
+/**
+ * Server-side options for fetching event requests (visa letters or travel fund) — required pagination/sort fields
+ */
+export interface GetEventRequestsOptions {
+ eventId?: string;
+ projectName?: string;
+ searchQuery?: string;
+ status?: string;
+ sortField?: string;
+ pageSize: number;
+ offset: number;
+ sortOrder: EventSortOrder;
+}
+
/**
* Server-side options for fetching user events (required pagination/sort fields)
*/
@@ -271,10 +380,25 @@ export interface GetMyEventsOptions {
pageSize: number;
offset: number;
sortOrder: EventSortOrder;
+ /** When true, only events the user has registered for are returned */
+ registeredOnly?: boolean;
+ /** ISO 8601 date string — include only events starting on or after this date */
+ startDateFrom?: string;
+ /** ISO 8601 date string — include only events starting on or before this date */
+ startDateTo?: string;
+ /** Filter events by country (e.g. "United States") */
+ country?: string;
/** Project slugs from persona detection — scopes upcoming events to affiliated projects */
affiliatedProjectSlugs?: string[];
}
+/**
+ * Response for distinct event countries
+ */
+export interface GetUpcomingCountriesResponse {
+ data: string[];
+}
+
/**
* Server-side options for fetching foundation events (required pagination/sort fields)
*/
@@ -301,3 +425,80 @@ export interface GetEventOrganizationsOptions {
/** Project slugs from persona detection — scopes upcoming foundations to affiliated projects */
affiliatedProjectSlugs?: string[];
}
+
+// ---------------------------------------------------------------------------
+// Travel Fund Application
+// ---------------------------------------------------------------------------
+
+export interface TravelFundAboutMe {
+ firstName: string;
+ lastName: string;
+ email: string;
+ citizenshipCountry: string;
+ profileLink: string;
+ company: string;
+ canReceiveFunds: string;
+ travelFromCountry: string;
+ openSourceInvolvement: string;
+ isLgbtqia: boolean;
+ isWoman: boolean;
+ isPersonWithDisability: boolean;
+ isDiversityOther: boolean;
+ preferNotToAnswer: boolean;
+ attendingForCompany: string;
+ willingToBlog: string;
+}
+
+export interface TravelFundExpenses {
+ airfareCost: number;
+ airfareNotes: string;
+ hotelCost: number;
+ hotelNotes: string;
+ groundTransportCost: number;
+ groundTransportNotes: string;
+ estimatedTotal: number;
+}
+
+export interface TravelFundApplication {
+ eventId: string;
+ eventName: string;
+ termsAccepted: boolean;
+ aboutMe: TravelFundAboutMe;
+ expenses: TravelFundExpenses;
+}
+
+export interface TravelFundApplicationResponse {
+ success: boolean;
+ message: string;
+}
+
+// ---------------------------------------------------------------------------
+// Visa Request Application
+// ---------------------------------------------------------------------------
+
+export interface VisaRequestApplicantInfo {
+ firstName: string;
+ lastName: string;
+ email: string;
+ passportNumber: string;
+ citizenshipCountry: string;
+ passportExpiryDate: Date | null;
+ embassyCity: string;
+ company: string;
+ mailingAddress: string;
+}
+
+export interface VisaRequestApplication {
+ eventId: string;
+ eventName: string;
+ termsAccepted: boolean;
+ applicantInfo: VisaRequestApplicantInfo;
+}
+
+export interface VisaRequestApplicationResponse {
+ success: boolean;
+ message: string;
+}
+
+export type RequestType = 'visa' | 'travel-fund';
+export type TimeFilterValue = 'any' | 'this-month' | 'next-3-months';
diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts
index 177f66094..3207d6216 100644
--- a/packages/shared/src/interfaces/index.ts
+++ b/packages/shared/src/interfaces/index.ts
@@ -104,9 +104,8 @@ export * from './lens.interface';
// Persona detection interfaces
export * from './persona-detection.interface';
-// My Event interfaces
-export * from './my-event.interface';
-
+// Events interfaces (my events, foundation events, travel fund, visa request)
+export * from './events.interface';
// Training interfaces
export * from './training.interface';