Medication reminder and pdr worker
Deploy / deploy-dev (push) Successful in 2m39s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
Corey Blais
2026-06-03 15:21:21 -04:00
parent 979a17132d
commit c3bec15c63
13 changed files with 1202 additions and 31 deletions
+520 -1
View File
@@ -20,6 +20,7 @@
"pdfkit": "^0.18.0", "pdfkit": "^0.18.0",
"pg": "8.13.1", "pg": "8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5",
"stripe": "^22.0.2", "stripe": "^22.0.2",
"zod": "3.24.1" "zod": "3.24.1"
}, },
@@ -35,6 +36,16 @@
"typescript": "5.7.2" "typescript": "5.7.2"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.23.1", "version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
@@ -443,6 +454,471 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -1065,7 +1541,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -2383,6 +2858,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+1
View File
@@ -26,6 +26,7 @@
"pdfkit": "^0.18.0", "pdfkit": "^0.18.0",
"pg": "8.13.1", "pg": "8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5",
"stripe": "^22.0.2", "stripe": "^22.0.2",
"zod": "3.24.1" "zod": "3.24.1"
}, },
+255 -21
View File
@@ -13,7 +13,9 @@ import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js';
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js'; import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
import { import {
consumeMagicLinkToken, consumeMagicLinkToken,
consumeOAuthState, consumeOAuthState,
@@ -35,6 +37,7 @@ import {
completePendingBirdTransfersForOwner, completePendingBirdTransfersForOwner,
createBird, createBird,
createBirdMilestoneReminderDelivery, createBirdMilestoneReminderDelivery,
createMedicationReminderDelivery,
createBirdTransferCode, createBirdTransferCode,
createMedicationForBird, createMedicationForBird,
createPendingBirdTransfer, createPendingBirdTransfer,
@@ -49,6 +52,7 @@ import {
getOpenBirdTransferCode, getOpenBirdTransferCode,
listBirds, listBirds,
listDueBirdMilestoneReminders, listDueBirdMilestoneReminders,
listDueMedicationReminders,
listMemorializedBirds, listMemorializedBirds,
listMedicationAdministrationsForBird, listMedicationAdministrationsForBird,
listMedicationsForBird, listMedicationsForBird,
@@ -64,7 +68,6 @@ import {
updateVetVisitForBird, updateVetVisitForBird,
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
import { renderAdoptionReportPdf } from './reports/adoptionReport.js';
import { import {
createAuditLogEntry, createAuditLogEntry,
createFlockNote, createFlockNote,
@@ -131,8 +134,9 @@ import type {
FlockNoteRow, FlockNoteRow,
IntegrationTokenRow, IntegrationTokenRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationReminderCandidateRow,
MedicationRow,
ProviderKey, ProviderKey,
RescueVerificationStatus, RescueVerificationStatus,
SubscriptionStatus, SubscriptionStatus,
@@ -161,10 +165,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`; const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
const sessionDays = 30; const sessionDays = 30;
const trustProxy = process.env.TRUST_PROXY?.trim() ?? ''; const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000; const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
const adoptionReportWeightHistoryDays = 425;
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy'; const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
if (trustProxy) { if (trustProxy) {
@@ -338,6 +343,7 @@ const medicationSchema = z
startDate: dateStringSchema, startDate: dateStringSchema,
endDate: dateStringSchema.optional().or(z.literal('')), endDate: dateStringSchema.optional().or(z.literal('')),
notes: z.string().trim().max(1000).optional().or(z.literal('')), notes: z.string().trim().max(1000).optional().or(z.literal('')),
remindersEnabled: z.boolean().optional(),
}) })
.refine((value) => !value.endDate || value.endDate >= value.startDate, { .refine((value) => !value.endDate || value.endDate >= value.startDate, {
message: 'End date must be on or after start date.', message: 'End date must be on or after start date.',
@@ -746,6 +752,7 @@ const normalizeMedication = (row: MedicationRow) => ({
startDate: row.start_date, startDate: row.start_date,
endDate: row.end_date, endDate: row.end_date,
notes: row.notes, notes: row.notes,
remindersEnabled: row.reminders_enabled,
}); });
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
@@ -1224,6 +1231,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
}; };
const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0');
const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0');
return `${hour === '24' ? '00' : hour}:${minute}`;
};
const formatOrdinal = (value: number) => { const formatOrdinal = (value: number) => {
const remainder = value % 100; const remainder = value % 100;
if (remainder >= 11 && remainder <= 13) { if (remainder >= 11 && remainder <= 13) {
@@ -1762,6 +1781,33 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
}; };
}; };
const formatReminderDoseTime = (time: string) => {
const [rawHour, rawMinute] = time.split(':');
const hour = Number(rawHour);
const minute = rawMinute ?? '00';
if (Number.isNaN(hour)) {
return time;
}
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour % 12 || 12;
return `${displayHour}:${minute} ${period}`;
};
const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => {
const doseTime = formatReminderDoseTime(reminder.administration_time);
const slotLabel = reminder.administration_label || 'Dose';
const route = reminder.route ? ` by ${reminder.route}` : '';
return {
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
eyebrow: 'Medication Reminder',
headline: `${slotLabel} time for ${reminder.name}`,
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
body: `Dose: ${reminder.dosage}${route}.`,
detailLabel: `${slotLabel} at ${doseTime}`,
};
};
const sendBirdMilestoneReminderNotification = async ({ const sendBirdMilestoneReminderNotification = async ({
reminder, reminder,
recipients, recipients,
@@ -1868,6 +1914,120 @@ const sendBirdMilestoneReminderNotification = async ({
return { delivered: true }; return { delivered: true };
}; };
const sendMedicationReminderNotification = async ({
reminder,
recipients,
}: {
reminder: MedicationReminderCandidateRow;
recipients: string[];
}) => {
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
if (!uniqueRecipients.length) {
return { delivered: false };
}
const copy = buildMedicationReminderCopy(reminder);
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const logoAttachment = getFlockPalLogoAttachment();
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
if (logoAttachment) {
attachments.push(logoAttachment);
}
if (uploadedBirdPhoto) {
attachments.push({
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
content: uploadedBirdPhoto.content,
contentType: uploadedBirdPhoto.contentType,
cid: birdPhotoCid,
contentDisposition: 'inline',
});
} else if (defaultBirdPhoto) {
attachments.push(defaultBirdPhoto);
}
const birdPhotoHtml = birdPhotoCid
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
const medicationNotesHtml = reminder.medication_notes
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
: '';
const lines = [
copy.headline,
'',
copy.intro,
copy.body,
'',
`Bird: ${reminder.name}`,
`Medication: ${reminder.medication_name}`,
`Dose: ${reminder.dosage}`,
`Scheduled dose: ${copy.detailLabel}`,
reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '',
'',
`Open FlockPal: ${frontendBaseUrl}`,
].filter(Boolean);
if (!mailTransport) {
console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
return { delivered: false };
}
await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: smtpFromEmail,
bcc: uniqueRecipients,
subject: copy.subject,
text: lines.join('\n'),
attachments,
html: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
${medicationNotesHtml}
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
});
return { delivered: true };
};
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => { export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
const reminders = await listDueBirdMilestoneReminders(runDate); const reminders = await listDueBirdMilestoneReminders(runDate);
let sent = 0; let sent = 0;
@@ -1912,7 +2072,53 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
}; };
}; };
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
const reminders = await listDueMedicationReminders(runDate, currentTime);
let sent = 0;
let skipped = 0;
let failed = 0;
for (const reminder of reminders) {
try {
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
const result = await sendMedicationReminderNotification({ reminder, recipients });
if (!result.delivered) {
skipped += 1;
continue;
}
const delivery = await createMedicationReminderDelivery({
medicationId: reminder.medication_id,
birdId: reminder.id,
workspaceId: reminder.workspace_id,
scheduledOn: runDate,
administrationSlot: reminder.administration_slot,
});
if (delivery) {
sent += 1;
} else {
skipped += 1;
}
} catch (error) {
failed += 1;
console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error);
}
}
return {
runDate,
currentTime,
checked: reminders.length,
sent,
skipped,
failed,
};
};
let lastMilestoneReminderRunDate = ''; let lastMilestoneReminderRunDate = '';
let lastMedicationReminderRunKey = '';
export const startBirdMilestoneReminderScheduler = () => { export const startBirdMilestoneReminderScheduler = () => {
if (!milestoneRemindersEnabled) { if (!milestoneRemindersEnabled) {
@@ -1946,6 +2152,42 @@ export const startBirdMilestoneReminderScheduler = () => {
}, milestoneReminderCheckIntervalMs); }, milestoneReminderCheckIntervalMs);
}; };
export const startMedicationReminderScheduler = () => {
if (!medicationRemindersEnabled) {
console.log('Medication reminders are disabled.');
return;
}
const runIfNeeded = async () => {
const now = new Date();
const runDate = getDateInTimeZone(now);
const currentTime = getTimeInTimeZone(now);
const runKey = `${runDate}-${currentTime.slice(0, 2)}`;
if (lastMedicationReminderRunKey === runKey) {
return;
}
lastMedicationReminderRunKey = runKey;
const job = await enqueueMedicationReminderJob(runDate, currentTime);
console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`);
};
setTimeout(() => {
void runIfNeeded().catch((error) => {
lastMedicationReminderRunKey = '';
console.error('Medication reminder scheduler failed', error);
});
}, 30_000);
setInterval(() => {
void runIfNeeded().catch((error) => {
lastMedicationReminderRunKey = '';
console.error('Medication reminder scheduler failed', error);
});
}, milestoneReminderCheckIntervalMs);
};
const readBearerToken = (authorizationHeader?: string) => { const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) { if (!authorizationHeader) {
return ''; return '';
@@ -2097,6 +2339,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
try { try {
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts(); const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
res.json({ res.json({
startedAt: requestMetrics.startedAt, startedAt: requestMetrics.startedAt,
@@ -2118,6 +2361,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
}, },
queues: { queues: {
birdMilestoneReminders: birdMilestoneReminderQueueCounts, birdMilestoneReminders: birdMilestoneReminderQueueCounts,
medicationReminders: medicationReminderQueueCounts,
}, },
}); });
} catch (error) { } catch (error) {
@@ -3599,27 +3843,15 @@ app.post(
throw new Error('Unable to create bird transfer code.'); throw new Error('Unable to create bird transfer code.');
} }
const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([ await adoptionReportQueueEvents.waitUntilReady();
listWeightsForBird(sourceBird.id, req.auth!.workspace.id, adoptionReportWeightHistoryDays), const reportJob = await enqueueAdoptionReportJob({
listVetVisitsForBird(sourceBird.id, req.auth!.workspace.id), birdId: sourceBird.id,
listFlockNotes(req.auth!.workspace.id), workspaceId: req.auth!.workspace.id,
loadBirdReportPhotoBuffer(sourceBird),
]);
const birdNotes = notes.filter((note) => note.bird_id === sourceBird.id);
const pdf = await renderAdoptionReportPdf({
bird: sourceBird,
weights,
vetVisits,
notes: birdNotes,
transferCode: transferCode.code, transferCode: transferCode.code,
birdPhotoBuffer,
printFriendly: req.query.printFriendly === 'true', printFriendly: req.query.printFriendly === 'true',
assets: {
logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'),
wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'),
defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'),
},
}); });
const reportResult = await reportJob.waitUntilFinished(adoptionReportQueueEvents, adoptionReportRenderTimeoutMs);
const pdf = Buffer.from(reportResult.pdfBase64, 'base64');
await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, { await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, {
transferCodeId: transferCode.id, transferCodeId: transferCode.id,
@@ -4031,6 +4263,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
parsed.data.startDate, parsed.data.startDate,
emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.endDate),
emptyToNull(parsed.data.notes), emptyToNull(parsed.data.notes),
parsed.data.remindersEnabled ?? false,
); );
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, { await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
@@ -4074,6 +4307,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
parsed.data.startDate, parsed.data.startDate,
emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.endDate),
emptyToNull(parsed.data.notes), emptyToNull(parsed.data.notes),
parsed.data.remindersEnabled ?? false,
); );
if (!medication) { if (!medication) {
+18
View File
@@ -471,6 +471,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE, end_date DATE,
notes VARCHAR(1000), notes VARCHAR(1000),
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (end_date IS NULL OR end_date >= start_date) CHECK (end_date IS NULL OR end_date >= start_date)
); );
@@ -478,6 +479,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medications ALTER TABLE medications
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb; ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
ALTER TABLE medications
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries ( CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -511,6 +515,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medication_administrations ALTER TABLE medication_administrations
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1'; ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
scheduled_on DATE NOT NULL,
administration_slot VARCHAR(80) NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (medication_id, scheduled_on, administration_slot)
);
ALTER TABLE medication_administrations ALTER TABLE medication_administrations
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key; DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
@@ -529,6 +544,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC); ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
ON medication_administrations (bird_id, administered_on DESC); ON medication_administrations (bird_id, administered_on DESC);
+42
View File
@@ -0,0 +1,42 @@
import { Queue, QueueEvents, type Job } from 'bullmq';
import { redisConnection } from './redisConnection.js';
export type AdoptionReportJobData = {
birdId: string;
workspaceId: number;
transferCode: string;
printFriendly: boolean;
};
export type AdoptionReportJobResult = {
pdfBase64: string;
};
export const adoptionReportQueueName = 'adoption-reports';
export const adoptionReportQueue = new Queue<AdoptionReportJobData, AdoptionReportJobResult>(adoptionReportQueueName, {
connection: redisConnection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 10_000,
},
removeOnComplete: 50,
removeOnFail: 500,
},
});
export const adoptionReportQueueEvents = new QueueEvents(adoptionReportQueueName, {
connection: redisConnection,
});
export const enqueueAdoptionReportJob = (
data: AdoptionReportJobData,
): Promise<Job<AdoptionReportJobData, AdoptionReportJobResult>> => adoptionReportQueue.add('render-adoption-report', data);
export const closeAdoptionReportQueue = async () => {
await adoptionReportQueue.close();
await adoptionReportQueueEvents.close();
};
@@ -0,0 +1,55 @@
import { Queue, type Job } from 'bullmq';
import { redisConnection } from './redisConnection.js';
export type MedicationReminderJobData = {
runDate: string;
currentTime: string;
requestedBy: 'scheduler';
};
export type MedicationReminderJobResult = {
runDate: string;
currentTime: string;
checked: number;
sent: number;
skipped: number;
failed: number;
};
export const medicationReminderQueueName = 'medication-reminders';
export const medicationReminderQueue = new Queue<MedicationReminderJobData, MedicationReminderJobResult>(medicationReminderQueueName, {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 60_000,
},
removeOnComplete: 100,
removeOnFail: 1_000,
},
});
export const enqueueMedicationReminderJob = (
runDate: string,
currentTime: string,
): Promise<Job<MedicationReminderJobData, MedicationReminderJobResult>> =>
medicationReminderQueue.add(
'run-medication-reminders',
{
runDate,
currentTime,
requestedBy: 'scheduler',
},
{
jobId: `medication-reminders-${runDate}-${currentTime.slice(0, 2)}`,
},
);
export const closeMedicationReminderQueue = async () => {
await medicationReminderQueue.close();
};
export const getMedicationReminderQueueCounts = () => medicationReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
+99
View File
@@ -0,0 +1,99 @@
import path from 'path';
import sharp from 'sharp';
import { listFlockNotes } from '../repositories/auditRepository.js';
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
import type { BirdRow } from '../types.js';
import { renderAdoptionReportPdf } from './adoptionReport.js';
const adoptionReportWeightHistoryDays = 14;
const parseDataImage = (value: string | null) => {
if (!value) {
return null;
}
const match = value.match(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,(.+)$/);
return match ? Buffer.from(match[1], 'base64') : null;
};
const normalizeReportPhotoBuffer = async (imageBuffer: Buffer | null) => {
if (!imageBuffer) {
return null;
}
try {
return await sharp(imageBuffer).rotate().png().toBuffer();
} catch (error) {
console.warn('Unable to normalize bird photo for adoption report:', error);
return null;
}
};
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
if (!bird.photo_object_key) {
return normalizeReportPhotoBuffer(parseDataImage(bird.photo_data_url));
}
const s3Config = getS3ImageStorageConfig();
if (!s3Config) {
return null;
}
const signedUrl = getSignedS3ObjectUrl({
config: s3Config,
objectKey: bird.photo_object_key,
expiresInSeconds: 5 * 60,
});
const imageResponse = await fetch(signedUrl);
if (!imageResponse.ok) {
return null;
}
return normalizeReportPhotoBuffer(Buffer.from(await imageResponse.arrayBuffer()));
};
export const renderAdoptionReportForBird = async ({
birdId,
workspaceId,
transferCode,
printFriendly,
}: {
birdId: string;
workspaceId: number;
transferCode: string;
printFriendly: boolean;
}) => {
const bird = await getBirdById(birdId, workspaceId);
if (!bird) {
throw new Error('Bird not found.');
}
const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([
listWeightsForBird(bird.id, workspaceId, adoptionReportWeightHistoryDays),
listVetVisitsForBird(bird.id, workspaceId),
listFlockNotes(workspaceId),
loadBirdReportPhotoBuffer(bird),
]);
const birdNotes = notes.filter((note) => note.bird_id === bird.id);
return renderAdoptionReportPdf({
bird,
weights,
vetVisits,
notes: birdNotes,
transferCode,
birdPhotoBuffer,
printFriendly,
assets: {
logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'),
wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'),
defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'),
},
});
};
+86 -8
View File
@@ -9,6 +9,8 @@ import type {
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationDoseScheduleItem, MedicationDoseScheduleItem,
MedicationReminderCandidateRow,
MedicationReminderDeliveryRow,
MedicationRow, MedicationRow,
PendingBirdTransferRow, PendingBirdTransferRow,
VetVisitRow, VetVisitRow,
@@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
const result = await db.query<MedicationReminderCandidateRow>(
`SELECT
${birdSelectFields},
workspaces.name AS workspace_name,
medications.id AS medication_id,
medications.name AS medication_name,
medications.dosage,
medications.frequency,
medications.dose_schedule,
medications.route,
medications.start_date::text AS medication_start_date,
medications.end_date::text AS medication_end_date,
medications.notes AS medication_notes,
$1::date::text AS scheduled_on,
dose.key AS administration_slot,
dose.label AS administration_label,
dose.time AS administration_time
FROM medications
INNER JOIN birds ON birds.id = medications.bird_id
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE medications.reminders_enabled = TRUE
AND birds.memorialized_at IS NULL
AND medications.start_date <= $1::date
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
AND dose.time <= $2
AND NOT EXISTS (
SELECT 1
FROM medication_reminder_deliveries deliveries
WHERE deliveries.medication_id = medications.id
AND deliveries.scheduled_on = $1::date
AND deliveries.administration_slot = dose.key
)
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
[runDate, currentTime],
);
return result.rows;
};
export const createMedicationReminderDelivery = async ({
medicationId,
birdId,
workspaceId,
scheduledOn,
administrationSlot,
}: {
medicationId: string;
birdId: string;
workspaceId: number;
scheduledOn: string;
administrationSlot: string;
}) => {
const result = await db.query<MedicationReminderDeliveryRow>(
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
);
return result.rows[0] ?? null;
};
export const createBird = async ({ export const createBird = async ({
birdId, birdId,
workspaceId, workspaceId,
@@ -886,7 +961,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes `SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
FROM medications FROM medications
WHERE bird_id = $1 WHERE bird_id = $1
AND EXISTS ( AND EXISTS (
@@ -912,12 +987,13 @@ export const createMedicationForBird = async (
startDate: string, startDate: string,
endDate: string | null, endDate: string | null,
notes: string | null, notes: string | null,
remindersEnabled: boolean,
) => { ) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes) `INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], [birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -934,6 +1010,7 @@ export const updateMedicationForBird = async (
startDate: string, startDate: string,
endDate: string | null, endDate: string | null,
notes: string | null, notes: string | null,
remindersEnabled: boolean,
) => { ) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`UPDATE medications `UPDATE medications
@@ -944,11 +1021,12 @@ export const updateMedicationForBird = async (
route = $7, route = $7,
start_date = $8, start_date = $8,
end_date = $9, end_date = $9,
notes = $10 notes = $10,
reminders_enabled = $11
WHERE id = $1 WHERE id = $1
AND bird_id = $2 AND bird_id = $2
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], [medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
+28
View File
@@ -231,6 +231,7 @@ export type MedicationRow = {
start_date: string; start_date: string;
end_date: string | null; end_date: string | null;
notes: string | null; notes: string | null;
reminders_enabled: boolean;
}; };
export type MedicationDoseScheduleItem = { export type MedicationDoseScheduleItem = {
@@ -239,6 +240,33 @@ export type MedicationDoseScheduleItem = {
time: string; time: string;
}; };
export type MedicationReminderCandidateRow = BirdRow & {
workspace_name: string;
medication_id: string;
medication_name: string;
dosage: string;
frequency: string;
dose_schedule: MedicationDoseScheduleItem[];
route: string | null;
medication_start_date: string;
medication_end_date: string | null;
medication_notes: string | null;
scheduled_on: string;
administration_slot: string;
administration_label: string;
administration_time: string;
};
export type MedicationReminderDeliveryRow = {
id: string;
medication_id: string;
bird_id: string;
workspace_id: number;
scheduled_on: string;
administration_slot: string;
delivered_at: string;
};
export type MedicationAdministrationRow = { export type MedicationAdministrationRow = {
id: string; id: string;
medication_id: string; medication_id: string;
+64 -1
View File
@@ -2,16 +2,36 @@ import { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { db } from './db/client.js'; import { db } from './db/client.js';
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js'; import {
runBirdMilestoneReminders,
runMedicationReminders,
startBirdMilestoneReminderScheduler,
startMedicationReminderScheduler,
} from './app.js';
import {
adoptionReportQueueName,
closeAdoptionReportQueue,
type AdoptionReportJobData,
type AdoptionReportJobResult,
} from './queues/adoptionReportQueue.js';
import { import {
birdMilestoneReminderQueueName, birdMilestoneReminderQueueName,
closeBirdMilestoneReminderQueue, closeBirdMilestoneReminderQueue,
type BirdMilestoneReminderJobData, type BirdMilestoneReminderJobData,
type BirdMilestoneReminderJobResult, type BirdMilestoneReminderJobResult,
} from './queues/birdMilestoneReminderQueue.js'; } from './queues/birdMilestoneReminderQueue.js';
import {
closeMedicationReminderQueue,
medicationReminderQueueName,
type MedicationReminderJobData,
type MedicationReminderJobResult,
} from './queues/medicationReminderQueue.js';
import { redisConnection } from './queues/redisConnection.js'; import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null; let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
const startWorker = async () => { const startWorker = async () => {
await ensureSchema(); await ensureSchema();
@@ -35,14 +55,57 @@ const startWorker = async () => {
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error); console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
}); });
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
medicationReminderQueueName,
async (job) => {
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
console.log(
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
);
return result;
},
{
connection: redisConnection,
concurrency: 1,
},
);
medicationReminderWorker.on('failed', (job, error) => {
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
});
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
adoptionReportQueueName,
async (job) => {
const pdf = await renderAdoptionReportForBird(job.data);
console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`);
return {
pdfBase64: pdf.toString('base64'),
};
},
{
connection: redisConnection,
concurrency: 1,
},
);
adoptionReportWorker.on('failed', (job, error) => {
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
});
startBirdMilestoneReminderScheduler(); startBirdMilestoneReminderScheduler();
startMedicationReminderScheduler();
console.log('FlockPal worker started.'); console.log('FlockPal worker started.');
}; };
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`); console.log(`FlockPal worker received ${signal}; shutting down.`);
await birdMilestoneWorker?.close(); await birdMilestoneWorker?.close();
await medicationReminderWorker?.close();
await adoptionReportWorker?.close();
await closeBirdMilestoneReminderQueue(); await closeBirdMilestoneReminderQueue();
await closeMedicationReminderQueue();
await closeAdoptionReportQueue();
await db.close(); await db.close();
process.exit(0); process.exit(0);
}; };
+3
View File
@@ -43,6 +43,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database} IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
S3_ENDPOINT: ${S3_ENDPOINT:-} S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-} S3_REGION: ${S3_REGION:-}
@@ -58,6 +59,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -138,6 +140,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
+3
View File
@@ -41,6 +41,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database} IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
S3_ENDPOINT: ${S3_ENDPOINT:-} S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-} S3_REGION: ${S3_REGION:-}
@@ -56,6 +57,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -131,6 +133,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
+28
View File
@@ -75,6 +75,7 @@ type Medication = {
startDate: string; startDate: string;
endDate: string | null; endDate: string | null;
notes: string | null; notes: string | null;
remindersEnabled: boolean;
}; };
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed'; type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
@@ -1644,6 +1645,7 @@ function App() {
startDate: new Date().toISOString().slice(0, 10), startDate: new Date().toISOString().slice(0, 10),
endDate: '', endDate: '',
notes: '', notes: '',
remindersEnabled: false,
}); });
const [flockTransferForm, setFlockTransferForm] = useState({ const [flockTransferForm, setFlockTransferForm] = useState({
birdId: '', birdId: '',
@@ -3927,6 +3929,7 @@ function App() {
startDate: new Date().toISOString().slice(0, 10), startDate: new Date().toISOString().slice(0, 10),
endDate: '', endDate: '',
notes: '', notes: '',
remindersEnabled: false,
}); });
setEditingMedicationId(''); setEditingMedicationId('');
} catch (submitError) { } catch (submitError) {
@@ -3946,6 +3949,7 @@ function App() {
startDate: medication.startDate, startDate: medication.startDate,
endDate: medication.endDate ?? '', endDate: medication.endDate ?? '',
notes: medication.notes ?? '', notes: medication.notes ?? '',
remindersEnabled: medication.remindersEnabled,
}); });
setError(''); setError('');
}; };
@@ -3961,6 +3965,7 @@ function App() {
startDate: new Date().toISOString().slice(0, 10), startDate: new Date().toISOString().slice(0, 10),
endDate: '', endDate: '',
notes: '', notes: '',
remindersEnabled: false,
}); });
}; };
@@ -5262,6 +5267,7 @@ function App() {
<small> <small>
{formatDate(medication.startDate)} to {formatDate(medication.endDate)} {formatDate(medication.startDate)} to {formatDate(medication.endDate)}
</small> </small>
<small>{medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'}</small>
<small>{medication.notes || 'No notes recorded.'}</small> <small>{medication.notes || 'No notes recorded.'}</small>
{latestAdministration ? ( {latestAdministration ? (
<small> <small>
@@ -6499,6 +6505,17 @@ function App() {
))} ))}
</div> </div>
</label> </label>
<label className="checkbox-row wide-field">
<input
type="checkbox"
checked={medicationForm.remindersEnabled}
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
/>
<span>
<strong>Send medication reminders</strong>
<small>Uses the medication frequency, dose times, and start/end dates.</small>
</span>
</label>
<label className="wide-field"> <label className="wide-field">
Notes Notes
<textarea <textarea
@@ -8490,6 +8507,17 @@ function App() {
))} ))}
</div> </div>
</label> </label>
<label className="checkbox-row wide-field">
<input
type="checkbox"
checked={medicationForm.remindersEnabled}
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
/>
<span>
<strong>Send medication reminders</strong>
<small>Uses the medication frequency, dose times, and start/end dates.</small>
</span>
</label>
<label className="wide-field"> <label className="wide-field">
Notes Notes
<textarea <textarea