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),
+ };
+ }
}