Auspicious14 commited on
Commit
80b0cdb
·
1 Parent(s): ec5b901

feat: add custom user symptom support

Browse files

- Add SymptomType.OTHER enum and customSymptom field to SymptomRecord prisma schema
- Update CreateSymptomDto with validation for customSymptom (string, max 80 chars, optional)
- Implement validation logic in SymptomsService to require customSymptom when using OTHER type, normalize input, and check for duplicate symptoms in last 12 hours
- Fix notification schedulers to only send reminders if user has not logged a BP reading since the cutoff period
- Overhaul ClinicFinderService with improved Overpass API queries, deduplication, distance-based sorting, and better error handling

prisma/schema.prisma CHANGED
@@ -211,7 +211,7 @@ model BloodPressureReading {
211
  *
212
  * CLINICAL SAFETY:
213
  * - Enumerated list of pregnancy hypertension warning signs
214
- * - No free-text symptoms
215
  * - No severity levels
216
  * - Based on clinical guidelines for preeclampsia/eclampsia
217
  */
@@ -223,6 +223,7 @@ enum SymptomType {
223
  REDUCED_URINE // Decreased urine output
224
  NAUSEA_VOMITING // Persistent nausea/vomiting
225
  SHORTNESS_OF_BREATH // Difficulty breathing
 
226
  }
227
 
228
  /**
@@ -242,8 +243,9 @@ enum SymptomType {
242
  model SymptomRecord {
243
  id String @id @default(uuid())
244
  userId String
245
- symptomType SymptomType
246
- recordedAt DateTime @default(now())
 
247
  createdAt DateTime @default(now())
248
 
249
  // Relation to UserAuth
 
211
  *
212
  * CLINICAL SAFETY:
213
  * - Enumerated list of pregnancy hypertension warning signs
214
+ * - Free-text is only allowed with OTHER for user-entered symptoms
215
  * - No severity levels
216
  * - Based on clinical guidelines for preeclampsia/eclampsia
217
  */
 
223
  REDUCED_URINE // Decreased urine output
224
  NAUSEA_VOMITING // Persistent nausea/vomiting
225
  SHORTNESS_OF_BREATH // Difficulty breathing
226
+ OTHER // User-entered symptom text stored separately
227
  }
228
 
229
  /**
 
243
  model SymptomRecord {
244
  id String @id @default(uuid())
245
  userId String
246
+ symptomType SymptomType
247
+ customSymptom String?
248
+ recordedAt DateTime @default(now())
249
  createdAt DateTime @default(now())
250
 
251
  // Relation to UserAuth
src/clinic-finder/clinic-finder.service.ts CHANGED
@@ -2,6 +2,18 @@ import { Injectable, Logger } from "@nestjs/common";
2
  import axios from "axios";
3
  import { CLINIC_LOCATIONS, ClinicLocation } from "./data/clinics.data";
4
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  @Injectable()
6
  export class ClinicFinderService {
7
  private readonly logger = new Logger(ClinicFinderService.name);
@@ -10,44 +22,82 @@ export class ClinicFinderService {
10
  async findNearby(
11
  lat: number,
12
  lng: number,
13
- radius: number = 5000
14
  ): Promise<ClinicLocation[]> {
 
 
15
  try {
16
- this.logger.log(`Searching for hospitals near ${lat}, ${lng} within ${radius}m using Overpass API`);
17
-
 
 
18
  const query = `
19
  [out:json][timeout:25];
20
  (
21
  node["amenity"="hospital"](around:${radius},${lat},${lng});
22
  way["amenity"="hospital"](around:${radius},${lat},${lng});
 
23
  node["amenity"="clinic"](around:${radius},${lat},${lng});
24
  way["amenity"="clinic"](around:${radius},${lat},${lng});
 
 
 
 
 
 
 
 
25
  );
26
- out center;
27
  `;
28
 
29
- const response = await axios.post(this.overpassUrl, `data=${encodeURIComponent(query)}`);
 
 
 
 
30
 
31
- if (response.data && response.data.elements) {
32
- return response.data.elements.map((element: any) => ({
33
- id: element.id.toString(),
34
- name: element.tags.name || "Unnamed Hospital",
35
- latitude: element.lat || element.center?.lat,
36
- longitude: element.lon || element.center?.lon,
37
- address: element.tags["addr:street"]
38
- ? `${element.tags["addr:housenumber"] || ""} ${element.tags["addr:street"]}`.trim()
39
- : "Address not available",
40
- city: element.tags["addr:city"] || "Detected",
41
- country: "Nigeria",
42
- phone: element.tags.phone || element.tags["contact:phone"] || "Contact via Maps",
43
- isEmergency: element.tags.amenity === "hospital",
44
- }));
45
- }
46
 
47
- return [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  } catch (error: any) {
49
- this.logger.error(`Failed to fetch nearby hospitals from Overpass: ${error.message}`);
50
- return this.findAll(); // Fallback to seed data on error
 
 
51
  }
52
  }
53
 
@@ -57,17 +107,11 @@ export class ClinicFinderService {
57
 
58
  findByCity(city: string): ClinicLocation[] {
59
  const normalized = city.trim().toLowerCase();
60
-
61
- // If city is "Ikeja", include "Lagos" clinics as well, as Ikeja is part of Lagos
62
- const isIkeja = normalized === "ikeja";
63
 
64
  return CLINIC_LOCATIONS.filter((clinic) => {
65
- const clinicCity = clinic.city.toLowerCase();
66
- if (isIkeja) {
67
- return clinicCity === "ikeja" || clinicCity === "lagos";
68
- }
69
- return clinicCity === normalized;
70
  });
71
  }
72
  }
73
-
 
2
  import axios from "axios";
3
  import { CLINIC_LOCATIONS, ClinicLocation } from "./data/clinics.data";
4
 
5
+ function distanceKm(lat1: number, lng1: number, lat2: number, lng2: number) {
6
+ const toRad = (value: number) => (value * Math.PI) / 180;
7
+ const earthKm = 6371;
8
+ const dLat = toRad(lat2 - lat1);
9
+ const dLng = toRad(lng2 - lng1);
10
+ const a =
11
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
12
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
13
+ Math.sin(dLng / 2) * Math.sin(dLng / 2);
14
+ return earthKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
15
+ }
16
+
17
  @Injectable()
18
  export class ClinicFinderService {
19
  private readonly logger = new Logger(ClinicFinderService.name);
 
22
  async findNearby(
23
  lat: number,
24
  lng: number,
25
+ radius: number = 8000,
26
  ): Promise<ClinicLocation[]> {
27
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return [];
28
+
29
  try {
30
+ this.logger.log(
31
+ `Searching for maternal care near ${lat}, ${lng} within ${radius}m using Overpass API`,
32
+ );
33
+
34
  const query = `
35
  [out:json][timeout:25];
36
  (
37
  node["amenity"="hospital"](around:${radius},${lat},${lng});
38
  way["amenity"="hospital"](around:${radius},${lat},${lng});
39
+ relation["amenity"="hospital"](around:${radius},${lat},${lng});
40
  node["amenity"="clinic"](around:${radius},${lat},${lng});
41
  way["amenity"="clinic"](around:${radius},${lat},${lng});
42
+ relation["amenity"="clinic"](around:${radius},${lat},${lng});
43
+ node["healthcare"~"hospital|clinic|doctor|centre"](around:${radius},${lat},${lng});
44
+ way["healthcare"~"hospital|clinic|doctor|centre"](around:${radius},${lat},${lng});
45
+ relation["healthcare"~"hospital|clinic|doctor|centre"](around:${radius},${lat},${lng});
46
+ node["emergency"="yes"](around:${radius},${lat},${lng});
47
+ way["emergency"="yes"](around:${radius},${lat},${lng});
48
+ node["name"~"maternity|maternal|women|obstetric|clinic|hospital", i](around:${radius},${lat},${lng});
49
+ way["name"~"maternity|maternal|women|obstetric|clinic|hospital", i](around:${radius},${lat},${lng});
50
  );
51
+ out center tags;
52
  `;
53
 
54
+ const response = await axios.post(
55
+ this.overpassUrl,
56
+ `data=${encodeURIComponent(query)}`,
57
+ { headers: { "Content-Type": "application/x-www-form-urlencoded" } },
58
+ );
59
 
60
+ const seen = new Set<string>();
61
+ return (response.data?.elements || [])
62
+ .map((element: any) => {
63
+ const latitude = element.lat || element.center?.lat;
64
+ const longitude = element.lon || element.center?.lon;
65
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
 
 
 
 
 
 
 
 
 
66
 
67
+ const tags = element.tags || {};
68
+ const name = tags.name || tags["official_name"] || "Health facility";
69
+ const street = tags["addr:street"];
70
+ const address = street
71
+ ? `${tags["addr:housenumber"] || ""} ${street}`.trim()
72
+ : tags["addr:full"] || tags["addr:place"] || "Address not available";
73
+ const city = tags["addr:city"] || tags["addr:town"] || tags["addr:suburb"] || "Nearby";
74
+ const key = `${name.toLowerCase()}-${Number(latitude).toFixed(5)}-${Number(longitude).toFixed(5)}`;
75
+ if (seen.has(key)) return null;
76
+ seen.add(key);
77
+
78
+ return {
79
+ id: `${element.type || "osm"}-${element.id}`,
80
+ name,
81
+ latitude,
82
+ longitude,
83
+ address,
84
+ city,
85
+ country: tags["addr:country"] || "Nigeria",
86
+ phone: tags.phone || tags["contact:phone"] || tags["healthcare:phone"],
87
+ isEmergency: tags.emergency === "yes" || tags.amenity === "hospital",
88
+ } as ClinicLocation;
89
+ })
90
+ .filter(Boolean)
91
+ .sort(
92
+ (a: ClinicLocation, b: ClinicLocation) =>
93
+ distanceKm(lat, lng, a.latitude, a.longitude) -
94
+ distanceKm(lat, lng, b.latitude, b.longitude),
95
+ );
96
  } catch (error: any) {
97
+ this.logger.error(
98
+ `Failed to fetch nearby facilities from Overpass: ${error.message}`,
99
+ );
100
+ return [];
101
  }
102
  }
103
 
 
107
 
108
  findByCity(city: string): ClinicLocation[] {
109
  const normalized = city.trim().toLowerCase();
110
+ if (!normalized) return [];
 
 
111
 
112
  return CLINIC_LOCATIONS.filter((clinic) => {
113
+ const haystack = `${clinic.name} ${clinic.address} ${clinic.city}`.toLowerCase();
114
+ return haystack.includes(normalized);
 
 
 
115
  });
116
  }
117
  }
 
src/email/email.scheduler.ts CHANGED
@@ -117,6 +117,13 @@ export class EmailScheduler {
117
  const lastReading = user.bloodPressureReadings[0];
118
  if (!lastReading) continue; // Should not happen given the query but safe-guard
119
 
 
 
 
 
 
 
 
120
  const diffTime = Math.abs(new Date().getTime() - lastReading.recordedAt.getTime());
121
  const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
122
 
 
117
  const lastReading = user.bloodPressureReadings[0];
118
  if (!lastReading) continue; // Should not happen given the query but safe-guard
119
 
120
+ const latestReading = await this.prisma.bloodPressureReading.findFirst({
121
+ where: { userId: user.id },
122
+ orderBy: { recordedAt: 'desc' },
123
+ select: { recordedAt: true },
124
+ });
125
+ if (!latestReading || latestReading.recordedAt.getTime() !== lastReading.recordedAt.getTime()) continue;
126
+
127
  const diffTime = Math.abs(new Date().getTime() - lastReading.recordedAt.getTime());
128
  const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
129
 
src/monitoring-engine/monitoring-scheduler.service.ts CHANGED
@@ -107,6 +107,15 @@ export class MonitoringSchedulerService {
107
  continue;
108
  }
109
 
 
 
 
 
 
 
 
 
 
110
  if (lastBpReading.recordedAt < sevenDaysAgo) {
111
  // No BP logged in >7 days -> escalate messaging urgency
112
  await this.notificationsService.sendPushNotification(
 
107
  continue;
108
  }
109
 
110
+ const latestReading = await this.prisma.bloodPressureReading.findFirst({
111
+ where: { userId: user.id },
112
+ orderBy: { recordedAt: "desc" },
113
+ select: { recordedAt: true },
114
+ });
115
+ if (!latestReading || latestReading.recordedAt.getTime() !== lastBpReading.recordedAt.getTime()) {
116
+ continue;
117
+ }
118
+
119
  if (lastBpReading.recordedAt < sevenDaysAgo) {
120
  // No BP logged in >7 days -> escalate messaging urgency
121
  await this.notificationsService.sendPushNotification(
src/notifications/notifications.service.ts CHANGED
@@ -50,6 +50,20 @@ export class NotificationsService {
50
  return this.expoInstance;
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  /**
54
  * Helper to get user's email
55
  */
