diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4451829..3fda256 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,13 +1,14 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; +import { authInterceptor } from './auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient() + provideHttpClient(withInterceptors([authInterceptor])) ] }; diff --git a/src/app/auth.interceptor.ts b/src/app/auth.interceptor.ts new file mode 100644 index 0000000..af757d9 --- /dev/null +++ b/src/app/auth.interceptor.ts @@ -0,0 +1,19 @@ +import { HttpInterceptorFn } from '@angular/common/http'; + +const TOKEN_KEY = 'sentinent_token'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const token = localStorage.getItem(TOKEN_KEY); + + if (!token || req.url.startsWith('http://') || req.url.startsWith('https://')) { + return next(req); + } + + return next( + req.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }), + ); +}; diff --git a/src/app/components/decision-form/decision-form.component.ts b/src/app/components/decision-form/decision-form.component.ts index d06aa83..8b5e875 100644 --- a/src/app/components/decision-form/decision-form.component.ts +++ b/src/app/components/decision-form/decision-form.component.ts @@ -47,8 +47,12 @@ export class DecisionFormComponent implements OnInit { } loadDecision(id: string): void { + if (!this.workspaceId) { + return; + } + this.isLoading = true; - this.decisionService.getDecision(id).subscribe(decision => { + this.decisionService.getDecision(this.workspaceId, id).subscribe(decision => { this.isLoading = false; if (decision) { this.decisionForm.patchValue({ @@ -71,7 +75,11 @@ export class DecisionFormComponent implements OnInit { const formValue = this.decisionForm.value; if (this.isEditMode && this.decisionId) { - this.decisionService.updateDecision(this.decisionId, formValue).subscribe(() => { + if (!this.workspaceId) { + return; + } + + this.decisionService.updateDecision(this.workspaceId, this.decisionId, formValue).subscribe(() => { this.router.navigate(['../../'], { relativeTo: this.route }); }); } else { diff --git a/src/app/components/decision-form/decision-form.css b/src/app/components/decision-form/decision-form.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/components/decision-form/decision-form.html b/src/app/components/decision-form/decision-form.html deleted file mode 100644 index 7e9c4ac..0000000 --- a/src/app/components/decision-form/decision-form.html +++ /dev/null @@ -1 +0,0 @@ -

decision-form works!

diff --git a/src/app/components/decision-form/decision-form.ts b/src/app/components/decision-form/decision-form.ts deleted file mode 100644 index 0a59e66..0000000 --- a/src/app/components/decision-form/decision-form.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-decision-form', - imports: [], - templateUrl: './decision-form.html', - styleUrl: './decision-form.css', -}) -export class DecisionForm { - -} diff --git a/src/app/components/decision-list/decision-list.component.ts b/src/app/components/decision-list/decision-list.component.ts index 261ca5c..48aac5b 100644 --- a/src/app/components/decision-list/decision-list.component.ts +++ b/src/app/components/decision-list/decision-list.component.ts @@ -14,6 +14,7 @@ import { Observable } from 'rxjs'; }) export class DecisionListComponent implements OnInit { decisions$: Observable | undefined; + private workspaceId: string | null = null; constructor( private decisionService: DecisionService, @@ -21,15 +22,17 @@ export class DecisionListComponent implements OnInit { ) { } ngOnInit(): void { - const workspaceId = this.getWorkspaceIdFromRoute(); - if (workspaceId) { - this.decisions$ = this.decisionService.getDecisions(workspaceId); + this.workspaceId = this.getWorkspaceIdFromRoute(); + if (this.workspaceId) { + this.decisions$ = this.decisionService.getDecisions(this.workspaceId); } } deleteDecision(id: string): void { - if (confirm('Are you sure you want to delete this decision?')) { - this.decisionService.deleteDecision(id); + if (confirm('Are you sure you want to delete this decision?') && this.workspaceId) { + this.decisionService.deleteDecision(this.workspaceId, id).subscribe(() => { + this.decisions$ = this.decisionService.getDecisions(this.workspaceId!); + }); } } diff --git a/src/app/components/decision-list/decision-list.css b/src/app/components/decision-list/decision-list.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/components/decision-list/decision-list.html b/src/app/components/decision-list/decision-list.html deleted file mode 100644 index 0c2fbfa..0000000 --- a/src/app/components/decision-list/decision-list.html +++ /dev/null @@ -1 +0,0 @@ -

decision-list works!

diff --git a/src/app/components/decision-list/decision-list.ts b/src/app/components/decision-list/decision-list.ts deleted file mode 100644 index 732933e..0000000 --- a/src/app/components/decision-list/decision-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-decision-list', - imports: [], - templateUrl: './decision-list.html', - styleUrl: './decision-list.css', -}) -export class DecisionList { - -} diff --git a/src/app/components/workspace-integrations/workspace-integrations.spec.ts b/src/app/components/workspace-integrations/workspace-integrations.spec.ts index da8cf55..fbfdb7f 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.spec.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.spec.ts @@ -11,18 +11,15 @@ describe('WorkspaceIntegrationsComponent', () => { beforeEach(async () => { mockIntegrationService = jasmine.createSpyObj('IntegrationService', [ - 'getSlackAuthUrl', 'getSlackChannels', 'connectSlack', 'updateSlackChannels', 'disconnectSlack', - 'getGitHubAuthUrl', 'getGitHubRepos', 'connectGitHub', 'updateGitHubRepos', 'disconnectGitHub', - 'syncGitHub', - 'getSyncStatus' + 'syncGitHub' ]); mockIntegrationService.getSlackChannels.and.returnValue(of({ @@ -36,8 +33,7 @@ describe('WorkspaceIntegrationsComponent', () => { })); mockIntegrationService.updateSlackChannels.and.returnValue(of(void 0)); mockIntegrationService.disconnectSlack.and.returnValue(of(void 0)); - mockIntegrationService.getSlackAuthUrl.and.returnValue(of({ authUrl: 'https://slack.com/mock' })); - mockIntegrationService.connectSlack.and.returnValue(of({ connected: true })); + mockIntegrationService.connectSlack.and.returnValue(of(void 0)); mockIntegrationService.getGitHubRepos.and.returnValue(of({ connected: true, @@ -49,16 +45,9 @@ describe('WorkspaceIntegrationsComponent', () => { lastSyncAt: new Date('2026-03-23T10:00:00Z') })); mockIntegrationService.updateGitHubRepos.and.returnValue(of(void 0)); - mockIntegrationService.syncGitHub.and.returnValue(of({ syncId: 'sync-1' })); - mockIntegrationService.getSyncStatus.and.returnValue(of({ - syncId: 'sync-1', - status: 'completed', - itemsSynced: 6, - completedAt: new Date('2026-03-23T10:05:00Z') - })); + mockIntegrationService.syncGitHub.and.returnValue(of({ syncId: 'sync-1', status: 'in_progress' })); mockIntegrationService.disconnectGitHub.and.returnValue(of(void 0)); - mockIntegrationService.getGitHubAuthUrl.and.returnValue(of({ authUrl: 'https://github.com/mock' })); - mockIntegrationService.connectGitHub.and.returnValue(of({ connected: true })); + mockIntegrationService.connectGitHub.and.returnValue(of(void 0)); await TestBed.configureTestingModule({ imports: [WorkspaceIntegrationsComponent], @@ -93,13 +82,13 @@ describe('WorkspaceIntegrationsComponent', () => { fixture.detectChanges(); expect(mockIntegrationService.syncGitHub).toHaveBeenCalled(); - expect(component.githubFeedbackMessage).toContain('6 items refreshed'); + expect(component.githubFeedbackMessage).toContain('started'); }); it('should save slack channel selection', () => { component.saveSlackChannelSelection(); - expect(mockIntegrationService.updateSlackChannels).toHaveBeenCalled(); + expect(mockIntegrationService.updateSlackChannels).toHaveBeenCalledWith('workspace-1', ['C123']); expect(component.slackFeedbackMessage).toContain('saved'); }); }); diff --git a/src/app/components/workspace-integrations/workspace-integrations.ts b/src/app/components/workspace-integrations/workspace-integrations.ts index ac08faa..86ad40d 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.ts @@ -48,16 +48,16 @@ export class WorkspaceIntegrationsComponent implements OnInit { connectSlack(): void { this.slackErrorMessage = ''; this.slackFeedbackMessage = 'Starting Slack connection...'; - this.integrationService.getSlackAuthUrl().subscribe(() => { - this.integrationService.connectSlack().subscribe(() => { - this.slackFeedbackMessage = 'Slack workspace connected. Choose the channels you want to monitor.'; - this.loadSlackChannels(); - }); + this.integrationService.connectSlack(this.workspaceId).subscribe({ + error: (error: Error) => { + this.slackErrorMessage = error.message; + this.slackFeedbackMessage = ''; + } }); } disconnectSlack(): void { - this.integrationService.disconnectSlack().subscribe(() => { + this.integrationService.disconnectSlack(this.workspaceId).subscribe(() => { this.slackFeedbackMessage = 'Slack integration disconnected.'; this.loadSlackChannels(); }); @@ -66,11 +66,11 @@ export class WorkspaceIntegrationsComponent implements OnInit { connectGitHub(): void { this.githubErrorMessage = ''; this.githubFeedbackMessage = 'Starting GitHub connection...'; - this.integrationService.getGitHubAuthUrl().subscribe(() => { - this.integrationService.connectGitHub().subscribe(() => { - this.githubFeedbackMessage = 'GitHub account connected. Select the repositories you want Sentinent to monitor.'; - this.loadRepos(); - }); + this.integrationService.connectGitHub().subscribe({ + error: (error: Error) => { + this.githubErrorMessage = error.message; + this.githubFeedbackMessage = ''; + } }); } @@ -107,7 +107,7 @@ export class WorkspaceIntegrationsComponent implements OnInit { saveSlackChannelSelection(): void { this.isSlackSaving = true; this.slackErrorMessage = ''; - this.integrationService.updateSlackChannels(this.selectedSlackChannelIds).subscribe({ + this.integrationService.updateSlackChannels(this.workspaceId, this.selectedSlackChannelIds).subscribe({ next: () => { this.isSlackSaving = false; this.slackFeedbackMessage = 'Slack channel selection saved.'; @@ -140,15 +140,13 @@ export class WorkspaceIntegrationsComponent implements OnInit { this.isSyncing = true; this.githubErrorMessage = ''; this.integrationService.syncGitHub().subscribe({ - next: ({ syncId }) => { - this.integrationService.getSyncStatus(syncId).subscribe(status => { - this.githubSyncStatus = status; - this.isSyncing = false; - this.githubLastSyncAt = status.completedAt; - this.githubFeedbackMessage = status.status === 'completed' - ? `Sync completed. ${status.itemsSynced ?? 0} items refreshed.` - : 'GitHub sync failed.'; - }); + next: (status) => { + this.githubSyncStatus = status; + this.isSyncing = false; + this.githubFeedbackMessage = status.status === 'in_progress' + ? 'GitHub sync started. Signals will refresh shortly.' + : 'GitHub sync could not be started.'; + this.loadRepos(); }, error: (error: Error) => { this.isSyncing = false; @@ -166,7 +164,7 @@ export class WorkspaceIntegrationsComponent implements OnInit { } private loadSlackChannels(): void { - this.integrationService.getSlackChannels().subscribe(response => { + this.integrationService.getSlackChannels(this.workspaceId).subscribe(response => { this.isSlackConnected = response.connected; this.slackChannels = response.channels; this.selectedSlackChannelIds = response.channels.filter(channel => channel.isConnected).map(channel => channel.id); diff --git a/src/app/components/workspace/create-workspace-placeholder.ts b/src/app/components/workspace/create-workspace-placeholder.ts deleted file mode 100644 index e785b87..0000000 --- a/src/app/components/workspace/create-workspace-placeholder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Component } from '@angular/core'; -import { RouterLink } from '@angular/router'; - -@Component({ - selector: 'app-create-workspace-placeholder', - standalone: true, - imports: [RouterLink], - template: ` -
-

Create Workspace

-

This feature is being implemented in Issue #4.

- Back to Dashboard -
- ` -}) -export class CreateWorkspacePlaceholder { } diff --git a/src/app/services/decision.service.ts b/src/app/services/decision.service.ts index db018fb..a22e3aa 100644 --- a/src/app/services/decision.service.ts +++ b/src/app/services/decision.service.ts @@ -1,100 +1,101 @@ -import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { catchError, map, Observable, of, throwError } from 'rxjs'; import { Decision } from '../models/decision.model'; +import { toError } from './http-error'; + +interface DecisionResponse { + id: number; + workspace_id: number; + user_id: number; + title: string; + description?: string; + status: 'DRAFT' | 'OPEN' | 'CLOSED'; + due_date?: string | null; + created_at: string; + updated_at: string; +} @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DecisionService { - private decisions: Decision[] = []; - private decisionsSubject = new BehaviorSubject([]); + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/workspaces'; - constructor() { - // Initialize with some mock data - this.decisions = [ - { - id: '1', - title: 'Choose Frontend Framework', - description: 'Decide between Angular and React', - status: 'CLOSED', - workspaceId: 'ws-1', - userId: 'user-1', - dueDate: new Date('2023-11-01'), - createdAt: new Date('2023-10-25'), - updatedAt: new Date('2023-10-26'), - isDeleted: false - }, - { - id: '2', - title: 'Database Selection', - description: 'Evaluate SQLite vs PostgreSQL for local dev', - status: 'OPEN', - workspaceId: 'ws-1', - userId: 'user-1', - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false - } - ]; - this.decisionsSubject.next(this.decisions); - } + getDecisions(workspaceId: string): Observable { + return this.http.get(`${this.apiUrl}/${workspaceId}/decisions`).pipe( + map((decisions) => decisions.map((decision) => this.mapDecision(decision))), + catchError((error) => throwError(() => toError(error, 'Unable to load decisions.'))), + ); + } - getDecisions(workspaceId: string): Observable { - return this.decisionsSubject.asObservable().pipe( - map(decisions => decisions.filter(d => d.workspaceId === workspaceId && !d.isDeleted)) - ); - } + getDecision(workspaceId: string, id: string): Observable { + return this.http.get(`${this.apiUrl}/${workspaceId}/decisions/${id}`).pipe( + map((decision) => this.mapDecision(decision)), + catchError((error) => { + if (error.status === 404) { + return of(undefined); + } + return throwError(() => toError(error, 'Unable to load decision.')); + }), + ); + } - getDecision(id: string): Observable { - return this.decisionsSubject.asObservable().pipe( - map(decisions => decisions.find(d => d.id === id)) - ); + createDecision(decision: Partial): Observable { + if (!decision.workspaceId) { + throw new Error('workspaceId is required to create a decision'); } - createDecision(decision: Partial): Observable { - if (!decision.workspaceId) { - throw new Error('workspaceId is required to create a decision'); - } + return this.http + .post(`${this.apiUrl}/${decision.workspaceId}/decisions`, this.toPayload(decision)) + .pipe( + map((created) => this.mapDecision(created)), + catchError((error) => throwError(() => toError(error, 'Unable to create decision.'))), + ); + } - const newDecision: Decision = { - id: Math.random().toString(36).substring(2, 9), - title: decision.title!, - description: decision.description, - status: decision.status || 'DRAFT', - workspaceId: decision.workspaceId, - userId: 'user-1', // Mock user - dueDate: decision.dueDate, - createdAt: new Date(), - updatedAt: new Date(), - isDeleted: false - }; + updateDecision(workspaceId: string, id: string, updates: Partial): Observable { + return this.http + .patch(`${this.apiUrl}/${workspaceId}/decisions/${id}`, this.toPayload(updates)) + .pipe( + map((decision) => this.mapDecision(decision)), + catchError((error) => { + if (error.status === 404) { + return of(undefined); + } + return throwError(() => toError(error, 'Unable to update decision.')); + }), + ); + } - this.decisions.push(newDecision); - this.decisionsSubject.next(this.decisions); - return of(newDecision); - } + deleteDecision(workspaceId: string, id: string): Observable { + return this.http.delete(`${this.apiUrl}/${workspaceId}/decisions/${id}`).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to delete decision.'))), + ); + } - updateDecision(id: string, updates: Partial): Observable { - const index = this.decisions.findIndex(d => d.id === id); - if (index !== -1) { - this.decisions[index] = { - ...this.decisions[index], - ...updates, - updatedAt: new Date() - }; - this.decisionsSubject.next(this.decisions); - return of(this.decisions[index]); - } - return of(undefined); - } + private toPayload(decision: Partial) { + return { + title: decision.title, + description: decision.description ?? '', + status: decision.status ?? 'DRAFT', + due_date: decision.dueDate ? new Date(decision.dueDate).toISOString() : null, + }; + } - deleteDecision(id: string): Observable { - const index = this.decisions.findIndex(d => d.id === id); - if (index !== -1) { - this.decisions[index].isDeleted = true; - this.decisionsSubject.next(this.decisions); - } - return of(void 0); - } + private mapDecision(decision: DecisionResponse): Decision { + return { + id: String(decision.id), + workspaceId: String(decision.workspace_id), + userId: String(decision.user_id), + title: decision.title, + description: decision.description ?? '', + status: decision.status, + dueDate: decision.due_date ? new Date(decision.due_date) : undefined, + createdAt: new Date(decision.created_at), + updatedAt: new Date(decision.updated_at), + isDeleted: false, + }; + } } diff --git a/src/app/services/http-error.ts b/src/app/services/http-error.ts new file mode 100644 index 0000000..c511be3 --- /dev/null +++ b/src/app/services/http-error.ts @@ -0,0 +1,26 @@ +import { HttpErrorResponse } from '@angular/common/http'; + +export function toError(error: unknown, fallback: string): Error { + if (error instanceof HttpErrorResponse) { + if (typeof error.error === 'string' && error.error.trim()) { + return new Error(error.error.trim()); + } + + if (error.error && typeof error.error === 'object') { + const message = (error.error['error'] ?? error.error['message']); + if (typeof message === 'string' && message.trim()) { + return new Error(message.trim()); + } + } + + if (error.message) { + return new Error(error.message); + } + } + + if (error instanceof Error) { + return error; + } + + return new Error(fallback); +} diff --git a/src/app/services/integration.service.spec.ts b/src/app/services/integration.service.spec.ts index c28f670..72d7d57 100644 --- a/src/app/services/integration.service.spec.ts +++ b/src/app/services/integration.service.spec.ts @@ -1,96 +1,138 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { IntegrationService } from './integration.service'; describe('IntegrationService', () => { let service: IntegrationService; + let httpMock: HttpTestingController; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [IntegrationService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(IntegrationService); + httpMock = TestBed.inject(HttpTestingController); }); - it('connects Slack and exposes workspace details', () => { - let connected = false; - let workspaceName: string | undefined; - - service.connectSlack().subscribe(result => { - connected = result.connected; - }); - service.getSlackChannels().subscribe(result => { - workspaceName = result.workspaceName; - }); - - expect(connected).toBeTrue(); - expect(workspaceName).toBe('Sentinent Ops'); + afterEach(() => { + httpMock.verify(); }); - it('updates Slack channel selections', () => { - let connectedChannels: string[] = []; + it('requests the Slack OAuth URL with the workspace id', () => { + let authUrl = ''; - service.updateSlackChannels(['C987654']).subscribe(); - service.getSlackChannels().subscribe(result => { - connectedChannels = result.channels.filter(channel => channel.isConnected).map(channel => channel.id); + service.getSlackAuthUrl('7').subscribe((result) => { + authUrl = result.authUrl; }); - expect(connectedChannels).toEqual(['C987654']); + const request = httpMock.expectOne('/api/integrations/slack/auth?workspace_id=7'); + expect(request.request.method).toBe('GET'); + request.flush({ auth_url: 'https://slack.com/oauth/mock' }); + + expect(authUrl).toBe('https://slack.com/oauth/mock'); }); - it('disconnects Slack and clears all connected channels', () => { - let connected = true; - let connectedChannels = 1; + it('maps Slack channels using selected ids from integration metadata', () => { + let result: any; - service.connectSlack().subscribe(); - service.disconnectSlack().subscribe(); - service.getSlackChannels().subscribe(result => { - connected = result.connected; - connectedChannels = result.channels.filter(channel => channel.isConnected).length; + service.getSlackChannels('9').subscribe((state) => { + result = state; }); - expect(connected).toBeFalse(); - expect(connectedChannels).toBe(0); + const integrationsRequest = httpMock.expectOne('/api/integrations?workspace_id=9'); + expect(integrationsRequest.request.method).toBe('GET'); + integrationsRequest.flush([ + { + id: 4, + provider: 'slack', + workspace_id: 9, + metadata: JSON.stringify({ + team_name: 'Sentinent Ops', + selected_channels: ['C123'], + }), + updated_at: '2026-03-24T10:00:00Z', + }, + ]); + + const channelsRequest = httpMock.expectOne('/api/integrations/slack/channels?integration_id=4'); + expect(channelsRequest.request.method).toBe('GET'); + channelsRequest.flush({ + channels: [ + { id: 'C123', name: 'general' }, + { id: 'C456', name: 'engineering' }, + ], + }); + + expect(result.connected).toBeTrue(); + expect(result.workspaceName).toBe('Sentinent Ops'); + expect(result.channels[0].isConnected).toBeTrue(); + expect(result.channels[1].isConnected).toBeFalse(); }); - it('returns an error when GitHub sync is requested before connection', () => { - let errorMessage = ''; + it('maps GitHub repositories using selected repo ids from integration metadata', () => { + let repos: Array<{ isConnected: boolean }> = []; - service.syncGitHub().subscribe({ - next: () => fail('expected syncGitHub to fail when GitHub is disconnected'), - error: error => { - errorMessage = error.message; - } + service.getGitHubRepos().subscribe((state) => { + repos = state.repos; }); - expect(errorMessage).toContain('Connect GitHub'); + const integrationsRequest = httpMock.expectOne('/api/integrations'); + expect(integrationsRequest.request.method).toBe('GET'); + integrationsRequest.flush([ + { + id: 8, + provider: 'github', + metadata: JSON.stringify({ + selected_repo_ids: [101], + }), + updated_at: '2026-03-24T10:00:00Z', + }, + ]); + + const reposRequest = httpMock.expectOne('/api/integrations/github/repos'); + expect(reposRequest.request.method).toBe('GET'); + reposRequest.flush([ + { + id: 101, + name: 'frontend-angular', + full_name: 'Sentinent-AI/frontend-angular', + owner: { login: 'Sentinent-AI' }, + }, + { + id: 102, + name: 'backend-go', + full_name: 'Sentinent-AI/backend-go', + owner: { login: 'Sentinent-AI' }, + }, + ]); + + expect(repos.length).toBe(2); + expect(repos[0].isConnected).toBeTrue(); + expect(repos[1].isConnected).toBeFalse(); }); - it('syncs connected GitHub repositories and exposes the sync status', () => { - let syncId = ''; - let itemsSynced = 0; - - service.connectGitHub().subscribe(); + it('persists GitHub repository selections through the backend API', () => { service.updateGitHubRepos([101, 103]).subscribe(); - service.syncGitHub().subscribe(result => { - syncId = result.syncId; - }); - service.getSyncStatus(syncId).subscribe(result => { - itemsSynced = result.itemsSynced ?? 0; - }); - expect(syncId).toContain('sync-'); - expect(itemsSynced).toBe(12); + const request = httpMock.expectOne('/api/integrations/github/repos'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual({ repo_ids: [101, 103] }); + request.flush(null, { status: 204, statusText: 'No Content' }); }); - it('disconnects GitHub and clears the latest sync state', () => { - let status = 'unknown'; + it('maps GitHub sync start responses into UI sync state', () => { + let status = ''; - service.connectGitHub().subscribe(); - service.syncGitHub().subscribe(result => { - service.disconnectGitHub().subscribe(); - service.getSyncStatus(result.syncId).subscribe(syncStatus => { - status = syncStatus.status; - }); + service.syncGitHub().subscribe((result) => { + status = result.status; }); - expect(status).toBe('failed'); + const request = httpMock.expectOne('/api/integrations/github/sync'); + expect(request.request.method).toBe('POST'); + request.flush({ status: 'sync_started' }); + + expect(status).toBe('in_progress'); }); }); diff --git a/src/app/services/integration.service.ts b/src/app/services/integration.service.ts index 9fea9e2..3402ced 100644 --- a/src/app/services/integration.service.ts +++ b/src/app/services/integration.service.ts @@ -1,172 +1,234 @@ -import { Injectable } from '@angular/core'; -import { Observable, of, throwError } from 'rxjs'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { catchError, map, Observable, of, switchMap, throwError } from 'rxjs'; import { GitHubConnectionState, GitHubRepo, SyncStatus } from '../models/github-integration.model'; -import { SlackChannel, SlackConnectionState } from '../models/slack-integration.model'; +import { SlackConnectionState } from '../models/slack-integration.model'; +import { toError } from './http-error'; + +interface IntegrationRecord { + id: number; + provider: 'slack' | 'github'; + workspace_id?: number; + metadata?: string; + updated_at: string; +} -@Injectable({ - providedIn: 'root' -}) -export class IntegrationService { - private slackState: SlackConnectionState = { - connected: false, - channels: [ - { id: 'C123456', name: 'general', isConnected: true }, - { id: 'C456789', name: 'engineering', isConnected: true }, - { id: 'C987654', name: 'product-launch', isConnected: false } - ] - }; +interface SlackChannelsResponse { + channels: Array<{ + id: string; + name: string; + }>; +} - private githubState: GitHubConnectionState = { - connected: false, - repos: [ - { id: 101, name: 'frontend-angular', fullName: 'Sentinent-AI/frontend-angular', isConnected: true }, - { id: 102, name: 'backend-go', fullName: 'Sentinent-AI/backend-go', isConnected: false }, - { id: 103, name: 'Sentinent', fullName: 'Sentinent-AI/Sentinent', isConnected: true } - ] +interface GitHubRepoResponse { + id: number; + name: string; + full_name: string; + owner?: { + login?: string; }; +} - private latestSync: SyncStatus | null = null; +interface OAuthResponse { + auth_url: string; +} - getSlackAuthUrl(): Observable<{ authUrl: string }> { - return of({ - authUrl: 'https://slack.com/oauth/v2/authorize?client_id=sentinent-demo&scope=channels:read%20chat:write&state=mock-state' - }); - } +interface GitHubSyncResponse { + status: string; +} - getSlackChannels(): Observable<{ connected: boolean; channels: SlackChannel[]; workspaceName?: string; workspaceUrl?: string; lastSyncAt?: Date }> { - return of({ - connected: this.slackState.connected, - channels: this.slackState.channels, - workspaceName: this.slackState.workspaceName, - workspaceUrl: this.slackState.workspaceUrl, - lastSyncAt: this.slackState.lastSyncAt - }); +@Injectable({ + providedIn: 'root', +}) +export class IntegrationService { + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/integrations'; + + getSlackAuthUrl(workspaceId: string): Observable<{ authUrl: string }> { + const params = new HttpParams().set('workspace_id', workspaceId); + return this.http.get(`${this.apiUrl}/slack/auth`, { params }).pipe( + map((response) => ({ authUrl: response.auth_url })), + catchError((error) => throwError(() => toError(error, 'Unable to start Slack connection.'))), + ); } - connectSlack(): Observable<{ connected: boolean }> { - this.slackState = { - ...this.slackState, - connected: true, - workspaceName: 'Sentinent Ops', - workspaceUrl: 'sentinent.slack.com' - }; - - return of({ connected: true }); + connectSlack(workspaceId: string): Observable { + return this.getSlackAuthUrl(workspaceId).pipe( + map(({ authUrl }) => { + window.location.assign(authUrl); + }), + ); } - updateSlackChannels(channelIds: string[]): Observable { - this.slackState = { - ...this.slackState, - channels: this.slackState.channels.map(channel => ({ - ...channel, - isConnected: channelIds.includes(channel.id) - })), - lastSyncAt: new Date() - }; - - return of(void 0); + getSlackChannels(workspaceId: string): Observable { + return this.getIntegrations(workspaceId).pipe( + switchMap((integrations) => { + const slackIntegration = integrations.find((integration) => integration.provider === 'slack'); + if (!slackIntegration) { + return of({ + connected: false, + channels: [], + }); + } + + const metadata = this.parseMetadata(slackIntegration.metadata); + const params = new HttpParams().set('integration_id', String(slackIntegration.id)); + + return this.http.get(`${this.apiUrl}/slack/channels`, { params }).pipe( + map((response) => ({ + connected: true, + workspaceName: this.readString(metadata['team_name']), + workspaceUrl: this.readString(metadata['team_domain']), + channels: response.channels.map((channel) => ({ + id: channel.id, + name: channel.name, + isConnected: this.readStringArray(metadata['selected_channels']).includes(channel.id), + })), + lastSyncAt: slackIntegration.updated_at ? new Date(slackIntegration.updated_at) : undefined, + })), + ); + }), + catchError((error) => throwError(() => toError(error, 'Unable to load Slack channels.'))), + ); } - disconnectSlack(): Observable { - this.slackState = { - ...this.slackState, - connected: false, - workspaceName: undefined, - workspaceUrl: undefined, - lastSyncAt: undefined, - channels: this.slackState.channels.map(channel => ({ - ...channel, - isConnected: false - })) - }; + updateSlackChannels(workspaceId: string, channelIds: string[]): Observable { + const params = new HttpParams().set('workspace_id', workspaceId); + return this.http + .patch(`${this.apiUrl}/slack/channels`, { channel_ids: channelIds }, { params }) + .pipe( + catchError((error) => throwError(() => toError(error, 'Unable to save Slack channel selection.'))), + ); + } - return of(void 0); + disconnectSlack(workspaceId: string): Observable { + return this.getIntegrations(workspaceId).pipe( + switchMap((integrations) => { + const slackIntegration = integrations.find((integration) => integration.provider === 'slack'); + if (!slackIntegration) { + return of(void 0); + } + return this.http.delete(`${this.apiUrl}/${slackIntegration.id}`); + }), + catchError((error) => throwError(() => toError(error, 'Unable to disconnect Slack.'))), + ); } getGitHubAuthUrl(): Observable<{ authUrl: string }> { - return of({ - authUrl: 'https://github.com/login/oauth/authorize?client_id=sentinent-demo&scope=read:user%20read:org%20repo&state=mock-state' - }); - } - - getGitHubRepos(): Observable<{ connected: boolean; repos: GitHubRepo[]; accountName?: string; accountHandle?: string; lastSyncAt?: Date }> { - return of({ - connected: this.githubState.connected, - repos: this.githubState.repos, - accountName: this.githubState.accountName, - accountHandle: this.githubState.accountHandle, - lastSyncAt: this.githubState.lastSyncAt - }); - } - - connectGitHub(): Observable<{ connected: boolean }> { - this.githubState = { - ...this.githubState, - connected: true, - accountName: 'Sentinent Engineering', - accountHandle: '@sentinent-dev' - }; + return this.http.get(`${this.apiUrl}/github/auth`).pipe( + map((response) => ({ authUrl: response.auth_url })), + catchError((error) => throwError(() => toError(error, 'Unable to start GitHub connection.'))), + ); + } - return of({ connected: true }); + connectGitHub(): Observable { + return this.getGitHubAuthUrl().pipe( + map(({ authUrl }) => { + window.location.assign(authUrl); + }), + ); } - updateGitHubRepos(repoIds: number[]): Observable { - this.githubState = { - ...this.githubState, - repos: this.githubState.repos.map(repo => ({ - ...repo, - isConnected: repoIds.includes(repo.id) - })) - }; + getGitHubRepos(): Observable { + return this.getIntegrations().pipe( + switchMap((integrations) => { + const githubIntegration = integrations.find((integration) => integration.provider === 'github'); + if (!githubIntegration) { + return of({ + connected: false, + repos: [], + }); + } + + const metadata = this.parseMetadata(githubIntegration.metadata); + const selectedRepoIds = this.readNumberArray(metadata['selected_repo_ids']); + + return this.http.get(`${this.apiUrl}/github/repos`).pipe( + map((repos) => ({ + connected: true, + repos: repos.map((repo) => this.mapGitHubRepo(repo, selectedRepoIds)), + accountName: repos[0]?.owner?.login ?? undefined, + accountHandle: repos[0]?.owner?.login ? `@${repos[0].owner.login}` : undefined, + lastSyncAt: githubIntegration.updated_at ? new Date(githubIntegration.updated_at) : undefined, + })), + ); + }), + catchError((error) => throwError(() => toError(error, 'Unable to load GitHub repositories.'))), + ); + } - return of(void 0); + updateGitHubRepos(repoIds: number[]): Observable { + return this.http.patch(`${this.apiUrl}/github/repos`, { repo_ids: repoIds }).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to save repository selection.'))), + ); } disconnectGitHub(): Observable { - this.githubState = { - ...this.githubState, - connected: false, - accountName: undefined, - accountHandle: undefined, - lastSyncAt: undefined, - repos: this.githubState.repos.map(repo => ({ - ...repo, - isConnected: false - })) - }; - this.latestSync = null; + return this.http.delete(`${this.apiUrl}/github`).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to disconnect GitHub.'))), + ); + } - return of(void 0); + syncGitHub(): Observable { + return this.http.post(`${this.apiUrl}/github/sync`, {}).pipe( + map((response) => { + const status: SyncStatus['status'] = response.status === 'sync_started' ? 'in_progress' : 'failed'; + return { + syncId: `sync-${Date.now()}`, + status, + }; + }), + catchError((error) => throwError(() => toError(error, 'Unable to sync GitHub.'))), + ); } - syncGitHub(): Observable<{ syncId: string }> { - if (!this.githubState.connected) { - return throwError(() => new Error('Connect GitHub before starting a sync.')); + private getIntegrations(workspaceId?: string): Observable { + let params = new HttpParams(); + if (workspaceId) { + params = params.set('workspace_id', workspaceId); } - const syncId = `sync-${Date.now()}`; - this.latestSync = { - syncId, - status: 'completed', - itemsSynced: this.githubState.repos.filter(repo => repo.isConnected).length * 6, - completedAt: new Date() - }; - this.githubState = { - ...this.githubState, - lastSyncAt: this.latestSync.completedAt - }; - - return of({ syncId }); + return this.http.get(this.apiUrl, { params }).pipe( + catchError((error) => { + if (error.status === 404) { + return of([]); + } + return throwError(() => toError(error, 'Unable to load integrations.')); + }), + ); } - getSyncStatus(syncId: string): Observable { - if (this.latestSync && this.latestSync.syncId === syncId) { - return of(this.latestSync); + private parseMetadata(metadata?: string): Record { + if (!metadata) { + return {}; } - return of({ - syncId, - status: 'failed' - }); + try { + const parsed = JSON.parse(metadata); + return typeof parsed === 'object' && parsed !== null ? parsed as Record : {}; + } catch { + return {}; + } + } + + private readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; + } + + private readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + } + + private readNumberArray(value: unknown): number[] { + return Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : []; + } + + private mapGitHubRepo(repo: GitHubRepoResponse, selectedRepoIds: number[]): GitHubRepo { + return { + id: repo.id, + name: repo.name, + fullName: repo.full_name, + isConnected: selectedRepoIds.includes(repo.id), + }; } } diff --git a/src/app/services/search.service.spec.ts b/src/app/services/search.service.spec.ts index 7708aa3..b29ff98 100644 --- a/src/app/services/search.service.spec.ts +++ b/src/app/services/search.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { SearchService } from './search.service'; import { SignalService } from './signal.service'; import { DecisionService } from './decision.service'; +import { WorkspaceService } from './workspace'; import { of } from 'rxjs'; import { Signal } from '../models/signal.model'; import { Decision } from '../models/decision.model'; @@ -10,6 +11,7 @@ describe('SearchService', () => { let service: SearchService; let mockSignalService: any; let mockDecisionService: any; + let mockWorkspaceService: any; beforeEach(() => { mockSignalService = { @@ -20,11 +22,24 @@ describe('SearchService', () => { getDecisions: jasmine.createSpy('getDecisions').and.returnValue(of([])) }; + mockWorkspaceService = { + getWorkspaces: jasmine.createSpy('getWorkspaces').and.returnValue(of([ + { + id: 'ws-1', + name: 'Engineering', + description: '', + createdDate: new Date('2023-01-01T00:00:00Z'), + ownerId: 'user-1' + } + ])) + }; + TestBed.configureTestingModule({ providers: [ SearchService, { provide: SignalService, useValue: mockSignalService }, - { provide: DecisionService, useValue: mockDecisionService } + { provide: DecisionService, useValue: mockDecisionService }, + { provide: WorkspaceService, useValue: mockWorkspaceService } ] }); @@ -94,6 +109,7 @@ describe('SearchService', () => { expect(results.length).toBe(0); expect(mockSignalService.getSignals).not.toHaveBeenCalled(); expect(mockDecisionService.getDecisions).not.toHaveBeenCalled(); + expect(mockWorkspaceService.getWorkspaces).not.toHaveBeenCalled(); done(); }); }); @@ -106,6 +122,8 @@ describe('SearchService', () => { expect(results[0].title).toBe('Use OAuth2 for SSO'); expect(results[1].type).toBe('signal'); expect(results[1].title).toBe('Fix login bug'); + expect(mockWorkspaceService.getWorkspaces).toHaveBeenCalled(); + expect(mockDecisionService.getDecisions).toHaveBeenCalledWith('ws-1'); done(); }); }); diff --git a/src/app/services/search.service.ts b/src/app/services/search.service.ts index 28b91f3..da57916 100644 --- a/src/app/services/search.service.ts +++ b/src/app/services/search.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@angular/core'; -import { Observable, combineLatest, map, of } from 'rxjs'; +import { Observable, combineLatest, forkJoin, map, of, switchMap } from 'rxjs'; import { SignalService } from './signal.service'; import { DecisionService } from './decision.service'; import { Signal } from '../models/signal.model'; import { Decision } from '../models/decision.model'; +import { WorkspaceService } from './workspace'; export type SearchResultType = 'signal' | 'decision'; @@ -27,7 +28,8 @@ export class SearchService { constructor( private signalService: SignalService, - private decisionService: DecisionService + private decisionService: DecisionService, + private workspaceService: WorkspaceService ) {} search(query: string, sourceFilter: string = 'all'): Observable { @@ -45,7 +47,16 @@ export class SearchService { ); // Decision filtering - const decisions$ = this.decisionService.getDecisions('ws-1').pipe( + const decisions$ = this.workspaceService.getWorkspaces().pipe( + switchMap(workspaces => { + if (workspaces.length === 0) { + return of([] as Decision[]); + } + + return forkJoin(workspaces.map(workspace => this.decisionService.getDecisions(workspace.id))).pipe( + map(decisionGroups => decisionGroups.flat()) + ); + }), map(decisions => decisions.filter(d => { const matchesQuery = (d.title + ' ' + (d.description || '')).toLowerCase().includes(q); const matchesSource = sourceFilter === 'all' || sourceFilter === 'decision'; diff --git a/src/app/services/signal.service.spec.ts b/src/app/services/signal.service.spec.ts index 8ef6f89..9d60ff1 100644 --- a/src/app/services/signal.service.spec.ts +++ b/src/app/services/signal.service.spec.ts @@ -1,46 +1,73 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { SignalService } from './signal.service'; -import { Signal } from '../models/signal.model'; describe('SignalService', () => { let service: SignalService; + let httpMock: HttpTestingController; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [SignalService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SignalService); + httpMock = TestBed.inject(HttpTestingController); }); - it('filters signals by source and status', () => { - let results: Signal[] = []; + afterEach(() => { + httpMock.verify(); + }); - service.getSignals({ source: 'github', status: 'unread' }).subscribe(signals => { + it('maps backend signals and applies request filters', () => { + let results: Array<{ id: string; metadata: { number?: number } }> = []; + + service.getSignals({ source: 'github', status: 'unread' }).subscribe((signals) => { results = signals; }); - expect(results.length).toBe(2); - expect(results.every(signal => signal.sourceType === 'github')).toBeTrue(); - expect(results.every(signal => signal.status === 'unread')).toBeTrue(); - }); + const request = httpMock.expectOne('/api/signals?source_type=github&status=unread'); + expect(request.request.method).toBe('GET'); + request.flush([ + { + id: 42, + source_type: 'github', + source_id: 'Sentinent-AI/frontend-angular#42', + external_id: '42', + title: 'Refine invitation flow', + content: 'Update the redirect behaviour', + author: '@neethi', + status: 'unread', + source_metadata: { + type: 'issue', + number: 42, + repository: 'Sentinent-AI/frontend-angular', + }, + received_at: '2026-03-24T10:00:00Z', + }, + ]); - it('marks a signal as read', () => { - service.markAsRead('slack-101').subscribe(); + expect(results.length).toBe(1); + expect(results[0].id).toBe('42'); + expect(results[0].metadata.number).toBe(42); + }); - let updatedSignal: Signal | undefined; - service.getSignals({ source: 'slack', status: 'read' }).subscribe(signals => { - updatedSignal = signals.find(signal => signal.id === 'slack-101'); - }); + it('marks a signal as read through the API', () => { + service.markAsRead('12').subscribe(); - expect(updatedSignal?.status).toBe('read'); + const request = httpMock.expectOne('/api/signals/12/read'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({}); + request.flush(null, { status: 204, statusText: 'No Content' }); }); - it('archives a signal', () => { - service.archive('github-101').subscribe(); - - let activeSignals: Signal[] = []; - service.getSignals({ source: 'github', status: 'unread' }).subscribe(signals => { - activeSignals = signals; - }); + it('archives a signal through the API', () => { + service.archive('33').subscribe(); - expect(activeSignals.some(signal => signal.id === 'github-101')).toBeFalse(); + const request = httpMock.expectOne('/api/signals/33/archive'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({}); + request.flush(null, { status: 204, statusText: 'No Content' }); }); }); diff --git a/src/app/services/signal.service.ts b/src/app/services/signal.service.ts index ca31527..d533cf4 100644 --- a/src/app/services/signal.service.ts +++ b/src/app/services/signal.service.ts @@ -1,139 +1,94 @@ -import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; -import { Signal, SignalFilters } from '../models/signal.model'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { catchError, map, Observable, throwError } from 'rxjs'; +import { Signal, SignalFilters, SignalMetadata } from '../models/signal.model'; +import { toError } from './http-error'; + +interface SignalResponse { + id: number; + workspace_id?: number; + source_type: 'slack' | 'github'; + source_id: string; + external_id?: string; + title: string; + content?: string; + author?: string; + url?: string; + status: 'unread' | 'read' | 'archived'; + source_metadata?: { + type?: 'issue' | 'pull_request'; + number?: number; + repository?: string; + state?: 'open' | 'closed'; + labels?: string[]; + }; + received_at?: string; +} @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class SignalService { - private signals: Signal[] = [ - { - id: 'slack-101', - sourceType: 'slack', - sourceId: 'C123456', - externalId: '1735200123.0001', - title: 'Message from #general', - content: 'Heads up: the deployment window starts at 5 PM Eastern. Please confirm your checklist items.', - author: '@sam.ops', - status: 'unread', - receivedAt: new Date('2026-03-24T09:10:00Z'), - url: 'https://sentinent.slack.com/archives/C123456/p17352001230001', - metadata: { - channel: 'general', - channelId: 'C123456', - timestamp: '1735200123.0001', - user: 'U123456' - } - }, - { - id: 'slack-102', - sourceType: 'slack', - sourceId: 'C456789', - externalId: '1735201123.0002', - title: 'Message from #engineering', - content: 'Can someone review the API rate-limit handling before we push the Slack integration branch?', - author: '@jordan.dev', - status: 'read', - receivedAt: new Date('2026-03-24T10:05:00Z'), - url: 'https://sentinent.slack.com/archives/C456789/p17352011230002', - metadata: { - channel: 'engineering', - channelId: 'C456789', - timestamp: '1735201123.0002', - user: 'U456789' - } - }, - { - id: 'github-101', - sourceType: 'github', - sourceId: 'Sentinent-AI/frontend-angular', - externalId: '42', - title: 'Refine invitation acceptance flow', - content: 'The accept invitation screen should redirect users back to the intended workspace after login.', - author: '@neethi', - status: 'unread', - receivedAt: new Date('2026-03-23T09:00:00Z'), - url: 'https://github.com/Sentinent-AI/frontend-angular/issues/42', - metadata: { - type: 'issue', - number: 42, - repository: 'Sentinent-AI/frontend-angular', - state: 'open', - labels: ['frontend', 'ux'], - assignees: ['@neethi'], - createdAt: new Date('2026-03-22T20:00:00Z'), - updatedAt: new Date('2026-03-23T09:00:00Z') - } - }, - { - id: 'github-102', - sourceType: 'github', - sourceId: 'Sentinent-AI/Sentinent', - externalId: '14', - title: 'Story: GitHub Integration (US-6)', - content: 'Build OAuth connection, repository selection, and GitHub signal filtering in the dashboard.', - author: '@yashrastogi', - status: 'read', - receivedAt: new Date('2026-03-23T11:15:00Z'), - url: 'https://github.com/Sentinent-AI/Sentinent/issues/14', - metadata: { - type: 'issue', - number: 14, - repository: 'Sentinent-AI/Sentinent', - state: 'open', - labels: ['user-story'], - assignees: ['@me'], - createdAt: new Date('2026-03-23T01:20:00Z'), - updatedAt: new Date('2026-03-23T11:15:00Z') - } - }, - { - id: 'github-103', - sourceType: 'github', - sourceId: 'Sentinent-AI/backend-go', - externalId: '31', - title: 'Add GitHub sync status endpoint', - content: 'Expose last run progress so the frontend can show whether a manual sync completed successfully.', - author: '@backend-bot', - status: 'unread', - receivedAt: new Date('2026-03-23T12:05:00Z'), - url: 'https://github.com/Sentinent-AI/backend-go/pull/31', - metadata: { - type: 'pull_request', - number: 31, - repository: 'Sentinent-AI/backend-go', - state: 'open', - labels: ['backend', 'integrations'], - assignees: ['@neethi', '@backend-bot'], - createdAt: new Date('2026-03-23T10:00:00Z'), - updatedAt: new Date('2026-03-23T12:05:00Z') - } - } - ]; + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/signals'; getSignals(filters: SignalFilters): Observable { - return of( - this.signals.filter(signal => { - const sourceMatch = filters.source === 'all' || signal.sourceType === filters.source; - const statusMatch = filters.status === 'all' || signal.status === filters.status; - return sourceMatch && statusMatch; - }) + let params = new HttpParams(); + + if (filters.source !== 'all') { + params = params.set('source_type', filters.source); + } + + if (filters.status !== 'all') { + params = params.set('status', filters.status); + } + + return this.http.get(this.apiUrl, { params }).pipe( + map((signals) => signals.map((signal) => this.mapSignal(signal))), + catchError((error) => throwError(() => toError(error, 'Unable to load signals.'))), ); } markAsRead(signalId: string): Observable { - this.signals = this.signals.map(signal => - signal.id === signalId ? { ...signal, status: 'read' } : signal + return this.http.post(`${this.apiUrl}/${signalId}/read`, {}).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to mark signal as read.'))), ); - - return of(void 0); } archive(signalId: string): Observable { - this.signals = this.signals.map(signal => - signal.id === signalId ? { ...signal, status: 'archived' } : signal + return this.http.post(`${this.apiUrl}/${signalId}/archive`, {}).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to archive signal.'))), ); + } + + private mapSignal(signal: SignalResponse): Signal { + return { + id: String(signal.id), + sourceType: signal.source_type, + sourceId: signal.source_id, + externalId: signal.external_id ?? '', + title: signal.title, + content: signal.content ?? '', + author: signal.author ?? '', + status: signal.status, + receivedAt: signal.received_at ? new Date(signal.received_at) : new Date(), + url: signal.url, + metadata: this.mapMetadata(signal), + }; + } + + private mapMetadata(signal: SignalResponse): SignalMetadata { + if (signal.source_type === 'slack') { + const [channelId, timestamp] = signal.source_id.split(':'); + return { + channel: channelId, + channelId, + timestamp: signal.external_id ?? timestamp, + }; + } - return of(void 0); + return { + ...(signal.source_metadata ?? {}), + }; } } diff --git a/src/app/services/workspace-member.service.spec.ts b/src/app/services/workspace-member.service.spec.ts index ba34013..b71aa8e 100644 --- a/src/app/services/workspace-member.service.spec.ts +++ b/src/app/services/workspace-member.service.spec.ts @@ -1,129 +1,111 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { WorkspaceMemberService } from './workspace-member.service'; -import { Invitation, WorkspaceMember } from '../models/workspace-member.model'; describe('WorkspaceMemberService', () => { let service: WorkspaceMemberService; + let httpMock: HttpTestingController; beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(WorkspaceMemberService); - }); - - it('returns members for a workspace', () => { - let members: WorkspaceMember[] = []; - - service.getMembers('1').subscribe(result => { - members = result; + TestBed.configureTestingModule({ + providers: [WorkspaceMemberService, provideHttpClient(), provideHttpClientTesting()], }); - expect(members.length).toBe(3); - expect(members.map(member => member.email)).toContain('owner@example.com'); + service = TestBed.inject(WorkspaceMemberService); + httpMock = TestBed.inject(HttpTestingController); }); - it('creates an invitation for a new member', () => { - let invitation: Invitation | undefined; - - service.inviteMember('1', 'designer@example.com', 'viewer').subscribe(result => { - invitation = result; - }); - - expect(invitation?.email).toBe('designer@example.com'); - - let invitations: Invitation[] = []; - service.getPendingInvitations('1').subscribe(result => { - invitations = result; - }); - - expect(invitations.some(item => item.email === 'designer@example.com')).toBeTrue(); + afterEach(() => { + httpMock.verify(); }); - it('rejects duplicate invitations or existing members', () => { - let errorMessage = ''; + it('maps workspace members from the backend response', () => { + let members: Array<{ userId: number; joinedAt: Date }> = []; - service.inviteMember('1', 'owner@example.com', 'member').subscribe({ - next: () => fail('expected duplicate invite to fail'), - error: error => { - errorMessage = error.message; - } + service.getMembers('1').subscribe((result) => { + members = result; }); - expect(errorMessage).toContain('already a member'); + const request = httpMock.expectOne('/api/workspaces/1/members'); + expect(request.request.method).toBe('GET'); + request.flush([ + { + user_id: 2, + email: 'member@example.com', + role: 'member', + joined_at: '2026-03-20T00:00:00Z', + }, + ]); + + expect(members.length).toBe(1); + expect(members[0].userId).toBe(2); + expect(members[0].joinedAt instanceof Date).toBeTrue(); }); - it('updates the role for a non-owner member', () => { - let updatedRole = ''; + it('creates an invitation and exposes the token for the UI link', () => { + let invitationToken = ''; - service.updateRole('1', 2, 'viewer').subscribe(result => { - updatedRole = result.role; + service.inviteMember('1', 'designer@example.com', 'viewer').subscribe((result) => { + invitationToken = result.token; }); - expect(updatedRole).toBe('viewer'); - }); - - it('rejects owner role changes', () => { - let errorMessage = ''; - - service.updateRole('1', 1, 'member').subscribe({ - next: () => fail('expected owner update to fail'), - error: error => { - errorMessage = error.message; - } + const request = httpMock.expectOne('/api/workspaces/1/invitations'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + email: 'designer@example.com', + role: 'viewer', }); - - expect(errorMessage).toContain("owner's role"); - }); - - it('removes a non-owner member', () => { - let members: WorkspaceMember[] = []; - - service.removeMember('1', 3).subscribe(); - service.getMembers('1').subscribe(result => { - members = result; + request.flush({ + id: 7, + email: 'designer@example.com', + token: 'invite-token', + role: 'viewer', + expires_at: '2026-04-01T00:00:00Z', + created_at: '2026-03-24T00:00:00Z', }); - expect(members.some(member => member.userId === 3)).toBeFalse(); + expect(invitationToken).toBe('invite-token'); }); - it('rejects owner removal', () => { - let errorMessage = ''; + it('maps invitation validation into the frontend shape', () => { + let invitedBy = ''; - service.removeMember('1', 1).subscribe({ - next: () => fail('expected owner removal to fail'), - error: error => { - errorMessage = error.message; - } + service.validateInvitation('invite-token').subscribe((result) => { + invitedBy = result.invitedBy.email; }); - expect(errorMessage).toContain('Cannot remove workspace owner'); - }); - - it('validates an invitation token', () => { - let role = ''; - - service.validateInvitation('invite_token_member').subscribe(result => { - role = result.role; + const request = httpMock.expectOne('/api/invitations/invite-token'); + expect(request.request.method).toBe('GET'); + request.flush({ + valid: true, + workspace: { + id: 1, + name: 'Engineering', + }, + invited_by: { + email: 'owner@example.com', + }, + role: 'member', }); - expect(role).toBe('member'); + expect(invitedBy).toBe('owner@example.com'); }); - it('accepts a valid invitation for a new user', () => { - let acceptedWorkspaceId = ''; + it('maps accepted invitations back to the workspace route id', () => { + let workspaceId = ''; - (service as any).currentUserEmail = 'newuser@example.com'; - - service.acceptInvitation('invite_token_viewer').subscribe(result => { - acceptedWorkspaceId = result.workspaceId; + service.acceptInvitation('invite-token').subscribe((result) => { + workspaceId = result.workspaceId; }); - expect(acceptedWorkspaceId).toBe('1'); - - let members: WorkspaceMember[] = []; - service.getMembers('1').subscribe(result => { - members = result; + const request = httpMock.expectOne('/api/invitations/invite-token/accept'); + expect(request.request.method).toBe('POST'); + request.flush({ + workspace_id: 12, + role: 'viewer', }); - expect(members.some(member => member.email === 'newuser@example.com' && member.role === 'viewer')).toBeTrue(); + expect(workspaceId).toBe('12'); }); }); diff --git a/src/app/services/workspace-member.service.ts b/src/app/services/workspace-member.service.ts index 2fb79ae..0473972 100644 --- a/src/app/services/workspace-member.service.ts +++ b/src/app/services/workspace-member.service.ts @@ -1,158 +1,143 @@ -import { Injectable } from '@angular/core'; -import { Observable, of, throwError } from 'rxjs'; -import { Invitation, InvitationRole, InvitationValidation, WorkspaceMember, WorkspaceRole } from '../models/workspace-member.model'; +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { catchError, map, Observable, throwError } from 'rxjs'; +import { + Invitation, + InvitationRole, + InvitationValidation, + WorkspaceMember, + WorkspaceRole, +} from '../models/workspace-member.model'; +import { toError } from './http-error'; + +interface WorkspaceMemberResponse { + user_id: number; + email: string; + role: WorkspaceRole; + joined_at: string; +} -@Injectable({ - providedIn: 'root' -}) -export class WorkspaceMemberService { - private membersByWorkspace: Record = { - '1': [ - { userId: 1, email: 'owner@example.com', role: 'owner', joinedAt: new Date('2026-03-01T00:00:00Z') }, - { userId: 2, email: 'member@example.com', role: 'member', joinedAt: new Date('2026-03-20T00:00:00Z') }, - { userId: 3, email: 'viewer@example.com', role: 'viewer', joinedAt: new Date('2026-03-21T00:00:00Z') } - ] - }; +interface InvitationResponse { + id: number; + email: string; + token?: string; + role: InvitationRole; + expires_at: string; + created_at: string; +} - private invitationsByWorkspace: Record = { - '1': [ - { - id: 'invite-1', - email: 'newhire@example.com', - role: 'member', - token: 'invite_token_member', - expiresAt: new Date('2026-04-01T00:00:00Z'), - createdAt: new Date('2026-03-23T00:00:00Z') - }, - { - id: 'invite-2', - email: 'observer@example.com', - role: 'viewer', - token: 'invite_token_viewer', - expiresAt: new Date('2026-04-03T00:00:00Z'), - createdAt: new Date('2026-03-24T00:00:00Z') - } - ] +interface InvitationValidationResponse { + valid: boolean; + workspace: { + id: number; + name: string; + }; + invited_by?: { + email: string; }; + role: InvitationRole; +} - private currentUserEmail = 'owner@example.com'; +interface AcceptInvitationResponse { + workspace_id: number; + role: WorkspaceRole; +} + +@Injectable({ + providedIn: 'root', +}) +export class WorkspaceMemberService { + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api'; getMembers(workspaceId: string): Observable { - return of([...(this.membersByWorkspace[workspaceId] ?? [])]); + return this.http.get(`${this.apiUrl}/workspaces/${workspaceId}/members`).pipe( + map((members) => members.map((member) => this.mapMember(member))), + catchError((error) => throwError(() => toError(error, 'Unable to load workspace members.'))), + ); } inviteMember(workspaceId: string, email: string, role: InvitationRole): Observable { - const existingMember = (this.membersByWorkspace[workspaceId] ?? []).find(member => member.email.toLowerCase() === email.toLowerCase()); - const existingInvitation = (this.invitationsByWorkspace[workspaceId] ?? []).find(invitation => invitation.email.toLowerCase() === email.toLowerCase()); - - if (existingMember || existingInvitation) { - return throwError(() => new Error('User is already a member or has pending invitation')); - } - - const invitation: Invitation = { - id: `invite-${Date.now()}`, - email, - role, - token: `token-${Date.now()}`, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date() - }; - - this.invitationsByWorkspace[workspaceId] = [...(this.invitationsByWorkspace[workspaceId] ?? []), invitation]; - return of(invitation); + return this.http + .post(`${this.apiUrl}/workspaces/${workspaceId}/invitations`, { email, role }) + .pipe( + map((invitation) => this.mapInvitation(invitation)), + catchError((error) => throwError(() => toError(error, 'Unable to create invitation.'))), + ); } updateRole(workspaceId: string, userId: number, role: WorkspaceRole): Observable { - const members = this.membersByWorkspace[workspaceId] ?? []; - const targetMember = members.find(member => member.userId === userId); - - if (!targetMember) { - return throwError(() => new Error('Member not found')); - } - - if (targetMember.role === 'owner') { - return throwError(() => new Error("Cannot modify owner's role")); - } - - const updatedMember = { ...targetMember, role }; - this.membersByWorkspace[workspaceId] = members.map(member => member.userId === userId ? updatedMember : member); - return of(updatedMember); + return this.http + .patch(`${this.apiUrl}/workspaces/${workspaceId}/members/${userId}`, { role }) + .pipe( + map((member) => this.mapMember(member)), + catchError((error) => throwError(() => toError(error, 'Unable to update member role.'))), + ); } removeMember(workspaceId: string, userId: number): Observable { - const members = this.membersByWorkspace[workspaceId] ?? []; - const targetMember = members.find(member => member.userId === userId); - - if (targetMember?.role === 'owner') { - return throwError(() => new Error('Cannot remove workspace owner')); - } - - this.membersByWorkspace[workspaceId] = members.filter(member => member.userId !== userId); - return of(void 0); + return this.http.delete(`${this.apiUrl}/workspaces/${workspaceId}/members/${userId}`).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to remove member.'))), + ); } getPendingInvitations(workspaceId: string): Observable { - return of([...(this.invitationsByWorkspace[workspaceId] ?? [])]); + return this.http.get(`${this.apiUrl}/workspaces/${workspaceId}/invitations`).pipe( + map((invitations) => invitations.map((invitation) => this.mapInvitation(invitation))), + catchError((error) => throwError(() => toError(error, 'Unable to load invitations.'))), + ); } cancelInvitation(workspaceId: string, invitationId: string): Observable { - this.invitationsByWorkspace[workspaceId] = (this.invitationsByWorkspace[workspaceId] ?? []).filter(invitation => invitation.id !== invitationId); - return of(void 0); + return this.http.delete(`${this.apiUrl}/workspaces/${workspaceId}/invitations/${invitationId}`).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to cancel invitation.'))), + ); } validateInvitation(token: string): Observable { - const invitation = this.findInvitationByToken(token); - - if (!invitation) { - return throwError(() => new Error('Invitation expired or invalid')); - } - - return of({ - valid: true, - workspace: { - id: '1', - name: 'Engineering' - }, - invitedBy: { - email: 'owner@example.com' - }, - role: invitation.role - }); + return this.http.get(`${this.apiUrl}/invitations/${token}`).pipe( + map((response) => ({ + valid: response.valid, + workspace: { + id: String(response.workspace.id), + name: response.workspace.name, + }, + invitedBy: { + email: response.invited_by?.email ?? '', + }, + role: response.role, + })), + catchError((error) => throwError(() => toError(error, 'Unable to validate invitation.'))), + ); } acceptInvitation(token: string): Observable<{ workspaceId: string; role: WorkspaceRole }> { - const invitation = this.findInvitationByToken(token); - - if (!invitation) { - return throwError(() => new Error('Invitation expired or invalid')); - } - - const workspaceId = '1'; - const members = this.membersByWorkspace[workspaceId] ?? []; - - if (members.some(member => member.email.toLowerCase() === this.currentUserEmail.toLowerCase())) { - return throwError(() => new Error('You are already a member of this workspace')); - } - - const nextUserId = members.reduce((highest, member) => Math.max(highest, member.userId), 0) + 1; - const memberRole: WorkspaceRole = invitation.role === 'viewer' ? 'viewer' : 'member'; - - this.membersByWorkspace[workspaceId] = [ - ...members, - { - userId: nextUserId, - email: this.currentUserEmail, - role: memberRole, - joinedAt: new Date() - } - ]; - - this.invitationsByWorkspace[workspaceId] = (this.invitationsByWorkspace[workspaceId] ?? []).filter(item => item.token !== token); + return this.http.post(`${this.apiUrl}/invitations/${token}/accept`, {}).pipe( + map((response) => ({ + workspaceId: String(response.workspace_id), + role: response.role, + })), + catchError((error) => throwError(() => toError(error, 'Unable to accept invitation.'))), + ); + } - return of({ workspaceId, role: memberRole }); + private mapMember(member: WorkspaceMemberResponse): WorkspaceMember { + return { + userId: member.user_id, + email: member.email, + role: member.role, + joinedAt: new Date(member.joined_at), + }; } - private findInvitationByToken(token: string): Invitation | undefined { - return Object.values(this.invitationsByWorkspace).flat().find(invitation => invitation.token === token); + private mapInvitation(invitation: InvitationResponse): Invitation { + return { + id: String(invitation.id), + email: invitation.email, + role: invitation.role, + token: invitation.token ?? '', + expiresAt: new Date(invitation.expires_at), + createdAt: new Date(invitation.created_at), + }; } } diff --git a/src/app/services/workspace.spec.ts b/src/app/services/workspace.spec.ts index a0d6ba0..eacc1a4 100644 --- a/src/app/services/workspace.spec.ts +++ b/src/app/services/workspace.spec.ts @@ -1,75 +1,99 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { WorkspaceService } from './workspace'; -import { Workspace } from '../models/workspace'; describe('WorkspaceService', () => { let service: WorkspaceService; + let httpMock: HttpTestingController; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [WorkspaceService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(WorkspaceService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); }); - it('returns the seeded workspaces', () => { - let workspaces: Workspace[] = []; + it('maps the workspace list from the backend contract', () => { + let workspaces: Array<{ id: string; ownerId: string; createdDate: Date }> = []; - service.getWorkspaces().subscribe(result => { + service.getWorkspaces().subscribe((result) => { workspaces = result; }); - expect(workspaces.length).toBe(3); - expect(workspaces.map(workspace => workspace.name)).toContain('Engineering'); + const request = httpMock.expectOne('/api/workspaces'); + expect(request.request.method).toBe('GET'); + request.flush([ + { + id: 1, + name: 'Engineering', + description: 'Platform team', + owner_id: 7, + created_at: '2026-03-24T10:00:00Z', + }, + ]); + + expect(workspaces.length).toBe(1); + expect(workspaces[0].id).toBe('1'); + expect(workspaces[0].ownerId).toBe('7'); + expect(workspaces[0].createdDate instanceof Date).toBeTrue(); }); - it('creates a new workspace', () => { + it('creates a workspace through the backend API', () => { let createdName = ''; - let workspaceCount = 0; - service.createWorkspace('Support', 'Support team workspace').subscribe(result => { + service.createWorkspace('Support', 'Support team workspace').subscribe((result) => { createdName = result.name; }); - service.getWorkspaces().subscribe(result => { - workspaceCount = result.length; + const request = httpMock.expectOne('/api/workspaces'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + name: 'Support', + description: 'Support team workspace', }); - - expect(createdName).toBe('Support'); - expect(workspaceCount).toBe(4); - }); - - it('updates an existing workspace', () => { - let updatedDescription: string | undefined; - - service.updateWorkspace('1', 'Engineering', 'Updated description').subscribe(result => { - updatedDescription = result?.description; + request.flush({ + id: 2, + name: 'Support', + description: 'Support team workspace', + owner_id: 9, + created_at: '2026-03-24T11:00:00Z', }); - expect(updatedDescription).toBe('Updated description'); + expect(createdName).toBe('Support'); }); - it('returns undefined when updating a missing workspace', () => { + it('returns undefined when a workspace is missing', () => { let resultValue = 'unset'; - service.updateWorkspace('missing', 'Name', 'Description').subscribe(result => { + service.getWorkspace('404').subscribe((result) => { resultValue = result === undefined ? 'undefined' : 'defined'; }); + const request = httpMock.expectOne('/api/workspaces/404'); + expect(request.request.method).toBe('GET'); + request.flush('Workspace not found', { status: 404, statusText: 'Not Found' }); + expect(resultValue).toBe('undefined'); }); - it('deletes an existing workspace', () => { + it('deletes a workspace through the backend API', () => { let deleted = false; - let workspaceCount = 0; - service.deleteWorkspace('3').subscribe(result => { + service.deleteWorkspace('3').subscribe((result) => { deleted = result; }); - service.getWorkspaces().subscribe(result => { - workspaceCount = result.length; - }); + const request = httpMock.expectOne('/api/workspaces/3'); + expect(request.request.method).toBe('DELETE'); + request.flush(null, { status: 204, statusText: 'No Content' }); expect(deleted).toBeTrue(); - expect(workspaceCount).toBe(2); }); }); diff --git a/src/app/services/workspace.ts b/src/app/services/workspace.ts index 5656664..769b0c3 100644 --- a/src/app/services/workspace.ts +++ b/src/app/services/workspace.ts @@ -1,69 +1,79 @@ -import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { catchError, map, Observable, of, throwError } from 'rxjs'; import { Workspace } from '../models/workspace'; +import { toError } from './http-error'; + +interface WorkspaceResponse { + id: number; + name: string; + description?: string; + owner_id: number; + created_at: string; +} @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class WorkspaceService { - private mockWorkspaces: Workspace[] = [ - { - id: '1', - name: 'Engineering', - description: 'Engineering team workspace', - createdDate: new Date(), - ownerId: 'user1' - }, - { - id: '2', - name: 'Product', - description: 'Product team workspace', - createdDate: new Date(), - ownerId: 'user1' - }, - { - id: '3', - name: 'Marketing', - description: 'Marketing team workspace', - createdDate: new Date(), - ownerId: 'user1' - } - ]; + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/workspaces'; - getWorkspaces(): Observable { - return of(this.mockWorkspaces); - } + getWorkspaces(): Observable { + return this.http.get(this.apiUrl).pipe( + map((workspaces) => workspaces.map((workspace) => this.mapWorkspace(workspace))), + ); + } - getWorkspace(id: string): Observable { - return of(this.mockWorkspaces.find(w => w.id === id)); - } + getWorkspace(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`).pipe( + map((workspace) => this.mapWorkspace(workspace)), + catchError((error) => { + if (error.status === 404) { + return of(undefined); + } + return throwError(() => toError(error, 'Unable to load workspace.')); + }), + ); + } - createWorkspace(name: string, description: string): Observable { - const newWorkspace: Workspace = { - id: Math.random().toString(36).substring(7), - name, - description, - createdDate: new Date(), - ownerId: 'user1' // Mock owner - }; - this.mockWorkspaces.push(newWorkspace); - return of(newWorkspace); - } + createWorkspace(name: string, description: string): Observable { + return this.http + .post(this.apiUrl, { name, description }) + .pipe( + map((workspace) => this.mapWorkspace(workspace)), + catchError((error) => throwError(() => toError(error, 'Unable to create workspace.'))), + ); + } - updateWorkspace(id: string, name: string, description: string): Observable { - const workspace = this.mockWorkspaces.find(w => w.id === id); - if (!workspace) { + updateWorkspace(id: string, name: string, description: string): Observable { + return this.http + .patch(`${this.apiUrl}/${id}`, { name, description }) + .pipe( + map((workspace) => this.mapWorkspace(workspace)), + catchError((error) => { + if (error.status === 404) { return of(undefined); - } + } + return throwError(() => toError(error, 'Unable to update workspace.')); + }), + ); + } - workspace.name = name; - workspace.description = description; - return of(workspace); - } + deleteWorkspace(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`).pipe( + map(() => true), + catchError((error) => throwError(() => toError(error, 'Unable to delete workspace.'))), + ); + } - deleteWorkspace(id: string): Observable { - const initialLength = this.mockWorkspaces.length; - this.mockWorkspaces = this.mockWorkspaces.filter(w => w.id !== id); - return of(this.mockWorkspaces.length < initialLength); - } + private mapWorkspace(workspace: WorkspaceResponse): Workspace { + return { + id: String(workspace.id), + name: workspace.name, + description: workspace.description ?? '', + createdDate: new Date(workspace.created_at), + ownerId: String(workspace.owner_id), + }; + } }