add email notifications
This commit is contained in:
parent
0584f160b2
commit
5d92bcfd1b
10 changed files with 1731 additions and 1 deletions
1
.cursorignore
Normal file
1
.cursorignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
25
bun.lock
25
bun.lock
|
@ -36,6 +36,7 @@
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"rehype-expressive-code": "^0.40.2",
|
"rehype-expressive-code": "^0.40.2",
|
||||||
|
"resend": "^4.5.1",
|
||||||
"tailwindcss": "^3.4.16",
|
"tailwindcss": "^3.4.16",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -511,6 +512,8 @@
|
||||||
|
|
||||||
"@react-aria/visually-hidden": ["@react-aria/visually-hidden@3.8.20", "", { "dependencies": { "@react-aria/interactions": "^3.24.0", "@react-aria/utils": "^3.28.0", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Y7JbrpheUhNgnJWogDWxuxxiWAnuaW9MKOUY5vD3KOa+vEWuc2IBOGSzOOUkAGnVP4L2rvaHeZIuR5flqyeskA=="],
|
"@react-aria/visually-hidden": ["@react-aria/visually-hidden@3.8.20", "", { "dependencies": { "@react-aria/interactions": "^3.24.0", "@react-aria/utils": "^3.28.0", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Y7JbrpheUhNgnJWogDWxuxxiWAnuaW9MKOUY5vD3KOa+vEWuc2IBOGSzOOUkAGnVP4L2rvaHeZIuR5flqyeskA=="],
|
||||||
|
|
||||||
|
"@react-email/render": ["@react-email/render@1.0.6", "", { "dependencies": { "html-to-text": "9.0.5", "prettier": "3.5.3", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ=="],
|
||||||
|
|
||||||
"@react-stately/calendar": ["@react-stately/calendar@3.7.1", "", { "dependencies": { "@internationalized/date": "^3.7.0", "@react-stately/utils": "^3.10.5", "@react-types/calendar": "^3.6.1", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-DXsJv2Xm1BOqJAx5846TmTG1IZ0oKrBqYAzWZG7hiDq3rPjYGgKtC/iJg9MUev6pHhoZlP9fdRCNFiCfzm5bLQ=="],
|
"@react-stately/calendar": ["@react-stately/calendar@3.7.1", "", { "dependencies": { "@internationalized/date": "^3.7.0", "@react-stately/utils": "^3.10.5", "@react-types/calendar": "^3.6.1", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-DXsJv2Xm1BOqJAx5846TmTG1IZ0oKrBqYAzWZG7hiDq3rPjYGgKtC/iJg9MUev6pHhoZlP9fdRCNFiCfzm5bLQ=="],
|
||||||
|
|
||||||
"@react-stately/checkbox": ["@react-stately/checkbox@3.6.12", "", { "dependencies": { "@react-stately/form": "^3.1.2", "@react-stately/utils": "^3.10.5", "@react-types/checkbox": "^3.9.2", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gMxrWBl+styUD+2ntNIcviVpGt2Y+cHUGecAiNI3LM8/K6weI7938DWdLdK7i0gDmgSJwhoNRSavMPI1W6aMZQ=="],
|
"@react-stately/checkbox": ["@react-stately/checkbox@3.6.12", "", { "dependencies": { "@react-stately/form": "^3.1.2", "@react-stately/utils": "^3.10.5", "@react-types/checkbox": "^3.9.2", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gMxrWBl+styUD+2ntNIcviVpGt2Y+cHUGecAiNI3LM8/K6weI7938DWdLdK7i0gDmgSJwhoNRSavMPI1W6aMZQ=="],
|
||||||
|
@ -651,6 +654,8 @@
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug=="],
|
||||||
|
|
||||||
|
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||||
|
|
||||||
"@shikijs/core": ["@shikijs/core@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-FhsdxMWYu/C11sFisEp7FMGBtX/OSSbnXZDMBhGuUDBNTdsoZlMSgQv5f90rwvzWAdWIW6VobD+G3IrazxA6dQ=="],
|
"@shikijs/core": ["@shikijs/core@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-FhsdxMWYu/C11sFisEp7FMGBtX/OSSbnXZDMBhGuUDBNTdsoZlMSgQv5f90rwvzWAdWIW6VobD+G3IrazxA6dQ=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-eMdcUzN3FMQYxOmRf2rmU8frikzoSHbQDFH2hIuXsrMO+IBOCI9BeeRkCiBkcLDHeRKbOCtYMJK3D6U32ooU9Q=="],
|
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-eMdcUzN3FMQYxOmRf2rmU8frikzoSHbQDFH2hIuXsrMO+IBOCI9BeeRkCiBkcLDHeRKbOCtYMJK3D6U32ooU9Q=="],
|
||||||
|
@ -985,6 +990,8 @@
|
||||||
|
|
||||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
|
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
|
||||||
|
|
||||||
"fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="],
|
"fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="],
|
||||||
|
@ -1073,6 +1080,8 @@
|
||||||
|
|
||||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||||
|
|
||||||
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
||||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||||
|
|
||||||
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||||
|
@ -1149,6 +1158,8 @@
|
||||||
|
|
||||||
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
|
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
|
||||||
|
|
||||||
|
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||||
|
|
||||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
@ -1363,6 +1374,8 @@
|
||||||
|
|
||||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||||
|
|
||||||
|
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
@ -1371,6 +1384,8 @@
|
||||||
|
|
||||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
|
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||||
|
|
||||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
@ -1431,6 +1446,8 @@
|
||||||
|
|
||||||
"react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="],
|
"react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="],
|
||||||
|
|
||||||
|
"react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||||
|
|
||||||
"react-textarea-autosize": ["react-textarea-autosize@8.5.8", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iUiIj70JefrTuSJ4LbVFiSqWiHHss5L63L717bqaWHMgkm9sz6eEvro4vZ3uQfGJbevzwT6rHOszHKA8RkhRMg=="],
|
"react-textarea-autosize": ["react-textarea-autosize@8.5.8", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iUiIj70JefrTuSJ4LbVFiSqWiHHss5L63L717bqaWHMgkm9sz6eEvro4vZ3uQfGJbevzwT6rHOszHKA8RkhRMg=="],
|
||||||
|
@ -1481,6 +1498,8 @@
|
||||||
|
|
||||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||||
|
|
||||||
|
"resend": ["resend@4.5.1", "", { "dependencies": { "@react-email/render": "1.0.6" } }, "sha512-ryhHpZqCBmuVyzM19IO8Egtc2hkWI4JOL5lf5F3P7Dydu3rFeX6lHNpGqG0tjWoZ63rw0l731JEmuJZBdDm3og=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="],
|
"resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="],
|
||||||
|
|
||||||
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
|
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
|
||||||
|
@ -1509,6 +1528,8 @@
|
||||||
|
|
||||||
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.0.10", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg=="],
|
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.0.10", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg=="],
|
||||||
|
|
||||||
|
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
|
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
|
||||||
|
|
||||||
"send": ["send@1.1.0", "", { "dependencies": { "debug": "^4.3.5", "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^0.5.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA=="],
|
"send": ["send@1.1.0", "", { "dependencies": { "debug": "^4.3.5", "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^0.5.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA=="],
|
||||||
|
@ -1825,6 +1846,8 @@
|
||||||
|
|
||||||
"@react-aria/toggle/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="],
|
"@react-aria/toggle/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="],
|
||||||
|
|
||||||
|
"@react-email/render/prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
|
||||||
|
|
||||||
"@react-stately/virtualizer/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="],
|
"@react-stately/virtualizer/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="],
|
||||||
|
|
||||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
@ -1869,6 +1892,8 @@
|
||||||
|
|
||||||
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
||||||
|
|
||||||
|
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||||
|
|
||||||
"micromark/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
"micromark/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||||
|
|
||||||
"micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
"micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||||
|
|
|
@ -46,6 +46,7 @@
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"rehype-expressive-code": "^0.40.2",
|
"rehype-expressive-code": "^0.40.2",
|
||||||
|
"resend": "^4.5.1",
|
||||||
"tailwindcss": "^3.4.16"
|
"tailwindcss": "^3.4.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Icon } from '@iconify/react';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
import ReceiptForm from './ReceiptForm';
|
import ReceiptForm from './ReceiptForm';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
@ -331,6 +332,14 @@ export default function ReimbursementForm() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
try {
|
||||||
|
await EmailClient.notifySubmission(newReimbursement.id);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send submission email notification:', emailError);
|
||||||
|
// Don't fail the entire operation if email fails
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting reimbursement request:', error);
|
console.error('Error submitting reimbursement request:', error);
|
||||||
toast.error('Failed to submit reimbursement request. Please try again.');
|
toast.error('Failed to submit reimbursement request. Please try again.');
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
||||||
|
@ -443,14 +444,25 @@ export default function ReimbursementManagementPortal() {
|
||||||
|
|
||||||
if (!userId) throw new Error('User not authenticated');
|
if (!userId) throw new Error('User not authenticated');
|
||||||
|
|
||||||
|
// Store previous status for email notification
|
||||||
|
const previousStatus = selectedReimbursement?.status || 'unknown';
|
||||||
|
|
||||||
await update.updateFields('reimbursement', id, { status });
|
await update.updateFields('reimbursement', id, { status });
|
||||||
|
|
||||||
// Add audit log for status change
|
// Add audit log for status change
|
||||||
await addAuditLog(id, 'status_change', {
|
await addAuditLog(id, 'status_change', {
|
||||||
from: selectedReimbursement?.status,
|
from: previousStatus,
|
||||||
to: status
|
to: status
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
try {
|
||||||
|
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send email notification:', emailError);
|
||||||
|
// Don't fail the entire operation if email fails
|
||||||
|
}
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.success(`Reimbursement ${status} successfully`);
|
toast.success(`Reimbursement ${status} successfully`);
|
||||||
}
|
}
|
||||||
|
@ -651,6 +663,21 @@ export default function ReimbursementManagementPortal() {
|
||||||
is_private: isPrivateNote
|
is_private: isPrivateNote
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send email notification for public comments
|
||||||
|
if (!isPrivateNote) {
|
||||||
|
try {
|
||||||
|
await EmailClient.notifyComment(
|
||||||
|
selectedReimbursement.id,
|
||||||
|
auditNote.trim(),
|
||||||
|
userId,
|
||||||
|
isPrivateNote
|
||||||
|
);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send comment email notification:', emailError);
|
||||||
|
// Don't fail the entire operation if email fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast.success('Audit note saved successfully');
|
toast.success('Audit note saved successfully');
|
||||||
setAuditNote('');
|
setAuditNote('');
|
||||||
setIsPrivateNote(true);
|
setIsPrivateNote(true);
|
||||||
|
|
653
src/pages/api/email/send-reimbursement-notification.ts
Normal file
653
src/pages/api/email/send-reimbursement-notification.ts
Normal file
|
@ -0,0 +1,653 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
console.log('📨 Reimbursement email API called');
|
||||||
|
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
changedByUserId,
|
||||||
|
comment,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate,
|
||||||
|
additionalContext,
|
||||||
|
authData // Change to authData containing token and model
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
|
console.log('📋 Request data:', {
|
||||||
|
type,
|
||||||
|
reimbursementId,
|
||||||
|
hasAuthData: !!authData,
|
||||||
|
authDataHasToken: !!(authData?.token),
|
||||||
|
authDataHasModel: !!(authData?.model),
|
||||||
|
commentLength: comment?.length || 0,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!type || !reimbursementId) {
|
||||||
|
console.error('❌ Missing required parameters');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required parameters: type and reimbursementId' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import Resend and create direct PocketBase connection for server-side use
|
||||||
|
const { Resend } = await import('resend');
|
||||||
|
const PocketBase = await import('pocketbase').then(module => module.default);
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const pb = new PocketBase(import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
|
||||||
|
const resend = new Resend(import.meta.env.RESEND_API_KEY);
|
||||||
|
|
||||||
|
if (!import.meta.env.RESEND_API_KEY) {
|
||||||
|
throw new Error('RESEND_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate with PocketBase if auth data is provided
|
||||||
|
if (authData && authData.token && authData.model) {
|
||||||
|
console.log('🔐 Authenticating with PocketBase using provided auth data');
|
||||||
|
pb.authStore.save(authData.token, authData.model);
|
||||||
|
console.log('✅ PocketBase authentication successful');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ No auth data provided, proceeding without authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@ieeeucsd.org>';
|
||||||
|
const replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'treasurer@ieeeucsd.org';
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
console.log(`🎯 Processing email type: ${type}`);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'status_change':
|
||||||
|
if (!newStatus) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing newStatus for status_change notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
reimbursementId,
|
||||||
|
newStatus,
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
additionalContext
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'comment':
|
||||||
|
if (!comment || !commentByUserId) {
|
||||||
|
console.error('❌ Missing comment or commentByUserId for comment notification');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing comment or commentByUserId for comment notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendCommentEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
reimbursementId,
|
||||||
|
comment,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate: isPrivate || false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'submission':
|
||||||
|
success = await sendSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
reimbursementId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'test':
|
||||||
|
const { email } = additionalContext || {};
|
||||||
|
if (!email) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing email for test notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendTestEmail(resend, fromEmail, replyToEmail, email);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('❌ Unknown notification type:', type);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Unknown notification type: ${type}` }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 Email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success,
|
||||||
|
message: success ? 'Email notification sent successfully' : 'Failed to send email notification'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: success ? 200 : 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in email notification API:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for different email types
|
||||||
|
async function sendStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('📧 Starting status change email process...');
|
||||||
|
console.log('Environment check:', {
|
||||||
|
hasResendKey: !!import.meta.env.RESEND_API_KEY,
|
||||||
|
fromEmail,
|
||||||
|
replyToEmail,
|
||||||
|
pocketbaseUrl: import.meta.env.POCKETBASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get reimbursement details
|
||||||
|
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
|
||||||
|
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
||||||
|
console.log('✅ Reimbursement fetched:', { id: reimbursement.id, title: reimbursement.title });
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
console.log('👤 Fetching user details for:', reimbursement.submitted_by);
|
||||||
|
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('❌ User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('✅ User fetched:', { id: user.id, name: user.name, email: user.email });
|
||||||
|
|
||||||
|
// Get changed by user name if provided
|
||||||
|
let changedByName = 'System';
|
||||||
|
if (data.changedByUserId) {
|
||||||
|
try {
|
||||||
|
const changedByUser = await pb.collection('users').getOne(data.changedByUserId);
|
||||||
|
changedByName = changedByUser?.name || 'Unknown User';
|
||||||
|
console.log('👤 Changed by user:', changedByName);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Could not get changed by user name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
|
||||||
|
const statusColor = getStatusColor(data.newStatus);
|
||||||
|
const statusText = getStatusText(data.newStatus);
|
||||||
|
|
||||||
|
console.log('📝 Email details:', {
|
||||||
|
to: user.email,
|
||||||
|
subject,
|
||||||
|
status: data.newStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #666;">Status:</span>
|
||||||
|
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.previousStatus && data.previousStatus !== data.newStatus ? `
|
||||||
|
<div style="color: #666; font-size: 14px;">
|
||||||
|
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> → <strong>${statusText}</strong>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${changedByName !== 'System' ? `
|
||||||
|
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
||||||
|
Updated by: ${changedByName}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${data.newStatus === 'rejected' && data.additionalContext?.rejectionReason ? `
|
||||||
|
<div style="background: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; margin-top: 15px;">
|
||||||
|
<div style="font-weight: bold; color: #721c24; margin-bottom: 8px;">Rejection Reason:</div>
|
||||||
|
<div style="color: #721c24; font-style: italic;">${data.additionalContext.rejectionReason}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
${getNextStepsText(data.newStatus)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('📤 Attempting to send email via Resend...');
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Resend response:', result);
|
||||||
|
console.log('🎉 Status change email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send status change email:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommentEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('💬 Starting comment email process...');
|
||||||
|
console.log('Comment data received:', {
|
||||||
|
reimbursementId: data.reimbursementId,
|
||||||
|
commentByUserId: data.commentByUserId,
|
||||||
|
isPrivate: data.isPrivate,
|
||||||
|
commentLength: data.comment?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't send emails for private comments
|
||||||
|
if (data.isPrivate) {
|
||||||
|
console.log('🔒 Comment is private, skipping email notification');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reimbursement details
|
||||||
|
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
|
||||||
|
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
||||||
|
console.log('✅ Reimbursement fetched:', {
|
||||||
|
id: reimbursement.id,
|
||||||
|
title: reimbursement.title,
|
||||||
|
submitted_by: reimbursement.submitted_by
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
console.log('👤 Fetching submitter user details for:', reimbursement.submitted_by);
|
||||||
|
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('❌ User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('✅ Submitter user fetched:', {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get commenter user name
|
||||||
|
console.log('👤 Fetching commenter user details for:', data.commentByUserId);
|
||||||
|
let commentByName = 'Unknown User';
|
||||||
|
try {
|
||||||
|
const commentByUser = await pb.collection('users').getOne(data.commentByUserId);
|
||||||
|
commentByName = commentByUser?.name || 'Unknown User';
|
||||||
|
console.log('✅ Commenter user fetched:', {
|
||||||
|
id: commentByUser?.id,
|
||||||
|
name: commentByName
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Could not get commenter user name:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
console.log('📝 Comment email details:', {
|
||||||
|
to: user.email,
|
||||||
|
subject,
|
||||||
|
commentBy: commentByName,
|
||||||
|
commentPreview: data.comment.substring(0, 50) + (data.comment.length > 50 ? '...' : '')
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentByName}
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
||||||
|
<p style="margin: 0; font-style: italic;">${data.comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
||||||
|
<span style="background: ${getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
${getStatusText(reimbursement.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('📤 Attempting to send comment email via Resend...');
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Resend comment email response:', result);
|
||||||
|
console.log('🎉 Comment email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send comment email:', error);
|
||||||
|
console.error('Comment email error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">✅ Reimbursement Submitted Successfully</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
Submitted
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
||||||
|
<li>Your receipts will be reviewed by our team</li>
|
||||||
|
<li>You'll receive email updates as the status changes</li>
|
||||||
|
<li>Once approved, payment will be processed</li>
|
||||||
|
<li>Typical processing time is 1-2 weeks</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Submission confirmation email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send submission confirmation email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestEmail(resend: any, fromEmail: string, replyToEmail: string, email: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('🧪 Starting test email process...');
|
||||||
|
console.log('Test email configuration:', {
|
||||||
|
fromEmail,
|
||||||
|
replyToEmail,
|
||||||
|
toEmail: email,
|
||||||
|
hasResend: !!resend
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = 'Test Email from IEEE UCSD Reimbursement System';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">🧪 Test Email</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Email System Test</h2>
|
||||||
|
<p>This is a test email from the IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you receive this email, the notification system is working correctly!</p>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #155724;">✅ Email delivery successful</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is a test notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('📤 Sending test email via Resend...');
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Resend test email response:', result);
|
||||||
|
console.log('🎉 Test email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send test email:', error);
|
||||||
|
console.error('Test email error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return '#ffc107';
|
||||||
|
case 'under_review': return '#17a2b8';
|
||||||
|
case 'approved': return '#28a745';
|
||||||
|
case 'rejected': return '#dc3545';
|
||||||
|
case 'in_progress': return '#6f42c1';
|
||||||
|
case 'paid': return '#20c997';
|
||||||
|
default: return '#6c757d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return 'Submitted';
|
||||||
|
case 'under_review': return 'Under Review';
|
||||||
|
case 'approved': return 'Approved';
|
||||||
|
case 'rejected': return 'Rejected';
|
||||||
|
case 'in_progress': return 'In Progress';
|
||||||
|
case 'paid': return 'Paid';
|
||||||
|
default: return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextStepsText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
|
||||||
|
case 'under_review':
|
||||||
|
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
|
||||||
|
case 'approved':
|
||||||
|
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
|
||||||
|
case 'rejected':
|
||||||
|
return 'Your reimbursement has been rejected. Please review the rejection reason above and reach out to our treasurer if you have questions or need to resubmit with corrections.';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
|
||||||
|
case 'paid':
|
||||||
|
return 'Your reimbursement has been completed! Please check your account for the payment.';
|
||||||
|
default:
|
||||||
|
return 'Check your dashboard for more details about your reimbursement status.';
|
||||||
|
}
|
||||||
|
}
|
152
src/scripts/email/EmailClient.ts
Normal file
152
src/scripts/email/EmailClient.ts
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* Client-side helper for sending email notifications via API routes
|
||||||
|
* This runs in the browser and calls the server-side email API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Authentication } from '../pocketbase/Authentication';
|
||||||
|
|
||||||
|
interface EmailNotificationRequest {
|
||||||
|
type: 'status_change' | 'comment' | 'submission' | 'test';
|
||||||
|
reimbursementId: string;
|
||||||
|
previousStatus?: string;
|
||||||
|
newStatus?: string;
|
||||||
|
changedByUserId?: string;
|
||||||
|
comment?: string;
|
||||||
|
commentByUserId?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
|
additionalContext?: Record<string, any>;
|
||||||
|
authData?: { token: string; model: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailNotificationResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailClient {
|
||||||
|
private static getAuthData(): { token: string; model: any } | null {
|
||||||
|
try {
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
const token = auth.getAuthToken();
|
||||||
|
const model = auth.getCurrentUser();
|
||||||
|
|
||||||
|
if (token && model) {
|
||||||
|
return { token, model };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get auth data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async sendEmailNotification(request: EmailNotificationRequest): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const authData = this.getAuthData();
|
||||||
|
const requestWithAuth = {
|
||||||
|
...request,
|
||||||
|
authData
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/email/send-reimbursement-notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestWithAuth),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: EmailNotificationResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Email notification API error:', result.error || result.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send status change notification
|
||||||
|
*/
|
||||||
|
static async notifyStatusChange(
|
||||||
|
reimbursementId: string,
|
||||||
|
newStatus: string,
|
||||||
|
previousStatus?: string,
|
||||||
|
changedByUserId?: string,
|
||||||
|
additionalContext?: Record<string, any>
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'status_change',
|
||||||
|
reimbursementId,
|
||||||
|
newStatus,
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
additionalContext
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send comment notification
|
||||||
|
*/
|
||||||
|
static async notifyComment(
|
||||||
|
reimbursementId: string,
|
||||||
|
comment: string,
|
||||||
|
commentByUserId: string,
|
||||||
|
isPrivate: boolean = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'comment',
|
||||||
|
reimbursementId,
|
||||||
|
comment,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send submission confirmation
|
||||||
|
*/
|
||||||
|
static async notifySubmission(reimbursementId: string): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'submission',
|
||||||
|
reimbursementId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send rejection notification with reason
|
||||||
|
*/
|
||||||
|
static async notifyRejection(
|
||||||
|
reimbursementId: string,
|
||||||
|
rejectionReason: string,
|
||||||
|
previousStatus?: string,
|
||||||
|
changedByUserId?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'status_change',
|
||||||
|
reimbursementId,
|
||||||
|
newStatus: 'rejected',
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
additionalContext: { rejectionReason }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email
|
||||||
|
*/
|
||||||
|
static async sendTestEmail(email: string): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'test',
|
||||||
|
reimbursementId: 'test', // Required but not used for test emails
|
||||||
|
additionalContext: { email }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
410
src/scripts/email/EmailService.ts
Normal file
410
src/scripts/email/EmailService.ts
Normal file
|
@ -0,0 +1,410 @@
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
|
||||||
|
|
||||||
|
// Define email template types
|
||||||
|
export type EmailTemplateType =
|
||||||
|
| 'reimbursement_status_changed'
|
||||||
|
| 'reimbursement_comment_added'
|
||||||
|
| 'reimbursement_submitted'
|
||||||
|
| 'reimbursement_approved'
|
||||||
|
| 'reimbursement_rejected'
|
||||||
|
| 'reimbursement_paid';
|
||||||
|
|
||||||
|
// Email template data interfaces
|
||||||
|
export interface StatusChangeEmailData {
|
||||||
|
user: User;
|
||||||
|
reimbursement: Reimbursement;
|
||||||
|
previousStatus: string;
|
||||||
|
newStatus: string;
|
||||||
|
changedBy?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentEmailData {
|
||||||
|
user: User;
|
||||||
|
reimbursement: Reimbursement;
|
||||||
|
comment: string;
|
||||||
|
commentBy: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReimbursementEmailData {
|
||||||
|
user: User;
|
||||||
|
reimbursement: Reimbursement;
|
||||||
|
receipts?: Receipt[];
|
||||||
|
additionalData?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailService {
|
||||||
|
private resend: Resend;
|
||||||
|
private fromEmail: string;
|
||||||
|
private replyToEmail: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initialize Resend with API key from environment
|
||||||
|
// Use import.meta.env as used throughout the Astro project
|
||||||
|
const apiKey = import.meta.env.RESEND_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('RESEND_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resend = new Resend(apiKey);
|
||||||
|
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
|
||||||
|
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static instance: EmailService | null = null;
|
||||||
|
|
||||||
|
public static getInstance(): EmailService {
|
||||||
|
if (!EmailService.instance) {
|
||||||
|
EmailService.instance = new EmailService();
|
||||||
|
}
|
||||||
|
return EmailService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send reimbursement status change notification
|
||||||
|
*/
|
||||||
|
async sendStatusChangeEmail(data: StatusChangeEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, reimbursement, previousStatus, newStatus, changedBy, comment } = data;
|
||||||
|
|
||||||
|
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
|
||||||
|
const statusColor = this.getStatusColor(newStatus);
|
||||||
|
const statusText = this.getStatusText(newStatus);
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #666;">Status Change:</span>
|
||||||
|
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${previousStatus !== newStatus ? `
|
||||||
|
<div style="color: #666; font-size: 14px;">
|
||||||
|
Changed from: <span style="text-decoration: line-through;">${this.getStatusText(previousStatus)}</span> → <strong>${statusText}</strong>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${changedBy ? `
|
||||||
|
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
||||||
|
Updated by: ${changedBy}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${comment ? `
|
||||||
|
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Additional Note:</h4>
|
||||||
|
<p style="margin: 0; font-style: italic;">${comment}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
${this.getNextStepsText(newStatus)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Status change email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send status change email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send comment notification email
|
||||||
|
*/
|
||||||
|
async sendCommentEmail(data: CommentEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, reimbursement, comment, commentBy, isPrivate } = data;
|
||||||
|
|
||||||
|
// Don't send email for private comments unless the user is the recipient
|
||||||
|
if (isPrivate) {
|
||||||
|
return true; // Silently skip private comments for now
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentBy}
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
||||||
|
<p style="margin: 0; font-style: italic;">${comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
||||||
|
<span style="background: ${this.getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
${this.getStatusText(reimbursement.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Comment email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send comment email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send reimbursement submission confirmation
|
||||||
|
*/
|
||||||
|
async sendSubmissionConfirmation(data: ReimbursementEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, reimbursement } = data;
|
||||||
|
|
||||||
|
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">✅ Reimbursement Submitted Successfully</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
Submitted
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
||||||
|
<li>Your receipts will be reviewed by our team</li>
|
||||||
|
<li>You'll receive email updates as the status changes</li>
|
||||||
|
<li>Once approved, payment will be processed</li>
|
||||||
|
<li>Typical processing time is 1-2 weeks</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Submission confirmation email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send submission confirmation email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for styling
|
||||||
|
*/
|
||||||
|
private getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return '#ffc107';
|
||||||
|
case 'under_review': return '#17a2b8';
|
||||||
|
case 'approved': return '#28a745';
|
||||||
|
case 'rejected': return '#dc3545';
|
||||||
|
case 'in_progress': return '#6f42c1';
|
||||||
|
case 'paid': return '#20c997';
|
||||||
|
default: return '#6c757d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable status text
|
||||||
|
*/
|
||||||
|
private getStatusText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return 'Submitted';
|
||||||
|
case 'under_review': return 'Under Review';
|
||||||
|
case 'approved': return 'Approved';
|
||||||
|
case 'rejected': return 'Rejected';
|
||||||
|
case 'in_progress': return 'In Progress';
|
||||||
|
case 'paid': return 'Paid';
|
||||||
|
default: return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next steps text based on status
|
||||||
|
*/
|
||||||
|
private getNextStepsText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
|
||||||
|
case 'under_review':
|
||||||
|
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
|
||||||
|
case 'approved':
|
||||||
|
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
|
||||||
|
case 'rejected':
|
||||||
|
return 'Your reimbursement has been rejected. Please review the comments and reach out if you have questions.';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
|
||||||
|
case 'paid':
|
||||||
|
return 'Your reimbursement has been completed! Please check your account for the payment.';
|
||||||
|
default:
|
||||||
|
return 'Check your dashboard for more details about your reimbursement status.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
142
src/scripts/email/README.md
Normal file
142
src/scripts/email/README.md
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
# Email Notification System
|
||||||
|
|
||||||
|
This directory contains the email notification system for the IEEE UCSD reimbursement portal using Resend.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Add the following environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PocketBase Configuration
|
||||||
|
POCKETBASE_URL=https://pocketbase.ieeeucsd.org
|
||||||
|
|
||||||
|
# Resend API Configuration
|
||||||
|
RESEND_API_KEY=your_resend_api_key_here
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
FROM_EMAIL="IEEE UCSD <noreply@ieeeucsd.org>"
|
||||||
|
REPLY_TO_EMAIL="treasurer@ieeeucsd.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This project uses Astro's standard environment variable pattern with `import.meta.env.VARIABLE_NAME`. No PUBLIC_ prefix is needed as these are used in API routes and server-side code.
|
||||||
|
|
||||||
|
### Getting a Resend API Key
|
||||||
|
|
||||||
|
1. Sign up for a [Resend account](https://resend.com)
|
||||||
|
2. Go to your dashboard and create a new API key
|
||||||
|
3. Add the API key to your environment variables
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Automatic Email Notifications
|
||||||
|
|
||||||
|
The system automatically sends emails for the following events:
|
||||||
|
|
||||||
|
1. **Reimbursement Submitted** - Confirmation email when a user submits a new reimbursement request
|
||||||
|
2. **Status Changes** - Notification when reimbursement status is updated (submitted, under review, approved, rejected, in progress, paid)
|
||||||
|
3. **Comments Added** - Notification when someone adds a public comment to a reimbursement
|
||||||
|
4. **Rejections with Reasons** - Detailed rejection notification including the specific reason for rejection
|
||||||
|
|
||||||
|
Note: Private comments are not sent via email to maintain privacy.
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
|
||||||
|
All emails include:
|
||||||
|
- Professional IEEE UCSD branding
|
||||||
|
- Responsive design for mobile and desktop
|
||||||
|
- Clear status indicators with color coding
|
||||||
|
- Reimbursement details summary
|
||||||
|
- Next steps information
|
||||||
|
- Contact information for support
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### In React Components (Client-side)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
|
|
||||||
|
// Send status change notification
|
||||||
|
await EmailClient.notifyStatusChange(reimbursementId, newStatus, previousStatus, userId);
|
||||||
|
|
||||||
|
// Send comment notification
|
||||||
|
await EmailClient.notifyComment(reimbursementId, comment, commentByUserId, isPrivate);
|
||||||
|
|
||||||
|
// Send submission confirmation
|
||||||
|
await EmailClient.notifySubmission(reimbursementId);
|
||||||
|
|
||||||
|
// Send rejection with reason (recommended for rejections)
|
||||||
|
await EmailClient.notifyRejection(reimbursementId, rejectionReason, previousStatus, userId);
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
await EmailClient.sendTestEmail('your-email@example.com');
|
||||||
|
|
||||||
|
// Alternative: Send rejection via notifyStatusChange with additionalContext
|
||||||
|
await EmailClient.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
'rejected',
|
||||||
|
previousStatus,
|
||||||
|
userId,
|
||||||
|
{ rejectionReason: 'Missing receipt for coffee purchase. Please resubmit with proper documentation.' }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Route (Server-side)
|
||||||
|
|
||||||
|
The API route at `/api/email/send-reimbursement-notification` accepts POST requests with the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "status_change" | "comment" | "submission" | "test",
|
||||||
|
"reimbursementId": "string",
|
||||||
|
"newStatus": "string", // for status_change
|
||||||
|
"previousStatus": "string", // for status_change
|
||||||
|
"changedByUserId": "string", // for status_change
|
||||||
|
"comment": "string", // for comment
|
||||||
|
"commentByUserId": "string", // for comment
|
||||||
|
"isPrivate": boolean, // for comment
|
||||||
|
"additionalContext": {}, // for additional data
|
||||||
|
"authData": { // Authentication data for PocketBase access
|
||||||
|
"token": "string",
|
||||||
|
"model": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The email system uses a client-server architecture for security and authentication:
|
||||||
|
|
||||||
|
- `EmailService.ts` - Core email service using Resend (server-side only)
|
||||||
|
- `ReimbursementEmailNotifications.ts` - High-level notification service (server-side only)
|
||||||
|
- `EmailClient.ts` - Client-side helper that calls the API with authentication
|
||||||
|
- `/api/email/send-reimbursement-notification.ts` - API route that handles server-side email sending with PocketBase authentication
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. **Client-side**: `EmailClient` gets the current user's authentication token and model from the `Authentication` service
|
||||||
|
2. **API Request**: The auth data is sent to the server-side API route
|
||||||
|
3. **Server-side**: The API route authenticates with PocketBase using the provided auth data
|
||||||
|
4. **Database Access**: The authenticated PocketBase connection can access protected collections
|
||||||
|
5. **Email Sending**: Emails are sent using the Resend service with proper user data
|
||||||
|
|
||||||
|
This ensures that:
|
||||||
|
- API keys are never exposed to the client-side code
|
||||||
|
- Only authenticated users can trigger email notifications
|
||||||
|
- The server can access protected PocketBase collections
|
||||||
|
- Email operations respect user permissions and data security
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Email failures are logged but do not prevent the main operations from completing. This ensures that reimbursement processing continues even if email delivery fails.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- API keys are loaded from environment variables server-side only
|
||||||
|
- Authentication tokens are passed securely from client to server
|
||||||
|
- Email addresses are validated before sending
|
||||||
|
- Private comments are not sent via email (configurable)
|
||||||
|
- All emails include appropriate contact information
|
||||||
|
- PocketBase collection access respects authentication and permissions
|
310
src/scripts/email/ReimbursementEmailNotifications.ts
Normal file
310
src/scripts/email/ReimbursementEmailNotifications.ts
Normal file
|
@ -0,0 +1,310 @@
|
||||||
|
import { EmailService, type StatusChangeEmailData, type CommentEmailData, type ReimbursementEmailData } from './EmailService';
|
||||||
|
import { Get } from '../pocketbase/Get';
|
||||||
|
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
|
||||||
|
|
||||||
|
export class ReimbursementEmailNotifications {
|
||||||
|
private emailService: EmailService;
|
||||||
|
private get: Get;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.emailService = EmailService.getInstance();
|
||||||
|
this.get = Get.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static instance: ReimbursementEmailNotifications | null = null;
|
||||||
|
|
||||||
|
public static getInstance(): ReimbursementEmailNotifications {
|
||||||
|
if (!ReimbursementEmailNotifications.instance) {
|
||||||
|
ReimbursementEmailNotifications.instance = new ReimbursementEmailNotifications();
|
||||||
|
}
|
||||||
|
return ReimbursementEmailNotifications.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when reimbursement status changes
|
||||||
|
*/
|
||||||
|
async notifyStatusChange(
|
||||||
|
reimbursementId: string,
|
||||||
|
previousStatus: string,
|
||||||
|
newStatus: string,
|
||||||
|
changedByUserId?: string,
|
||||||
|
comment?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
||||||
|
if (!reimbursement) {
|
||||||
|
console.error('Reimbursement not found:', reimbursementId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get changed by user name if provided
|
||||||
|
let changedByName = 'System';
|
||||||
|
if (changedByUserId) {
|
||||||
|
try {
|
||||||
|
const changedByUser = await this.get.getOne<User>('users', changedByUserId);
|
||||||
|
changedByName = changedByUser?.name || 'Unknown User';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get changed by user name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData: StatusChangeEmailData = {
|
||||||
|
user,
|
||||||
|
reimbursement,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
changedBy: changedByName,
|
||||||
|
comment
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendStatusChangeEmail(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send status change notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when a comment is added to a reimbursement
|
||||||
|
*/
|
||||||
|
async notifyComment(
|
||||||
|
reimbursementId: string,
|
||||||
|
comment: string,
|
||||||
|
commentByUserId: string,
|
||||||
|
isPrivate: boolean = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Don't send emails for private comments (for now)
|
||||||
|
if (isPrivate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
||||||
|
if (!reimbursement) {
|
||||||
|
console.error('Reimbursement not found:', reimbursementId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send email if the commenter is the same as the submitter
|
||||||
|
if (commentByUserId === reimbursement.submitted_by) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commenter user name
|
||||||
|
let commentByName = 'Unknown User';
|
||||||
|
try {
|
||||||
|
const commentByUser = await this.get.getOne<User>('users', commentByUserId);
|
||||||
|
commentByName = commentByUser?.name || 'Unknown User';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get commenter user name:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData: CommentEmailData = {
|
||||||
|
user,
|
||||||
|
reimbursement,
|
||||||
|
comment,
|
||||||
|
commentBy: commentByName,
|
||||||
|
isPrivate
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendCommentEmail(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send comment notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send submission confirmation email
|
||||||
|
*/
|
||||||
|
async notifySubmission(reimbursementId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
||||||
|
if (!reimbursement) {
|
||||||
|
console.error('Reimbursement not found:', reimbursementId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get receipt details if needed
|
||||||
|
let receipts: Receipt[] = [];
|
||||||
|
if (reimbursement.receipts && reimbursement.receipts.length > 0) {
|
||||||
|
try {
|
||||||
|
receipts = await Promise.all(
|
||||||
|
reimbursement.receipts.map(id => this.get.getOne<Receipt>('receipts', id))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load receipt details:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData: ReimbursementEmailData = {
|
||||||
|
user,
|
||||||
|
reimbursement,
|
||||||
|
receipts
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendSubmissionConfirmation(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send submission confirmation:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send specific status-based notifications with custom logic
|
||||||
|
*/
|
||||||
|
async notifyByStatus(
|
||||||
|
reimbursementId: string,
|
||||||
|
status: string,
|
||||||
|
previousStatus?: string,
|
||||||
|
triggeredByUserId?: string,
|
||||||
|
additionalContext?: Record<string, any>
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return await this.notifySubmission(reimbursementId);
|
||||||
|
|
||||||
|
case 'approved':
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'under_review',
|
||||||
|
status,
|
||||||
|
triggeredByUserId,
|
||||||
|
'Your reimbursement has been approved and will be processed for payment.'
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'rejected':
|
||||||
|
const rejectionReason = additionalContext?.rejectionReason;
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'under_review',
|
||||||
|
status,
|
||||||
|
triggeredByUserId,
|
||||||
|
rejectionReason ? `Rejection reason: ${rejectionReason}` : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'paid':
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'in_progress',
|
||||||
|
status,
|
||||||
|
triggeredByUserId,
|
||||||
|
'Your reimbursement has been completed. Please check your account for the payment.'
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'under_review':
|
||||||
|
case 'in_progress':
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'submitted',
|
||||||
|
status,
|
||||||
|
triggeredByUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`No specific notification handler for status: ${status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send notification for status ${status}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch notify multiple users (for administrative notifications)
|
||||||
|
*/
|
||||||
|
async notifyAdmins(
|
||||||
|
subject: string,
|
||||||
|
message: string,
|
||||||
|
reimbursementId?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// This could be enhanced to get admin user emails from the officers table
|
||||||
|
// For now, we'll just log this functionality
|
||||||
|
console.log('Admin notification requested:', { subject, message, reimbursementId });
|
||||||
|
|
||||||
|
// TODO: Implement admin notification logic
|
||||||
|
// - Get list of admin users from officers table
|
||||||
|
// - Send email to all admins
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send admin notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test email functionality (useful for development)
|
||||||
|
*/
|
||||||
|
async testEmail(userEmail: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Create a test user object
|
||||||
|
const testUser: User = {
|
||||||
|
id: 'test-user',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
email: userEmail,
|
||||||
|
emailVisibility: true,
|
||||||
|
verified: true,
|
||||||
|
name: 'Test User'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a test reimbursement object
|
||||||
|
const testReimbursement: Reimbursement = {
|
||||||
|
id: 'test-reimbursement',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
title: 'Test Reimbursement',
|
||||||
|
total_amount: 99.99,
|
||||||
|
date_of_purchase: new Date().toISOString(),
|
||||||
|
payment_method: 'Personal Credit Card',
|
||||||
|
status: 'submitted',
|
||||||
|
submitted_by: 'test-user',
|
||||||
|
additional_info: 'This is a test reimbursement for email functionality.',
|
||||||
|
receipts: [],
|
||||||
|
department: 'events'
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailData: StatusChangeEmailData = {
|
||||||
|
user: testUser,
|
||||||
|
reimbursement: testReimbursement,
|
||||||
|
previousStatus: 'submitted',
|
||||||
|
newStatus: 'approved',
|
||||||
|
changedBy: 'Test Admin',
|
||||||
|
comment: 'This is a test email notification.'
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendStatusChangeEmail(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send test email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue