diff --git a/backend/package-lock.json b/backend/package-lock.json index 10d459b..361494d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "pdfkit": "^0.18.0", "pg": "8.13.1", "qrcode": "^1.5.4", + "sharp": "^0.34.5", "stripe": "^22.0.2", "zod": "3.24.1" }, @@ -35,6 +36,16 @@ "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": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -443,6 +454,471 @@ "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": { "version": "1.5.1", "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", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -2383,6 +2858,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 414f645..d904c9d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "pdfkit": "^0.18.0", "pg": "8.13.1", "qrcode": "^1.5.4", + "sharp": "^0.34.5", "stripe": "^22.0.2", "zod": "3.24.1" }, diff --git a/backend/src/app.ts b/backend/src/app.ts index 1be8569..fc7a4a7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,7 +13,9 @@ import Stripe from 'stripe'; import { z } from 'zod'; import { ensureSchema } from './db/schema.js'; +import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js'; import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js'; +import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js'; import { consumeMagicLinkToken, consumeOAuthState, @@ -35,6 +37,7 @@ import { completePendingBirdTransfersForOwner, createBird, createBirdMilestoneReminderDelivery, + createMedicationReminderDelivery, createBirdTransferCode, createMedicationForBird, createPendingBirdTransfer, @@ -49,6 +52,7 @@ import { getOpenBirdTransferCode, listBirds, listDueBirdMilestoneReminders, + listDueMedicationReminders, listMemorializedBirds, listMedicationAdministrationsForBird, listMedicationsForBird, @@ -64,7 +68,6 @@ import { updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; -import { renderAdoptionReportPdf } from './reports/adoptionReport.js'; import { createAuditLogEntry, createFlockNote, @@ -131,8 +134,9 @@ import type { FlockNoteRow, IntegrationTokenRow, LostBirdMatchRow, - MedicationRow, MedicationAdministrationRow, + MedicationReminderCandidateRow, + MedicationRow, ProviderKey, RescueVerificationStatus, SubscriptionStatus, @@ -161,10 +165,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000'; const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`; const sessionDays = 30; 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 medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; const milestoneReminderCheckIntervalMs = 60 * 60 * 1000; -const adoptionReportWeightHistoryDays = 425; const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy'; if (trustProxy) { @@ -338,6 +343,7 @@ const medicationSchema = z startDate: dateStringSchema, endDate: dateStringSchema.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, { message: 'End date must be on or after start date.', @@ -746,6 +752,7 @@ const normalizeMedication = (row: MedicationRow) => ({ startDate: row.start_date, endDate: row.end_date, notes: row.notes, + remindersEnabled: row.reminders_enabled, }); const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({ @@ -1224,6 +1231,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo 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 remainder = value % 100; 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 ({ reminder, recipients, @@ -1868,6 +1914,120 @@ const sendBirdMilestoneReminderNotification = async ({ 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 = []; + 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 + ? `${escapeHtml(reminder.name)}` + : `
${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}
`; + const medicationNotesHtml = reminder.medication_notes + ? `

Medication notes: ${escapeHtml(reminder.medication_notes)}

` + : ''; + 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: ` +
+
+ +
+
+
+ ${ + logoAttachment + ? 'FlockPal' + : 'FlockPal' + } +
+
+ + + + + +
+ ${birdPhotoHtml} + +

${escapeHtml(copy.eyebrow)}

+

${escapeHtml(copy.headline)}

+

${escapeHtml(copy.intro)}

+
+

${escapeHtml(copy.body)}

+

Schedule: ${escapeHtml(copy.detailLabel)}

+ ${medicationNotesHtml} +

+ Open FlockPal +

+
+
+
+ +
+
+ `, + }); + + return { delivered: true }; +}; + export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => { const reminders = await listDueBirdMilestoneReminders(runDate); 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 lastMedicationReminderRunKey = ''; export const startBirdMilestoneReminderScheduler = () => { if (!milestoneRemindersEnabled) { @@ -1946,6 +2152,42 @@ export const startBirdMilestoneReminderScheduler = () => { }, 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) => { if (!authorizationHeader) { return ''; @@ -2097,6 +2339,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re try { const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts(); + const medicationReminderQueueCounts = await getMedicationReminderQueueCounts(); res.json({ startedAt: requestMetrics.startedAt, @@ -2118,6 +2361,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re }, queues: { birdMilestoneReminders: birdMilestoneReminderQueueCounts, + medicationReminders: medicationReminderQueueCounts, }, }); } catch (error) { @@ -3599,27 +3843,15 @@ app.post( throw new Error('Unable to create bird transfer code.'); } - const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([ - listWeightsForBird(sourceBird.id, req.auth!.workspace.id, adoptionReportWeightHistoryDays), - listVetVisitsForBird(sourceBird.id, req.auth!.workspace.id), - listFlockNotes(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, + await adoptionReportQueueEvents.waitUntilReady(); + const reportJob = await enqueueAdoptionReportJob({ + birdId: sourceBird.id, + workspaceId: req.auth!.workspace.id, transferCode: transferCode.code, - birdPhotoBuffer, 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, { transferCodeId: transferCode.id, @@ -4031,6 +4263,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ parsed.data.startDate, emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.notes), + parsed.data.remindersEnabled ?? false, ); 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, emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.notes), + parsed.data.remindersEnabled ?? false, ); if (!medication) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 9cd346c..7c8ca11 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -471,6 +471,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { start_date DATE NOT NULL, end_date DATE, notes VARCHAR(1000), + reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (end_date IS NULL OR end_date >= start_date) ); @@ -478,6 +479,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE medications 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 ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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 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 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 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 ON medication_administrations (bird_id, administered_on DESC); diff --git a/backend/src/queues/adoptionReportQueue.ts b/backend/src/queues/adoptionReportQueue.ts new file mode 100644 index 0000000..a1c60d6 --- /dev/null +++ b/backend/src/queues/adoptionReportQueue.ts @@ -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(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> => adoptionReportQueue.add('render-adoption-report', data); + +export const closeAdoptionReportQueue = async () => { + await adoptionReportQueue.close(); + await adoptionReportQueueEvents.close(); +}; diff --git a/backend/src/queues/medicationReminderQueue.ts b/backend/src/queues/medicationReminderQueue.ts new file mode 100644 index 0000000..d6b831d --- /dev/null +++ b/backend/src/queues/medicationReminderQueue.ts @@ -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(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> => + 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'); diff --git a/backend/src/reports/adoptionReportJob.ts b/backend/src/reports/adoptionReportJob.ts new file mode 100644 index 0000000..f854e86 --- /dev/null +++ b/backend/src/reports/adoptionReportJob.ts @@ -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'), + }, + }); +}; diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index c3733dd..a1d4a0b 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -9,6 +9,8 @@ import type { LostBirdMatchRow, MedicationAdministrationRow, MedicationDoseScheduleItem, + MedicationReminderCandidateRow, + MedicationReminderDeliveryRow, MedicationRow, PendingBirdTransferRow, VetVisitRow, @@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({ return result.rows[0] ?? null; }; +export const listDueMedicationReminders = async (runDate: string, currentTime: string) => { + const result = await db.query( + `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( + `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 ({ birdId, workspaceId, @@ -886,7 +961,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) => export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { const result = await db.query( - `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 WHERE bird_id = $1 AND EXISTS ( @@ -912,12 +987,13 @@ export const createMedicationForBird = async ( startDate: string, endDate: string | null, notes: string | null, + remindersEnabled: boolean, ) => { const result = await db.query( - `INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, - [birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, 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, $10) + 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, remindersEnabled], ); return result.rows[0] ?? null; @@ -934,6 +1010,7 @@ export const updateMedicationForBird = async ( startDate: string, endDate: string | null, notes: string | null, + remindersEnabled: boolean, ) => { const result = await db.query( `UPDATE medications @@ -944,11 +1021,12 @@ export const updateMedicationForBird = async ( route = $7, start_date = $8, end_date = $9, - notes = $10 + notes = $10, + reminders_enabled = $11 WHERE id = $1 AND bird_id = $2 - RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, - [medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, 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, remindersEnabled], ); return result.rows[0] ?? null; diff --git a/backend/src/types.ts b/backend/src/types.ts index f12dd86..35a5a38 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -231,6 +231,7 @@ export type MedicationRow = { start_date: string; end_date: string | null; notes: string | null; + reminders_enabled: boolean; }; export type MedicationDoseScheduleItem = { @@ -239,6 +240,33 @@ export type MedicationDoseScheduleItem = { 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 = { id: string; medication_id: string; diff --git a/backend/src/worker.ts b/backend/src/worker.ts index f0f435c..7611d0e 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -2,16 +2,36 @@ import { Worker } from 'bullmq'; import { ensureSchema } from './db/schema.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 { birdMilestoneReminderQueueName, closeBirdMilestoneReminderQueue, type BirdMilestoneReminderJobData, type BirdMilestoneReminderJobResult, } from './queues/birdMilestoneReminderQueue.js'; +import { + closeMedicationReminderQueue, + medicationReminderQueueName, + type MedicationReminderJobData, + type MedicationReminderJobResult, +} from './queues/medicationReminderQueue.js'; import { redisConnection } from './queues/redisConnection.js'; +import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; let birdMilestoneWorker: Worker | null = null; +let medicationReminderWorker: Worker | null = null; +let adoptionReportWorker: Worker | null = null; const startWorker = async () => { await ensureSchema(); @@ -35,14 +55,57 @@ const startWorker = async () => { console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error); }); + medicationReminderWorker = new Worker( + 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( + 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(); + startMedicationReminderScheduler(); console.log('FlockPal worker started.'); }; const shutdown = async (signal: string) => { console.log(`FlockPal worker received ${signal}; shutting down.`); await birdMilestoneWorker?.close(); + await medicationReminderWorker?.close(); + await adoptionReportWorker?.close(); await closeBirdMilestoneReminderQueue(); + await closeMedicationReminderQueue(); + await closeAdoptionReportQueue(); await db.close(); process.exit(0); }; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 78a6a71..618df0e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -43,6 +43,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production} 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} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-} @@ -58,6 +59,7 @@ services: 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} 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} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} @@ -138,6 +140,7 @@ services: 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} 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} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} diff --git a/docker-compose.yml b/docker-compose.yml index dc25cec..15fe01c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password} 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} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-} @@ -56,6 +57,7 @@ services: 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} 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} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} @@ -131,6 +133,7 @@ services: 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} 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} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f32a396..8774d28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,6 +75,7 @@ type Medication = { startDate: string; endDate: string | null; notes: string | null; + remindersEnabled: boolean; }; 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), endDate: '', notes: '', + remindersEnabled: false, }); const [flockTransferForm, setFlockTransferForm] = useState({ birdId: '', @@ -3927,6 +3929,7 @@ function App() { startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', + remindersEnabled: false, }); setEditingMedicationId(''); } catch (submitError) { @@ -3946,6 +3949,7 @@ function App() { startDate: medication.startDate, endDate: medication.endDate ?? '', notes: medication.notes ?? '', + remindersEnabled: medication.remindersEnabled, }); setError(''); }; @@ -3961,6 +3965,7 @@ function App() { startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', + remindersEnabled: false, }); }; @@ -5262,6 +5267,7 @@ function App() { {formatDate(medication.startDate)} to {formatDate(medication.endDate)} + {medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'} {medication.notes || 'No notes recorded.'} {latestAdministration ? ( @@ -6499,6 +6505,17 @@ function App() { ))} +