Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/app/models/demi/notification.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
60 changes: 60 additions & 0 deletions src/app/models/demi/notification.ts
Original file line number Diff line number Diff line change
@@ -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<DemiNotification, 'id' | 'eagleId' | 'sourceSystem' | 'isPublished' | 'read'> & {
_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'] : [])
};
}
Original file line number Diff line number Diff line change
@@ -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<ProjectNotificationDetailComponent>;
let component: ProjectNotificationDetailComponent;
let documents: jasmine.SpyObj<DocumentService>;
let notifications: jasmine.SpyObj<NotificationProjectService>;
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = [];
Expand All @@ -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();
},
Expand All @@ -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));
});

Expand All @@ -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));
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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']) }
]
Expand Down Expand Up @@ -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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading