/* eslint-disable @typescript-eslint/no-invalid-void-type */ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, forkJoin, of, throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { User } from '../../../shared-kernel'; import { ServerEndpointStateService } from '../application/server-endpoint-state.service'; import type { BanServerMemberRequest, CreateServerInviteRequest, KickServerMemberRequest, ServerEndpoint, ServerInfo, ServerInviteInfo, ServerJoinAccessRequest, ServerJoinAccessResponse, ServerSourceSelector, UnbanServerMemberRequest } from '../domain/server-directory.models'; @Injectable({ providedIn: 'root' }) export class ServerDirectoryApiService { private readonly http = inject(HttpClient); private readonly endpointState = inject(ServerEndpointStateService); getApiBaseUrl(selector?: ServerSourceSelector): string { return `${this.resolveBaseServerUrl(selector)}/api`; } getWebSocketUrl(selector?: ServerSourceSelector): string { return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws'); } resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null { if (selector?.sourceId) { return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null; } if (selector?.sourceUrl) { return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null; } return this.endpointState.activeServer() ?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') ?? this.endpointState.servers()[0] ?? null; } searchServers(query: string, shouldSearchAllServers: boolean): Observable { if (shouldSearchAllServers) { return this.searchAllEndpoints(query); } return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); } getServers(shouldSearchAllServers: boolean): Observable { if (shouldSearchAllServers) { return this.getAllServersFromAllEndpoints(); } return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`) .pipe( map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), catchError((error) => { console.error('Failed to get servers:', error); return of([]); }) ); } getServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http .get(`${this.getApiBaseUrl(selector)}/servers/${serverId}`) .pipe( map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), catchError((error) => { console.error('Failed to get server:', error); return of(null); }) ); } registerServer( server: Omit & { id?: string; password?: string | null }, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.getApiBaseUrl(selector)}/servers`, server) .pipe( catchError((error) => { console.error('Failed to register server:', error); return throwError(() => error); }) ); } updateServer( serverId: string, updates: Partial & { currentOwnerId: string; actingRole?: string; password?: string | null; }, selector?: ServerSourceSelector ): Observable { return this.http .put(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates) .pipe( catchError((error) => { console.error('Failed to update server:', error); return throwError(() => error); }) ); } unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { return this.http .delete(`${this.getApiBaseUrl(selector)}/servers/${serverId}`) .pipe( catchError((error) => { console.error('Failed to unregister server:', error); return throwError(() => error); }) ); } getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { return this.http .get(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`) .pipe( catchError((error) => { console.error('Failed to get server users:', error); return of([]); }) ); } requestJoin( request: ServerJoinAccessRequest, selector?: ServerSourceSelector ): Observable { return this.http .post( `${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`, request ) .pipe( catchError((error) => { console.error('Failed to send join request:', error); return throwError(() => error); }) ); } createInvite( serverId: string, request: CreateServerInviteRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request) .pipe( catchError((error) => { console.error('Failed to create invite:', error); return throwError(() => error); }) ); } getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { return this.http .get(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`) .pipe( catchError((error) => { console.error('Failed to get invite:', error); return throwError(() => error); }) ); } kickServerMember( serverId: string, request: KickServerMemberRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request) .pipe( catchError((error) => { console.error('Failed to kick server member:', error); return throwError(() => error); }) ); } banServerMember( serverId: string, request: BanServerMemberRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request) .pipe( catchError((error) => { console.error('Failed to ban server member:', error); return throwError(() => error); }) ); } unbanServerMember( serverId: string, request: UnbanServerMemberRequest, selector?: ServerSourceSelector ): Observable { return this.http .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request) .pipe( catchError((error) => { console.error('Failed to unban server member:', error); return throwError(() => error); }) ); } notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { return this.http .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }) .pipe( catchError((error) => { console.error('Failed to notify leave:', error); return of(undefined); }) ); } updateUserCount(serverId: string, count: number): Observable { return this.http .patch(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }) .pipe( catchError((error) => { console.error('Failed to update user count:', error); return of(undefined); }) ); } sendHeartbeat(serverId: string): Observable { return this.http .post(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) .pipe( catchError((error) => { console.error('Failed to send heartbeat:', error); return of(undefined); }) ); } private resolveBaseServerUrl(selector?: ServerSourceSelector): string { if (selector?.sourceUrl) { return this.endpointState.sanitiseUrl(selector.sourceUrl); } return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl(); } private unwrapServersResponse( response: { servers: ServerInfo[]; total: number } | ServerInfo[] ): ServerInfo[] { return Array.isArray(response) ? response : (response.servers ?? []); } private searchSingleEndpoint( query: string, apiBaseUrl: string, source?: ServerEndpoint | null ): Observable { const params = new HttpParams().set('q', query); return this.http .get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) .pipe( map((response) => this.normalizeServerList(response, source)), catchError((error) => { console.error('Failed to search servers:', error); return of([]); }) ); } private searchAllEndpoints(query: string): Observable { const onlineEndpoints = this.endpointState.activeServers().filter( (endpoint) => endpoint.status !== 'offline' ); if (onlineEndpoints.length === 0) { return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); } return forkJoin( onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint)) ).pipe( map((resultArrays) => resultArrays.flat()), map((servers) => this.deduplicateById(servers)) ); } private getAllServersFromAllEndpoints(): Observable { const onlineEndpoints = this.endpointState.activeServers().filter( (endpoint) => endpoint.status !== 'offline' ); if (onlineEndpoints.length === 0) { return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`) .pipe( map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), catchError(() => of([])) ); } return forkJoin( onlineEndpoints.map((endpoint) => this.http .get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) .pipe( map((response) => this.normalizeServerList(response, endpoint)), catchError(() => of([] as ServerInfo[])) ) ) ).pipe(map((resultArrays) => resultArrays.flat())); } private deduplicateById(items: T[]): T[] { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) { return false; } seen.add(item.id); return true; }); } private normalizeServerList( response: { servers: ServerInfo[]; total: number } | ServerInfo[], source?: ServerEndpoint | null ): ServerInfo[] { return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); } private normalizeServerInfo( server: ServerInfo | Record, source?: ServerEndpoint | null ): ServerInfo { const candidate = server as Record; const sourceName = this.getStringValue(candidate['sourceName']); const sourceUrl = this.getStringValue(candidate['sourceUrl']); return { id: this.getStringValue(candidate['id']) ?? '', name: this.getStringValue(candidate['name']) ?? 'Unnamed server', description: this.getStringValue(candidate['description']), topic: this.getStringValue(candidate['topic']), hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API', ownerId: this.getStringValue(candidate['ownerId']), ownerName: this.getStringValue(candidate['ownerName']), ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']), userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])), maxUsers: this.getNumberValue(candidate['maxUsers']), hasPassword: this.getBooleanValue(candidate['hasPassword']), isPrivate: this.getBooleanValue(candidate['isPrivate']), tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [], createdAt: this.getNumberValue(candidate['createdAt'], Date.now()), sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id, sourceName: sourceName ?? source?.name, sourceUrl: sourceUrl ? this.endpointState.sanitiseUrl(sourceUrl) : (source ? this.endpointState.sanitiseUrl(source.url) : undefined) }; } private getBooleanValue(value: unknown): boolean { return typeof value === 'boolean' ? value : value === 1; } private getNumberValue(value: unknown, fallback = 0): number { return typeof value === 'number' ? value : fallback; } private getStringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } }