Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
d7a6d36
calc tasks for notifications
jonathanbataire May 24, 2025
4a8801f
fix date bug
jonathanbataire May 24, 2025
7975c9c
clean up
jonathanbataire May 25, 2025
e29d488
add dbsync
jonathanbataire May 26, 2025
08181fc
Merge branch 'master' into 9255-notifications
jonathanbataire May 26, 2025
a137242
refine service
jonathanbataire May 27, 2025
f23409a
fresh service :tada:
jonathanbataire May 27, 2025
f37c0ba
bug fix
jonathanbataire May 27, 2025
21d2ed6
offline bug fix
jonathanbataire May 28, 2025
24ad96c
Merge branch 'master' into 9255-notifications
jonathanbataire May 29, 2025
db78a22
clear sonar and lint errors
jonathanbataire May 29, 2025
dcb5540
tests
jonathanbataire May 29, 2025
828d106
test clean up
jonathanbataire May 29, 2025
c768104
:heavy_check_mark: test
jonathanbataire May 29, 2025
34aa546
Merge branch 'master' into 9255-notifications
jonathanbataire May 29, 2025
6a179ac
:tada: tests
jonathanbataire May 30, 2025
0abd99b
Merge branch 'master' into 9255-notifications
jonathanbataire May 30, 2025
e4ed7c1
clean up
jonathanbataire May 30, 2025
317f5fd
refresh service
jonathanbataire Jun 1, 2025
375b2b3
Merge branch 'master' into 9255-notifications
jonathanbataire Jun 4, 2025
8dd47fd
Merge branch 'master' into 9255-notifications
jonathanbataire Jun 5, 2025
3fc32da
address feedback
jonathanbataire Jun 10, 2025
ef69962
Merge branch 'master' into 9255-notifications
jonathanbataire Jun 11, 2025
28e0b7b
Merge branch 'master' into 9255-notifications
jonathanbataire Jun 23, 2025
c174a7a
address feedback
jonathanbataire Jun 23, 2025
5857bad
add warning for missing setting
jonathanbataire Jun 23, 2025
bd07cb6
Merge branch 'master' into 9255-notifications
jonathanbataire Jun 24, 2025
30f944f
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 1, 2025
85ab4c7
fix compile error
jonathanbataire Jul 1, 2025
021a7c6
feedback improvements
jonathanbataire Jul 1, 2025
029cc9f
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 1, 2025
45dfbb8
fix tests
jonathanbataire Jul 1, 2025
4f374ac
:tada:
jonathanbataire Jul 1, 2025
fa8479c
clean up tests
jonathanbataire Jul 1, 2025
82226f6
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 1, 2025
3b5213d
:tada:
jonathanbataire Jul 1, 2025
cafa6f4
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 2, 2025
6226305
Update webapp/tests/karma/ts/services/task-notifications.service.spec.ts
jonathanbataire Jul 15, 2025
133f1d8
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 15, 2025
d217147
clean up
jonathanbataire Jul 15, 2025
4331a8e
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 16, 2025
82d5dd4
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 17, 2025
5724e36
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 19, 2025
d5cc452
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 19, 2025
8798459
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 19, 2025
7d0b09d
Merge branch 'master' into 9255-notifications
jonathanbataire Jul 22, 2025
ca3c951
Merge branch 'master' into 9255-notifications
jonathanbataire Aug 1, 2025
3d95aee
add permission as default
jonathanbataire Aug 7, 2025
40af7bc
Merge branch 'master' into 9255-notifications
jonathanbataire Aug 7, 2025
8227757
Merge branch 'master' into 9255-notifications
jonathanbataire Aug 9, 2025
679d801
Merge branch 'master' into 9255-notifications
jonathanbataire Aug 18, 2025
b6f2969
Merge branch 'master' into 9255-notifications
jonathanbataire Aug 19, 2025
064ce3f
Merge branch 'master' into 9255-notifications
jonathanbataire Oct 3, 2025
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
1 change: 1 addition & 0 deletions config/default/app_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
"can_default_facility_filter": [],
"can_have_multiple_places": [],
"can_skip_password_change": [],
"can_get_task_notifications": [],
"can_export_devices_details": [
"national_admin"
]
Expand Down
11 changes: 11 additions & 0 deletions webapp/src/ts/services/android-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GeolocationService } from '@mm-services/geolocation.service';
import { MRDTService } from '@mm-services/mrdt.service';
import { SessionService } from '@mm-services/session.service';
import { NavigationService } from '@mm-services/navigation.service';
import { TasksNotificationService } from '@mm-services/task-notifications.service';