@@ -160,6 +174,7 @@ export class NotificationsService {
160
 
161
  for (const user of usersToRemind) {
162
  try {
 
163
  const template = MONITORING_TEMPLATES.DAILY_REMINDER;
164
  await this.prisma.notification.create({
165
  data: {
@@ -210,6 +225,7 @@ export class NotificationsService {
210
 
211
  for (const user of users) {
212
  try {
 
213
  const template = MONITORING_TEMPLATES.INACTIVITY_REMINDER;
214
  await this.prisma.notification.create({
215
  data: {
@@ -288,6 +304,7 @@ export class NotificationsService {
288
 
289
  for (const user of users) {
290
  try {
 
291
  const template = MONITORING_TEMPLATES.FOLLOW_UP_REMINDER;
292
  await this.prisma.notification.create({
293
  data: {
 
50
  return this.expoInstance;
51
  }
52
 
53
+ private async getLatestBPDate(userId: string): Promise<Date | null> {
54
+ const latest = await this.prisma.bloodPressureReading.findFirst({
55
+ where: { userId },
56
+ orderBy: { recordedAt: "desc" },
57
+ select: { recordedAt: true },
58
+ });
59
+ return latest?.recordedAt || null;
60
+ }
61
+
62
+ private async hasLoggedBPSince(userId: string, cutoff: Date): Promise<boolean> {
63
+ const latest = await this.getLatestBPDate(userId);
64
+ return Boolean(latest && latest >= cutoff);
65
+ }
66
+
67
  /**
68
  * Helper to get user's email
69
  */
 
174
 
175
  for (const user of usersToRemind) {
176
  try {
177
+ if (await this.hasLoggedBPSince(user.id, today)) continue;
178
  const template = MONITORING_TEMPLATES.DAILY_REMINDER;
179
  await this.prisma.notification.create({
180
  data: {
 
225
 
226
  for (const user of users) {
227
  try {
228
+ if (await this.hasLoggedBPSince(user.id, fiveDaysAgo)) continue;
229
  const template = MONITORING_TEMPLATES.INACTIVITY_REMINDER;
230
  await this.prisma.notification.create({
231
  data: {
 
304
 
305
  for (const user of users) {
306
  try {
307
+ if (await this.hasLoggedBPSince(user.id, fourHoursAgo)) continue;
308
  const template = MONITORING_TEMPLATES.FOLLOW_UP_REMINDER;
309
  await this.prisma.notification.create({
310
  data: {
src/symptoms/dto/create-symptom.dto.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { IsEnum, IsOptional, IsDateString } from "class-validator";
2
  import { SymptomType } from "@prisma/client";
3
 
4
  /**
@@ -18,6 +18,11 @@ export class CreateSymptomDto {
18
  @IsEnum(SymptomType)
19
  symptomType!: SymptomType;
20
 
 
 
 
 
 
21
  @IsOptional()
22
  @IsDateString()
23
  recordedAt?: string;
 
1
+ import { IsEnum, IsOptional, IsDateString, IsString, MaxLength } from "class-validator";
2
  import { SymptomType } from "@prisma/client";
3
 
4
  /**
 
18
  @IsEnum(SymptomType)
19
  symptomType!: SymptomType;
20
 
21
+ @IsOptional()
22
+ @IsString()
23
+ @MaxLength(80)
24
+ customSymptom?: string;
25
+
26
  @IsOptional()
27
  @IsDateString()
28
  recordedAt?: string;
src/symptoms/symptoms.service.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Injectable, Logger } from "@nestjs/common";
2
  import { PrismaService } from "../database/prisma.service";
3
  import { CreateSymptomDto } from "./dto/create-symptom.dto";
4
  import { SymptomType } from "@prisma/client";
@@ -35,11 +35,27 @@ export class SymptomsService {
35
  */
36
  async create(userId: string, createSymptomDto: CreateSymptomDto) {
37
  const { symptomType, recordedAt } = createSymptomDto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  const symptom = await this.prisma.symptomRecord.create({
40
  data: {
41
  userId,
42
  symptomType,
 
43
  recordedAt: recordedAt ? new Date(recordedAt) : new Date(),
44
  },
45
  });
 
1
+ import { BadRequestException, Injectable, Logger } from "@nestjs/common";
2
  import { PrismaService } from "../database/prisma.service";
3
  import { CreateSymptomDto } from "./dto/create-symptom.dto";
4
  import { SymptomType } from "@prisma/client";
 
35
  */
36
  async create(userId: string, createSymptomDto: CreateSymptomDto) {
37
  const { symptomType, recordedAt } = createSymptomDto;
38
+ const customSymptom = createSymptomDto.customSymptom?.trim().replace(/\s+/g, " ");
39
+
40
+ if (symptomType === SymptomType.OTHER && !customSymptom) {
41
+ throw new BadRequestException("Please describe the other symptom.");
42
+ }
43
+
44
+ const duplicate = await this.prisma.symptomRecord.findFirst({
45
+ where: {
46
+ userId,
47
+ symptomType,
48
+ customSymptom: symptomType === SymptomType.OTHER ? customSymptom : null,
49
+ recordedAt: { gte: new Date(Date.now() - 12 * 60 * 60 * 1000) },
50
+ },
51
+ });
52
+ if (duplicate) return duplicate;
53
 
54
  const symptom = await this.prisma.symptomRecord.create({
55
  data: {
56
  userId,
57
  symptomType,
58
+ customSymptom: symptomType === SymptomType.OTHER ? customSymptom : null,
59
  recordedAt: recordedAt ? new Date(recordedAt) : new Date(),
60
  },
61
  });