diff --git a/backend/package-lock.json b/backend/package-lock.json index 85efbd35..0b2b80e0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -178,7 +178,6 @@ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -199,7 +198,6 @@ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.28.5" }, @@ -216,7 +214,6 @@ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -1057,6 +1054,7 @@ "integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", @@ -1252,6 +1250,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1392,6 +1391,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -1623,7 +1623,6 @@ "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", @@ -1638,7 +1637,6 @@ "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" @@ -1669,7 +1667,6 @@ "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/shared": "3.5.26" @@ -1680,8 +1677,7 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/abort-controller": { "version": "3.0.0", @@ -1715,6 +1711,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2037,6 +2034,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2785,7 +2783,6 @@ "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -2886,6 +2883,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3609,8 +3607,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -4900,6 +4897,7 @@ "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -5189,7 +5187,6 @@ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -6478,7 +6475,6 @@ } ], "license": "MIT", - "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -7002,7 +6998,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7625,7 +7620,6 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8226,6 +8220,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8413,6 +8408,7 @@ "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", diff --git a/backend/src/controllers/messageController.ts b/backend/src/controllers/messageController.ts index 797fd71b..30a1f859 100644 --- a/backend/src/controllers/messageController.ts +++ b/backend/src/controllers/messageController.ts @@ -115,7 +115,9 @@ export const sendEmails = async (req: Request, res: Response, next: NextFunction } }; -const defaultPopulateConfig = [{ path: "recipients", select: "firstName lastName" }]; +const defaultPopulateConfig = [ + { path: "recipients", select: "firstName lastName email phoneNumber" }, +]; export const getMessages: RequestHandler = async (req, res, next) => { const errors = validationResult(req); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68b66e0e..ff78b13e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -215,6 +215,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1317,6 +1318,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.8.tgz", "integrity": "sha512-WiE9uCGRLUnShdjb9iP20sA3ToWrBbNXr14/N5mow7Nls9dmKgfGaGX5cynLvrltxq2OrDLh1VDNaUgsnS/k/g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.7.0", "@firebase/logger": "0.5.0", @@ -1383,6 +1385,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.8.tgz", "integrity": "sha512-4De6SUZ36zozl9kh5rZSxKWULpgty27rMzZ6x+xkoo7+NWyhWyFdsdvhFsWhTw/9GGj0wXIcbTjwHYCUIUuHyg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.14.8", "@firebase/component": "0.7.0", @@ -1398,7 +1401,8 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@firebase/auth": { "version": "1.12.0", @@ -1849,6 +1853,7 @@ "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2486,6 +2491,7 @@ "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-glob": "3.3.1" } @@ -2769,6 +2775,7 @@ "integrity": "sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.52.0", @@ -2867,6 +2874,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2894,6 +2902,7 @@ "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", @@ -2923,6 +2932,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -3423,7 +3433,6 @@ "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", @@ -3438,7 +3447,6 @@ "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" @@ -3450,7 +3458,6 @@ "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.26", @@ -3483,7 +3490,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3499,7 +3505,6 @@ "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/shared": "3.5.26" @@ -3510,8 +3515,7 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.15.0", @@ -3519,6 +3523,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3896,6 +3901,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4468,7 +4474,6 @@ "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -4734,6 +4739,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5066,6 +5072,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5250,6 +5257,7 @@ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -5444,6 +5452,7 @@ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", @@ -5840,8 +5849,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -7072,6 +7080,7 @@ "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -7276,7 +7285,6 @@ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -8812,9 +8820,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -8888,6 +8896,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8897,6 +8906,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9996,6 +10006,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10208,6 +10219,7 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -10533,6 +10545,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/app/api/messages.ts b/frontend/src/app/api/messages.ts new file mode 100644 index 00000000..93fe2638 --- /dev/null +++ b/frontend/src/app/api/messages.ts @@ -0,0 +1,84 @@ +import { get } from "./requests"; + +import type { APIResult } from "./requests"; + +export type MessageReponse = { + messages: Message[]; +}; + +export type Recipient = { + _id: string; + firstName?: string; + lastName?: string; + email?: string; + phoneNumber?: string; + created?: string; + updated?: string; +}; + +export type Message = { + _id: string; + subject: string | null; + body: string; + type: "text" | "email"; + recipients: Recipient[]; + status: "pending" | "sent"; + timestamp: string; +}; + +export type MessageCreationProps = { + subject: string; + date: Date; + type: "text" | "email"; + message: string; + recipients: string[]; +}; + +function isRecipient(value: unknown): value is Recipient { + if (!value || typeof value !== "object") return false; + const c = value as Partial; + return typeof c._id === "string"; +} + +function isMessageResponse(value: unknown): value is Message { + if (!value || typeof value !== "object") return false; + const c = value as Partial; + return ( + typeof c._id === "string" && + (typeof c.subject === "string" || c.subject === null) && + typeof c.body === "string" && + (c.type === "text" || c.type === "email") && + Array.isArray(c.recipients) && + c.recipients.every((r) => isRecipient(r)) && + (c.status === "pending" || c.status === "sent") && + typeof c.timestamp === "string" + ); +} + +function isMessagesResponse(value: unknown): value is MessageReponse { + if (!value || typeof value !== "object") return false; + const c = value as Partial; + return Array.isArray(c.messages) && c.messages.every((m) => isMessageResponse(m)); +} + +export const getMessages = async (): Promise> => { + try { + const response = await get("/api/message"); + if (!response.ok) { + return { success: false, error: await response.text() }; + } + const responseJson: unknown = await response.json(); + + if (!isMessagesResponse(responseJson)) { + return { + success: false, + error: `Unexpected message response format: ${JSON.stringify(responseJson)}`, + }; + } + + const { messages } = responseJson; + return { success: true, data: messages }; + } catch (_error) { + return { success: false, error: "An unexpected error occurred" }; + } +}; diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 995bc0cd..0c8b5219 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -15,6 +15,8 @@ import mailAsset from "@/assets/mail.svg"; import { initMsal, signInWithOutlook } from "@/auth/msal"; import LogoutButton from "@/components/LogoutButton"; import LogoutModal from "@/components/LogoutModal"; +// Temp for hardcoded data +import { MessageHistory } from "@/components/MessageHistorySections"; import Sidebar from "@/components/Sidebar"; import { auth } from "@/firebase/firebase"; @@ -114,6 +116,7 @@ export default function Dashboard() { })} + setShowLogoutModal(true)} /> {showLogoutModal && ( - - + + + \ No newline at end of file diff --git a/frontend/src/assets/ic_copy.svg b/frontend/src/assets/ic_copy.svg new file mode 100644 index 00000000..0722f2c0 --- /dev/null +++ b/frontend/src/assets/ic_copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/ic_edit.svg b/frontend/src/assets/ic_edit.svg new file mode 100644 index 00000000..7ea5a516 --- /dev/null +++ b/frontend/src/assets/ic_edit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/ic_volunteers_white.svg b/frontend/src/assets/ic_volunteers_white.svg new file mode 100644 index 00000000..766c061e --- /dev/null +++ b/frontend/src/assets/ic_volunteers_white.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ImportVolunteerModal.tsx b/frontend/src/components/ImportVolunteerModal.tsx index d7c5d25e..de50fe27 100644 --- a/frontend/src/components/ImportVolunteerModal.tsx +++ b/frontend/src/components/ImportVolunteerModal.tsx @@ -78,7 +78,7 @@ export default function ImportVolunteerModal({ onClose, onComplete }: ImportVolu tags: volunteer.tags, })), ] as ParsedVolunteerChange[], - } as ParsedCSVResult; + }; setCSVParsedInfo(data); setStatus("success"); diff --git a/frontend/src/components/MessageHistoryModal.module.css b/frontend/src/components/MessageHistoryModal.module.css new file mode 100644 index 00000000..b22becef --- /dev/null +++ b/frontend/src/components/MessageHistoryModal.module.css @@ -0,0 +1,167 @@ +.content { + display: flex; + flex-direction: column; + gap: 8px; + margin: 32px 0; +} + +.recipientsBar { + display: flex; + gap: 16px; + align-items: center; + flex-direction: row; + justify-content: flex-start; + padding: 16px; + + border-radius: 8px; + border: 1px solid var(--Lofi-Gray-2, #b9b9b9); + background: var(--Surface-page, #fbffff); +} + +.recipientsBarText { + color: #000; + + /* Body/md */ + font-family: var(--Font-family-Body, "Open Sans"); + font-size: var(--paragraph-md-font-size, 16px); + font-style: normal; + font-weight: 400; + line-height: var(--paragraph-md-line-height, 20px); /* 125% */ + letter-spacing: var(--paragraph-md-letter-spacing, 0.5px); +} + +.caretWrap { + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + + position: relative; +} + +.iconBackground { + display: flex; + width: 34px; + height: 34px; + padding: 8px; + align-items: center; + gap: 10px; + + border-radius: 100px; + background: var(--Surface-action, #1d3a6b); +} + +.dropdown { + display: flex; + flex-direction: row; + padding: 16px 24px; + justify-content: flex-start; + gap: 40px; + max-height: 220px; + height: auto; + + border-radius: 16px; + border: 0.2px solid var(--Border-focus, #0c182d); + background: var(--Surface-page, #fbffff); + + /* Level 4 */ + box-shadow: 0 15px 25px 0 rgba(0, 0, 0, 0.25); + + position: absolute; + top: calc(100% + 3.5px); + left: 0px; +} + +.toLabel { + color: var(--Text-gray, #676767); + + /* Body/md */ + font-family: var(--Font-family-Body, "Open Sans"); + font-size: var(--paragraph-md-font-size, 16px); + font-style: normal; + font-weight: 400; + line-height: var(--paragraph-md-line-height, 20px); /* 125% */ + letter-spacing: var(--paragraph-md-letter-spacing, 0.5px); +} + +.emails { + color: var(--Text-body, #1c1c1c); + + /* Body/md */ + font-family: var(--Font-family-Body, "Open Sans"); + font-size: var(--paragraph-md-font-size, 16px); + font-style: normal; + font-weight: 400; + line-height: var(--paragraph-md-line-height, 20px); /* 125% */ + letter-spacing: var(--paragraph-md-letter-spacing, 0.5px); + + overflow-y: auto; +} + +.text { + color: var(--Text-headings, #141414); + + /* Body/md */ + font-family: var(--Font-family-Body, "Open Sans"); + font-size: var(--paragraph-md-font-size, 16px); + font-style: normal; + font-weight: 400; + line-height: var(--paragraph-md-line-height, 20px); /* 125% */ + letter-spacing: var(--paragraph-md-letter-spacing, 0.5px); + padding: 24px; + + border-radius: 8px; + border: 1px solid var(--Lofi-Gray-2, #b9b9b9); + background: var(--Surface-page, #fbffff); +} + +.body { + min-height: 128px; +} + +.buttons { + display: flex; + flex-direction: row; + gap: 8px; + justify-content: flex-start; + width: 326px; +} + +.secondary { + display: flex; + justify-content: center; + align-items: center; + padding: 8px 12px; + height: 48px; + /* width: 100%; */ + border: 1px solid #1d3a6b; + border-radius: 4px; + background: transparent; + font-family: "Open Sans"; + font-size: 12px; + line-height: 16px; + color: #1d3a6b; + cursor: pointer; + min-width: 159px; +} + +.primary { + display: flex; + height: 48px; + padding: 8px 12px; + justify-content: center; + align-items: center; + gap: 16px; + border-radius: 4px; + background: var(--Surface-action, #1d3a6b); + + color: var(--Neutral-White, #fff); + text-align: center; + font-family: "Open Sans"; + font-size: var(--paragraph-xsm-font-size, 12px); + font-style: normal; + font-weight: 400; + line-height: var(--paragraph-xsm-line-height, 16px); /* 133.333% */ + min-width: 159px; +} diff --git a/frontend/src/components/MessageHistoryModal.tsx b/frontend/src/components/MessageHistoryModal.tsx new file mode 100644 index 00000000..b537f18f --- /dev/null +++ b/frontend/src/components/MessageHistoryModal.tsx @@ -0,0 +1,130 @@ +import Image from "next/image"; +import { useState } from "react"; + +import styles from "./MessageHistoryModal.module.css"; +import Modal from "./Modal"; + +import type { Message } from "@/app/api/messages"; + +import icCaretdownAsset from "@/assets/ic_caretdown.svg"; +import icCopyAsset from "@/assets/ic_copy.svg"; +import icEditAsset from "@/assets/ic_edit.svg"; +import icVolunteersWhiteAsset from "@/assets/ic_volunteers_white.svg"; + +const icVolunteersWhite = icVolunteersWhiteAsset as string; +const icCaretdown = icCaretdownAsset as string; +const icEdit = icEditAsset as string; +const icCopy = icCopyAsset as string; + +type MessageHistoryModalProps = { + message: Message; + onClose: () => void; + onActionButton: () => void; +}; + +// can move these to /apis for text/email history when added +const dateFormatter = new Intl.DateTimeFormat("en-US", { + month: "numeric", + day: "numeric", + year: "2-digit", +}); + +const timeFormatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, +}); + +export function MessageHistoryModal({ + message, + onClose, + onActionButton, +}: MessageHistoryModalProps) { + const [recipientDropDown, setRecipientDropDown] = useState(false); + + const getFormattedTimestamp = () => { + if (!message.timestamp) return "Unknown"; + const date = new Date(message.timestamp); + if (Number.isNaN(date.getTime())) return "Invalid date"; + return `${dateFormatter.format(date)} @ ${timeFormatter.format(date)}`; + }; + + return ( + { + setRecipientDropDown(false); + }} + width="1050px" + radius="12px" + title={message.type === "email" ? "Email Message" : "Text Message"} + subtitle={ + message.status === "pending" + ? `Scheduled to Send on ${getFormattedTimestamp()}` + : `Sent on ${getFormattedTimestamp()}` + } + titleFontSize="32px" + titleLineHeight={40} + padding="28px" + > +
+
+ Volunteers + + {message.recipients.length} Volunteer{message.recipients.length === 1 ? "" : "s"} + +
{ + e.stopPropagation(); + setRecipientDropDown(!recipientDropDown); + }} + > + {"Caret + {recipientDropDown && ( +
{ + e.stopPropagation(); + }} + > + to: + + {message.recipients.map((recipient, i) => ( +
+ {recipient.email || recipient.phoneNumber || "Unknown Recipient"} +
+ ))} +
+
+ )} +
+
+ {message.type === "email" &&
{message.subject}
} +
{message.body}
+
+
+ + {message.status === "pending" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/MessageHistorySections.module.css b/frontend/src/components/MessageHistorySections.module.css new file mode 100644 index 00000000..e1129c98 --- /dev/null +++ b/frontend/src/components/MessageHistorySections.module.css @@ -0,0 +1,214 @@ +.messageSection { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + width: 100%; + min-width: 0; +} + +.messageSectionHeader { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sectionTitle { + color: #000; + font-family: var(--Font-family-Headings, Viga); + font-size: 24px; + font-style: normal; + font-weight: 400; + line-height: 24px; + letter-spacing: 1.2px; +} + +.sectionSubtitle { + color: #676767; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.5px; +} + +/* Toggle */ + +.toggleContainer { + display: flex; + flex-direction: row; + align-items: center; + padding: 4px; + gap: 8px; + background: #ecf3f3; + border-radius: 8px; + align-self: stretch; +} + +.toggleTab { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 8px 0; + flex-grow: 1; + height: 36px; + background: #ecf3f3; + border-radius: 4px; + border: none; + cursor: pointer; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.5px; + color: #676767; +} + +.toggleTabActive { + background: #ffffff; + font-weight: 700; + color: #1d3a6b; +} + +/* Message list */ + +.messageList { + display: flex; + flex-direction: column; + align-self: stretch; + border-radius: 16px; + border: 1px solid #b9b9b9; + overflow: hidden; +} + +.messageRow { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + padding: 12px 20px; + background: #fbffff; + border-bottom: 1px solid #b9b9b9; + cursor: pointer; +} + +.messageRow:last-child { + border-bottom: none; +} + +.messageIconWrapper { + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 48px; + padding: 12px; + background: #e6f2f3; + border-radius: 60.8108px; + flex-shrink: 0; +} + +.messageInfo { + display: flex; + flex-direction: row; + align-items: center; + gap: 40px; + flex-grow: 1; + min-width: 0; +} + +.messageSubjectCol { + width: 200px; + flex-shrink: 0; + min-width: 0; +} + +.messageSubject { + display: block; + color: #141414; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.messagemessageCol { + flex-grow: 1; + min-width: 0; +} + +.messagemessage { + display: block; + color: #676767; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.messagePreviewCol { + flex-grow: 1; + min-width: 0; +} + +.messagePreview { + display: block; + color: #676767; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.messageDate { + color: #1c1c1c; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.8px; + white-space: nowrap; + flex-shrink: 0; +} + +/* View history */ + +.viewHistoryRow { + display: flex; + justify-content: flex-end; + align-self: stretch; +} + +.viewHistoryLink { + color: #676767; + font-family: var(--Font-family-Body, "Open Sans"); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.5px; + text-decoration: underline; +} + +.viewHistoryLink:hover { + color: #141414; +} diff --git a/frontend/src/components/MessageHistorySections.tsx b/frontend/src/components/MessageHistorySections.tsx new file mode 100644 index 00000000..95065f4c --- /dev/null +++ b/frontend/src/components/MessageHistorySections.tsx @@ -0,0 +1,121 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { MessageHistoryModal } from "./MessageHistoryModal"; +import styles from "./MessageHistorySections.module.css"; + +import type { Message } from "@/app/api/messages"; + +import { getMessages } from "@/app/api/messages"; +import messageAsset from "@/assets/ic_message.svg"; +import mailAsset from "@/assets/mail.svg"; + +const messageIcon = messageAsset as string; +const mailIcon = mailAsset as string; + +const formatMessageDate = (date: string | Date): string => { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "2-digit", + }); +}; + +function MessageRow({ message, onClick }: { message: Message; onClick: () => void }) { + const iconSrc = message.type === "text" ? messageIcon : mailIcon; + return ( +
+
+ +
+
+
+ + {message.subject || (message.type === "text" ? "[Text Message]" : "(No subject)")} + +
+
+ {message.body} +
+ {formatMessageDate(message.timestamp)} +
+
+ ); +} + +export function MessageHistory() { + const [selectedMessage, setSelectedMessage] = useState(undefined); + const [activeTab, setActiveTab] = useState<"sent" | "scheduled">("scheduled"); + + const [messages, setMessages] = useState([]); + + const fetchMessages = () => { + getMessages() + .then((result) => { + if (result.success) { + setMessages(result.data); + } else { + console.error(result.error); + } + }) + .catch((error) => { + console.error(error); + }); + }; + + useEffect(() => { + fetchMessages(); + }, []); + + return ( +
+
+

Message History

+

See an overview of the messages to be sent

+
+
+ + +
+
+ {messages + .slice(0, 5) + .filter((m) => (activeTab === "sent" ? m.status === "sent" : m.status === "pending")) + .map((msg) => ( + setSelectedMessage(msg)} /> + ))} +
+
+ + View Entire History + +
+ {selectedMessage && ( + { + setSelectedMessage(undefined); + }} + onActionButton={() => { + // go to edit message flow + console.info("Go to edit message flow"); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/components/Modal.module.css b/frontend/src/components/Modal.module.css index d7e6d4f7..4e3ee7a5 100644 --- a/frontend/src/components/Modal.module.css +++ b/frontend/src/components/Modal.module.css @@ -32,9 +32,15 @@ width: 100%; } +.titles { + display: flex; + flex-direction: column; + gap: 4px; +} + .title { flex: 1; - font-family: "Open Sans"; + font-family: var(--Font-family-Headings, Viga); font-weight: 400; display: flex; align-items: center; @@ -45,6 +51,18 @@ text-overflow: ellipsis; } +.subtitle { + color: var(--Text-gray, #676767); + + /* Body/sm */ + font-family: var(--Font-family-Body, "Open Sans"); + font-size: var(--paragraph-sm-font-size, 14px); + font-style: normal; + font-weight: 400; + line-height: var(--paragraph-sm-line-height, 20px); /* 142.857% */ + letter-spacing: var(--paragraph-sm-letter-spacing, 0.8px); +} + .close { display: flex; align-items: center; diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index acd86748..09081bd8 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -10,9 +10,11 @@ const icCloseLarge = icCloseLargeAsset as string; type ModalProps = { onClose: () => void; + onClick?: () => void; width: string; radius: string; title: string; + subtitle?: string; titleLineHeight: number; titleFontSize: string; padding: string; @@ -21,9 +23,11 @@ type ModalProps = { export default function Modal({ onClose, + onClick, width, radius, title, + subtitle, titleLineHeight, titleFontSize, padding, @@ -34,13 +38,24 @@ export default function Modal({
e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + onClick?.(); + }} >
-

- {title} -

-