/**
* An API to provide integration with the medic-android app.
Expand All @@ -24,6 +25,7 @@ export class AndroidApiService {
private sessionService:SessionService,
private zone:NgZone,
private navigationService:NavigationService,
private readonly tasksNotificationService: TasksNotificationService,
) { }

private runInZone(property:string, args:any[]=[]) {
Expand Down Expand Up @@ -168,6 +170,14 @@ export class AndroidApiService {
this.sessionService.logout();
}

/**
* Gets notifcations for tasks
* returns {Promise} A promise that resolves to Notification[].
*/
taskNotifications() {
return this.tasksNotificationService.get();
}

/**
* Handle the response from the MRDT app
* @param response The stringified JSON response from the MRDT app.
Expand Down Expand Up @@ -222,5 +232,6 @@ export class AndroidApiService {
smsStatusUpdate: (...args) => this.runInZone('smsStatusUpdate', args),
locationPermissionRequestResolved: () => this.runInZone('locationPermissionRequestResolve'),
resolveCHTExternalAppResponse: (...args) => this.runInZone('resolveCHTExternalAppResponse', args),
taskNotifications: () => this.runInZone('taskNotifications'),
};
}
54 changes: 31 additions & 23 deletions webapp/src/ts/services/rules-engine.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,20 @@ interface DebounceActive {
};
}

interface TaskDoc {
_id: string;
emission?: any;
owner: string;
stateHistory: Array<{ state: string, timestamp: number }>;
}

@Injectable({
providedIn: 'root'
})
export class RulesEngineCoreFactoryService {
constructor(
private dbService: DbService
) {}
) { }

get() {
return RulesEngineCore(this.dbService.get());
Expand All @@ -63,23 +70,23 @@ export class RulesEngineService implements OnDestroy {
private observable = new Subject();

constructor(
private translateService:TranslateService,
private authService:AuthService,
private sessionService:SessionService,
private settingsService:SettingsService,
private telemetryService:TelemetryService,
private performanceService:PerformanceService,
private uhcSettingsService:UHCSettingsService,
private userContactService:UserContactService,
private userSettingsService:UserSettingsService,
private parseProvider:ParseProvider,
private changesService:ChangesService,
private contactTypesService:ContactTypesService,
private translateFromService:TranslateFromService,
private rulesEngineCoreFactoryService:RulesEngineCoreFactoryService,
private calendarIntervalService:CalendarIntervalService,
private ngZone:NgZone,
private chtDatasourceService:CHTDatasourceService
private translateService: TranslateService,
private authService: AuthService,
private sessionService: SessionService,
private settingsService: SettingsService,
private telemetryService: TelemetryService,
private performanceService: PerformanceService,
private uhcSettingsService: UHCSettingsService,
private userContactService: UserContactService,
private userSettingsService: UserSettingsService,
private parseProvider: ParseProvider,
private changesService: ChangesService,
private contactTypesService: ContactTypesService,
private translateFromService: TranslateFromService,
private rulesEngineCoreFactoryService: RulesEngineCoreFactoryService,
private calendarIntervalService: CalendarIntervalService,
private ngZone: NgZone,
private chtDatasourceService: CHTDatasourceService
) {
this.initialized = this.initialize();
this.rulesEngineCore = this.rulesEngineCoreFactoryService.get();
Expand Down Expand Up @@ -142,7 +149,7 @@ export class RulesEngineService implements OnDestroy {
this.debounceActive[this.FRESHNESS_KEY] = {
active: true,
performance: {
name: this.getTelemetryTrackName( 'background-refresh', 'cancel'),
name: this.getTelemetryTrackName('background-refresh', 'cancel'),
track: this.performanceService.track()
},
debounceRef: rulesDebounceRef
Expand All @@ -169,15 +176,16 @@ export class RulesEngineService implements OnDestroy {
debounceInfo.active = false;
}

private waitForDebounce(entity):Promise<any> | undefined {
private waitForDebounce(entity): Promise<any> | undefined {
if (this.debounceActive[entity]?.active && this.debounceActive[entity]?.promise) {
return this.debounceActive[entity].promise;
}

return Promise.resolve();
}

private getRulesEngineContext(settingsDoc, userContactDoc, userSettingsDoc, enableTasks, enableTargets, chtScriptApi){
private getRulesEngineContext(settingsDoc, userContactDoc, userSettingsDoc,
enableTasks, enableTargets, chtScriptApi) {
return {
settingsDoc,
userContactDoc,
Expand Down Expand Up @@ -327,7 +335,7 @@ export class RulesEngineService implements OnDestroy {
return this.rulesEngineCore.updateEmissionsFor(_uniq(contactsWithUpdatedTasks));
}

private hydrateTaskDocs(taskDocs: { _id: string; emission?: any; owner: string }[] = []) {
private hydrateTaskDocs(taskDocs: Array<TaskDoc> = []) {
taskDocs.forEach(taskDoc => {
const { emission } = taskDoc;
if (!emission) {
Expand Down Expand Up @@ -501,7 +509,7 @@ export class RulesEngineService implements OnDestroy {
return this.observable.subscribe(callback);
}

private getTelemetryTrackName(...params:string[]) {
private getTelemetryTrackName(...params: string[]) {
return ['rules-engine', ...params].join(':');
}

Expand Down
117 changes: 117 additions & 0 deletions webapp/src/ts/services/task-notifications.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Injectable } from '@angular/core';
import * as moment from 'moment';

import { RulesEngineService } from '@mm-services/rules-engine.service';
import { TranslateService } from '@mm-services/translate.service';
import { FormatDateService } from '@mm-services/format-date.service';
import { SettingsService } from '@mm-services/settings.service';
import { AuthService } from '@mm-services/auth.service';

const TASK_NOTIFICATION_DAY = 'cht-task-notification-day';
const LATEST_NOTIFICATION_TIMESTAMP = 'cht-task-notification-timestamp';
const DEFAULT_MAX_NOTIFICATIONS = 10;

export interface Notification {
_id: string,
readyAt: number,
title: string,
contentText: string,
dueDate: string,
}

@Injectable({
providedIn: 'root'
})
export class TasksNotificationService {

constructor(
private readonly rulesEngineService: RulesEngineService,
private readonly translateService: TranslateService,
private readonly formatDateService: FormatDateService,
private readonly settingsService: SettingsService,
private readonly authService: AuthService,
) { }

private async fetchNotifications(): Promise<Notification[]> {
try {
const isEnabled = await this.rulesEngineService.isEnabled();
if (!isEnabled) {
return [];
}
const today = moment().format('YYYY-MM-DD');
let latestNotificationTimestamp = this.getLatestNotificationTimestamp();
const taskDocs = await this.rulesEngineService.fetchTaskDocsForAllContacts();

let notifications: Notification[] = [];

taskDocs.forEach(task => {
const readyAt = this.getReadyStateTimestamp(task.stateHistory);
const dueDate = task.emission.dueDate;
if (dueDate <= today && readyAt > latestNotificationTimestamp) {
notifications.push({
_id: task._id,
readyAt,
title: task.emission.title,
contentText: this.translateContentText(
task.emission.title,
task.emission.contact.name,
task.emission.dueDate
),
dueDate,
});
}
});

notifications = notifications.sort((a, b) => b.readyAt - a.readyAt);
notifications = notifications.slice(0, await this.getMaxNotificationSettings());
latestNotificationTimestamp = notifications[0]?.readyAt ?? latestNotificationTimestamp;
window.localStorage.setItem(LATEST_NOTIFICATION_TIMESTAMP, String(latestNotificationTimestamp));
return notifications;

} catch (exception) {
console.error('fetchNotifications(): Error fetching tasks', exception);
return [];
}
}

private getReadyStateTimestamp(stateHistory): number {
const readyState = stateHistory.find(state => state.state === 'Ready');
return readyState ? readyState.timestamp : 0;
}

private async getMaxNotificationSettings(): Promise<number> {
const settings = await this.settingsService.get();
if (settings?.tasks?.max_task_notifications) {
return settings.tasks.max_task_notifications;
}
console.warn('Invalid or missing max_task_notifications setting, using default value');
return DEFAULT_MAX_NOTIFICATIONS;
}

private getLatestNotificationTimestamp(): number {
if (this.isNewDay()) {
window.localStorage.setItem(TASK_NOTIFICATION_DAY, String(moment().startOf('day').valueOf()));
return 0;
}
return Number(window.localStorage.getItem(LATEST_NOTIFICATION_TIMESTAMP));
}

private isNewDay(): boolean {
const timestampToday = Number(window.localStorage.getItem(TASK_NOTIFICATION_DAY));
return !moment().isSame(timestampToday, 'day');
}

private translateContentText(taskName: string, contact: string, dueDate: string): string {
const key = 'android.notification.tasks.contentText';
const due = this.formatDateService.relative(dueDate, { task: true });
return this.translateService.instant(key, { taskName, contact, due });
}

async get(): Promise<Notification[]> {
const canGetTaskNotifications = await this.authService.has('can_get_task_notifications');
if (!canGetTaskNotifications) {
return [];
}
return await this.fetchNotifications();
}
}
16 changes: 16 additions & 0 deletions webapp/tests/karma/ts/services/android-api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { GeolocationService } from '@mm-services/geolocation.service';
import { MRDTService } from '@mm-services/mrdt.service';
import { NavigationService } from '@mm-services/navigation.service';
import { AndroidAppLauncherService } from '@mm-services/android-app-launcher.service';
import { TranslateService } from '@mm-services/translate.service';
import { RulesEngineService } from '@mm-services/rules-engine.service';
import { FormatDateService } from '@mm-services/format-date.service';
import { SettingsService } from '@mm-services/settings.service';
import { AuthService } from '@mm-services/auth.service';

describe('AndroidApi service', () => {

Expand All @@ -19,6 +24,7 @@ describe('AndroidApi service', () => {
let consoleErrorMock;
let navigationService;
let androidAppLauncherService;
let translateService;

beforeEach(() => {
sessionService = {
Expand Down Expand Up @@ -47,13 +53,23 @@ describe('AndroidApi service', () => {

consoleErrorMock = sinon.stub(console, 'error');

translateService = {
get: sinon.stub().resolvesArg(0),
instant: sinon.stub().returnsArg(0),
};

TestBed.configureTestingModule({
providers: [
{ provide: SessionService, useValue: sessionService },
{ provide: GeolocationService, useValue: geolocationService },
{ provide: MRDTService, useValue: mrdtService },
{ provide: NavigationService, useValue: navigationService },
{ provide: AndroidAppLauncherService, useValue: androidAppLauncherService },
{ provide: TranslateService, useValue: translateService },
{ provide: RulesEngineService, useValue: {} },
{ provide: FormatDateService, useValue: {} },
{ provide: SettingsService, useValue: {} },
{ provide: AuthService, useValue: {} },
],
});

Expand Down
Loading
Loading