diff --git a/src/app/models/demi/notification.spec.ts b/src/app/models/demi/notification.spec.ts new file mode 100644 index 000000000..ab3d7fe0c --- /dev/null +++ b/src/app/models/demi/notification.spec.ts @@ -0,0 +1,75 @@ +import { DemiNotification, legacyNotification, notificationSort } from './notification'; + +const NATIVE: DemiNotification = { + id: '6a3c4c034e547e4250daf03b', + eagleId: '6a3c4c034e547e4250daf03b', + sourceSystem: 'eagle', + name: 'Lynn Valley Coal Mine PN', + type: 'Mines', + subType: 'Coal Mines', + proponent: 'Lynn Valley People', + nature: 'New Construction', + region: 'Lower Mainland', + location: 'Lynn Valley', + decision: 'In Progress', + decisionDate: null, + notificationReceivedDate: '2026-06-24T07:00:00.000Z', + trigger: 'Organic/Inorganic Chemicals', + description: 'Test', + centroid: ['49.3347', '-123.0407'], + notificationThresholdValue: 75, + notificationThresholdUnits: 'tonnes/year', + associatedProjectId: '5885109faaecd9001b81314c', + associatedProjectName: 'Lynn Valley Mine', + pcp: 'open', + isMet: true, + metURL: 'https://engage.example/pcp', + dateStarted: '2026-07-01T07:00:00.000Z', + dateCompleted: null, + isPublished: true +}; + +describe('legacyNotification', () => { + it('keys the row on the Eagle id and drops the native-only fields', () => { + const row = legacyNotification({ ...NATIVE, id: 'native-1', eagleId: 'eagle-1' }); + + expect(row._id).toBe('eagle-1'); + expect(Object.keys(row)).not.toContain('id'); + expect(Object.keys(row)).not.toContain('eagleId'); + expect(Object.keys(row)).not.toContain('sourceSystem'); + expect(Object.keys(row)).not.toContain('isPublished'); + }); + + it('keeps the period fields eagle-api writes back from the body', () => { + const row = legacyNotification(NATIVE); + + expect(row.pcp).toBe('open'); + expect(row.isMet).toBeTrue(); + expect(row.metURL).toBe('https://engage.example/pcp'); + expect(row.associatedProjectId).toBe('5885109faaecd9001b81314c'); + }); + + it('derives read from isPublished when DEMI leaves read out', () => { + expect(legacyNotification(NATIVE).read).toEqual(['public']); + expect(legacyNotification({ ...NATIVE, isPublished: false }).read).toEqual([]); + }); + + it('keeps the staff read array when DEMI sends it', () => { + expect(legacyNotification({ ...NATIVE, read: ['staff', 'sysadmin'] }).read).toEqual(['staff', 'sysadmin']); + }); +}); + +describe('notificationSort', () => { + it('passes name and received date through with their direction', () => { + expect(notificationSort('+name')).toBe('name'); + expect(notificationSort('-name')).toBe('-name'); + expect(notificationSort('-notificationReceivedDate')).toBe('-notificationReceivedDate'); + }); + + it('drops fields DEMI cannot sort so its default order applies', () => { + expect(notificationSort('-_id')).toBeUndefined(); + expect(notificationSort('+region')).toBeUndefined(); + expect(notificationSort('')).toBeUndefined(); + expect(notificationSort(null)).toBeUndefined(); + }); +}); diff --git a/src/app/models/demi/notification.ts b/src/app/models/demi/notification.ts new file mode 100644 index 000000000..f86b332a3 --- /dev/null +++ b/src/app/models/demi/notification.ts @@ -0,0 +1,60 @@ +/** A native DEMI notification row. `id` is the Eagle `_id`; no project axis, `associatedProjectId` is a loose Eagle project id. */ +export interface DemiNotification { + id: string; + eagleId?: string; + sourceSystem?: string; + name: string | null; + type: string | null; + subType: string | null; + proponent: string | null; + nature: string | null; + region: string | null; + location: string | null; + decision: string | null; + decisionDate: string | null; + notificationReceivedDate: string | null; + trigger: string | null; + description: string | null; + centroid: string[]; + notificationThresholdValue: number | null; + notificationThresholdUnits: string | null; + associatedProjectId: string | null; + associatedProjectName: string | null; + pcp: string; + isMet: boolean; + metURL: string; + dateStarted: string | null; + dateCompleted: string | null; + isPublished: boolean; + /** Staff-side ACL array; DEMI leaves it out for a request with no staff credential. */ + read?: string[]; +} + +/** Eagle-shaped notification: native fields under the Eagle `_id`, with `read` always an array. */ +export type LegacyNotification = Omit & { + _id: string; + read: string[]; +}; + +/** Sort fields DEMI accepts on `/notifications`. */ +export const NOTIFICATION_SORTS: readonly string[] = ['name', 'notificationReceivedDate']; + +/** DEMI sort for a table `sortBy` (`+name`, `-name`, ...); undefined for a field DEMI cannot sort, so DEMI's default applies. */ +export function notificationSort(sortBy: string | null | undefined): string | undefined { + const field = (sortBy ?? '').replace(/^[+-]/, ''); + if (!NOTIFICATION_SORTS.includes(field)) { + return undefined; + } + return sortBy!.startsWith('-') ? `-${field}` : field; +} + +/** Eagle shape for screens and writes. Keeps pcp, isMet, metURL and period dates: eagle-api's PUT blanks any it is not sent. */ +export function legacyNotification(row: DemiNotification): LegacyNotification { + const { id, eagleId, isPublished, read, ...fields } = row; + delete fields.sourceSystem; + return { + ...fields, + _id: eagleId ?? id, + read: read ?? (isPublished ? ['public'] : []) + }; +} diff --git a/src/app/project-notification/detail/project-notification-detail.component.spec.ts b/src/app/project-notification/detail/project-notification-detail.component.spec.ts new file mode 100644 index 000000000..e92ebdd3c --- /dev/null +++ b/src/app/project-notification/detail/project-notification-detail.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { ProjectNotificationDetailComponent } from './project-notification-detail.component'; +import { LegacyNotification } from 'src/app/models/demi/notification'; +import { DocumentService } from 'src/app/services/document.service'; +import { LoggingService } from 'src/app/services/logging.service'; +import { NotificationProjectService } from 'src/app/services/notification-project.service'; +import { StorageService } from 'src/app/services/storage.service'; + +const NOTIFICATION = { _id: 'pn-eagle-1', name: 'Lynn Valley Coal Mine PN', centroid: [], read: ['staff'] } as LegacyNotification; + +// Native rows from DEMI: one with an Eagle record, one with none. +const LINKED = { id: 'demi-doc-1', eagleId: 'eagle-doc-1', projectEagleId: 'pn-eagle-1', displayName: 'Letter' }; +const UNLINKED = { id: 'demi-doc-2', displayName: 'No Eagle record' }; + +describe('ProjectNotificationDetailComponent', () => { + let fixture: ComponentFixture; + let component: ProjectNotificationDetailComponent; + let documents: jasmine.SpyObj; + let notifications: jasmine.SpyObj; + let storage: { state: any }; + + function create(notification: LegacyNotification | null = NOTIFICATION) { + storage.state = { currentProject: notification ? { type: 'currentProjectNotification', data: notification } : null }; + fixture = TestBed.createComponent(ProjectNotificationDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + beforeEach(() => { + documents = jasmine.createSpyObj('DocumentService', ['listNative', 'publish', 'unPublish', 'downloadDocument']); + documents.listNative.and.returnValue(of({ rows: [LINKED, UNLINKED] as any[], total: 2 })); + documents.publish.and.returnValue(of({} as any)); + documents.unPublish.and.returnValue(of({} as any)); + notifications = jasmine.createSpyObj('NotificationProjectService', ['save']); + notifications.save.and.returnValue(of({} as any)); + storage = { state: {} }; + + TestBed.configureTestingModule({ + imports: [ProjectNotificationDetailComponent], + providers: [ + provideRouter([]), + { provide: DocumentService, useValue: documents }, + { provide: NotificationProjectService, useValue: notifications }, + { provide: StorageService, useValue: storage }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error']) } + ] + }); + }); + + it('reads the notification documents from DEMI by notification id, newest first', () => { + create(); + + expect(documents.listNative).toHaveBeenCalledOnceWith({ + pageSize: 100, + sort: '-datePosted', + filters: { project: 'pn-eagle-1', documentSource: 'PROJECT-NOTIFICATION' } as any + }); + expect(component.documents.map(d => d._id)).toEqual(['eagle-doc-1', null]); + expect(component.loading).toBeFalse(); + }); + + it('publishes only documents with an Eagle record, then the notification', () => { + create(); + + component.publish(); + + expect(documents.publish).toHaveBeenCalledOnceWith('eagle-doc-1'); + expect(notifications.save).toHaveBeenCalledOnceWith(NOTIFICATION, true); + expect(component.isPublished).toBeTrue(); + }); + + it('unpublishes only documents with an Eagle record, then the notification', () => { + create({ ...NOTIFICATION, read: ['staff', 'public'] }); + expect(component.isPublished).toBeTrue(); + + component.unPublish(); + + expect(documents.unPublish).toHaveBeenCalledOnceWith('eagle-doc-1'); + expect(notifications.save).toHaveBeenCalledOnceWith(jasmine.objectContaining({ _id: 'pn-eagle-1' }), false); + expect(component.isPublished).toBeFalse(); + }); + + it('goes back to the list when no notification is loaded', () => { + const navigate = spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); + + create(null); + + expect(navigate).toHaveBeenCalledWith(['/project-notifications']); + expect(documents.listNative).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/project-notification/detail/project-notification-detail.component.ts b/src/app/project-notification/detail/project-notification-detail.component.ts index cc213a6e7..15be47f57 100644 --- a/src/app/project-notification/detail/project-notification-detail.component.ts +++ b/src/app/project-notification/detail/project-notification-detail.component.ts @@ -2,10 +2,10 @@ import { Component, OnInit, ChangeDetectorRef, DestroyRef, inject, ChangeDetecti import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; import { forkJoin } from 'rxjs'; -import { ProjectNotification } from 'src/app/models/projectNotification'; +import { LegacyNotification } from 'src/app/models/demi/notification'; +import { legacyDocumentRow, sourceFilters } from 'src/app/project/project-documents/document-edit/legacy-document-row'; import { DocumentService } from 'src/app/services/document.service'; import { NotificationProjectService } from 'src/app/services/notification-project.service'; -import { SearchService } from 'src/app/services/search.service'; import { StorageService } from 'src/app/services/storage.service'; import { DatePipe } from '@angular/common'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; @@ -24,13 +24,12 @@ export class ProjectNotificationDetailComponent implements OnInit { private router = inject(Router); notificationProjectService = inject(NotificationProjectService); documentService = inject(DocumentService); - private searchService = inject(SearchService); private storageService = inject(StorageService); private destroyRef = inject(DestroyRef); private logger = inject(LoggingService); private _cdr = inject(ChangeDetectorRef); - public projectNotification: ProjectNotification = null; + public projectNotification: LegacyNotification = null; public loading = false; public isPublished = false; public documents = []; @@ -52,14 +51,14 @@ export class ProjectNotificationDetailComponent implements OnInit { } this.loading = true; - this.searchService.getSearchResults( - null, 'Document', [], 1, 100, '-datePosted,+displayName', - { documentSource: 'PROJECT-NOTIFICATION', project: this.projectNotification._id } - ).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: (searchRes: any) => { - if (searchRes[0]?.data?.searchResults) { - this.documents = searchRes[0].data.searchResults; - } + // Notification documents store the notification id as their project. + this.documentService.listNative({ + pageSize: 100, + sort: '-datePosted', + filters: sourceFilters(this.projectNotification._id, 'PROJECT-NOTIFICATION') + }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: res => { + this.documents = res.rows.map(legacyDocumentRow); this.loading = false; this._cdr.markForCheck(); }, @@ -78,8 +77,8 @@ export class ProjectNotificationDetailComponent implements OnInit { this.loading = true; const observables = []; - // Publish the documents - this.documents.forEach(item => { + // Publish the documents; one with no Eagle record cannot be written. + this.documents.filter(item => item._id).forEach(item => { observables.push(this.documentService.publish(item._id)); }); @@ -100,7 +99,7 @@ export class ProjectNotificationDetailComponent implements OnInit { const observables = []; // Un-publish the documents - this.documents.forEach(item => { + this.documents.filter(item => item._id).forEach(item => { observables.push(this.documentService.unPublish(item._id)); }); diff --git a/src/app/project-notification/documents/project-notification-documents.component.spec.ts b/src/app/project-notification/documents/project-notification-documents.component.spec.ts index 3c87ecc60..04ae9b5b5 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.spec.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts @@ -1,12 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; import { ProjectNotificationDocumentsComponent } from './project-notification-documents.component'; import { ConfigService } from 'src/app/services/config.service'; import { KeycloakService } from 'src/app/services/keycloak.service'; import { LoggingService } from 'src/app/services/logging.service'; -import { SearchService } from 'src/app/services/search.service'; import { StorageService } from 'src/app/services/storage.service'; import { ToastService } from 'src/app/services/toast.service'; @@ -22,7 +21,7 @@ describe('ProjectNotificationDocumentsComponent', () => { provideHttpClientTesting(), { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, { provide: KeycloakService, useValue: { keycloakEnabled: true } }, - { provide: SearchService, useValue: {} }, + { provide: ActivatedRoute, useValue: { parent: { snapshot: { paramMap: convertToParamMap({ notificationProjectId: 'notification-9' }) } } } }, { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success', 'info']) }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error', 'warn', 'info']) } ] @@ -69,4 +68,18 @@ describe('ProjectNotificationDocumentsComponent', () => { expect(rows.filter(d => d.checkbox).map(d => d._id)).toEqual(['n1']); expect(fixture.componentInstance.selectedCount().total).toBe(1); }); + + it('loads the notification from DEMI by route id when none is stored, then lists its documents', () => { + const storage = TestBed.inject(StorageService); + storage.state = { type: 'currentProject', data: null }; + const fixture = TestBed.createComponent(ProjectNotificationDocumentsComponent); + fixture.detectChanges(); + + http.expectOne('/demi/api/notifications/notification-9').flush({ id: 'notification-9', eagleId: 'notification-9', name: 'Notice', isPublished: false }); + + expect(storage.currentProjectData._id).toBe('notification-9'); + const req = http.expectOne(r => r.url === '/demi/api/documents'); + expect(req.request.params.get('project')).toBe('notification-9'); + req.flush([]); + }); }); diff --git a/src/app/project-notification/documents/project-notification-documents.component.ts b/src/app/project-notification/documents/project-notification-documents.component.ts index 4cf30e9b4..2a31c60dd 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.ts @@ -7,7 +7,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule } from '@angular/forms'; import { ConfirmComponent } from 'src/app/confirm/confirm.component'; import { DocumentService } from 'src/app/services/document.service'; -import { SearchService } from 'src/app/services/search.service'; +import { NotificationProjectService } from 'src/app/services/notification-project.service'; import { StorageService } from 'src/app/services/storage.service'; import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object'; import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object'; @@ -18,6 +18,7 @@ import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort, sourceFilt import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; import { LoggingService } from 'src/app/services/logging.service'; import { nextSortBy } from 'src/app/shared/utils/table-template-utils'; +import { legacyNotification } from 'src/app/models/demi/notification'; @Component({ selector: 'app-project-notification-documents', @@ -37,7 +38,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit { private modalService = inject(NgbModal); private route = inject(ActivatedRoute); private router = inject(Router); - private searchService = inject(SearchService); + private notificationService = inject(NotificationProjectService); private toastService = inject(ToastService); private storageService = inject(StorageService); private logger = inject(LoggingService); @@ -79,13 +80,14 @@ export class ProjectNotificationDocumentsComponent implements OnInit { return; } // Load the project before fetching docs - this.searchService.getItem(notificationProjectId, 'ProjectNotification') + this.notificationService.getNative(notificationProjectId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: (res: any) => { - if (res?.data) { - this.storageService.state.currentProject = { type: 'currentProjectNotification', data: res.data, docTotal: 0 }; - this.currentProject.set(res.data); + next: row => { + if (row) { + const notification = legacyNotification(row); + this.storageService.state.currentProject = { type: 'currentProjectNotification', data: notification, docTotal: 0 }; + this.currentProject.set(notification); this.initTable(); } else { alert('Uh-oh, couldn\'t load documents'); diff --git a/src/app/project-notifications/project-notifications.component.spec.ts b/src/app/project-notifications/project-notifications.component.spec.ts new file mode 100644 index 000000000..e21aa9fdb --- /dev/null +++ b/src/app/project-notifications/project-notifications.component.spec.ts @@ -0,0 +1,61 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { ProjectNotificationsComponent } from './project-notifications.component'; +import { ConfigService } from '../services/config.service'; +import { KeycloakService } from '../services/keycloak.service'; +import { LoggingService } from '../services/logging.service'; +import { ToastService } from '../services/toast.service'; + +describe('ProjectNotificationsComponent', () => { + let http: HttpTestingController; + + function create(params: Record) { + TestBed.configureTestingModule({ + imports: [ProjectNotificationsComponent], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: ActivatedRoute, useValue: { params: of(params) } }, + { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, + { provide: KeycloakService, useValue: { keycloakEnabled: true } }, + { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success']) }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error', 'warn', 'info']) } + ] + }); + http = TestBed.inject(HttpTestingController); + const fixture = TestBed.createComponent(ProjectNotificationsComponent); + fixture.detectChanges(); + return fixture; + } + + afterEach(() => http.verify()); + + it('pages, sorts and searches notifications on DEMI and keys rows on the Eagle id', () => { + const fixture = create({ currentPage: '2', pageSize: '10', sortBy: '-name', keywords: 'coal' }); + + const req = http.expectOne(r => r.url === '/demi/api/notifications'); + expect(req.request.params.get('page')).toBe('2'); + expect(req.request.params.get('pageSize')).toBe('10'); + expect(req.request.params.get('sort')).toBe('-name'); + expect(req.request.params.get('q')).toBe('coal'); + expect(req.request.params.get('count')).toBe('true'); + req.flush([{ id: 'n1', eagleId: 'n1', name: 'Coal PN', isPublished: true }], { headers: { 'x-total-count': '21' } }); + + expect(fixture.componentInstance.tableParams.totalListItems).toBe(21); + expect(fixture.componentInstance.notificationProjects.map(n => n._id)).toEqual(['n1']); + }); + + it('leaves the sort to DEMI when the table asks for a column DEMI cannot sort', () => { + create({ sortBy: '+region' }); + + const req = http.expectOne(r => r.url === '/demi/api/notifications'); + expect(req.request.params.has('sort')).toBeFalse(); + expect(req.request.params.has('q')).toBeFalse(); + req.flush([]); + }); +}); diff --git a/src/app/project-notifications/project-notifications.component.ts b/src/app/project-notifications/project-notifications.component.ts index dc483e58b..193c433b2 100644 --- a/src/app/project-notifications/project-notifications.component.ts +++ b/src/app/project-notifications/project-notifications.component.ts @@ -3,11 +3,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router, ActivatedRoute } from '@angular/router'; import { switchMap } from 'rxjs/operators'; import { ProjectNotificationTableRowsComponent } from './project-notifications-table-rows/project-notifications-table-rows.component'; -import { ProjectNotification } from '../models/projectNotification'; +import { LegacyNotification, legacyNotification, notificationSort } from '../models/demi/notification'; import { SearchTerms } from '../models/search'; import { TableObject, TableColumn } from '../shared/components/table-template/table-object'; import { TableParamsObject } from '../shared/components/table-template/table-params-object'; -import { SearchService } from '../services/search.service'; +import { NOTIFICATION_LIST_LOADING, NotificationProjectService } from '../services/notification-project.service'; import { LoadingStateService } from '../services/loading-state.service'; import { TableTemplateUtils, nextSortBy } from '../shared/utils/table-template-utils'; import { FormsModule } from '@angular/forms'; @@ -32,12 +32,12 @@ export class ProjectNotificationsComponent implements OnInit { private router = inject(Router); private destroyRef = inject(DestroyRef); private tableTemplateUtils = inject(TableTemplateUtils); - private searchService = inject(SearchService); + private notificationService = inject(NotificationProjectService); public loadingState = inject(LoadingStateService); - public loading = this.loadingState.getOperationState('search-results'); + public loading = this.loadingState.getOperationState(NOTIFICATION_LIST_LOADING); public terms = new SearchTerms(); - public notificationProjects: ProjectNotification[] = null; + public notificationProjects: LegacyNotification[] = null; public documentTableData: TableObject; public documentTableColumns: TableColumn[] = [ @@ -49,22 +49,26 @@ export class ProjectNotificationsComponent implements OnInit { { name: 'Type', value: 'type', - width: '15%' + width: '15%', + nosort: true }, { name: 'Sub Type', value: 'subType', - width: '15%' + width: '15%', + nosort: true }, { name: 'Region', value: 'region', - width: '15%' + width: '15%', + nosort: true }, { name: 'Decision', value: 'decision', - width: '15%' + width: '15%', + nosort: true } ]; @@ -77,28 +81,18 @@ export class ProjectNotificationsComponent implements OnInit { takeUntilDestroyed(this.destroyRef), switchMap(params => { this.tableParams = this.tableTemplateUtils.getParamsFromUrl(params); - if (this.tableParams.sortBy === '') { - this.tableParams.sortBy = '-_id'; - } - - const pageNum = this.tableParams.currentPage || 1; - const pageSize = this.tableParams.pageSize || 10; - const sortBy = this.tableParams.sortBy || '-_id'; - const keywords = this.tableParams.keywords || ''; - - return this.searchService.getSearchResults( - keywords, 'ProjectNotification', null, - pageNum, pageSize, sortBy, {} - ); + // Unset sort = DEMI default: newest notification first, or keyword rank. + return this.notificationService.listNative({ + page: this.tableParams.currentPage || 1, + pageSize: this.tableParams.pageSize || 10, + count: true, + sort: notificationSort(this.tableParams.sortBy), + q: this.tableParams.keywords || undefined + }); }) - ).subscribe((res: any) => { - if (res && res[0].data.meta && res[0].data.meta.length > 0) { - this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal; - this.notificationProjects = res[0].data.searchResults; - } else { - this.tableParams.totalListItems = 0; - this.notificationProjects = []; - } + ).subscribe(res => { + this.tableParams.totalListItems = res.total ?? 0; + this.notificationProjects = res.rows.map(legacyNotification); this.setRowData(); }); } diff --git a/src/app/resolvers/project-notification.resolver.spec.ts b/src/app/resolvers/project-notification.resolver.spec.ts new file mode 100644 index 000000000..f9a1f3855 --- /dev/null +++ b/src/app/resolvers/project-notification.resolver.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RedirectCommand, convertToParamMap, provideRouter } from '@angular/router'; +import { Observable, firstValueFrom, of } from 'rxjs'; + +import { projectNotificationResolver } from './project-notification.resolver'; +import { NotificationProjectService } from '../services/notification-project.service'; +import { StorageService } from '../services/storage.service'; +import { DemiNotification, LegacyNotification } from '../models/demi/notification'; + +describe('projectNotificationResolver', () => { + let service: jasmine.SpyObj; + let storage: { state: Record }; + + beforeEach(() => { + service = jasmine.createSpyObj('NotificationProjectService', ['getNative']); + storage = { state: {} }; + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: NotificationProjectService, useValue: service }, + { provide: StorageService, useValue: storage } + ] + }); + }); + + function resolve(id: string): Promise { + const route = { paramMap: convertToParamMap({ notificationProjectId: id }) } as ActivatedRouteSnapshot; + const result = TestBed.runInInjectionContext(() => projectNotificationResolver(route, null)); + return firstValueFrom(result as Observable); + } + + it('returns and stores the notification keyed on the Eagle id', async () => { + service.getNative.and.returnValue(of({ id: 'e1', eagleId: 'e1', name: 'Notice', isPublished: true } as DemiNotification)); + + const notification = await resolve('e1') as LegacyNotification; + + expect(service.getNative).toHaveBeenCalledWith('e1'); + expect(notification._id).toBe('e1'); + expect(notification.read).toEqual(['public']); + expect(storage.state['currentProject'].data).toBe(notification); + }); + + it('redirects to search when the notification cannot be read', async () => { + service.getNative.and.returnValue(of(null)); + + expect(await resolve('missing')).toEqual(jasmine.any(RedirectCommand)); + }); +}); diff --git a/src/app/resolvers/project-notification.resolver.ts b/src/app/resolvers/project-notification.resolver.ts index 452b31f45..2e67f156b 100644 --- a/src/app/resolvers/project-notification.resolver.ts +++ b/src/app/resolvers/project-notification.resolver.ts @@ -1,25 +1,26 @@ import { inject } from '@angular/core'; import { RedirectCommand, Router } from '@angular/router'; -import { catchError, map, of } from 'rxjs'; +import { map } from 'rxjs'; import type { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; -import { SearchService } from '../services/search.service'; +import { NotificationProjectService } from '../services/notification-project.service'; import { StorageService } from '../services/storage.service'; +import { LegacyNotification, legacyNotification } from '../models/demi/notification'; -export const projectNotificationResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const searchService = inject(SearchService); +export const projectNotificationResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const notificationService = inject(NotificationProjectService); const storageService = inject(StorageService); const router = inject(Router); const id = route.paramMap.get('notificationProjectId')!; - return searchService.getItem(id, 'ProjectNotification').pipe( - map((res: any) => { - if (!res?.data) { + return notificationService.getNative(id).pipe( + map(row => { + if (!row) { return new RedirectCommand(router.parseUrl('/search')); } - // Keep storageService populated for backward compat with components not yet migrated - storageService.state.currentProject = { type: 'currentProjectNotification', data: res.data, docTotal: 0 }; - return res.data; - }), - catchError(() => of(new RedirectCommand(router.parseUrl('/search')))) + const notification = legacyNotification(row); + // Detail, documents and the edit screen read the notification from here. + storageService.state.currentProject = { type: 'currentProjectNotification', data: notification, docTotal: 0 }; + return notification; + }) ); }; diff --git a/src/app/services/notification-project.service.spec.ts b/src/app/services/notification-project.service.spec.ts new file mode 100644 index 000000000..abc090c65 --- /dev/null +++ b/src/app/services/notification-project.service.spec.ts @@ -0,0 +1,131 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { firstValueFrom } from 'rxjs'; + +import { NotificationProjectService } from './notification-project.service'; +import { ConfigService } from './config.service'; +import { KeycloakService } from './keycloak.service'; +import { LoggingService } from './logging.service'; +import { ToastService } from './toast.service'; +import { ProjectNotification } from '../models/projectNotification'; +import { DemiNotification, legacyNotification } from '../models/demi/notification'; + +const NATIVE = { + id: '6a3c4c034e547e4250daf03b', + eagleId: '6a3c4c034e547e4250daf03b', + sourceSystem: 'eagle', + name: 'Lynn Valley Coal Mine PN', + associatedProjectId: '5885109faaecd9001b81314c', + pcp: 'none', + isPublished: true +} as DemiNotification; + +describe('NotificationProjectService', () => { + let service: NotificationProjectService; + let http: HttpTestingController; + let toast: jasmine.SpyObj; + + beforeEach(() => { + toast = jasmine.createSpyObj('ToastService', ['error', 'success']); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, + { provide: KeycloakService, useValue: { keycloakEnabled: true } }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'warn', 'info', 'debug']) }, + { provide: ToastService, useValue: toast } + ] + }); + service = TestBed.inject(NotificationProjectService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + describe('listNative', () => { + it('sends the page, sort, keywords and native filters to DEMI and returns rows with the total', async () => { + const page = firstValueFrom(service.listNative({ + page: 2, pageSize: 10, count: true, sort: '-name', q: 'coal', + filters: { type: 'Mines', region: 'Lower Mainland', pcp: 'none', decision: 'In Progress' } + })); + + const req = http.expectOne(r => r.url === '/demi/api/notifications'); + expect(req.request.params.get('page')).toBe('2'); + expect(req.request.params.get('pageSize')).toBe('10'); + expect(req.request.params.get('count')).toBe('true'); + expect(req.request.params.get('sort')).toBe('-name'); + expect(req.request.params.get('q')).toBe('coal'); + expect(req.request.params.get('type')).toBe('Mines'); + expect(req.request.params.get('region')).toBe('Lower Mainland'); + expect(req.request.params.get('pcp')).toBe('none'); + expect(req.request.params.get('decision')).toBe('In Progress'); + req.flush([NATIVE], { headers: { 'x-total-count': '13' } }); + + const res = await page; + expect(res.total).toBe(13); + expect(res.rows[0].id).toBe(NATIVE.id); + }); + + it('shows an error and returns no rows when DEMI fails', async () => { + const page = firstValueFrom(service.listNative()); + http.expectOne(r => r.url === '/demi/api/notifications').flush({ error: 'boom' }, { status: 500, statusText: 'Server Error' }); + + expect((await page).rows).toEqual([]); + expect(toast.error).toHaveBeenCalled(); + }); + }); + + describe('getNative', () => { + it('reads one notification by its id', async () => { + const row = firstValueFrom(service.getNative(NATIVE.id)); + http.expectOne(`/demi/api/notifications/${NATIVE.id}`).flush(NATIVE); + + expect((await row)?.name).toBe(NATIVE.name); + }); + + it('returns null when the notification cannot be read', async () => { + const row = firstValueFrom(service.getNative('missing')); + http.expectOne('/demi/api/notifications/missing').flush({ error: 'Notification not found' }, { status: 404, statusText: 'Not Found' }); + + expect(await row).toBeNull(); + }); + }); + + describe('getAll', () => { + it('maps the table sort to DEMI and returns Eagle-keyed notifications', async () => { + const all = firstValueFrom(service.getAll(1, 1000, '+name')); + + const req = http.expectOne(r => r.url === '/demi/api/notifications'); + expect(req.request.params.get('pageSize')).toBe('1000'); + expect(req.request.params.get('sort')).toBe('name'); + req.flush([NATIVE]); + + const res = await all; + expect(res.totalCount).toBe(1); + expect(res.data[0]._id).toBe(NATIVE.eagleId); + }); + }); + + describe('save', () => { + it('puts a notification read from DEMI under its Eagle id with the publish flag', () => { + const row = legacyNotification({ ...NATIVE, id: 'native-1' }); + service.save(row, true).subscribe(); + + const req = http.expectOne('/api/projectNotification/6a3c4c034e547e4250daf03b?publish=true'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toBe(row); + req.flush({}); + }); + + it('puts a legacy notification unchanged', () => { + const legacy = new ProjectNotification({ _id: 'eagle-1', name: 'Old', associatedProjectId: 'eagle-project-1' }); + service.save(legacy).subscribe(); + + const req = http.expectOne('/api/projectNotification/eagle-1'); + expect(req.request.body).toBe(legacy); + req.flush({}); + }); + }); +}); diff --git a/src/app/services/notification-project.service.ts b/src/app/services/notification-project.service.ts index 5bfa07ffd..ae0aa6849 100644 --- a/src/app/services/notification-project.service.ts +++ b/src/app/services/notification-project.service.ts @@ -3,46 +3,59 @@ import { Observable, of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { ApiService } from './api'; +import { DemiApiService } from './demi-api.service'; import { LoadingStateService } from './loading-state.service'; import { LoggingService } from './logging.service'; import { ToastService } from './toast.service'; import { withLoading } from 'src/app/shared/utils/rxjs-operators'; import { ProjectNotification } from '../models/projectNotification'; +import { DemiList, DemiListParams } from '../models/demi/demi-list'; +import { DemiNotification, LegacyNotification, legacyNotification, notificationSort } from '../models/demi/notification'; + +/** Loading-state key for the notifications table. */ +export const NOTIFICATION_LIST_LOADING = 'project-notifications'; @Injectable({ providedIn: 'root' }) export class NotificationProjectService { private api = inject(ApiService); + private demiApi = inject(DemiApiService); private loadingState = inject(LoadingStateService); private toast = inject(ToastService); private logger = inject(LoggingService); - private projectNotificationList: ProjectNotification[] = []; - - getAll(pageNum = 1, pageSize = 20, sortBy: string = null): Observable { - const fields = ['name', 'location', 'decisionDate']; - let qs = `projectNotification?`; - if (pageNum !== null) { qs += `pageNum=${pageNum - 1}&`; } - if (pageSize !== null) { qs += `pageSize=${pageSize}&`; } - if (sortBy !== '' && sortBy !== null) { qs += `sortBy=${sortBy}&`; } - qs += `populate=true&fields=${this.api.buildValues(fields)}`; - return this.api.get(qs).pipe( - withLoading(this.loadingState, 'project-notifications'), - map((res: any) => { - if (res) { - this.projectNotificationList = res.map((pn: any) => new ProjectNotification(pn)); - return { totalCount: this.projectNotificationList.length, data: this.projectNotificationList }; - } - return {}; - }), + /** One page of notifications from DEMI. Filters: `type`, `region`, `pcp`, `decision`; sort: `name`, `notificationReceivedDate`. */ + listNative(params: DemiListParams = {}): Observable> { + return this.demiApi.list('notifications', params).pipe( + withLoading(this.loadingState, NOTIFICATION_LIST_LOADING), catchError(error => { this.logger.error('Failed to get project notifications', 'NotificationProjectService', error); this.toast.error('Failed to load project notifications.'); - return of({ totalCount: 0, data: [] }); + return of({ rows: [] as DemiNotification[] }); + }) + ); + } + + /** One notification by its id (the Eagle id); null when it cannot be read. */ + getNative(id: string): Observable { + return this.demiApi.get('notifications', id).pipe( + withLoading(this.loadingState, `project-notification-${id}`), + catchError(error => { + this.logger.error('Failed to get project notification', 'NotificationProjectService', error); + return of(null); + }) + ); + } + + getAll(pageNum = 1, pageSize = 20, sortBy: string = null): Observable<{ totalCount: number; data: ProjectNotification[] }> { + return this.listNative({ page: pageNum ?? undefined, pageSize: pageSize ?? undefined, sort: notificationSort(sortBy) }).pipe( + map(res => { + const data = res.rows.map(row => new ProjectNotification(legacyNotification(row))); + return { totalCount: data.length, data }; }) ); } - save(pn: ProjectNotification, publish: boolean = null): Observable { + save(pn: ProjectNotification | LegacyNotification, publish: boolean = null): Observable { let qs = `projectNotification/${pn._id}`; if (publish !== null) { qs += `?publish=${publish}`; } return this.api.put(qs, pn).pipe(