Skip to content

Commit 046219a

Browse files
committed
Add notification sending when a reminder is triggered
1 parent 18786f2 commit 046219a

File tree

17 files changed

+549
-18
lines changed

17 files changed

+549
-18
lines changed

pill_mate/backend/eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default tseslint.config(
1717
globals: globals.node,
1818
},
1919
rules: {
20+
'no-console': 'error',
2021
'max-len': ['error', { code: 100 }],
2122
indent: ['error', 4],
2223
semi: ['error', 'always'],

pill_mate/backend/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express from 'express';
22
import morgan from 'morgan';
33

44
import { createLogger } from './logger';
5+
import homeAssistantRoutes from './routes/homeAssistantRoutes';
56
import medicationRoutes from './routes/medicationRoutes';
67
import reminderRoutes from './routes/reminderRoutes';
78
import userRoutes from './routes/userRoutes';
@@ -24,6 +25,7 @@ app.use(express.json());
2425

2526
app.use(homeAssistantHeaders);
2627

28+
app.use('/homeassistant', homeAssistantRoutes);
2729
app.use('/medication', medicationRoutes);
2830
app.use('/reminder', reminderRoutes);
2931
app.use('/user', userRoutes);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import assert from 'assert';
2+
3+
import { Request, Response } from 'express';
4+
5+
import { HTTP_200_OK } from '../status';
6+
import { asyncErrorHandler } from '../utils';
7+
import { HomeAssistantService } from '../services/HomeAssistantService';
8+
9+
export const getMobileAppDevices = asyncErrorHandler(async (
10+
request: Request,
11+
response: Response,
12+
) => {
13+
assert(request.user !== undefined);
14+
15+
const devies = await HomeAssistantService.getMobileAppDevices();
16+
17+
response
18+
.status(HTTP_200_OK)
19+
.json(devies.map(device => device.name));
20+
});

pill_mate/backend/src/controllers/userController.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
HTTP_404_NOT_FOUND,
1313
} from '../status';
1414
import { asyncErrorHandler, checkUnexpectedKeys } from '../utils';
15+
import { HomeAssistantService } from '../services/HomeAssistantService';
1516

1617
export const me = asyncErrorHandler(async (request: Request, response: Response) => {
1718
assert(request.user !== undefined);
@@ -21,6 +22,7 @@ export const me = asyncErrorHandler(async (request: Request, response: Response)
2122
userName: request.homeAssistantUserName,
2223
userDisplayName: request.homeAssistantUserDisplayName,
2324
role: request.user.role,
25+
mobileAppDevice: request.user.mobileAppDevice,
2426
});
2527
});
2628

@@ -35,15 +37,25 @@ export const me = asyncErrorHandler(async (request: Request, response: Response)
3537
* properties:
3638
* role:
3739
* $ref: '#/components/schemas/UserRole'
40+
* mobileAppDevice:
41+
* type: string
42+
* nullable: true
43+
* description: The devices where notifications will be send.
44+
* example: Redmi Note 8T
3845
*/
3946
type CreateUserBody = {
4047
role: unknown,
48+
mobileAppDevice: unknown,
4149
};
4250

4351
export const createUser = asyncErrorHandler(async (request: Request, response: Response) => {
44-
if (!checkUnexpectedKeys<CreateUserBody>(request.body, ['role'], response)) return;
52+
if (!checkUnexpectedKeys<CreateUserBody>(
53+
request.body,
54+
['role', 'mobileAppDevice'],
55+
response,
56+
)) return;
4557

46-
const { role } = request.body as CreateUserBody;
58+
const { role, mobileAppDevice } = request.body as CreateUserBody;
4759

4860
if (role === undefined) {
4961
response
@@ -59,6 +71,19 @@ export const createUser = asyncErrorHandler(async (request: Request, response: R
5971
return;
6072
}
6173

74+
if ((mobileAppDevice !== undefined &&
75+
mobileAppDevice !== null &&
76+
typeof mobileAppDevice !== 'string') ||
77+
(typeof mobileAppDevice === 'string' &&
78+
(await HomeAssistantService.getMobileAppDevices()).find(
79+
device => device.name === mobileAppDevice,
80+
) === undefined)) {
81+
response
82+
.status(HTTP_400_BAD_REQUEST)
83+
.json({ message: 'Invalid mobileAppDevice.' });
84+
return;
85+
}
86+
6287
const user = await User.findOne({
6388
where: {
6489
homeAssistantUserId: request.homeAssistantUserId,
@@ -75,6 +100,7 @@ export const createUser = asyncErrorHandler(async (request: Request, response: R
75100
const newUser = await User.create({
76101
homeAssistantUserId: request.homeAssistantUserId,
77102
role,
103+
mobileAppDevice,
78104
});
79105

80106
response
@@ -85,6 +111,7 @@ export const createUser = asyncErrorHandler(async (request: Request, response: R
85111
userName: request.homeAssistantUserName,
86112
userDisplayName: request.homeAssistantUserDisplayName,
87113
role: newUser.role,
114+
mobileAppDevice: newUser.mobileAppDevice,
88115
});
89116
});
90117

pill_mate/backend/src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { config } from 'dotenv';
33
config({ path: '../.env' });
44

55
import app from './app';
6-
import { HomeAssistantService } from './services/homeAssistantService';
6+
import { HomeAssistantService } from './services/HomeAssistantService';
77
import { createLogger } from './logger';
8-
import { ReminderService } from './services/reminderService';
8+
import { ReminderService } from './services/ReminderService';
99
import { sequelize } from './sequelize';
1010

1111
const SUPERVISOR_TOKEN = process.env.SUPERVISOR_TOKEN;
@@ -20,9 +20,10 @@ const logger = createLogger('backend');
2020
}
2121

2222
await Promise.all([
23-
sequelize.sync({ alter: { drop: false } }).then(() => ReminderService.initAllTimeouts()),
23+
sequelize.sync({ alter: { drop: false } }),
2424
HomeAssistantService.init(SUPERVISOR_TOKEN),
2525
]);
26+
await ReminderService.initAllTimeouts();
2627

2728
app.listen(PORT, () => {
2829
logger.info(`Server running at http://localhost:${PORT}`);

pill_mate/backend/src/models/MedicationUnit.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import assert from 'assert';
12
/**
23
* @openapi
34
* components:
@@ -25,3 +26,20 @@ export enum MedicationUnit {
2526
export const isMedicationUnit = (value: unknown): value is MedicationUnit => {
2627
return typeof value === 'number' && value in MedicationUnit;
2728
};
29+
30+
export const medicationUnitToString = (medicationUnit: MedicationUnit, plural: boolean) => {
31+
switch (medicationUnit) {
32+
case MedicationUnit.TABLET:
33+
return plural ? 'comprimés' : 'comprimé';
34+
case MedicationUnit.PILL:
35+
return plural ? 'pilules' : 'pilule';
36+
case MedicationUnit.ML:
37+
return plural ? 'millilitres' : 'millilitre';
38+
case MedicationUnit.DROPS:
39+
return 'gouttes';
40+
case MedicationUnit.UNIT:
41+
return plural ? 'unités' : 'unité';
42+
default:
43+
assert(false, 'unreachable');
44+
}
45+
};

pill_mate/backend/src/models/Reminder.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
BelongsToCreateAssociationMixin,
3+
BelongsToGetAssociationMixin,
4+
BelongsToSetAssociationMixin,
5+
} from 'sequelize';
16
import {
27
AllowNull,
38
BeforeCreate,
@@ -12,7 +17,7 @@ import {
1217
Table,
1318
} from 'sequelize-typescript';
1419

15-
import { ReminderService } from '../services/reminderService';
20+
import { ReminderService } from '../services/ReminderService';
1621
import { Medication } from './Medication';
1722
import { User } from './User';
1823

@@ -74,14 +79,18 @@ export class Reminder extends Model {
7479
@Column(DataType.DATEONLY)
7580
declare nextDate: string;
7681

77-
@BelongsTo(() => Medication)
78-
declare medication: Medication;
79-
8082
@ForeignKey(() => Medication)
8183
@AllowNull(false)
8284
@Column
8385
declare medicationId: number;
8486

87+
@BelongsTo(() => Medication)
88+
declare medication: Medication;
89+
90+
declare getMedication: BelongsToGetAssociationMixin<Medication>;
91+
declare setMedication: BelongsToSetAssociationMixin<Medication, number>;
92+
declare createMedication: BelongsToCreateAssociationMixin<Medication>;
93+
8594
@ForeignKey(() => User)
8695
@AllowNull(false)
8796
@Column
@@ -90,6 +99,10 @@ export class Reminder extends Model {
9099
@BelongsTo(() => User)
91100
declare user: User;
92101

102+
declare getUser: BelongsToGetAssociationMixin<User>;
103+
declare setUser: BelongsToSetAssociationMixin<User, number>;
104+
declare createUser: BelongsToCreateAssociationMixin<User>;
105+
93106
/**
94107
* Computes the exact `Date` object when the reminder should trigger.
95108
*
@@ -100,6 +113,8 @@ export class Reminder extends Model {
100113
const nextDate = new Date(this.nextDate);
101114
nextDate.setHours(hours, minutes, 0, 0);
102115
return nextDate;
116+
117+
103118
}
104119

105120
@BeforeCreate

pill_mate/backend/src/models/User.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
BelongsToMany,
2626
Column,
2727
DataType,
28+
Default,
2829
ForeignKey,
2930
HasMany,
3031
Length,
@@ -62,6 +63,11 @@ import { UserRole } from './UserRole';
6263
* example: John Doe
6364
* role:
6465
* $ref: '#/components/schemas/UserRole'
66+
* mobileAppDevice:
67+
* type: string
68+
* nullable: true
69+
* description: The devices where notifications will be send.
70+
* example: Redmi Note 8T
6571
*/
6672

6773
@Table({ timestamps: false })
@@ -77,6 +83,10 @@ export class User extends Model {
7783
@Column
7884
declare role: UserRole;
7985

86+
@Default(null)
87+
@Column(DataType.STRING(255))
88+
declare mobileAppDevice: string | null;
89+
8090
@BelongsToMany(() => User, () => UserHelp)
8191
declare helpedUsers: User[];
8292

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import request from 'supertest';
2+
3+
import app from '../app';
4+
5+
import { User } from '../models/User';
6+
import { UserRole } from '../models/UserRole';
7+
import { HomeAssistantService } from '../services/HomeAssistantService';
8+
9+
jest.mock('../models/User', () => {
10+
return {
11+
User: {
12+
findOne: jest.fn(),
13+
},
14+
};
15+
});
16+
17+
jest.mock('../services/HomeAssistantService', () => {
18+
return {
19+
HomeAssistantService: {
20+
getMobileAppDevices: jest.fn(),
21+
},
22+
};
23+
});
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
describe('GET /homeassistant/mobile-app-devices', () => {
30+
it('should return 400 if the x-remote-user-id header is missing', async () => {
31+
const response = await request(app)
32+
.get('/homeassistant/mobile-app-devices')
33+
.set('x-remote-user-name', 'johndoe')
34+
.set('x-remote-user-display-name', 'John Doe');
35+
expect(response.body).toStrictEqual({
36+
message: 'Missing required header: x-remote-user-id.',
37+
});
38+
expect(response.status).toBe(400);
39+
});
40+
41+
it('should return 400 if the x-remote-user-id is not a valid user id', async () => {
42+
const response = await request(app)
43+
.get('/homeassistant/mobile-app-devices')
44+
.set('x-remote-user-id', 'bad home assistant id')
45+
.set('x-remote-user-name', 'johndoe')
46+
.set('x-remote-user-display-name', 'John Doe');
47+
expect(response.body).toStrictEqual({
48+
message: 'Invalid home assistant user id in x-remote-user-id.',
49+
});
50+
expect(response.status).toBe(400);
51+
});
52+
53+
it('should return 400 if the x-remote-user-name header is missing', async () => {
54+
const response = await request(app)
55+
.get('/homeassistant/mobile-app-devices')
56+
.set('x-remote-user-id', 'c355d2aaeee44e4e84ff8394fa4794a9')
57+
.set('x-remote-user-display-name', 'John Doe');
58+
expect(response.body).toStrictEqual({
59+
message: 'Missing required header: x-remote-user-name.',
60+
});
61+
expect(response.status).toBe(400);
62+
});
63+
64+
it('should return 400 if the x-remote-user-display-name header is missing', async () => {
65+
const response = await request(app)
66+
.get('/homeassistant/mobile-app-devices')
67+
.set('x-remote-user-id', 'c355d2aaeee44e4e84ff8394fa4794a9')
68+
.set('x-remote-user-name', 'johndoe');
69+
expect(response.body).toStrictEqual({
70+
message: 'Missing required header: x-remote-user-display-name.',
71+
});
72+
expect(response.status).toBe(400);
73+
});
74+
75+
it('should return 401 if user is not in the database', async () => {
76+
(User.findOne as jest.Mock).mockResolvedValue(null);
77+
78+
const response = await request(app)
79+
.get('/homeassistant/mobile-app-devices')
80+
.set('x-remote-user-id', 'c355d2aaeee44e4e84ff8394fa4794a9')
81+
.set('x-remote-user-name', 'johndoe')
82+
.set('x-remote-user-display-name', 'John Doe');
83+
expect(response.body).toStrictEqual({ message: 'User not registered.' });
84+
expect(response.status).toBe(401);
85+
expect(User.findOne).toHaveBeenCalledTimes(1);
86+
expect(User.findOne).toHaveBeenCalledWith({
87+
where: { homeAssistantUserId: 'c355d2aaeee44e4e84ff8394fa4794a9' },
88+
});
89+
});
90+
91+
it('should return the list of the names of the available devices', async () => {
92+
(User.findOne as jest.Mock).mockResolvedValue({
93+
id: 1,
94+
homeAssistantUserId: 'c355d2aaeee44e4e84ff8394fa4794a9',
95+
role: UserRole.HELPED,
96+
mobileAppDevice: 'Redmi Note 8T',
97+
});
98+
99+
(HomeAssistantService.getMobileAppDevices as jest.Mock).mockResolvedValue([
100+
{ name: 'Redmi Note 8T' },
101+
{ name: 'Redmi Note 9' },
102+
]);
103+
104+
const response = await request(app)
105+
.get('/homeassistant/mobile-app-devices')
106+
.set('x-remote-user-id', 'c355d2aaeee44e4e84ff8394fa4794a9')
107+
.set('x-remote-user-name', 'johndoe')
108+
.set('x-remote-user-display-name', 'John Doe');
109+
expect(response.body).toStrictEqual(['Redmi Note 8T', 'Redmi Note 9']);
110+
expect(response.status).toBe(200);
111+
expect(User.findOne).toHaveBeenCalledTimes(1);
112+
expect(User.findOne).toHaveBeenCalledWith({
113+
where: { homeAssistantUserId: 'c355d2aaeee44e4e84ff8394fa4794a9' },
114+
});
115+
});
116+
});

0 commit comments

Comments
 (0)