diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..6f9f00f --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) diff --git a/bun.lock b/bun.lock index db5c729..5c1f5bf 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "react-hot-toast": "^2.5.2", "react-icons": "^5.4.0", "rehype-expressive-code": "^0.40.2", + "resend": "^4.5.1", "tailwindcss": "^3.4.16", }, "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-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/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=="], + "@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/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=="], + "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=="], "fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="], @@ -1073,6 +1080,8 @@ "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=="], "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=="], + "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=="], "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=="], + "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-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1371,6 +1384,8 @@ "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=="], "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-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-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=="], + "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=="], "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=="], + "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=="], "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-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=="], "@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=="], + "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-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], diff --git a/package.json b/package.json index 4f19e0d..ffe96b1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "react-hot-toast": "^2.5.2", "react-icons": "^5.4.0", "rehype-expressive-code": "^0.40.2", + "resend": "^4.5.1", "tailwindcss": "^3.4.16" }, "devDependencies": { diff --git a/src/components/dashboard/reimbursement/ReimbursementForm.tsx b/src/components/dashboard/reimbursement/ReimbursementForm.tsx index e6f0524..b57a313 100644 --- a/src/components/dashboard/reimbursement/ReimbursementForm.tsx +++ b/src/components/dashboard/reimbursement/ReimbursementForm.tsx @@ -3,6 +3,7 @@ import { Icon } from '@iconify/react'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { Collections } from '../../../schemas/pocketbase/schema'; +import { EmailClient } from '../../../scripts/email/EmailClient'; import ReceiptForm from './ReceiptForm'; import { toast } from 'react-hot-toast'; 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) { console.error('Error submitting reimbursement request:', error); toast.error('Failed to submit reimbursement request. Please try again.'); diff --git a/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx b/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx index e46bbe1..469f8d3 100644 --- a/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx +++ b/src/components/dashboard/reimbursement/ReimbursementManagementPortal.tsx @@ -5,6 +5,7 @@ import { Get } from '../../../scripts/pocketbase/Get'; import { Update } from '../../../scripts/pocketbase/Update'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { FileManager } from '../../../scripts/pocketbase/FileManager'; +import { EmailClient } from '../../../scripts/email/EmailClient'; import { toast } from 'react-hot-toast'; import { motion, AnimatePresence } from 'framer-motion'; 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'); + // Store previous status for email notification + const previousStatus = selectedReimbursement?.status || 'unknown'; + await update.updateFields('reimbursement', id, { status }); // Add audit log for status change await addAuditLog(id, 'status_change', { - from: selectedReimbursement?.status, + from: previousStatus, 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) { toast.success(`Reimbursement ${status} successfully`); } @@ -651,6 +663,21 @@ export default function ReimbursementManagementPortal() { 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'); setAuditNote(''); setIsPrivateNote(true); diff --git a/src/pages/api/email/send-reimbursement-notification.ts b/src/pages/api/email/send-reimbursement-notification.ts new file mode 100644 index 0000000..a8ed2d5 --- /dev/null +++ b/src/pages/api/email/send-reimbursement-notification.ts @@ -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 '; + 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 { + 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 = ` + + + + + + ${subject} + + +
+

IEEE UCSD Reimbursement Update

+
+ +
+

Status Update

+

Hello ${user.name},

+

Your reimbursement request "${reimbursement.title}" has been updated.

+ +
+
+ Status: + ${statusText} +
+ + ${data.previousStatus && data.previousStatus !== data.newStatus ? ` +
+ Changed from: ${getStatusText(data.previousStatus)} โ†’ ${statusText} +
+ ` : ''} + + ${changedByName !== 'System' ? ` +
+ Updated by: ${changedByName} +
+ ` : ''} + + ${data.newStatus === 'rejected' && data.additionalContext?.rejectionReason ? ` +
+
Rejection Reason:
+
${data.additionalContext.rejectionReason}
+
+ ` : ''} +
+ +
+

Reimbursement Details

+ + + + + + + + + + + + + + + + + +
Amount:$${reimbursement.total_amount.toFixed(2)}
Date of Purchase:${new Date(reimbursement.date_of_purchase).toLocaleDateString()}
Department:${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}
Payment Method:${reimbursement.payment_method}
+
+ +
+

Next Steps:

+

+ ${getNextStepsText(data.newStatus)} +

+
+
+ +
+

This is an automated notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${replyToEmail}

+
+ + + `; + + 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 { + 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 = ` + + + + + + ${subject} + + +
+

IEEE UCSD Reimbursement Comment

+
+ +
+

New Comment Added

+

Hello ${user.name},

+

A new comment has been added to your reimbursement request "${reimbursement.title}".

+ +
+
+ Comment by: ${commentByName} +
+
+

${data.comment}

+
+
+ +
+

Reimbursement Details

+ + + + + + + + + + + + + +
Status: + + ${getStatusText(reimbursement.status)} + +
Amount:$${reimbursement.total_amount.toFixed(2)}
Date of Purchase:${new Date(reimbursement.date_of_purchase).toLocaleDateString()}
+
+
+ +
+

This is an automated notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${replyToEmail}

+
+ + + `; + + 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 { + 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 = ` + + + + + + ${subject} + + +
+

โœ… Reimbursement Submitted Successfully

+
+ +
+

Submission Confirmed

+

Hello ${user.name},

+

Your reimbursement request has been successfully submitted and is now under review.

+ +
+

Reimbursement Details

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Title:${reimbursement.title}
Amount:$${reimbursement.total_amount.toFixed(2)}
Date of Purchase:${new Date(reimbursement.date_of_purchase).toLocaleDateString()}
Department:${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}
Payment Method:${reimbursement.payment_method}
Status: + + Submitted + +
+
+ +
+

What happens next?

+
    +
  • Your receipts will be reviewed by our team
  • +
  • You'll receive email updates as the status changes
  • +
  • Once approved, payment will be processed
  • +
  • Typical processing time is 1-2 weeks
  • +
+
+
+ +
+

This is an automated notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${replyToEmail}

+
+ + + `; + + 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 { + 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 = ` + + + + + + ${subject} + + +
+

๐Ÿงช Test Email

+
+ +
+

Email System Test

+

This is a test email from the IEEE UCSD Reimbursement System.

+

If you receive this email, the notification system is working correctly!

+ +
+

โœ… Email delivery successful

+
+
+ +
+

This is a test notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${replyToEmail}

+
+ + + `; + + 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.'; + } +} \ No newline at end of file diff --git a/src/scripts/email/EmailClient.ts b/src/scripts/email/EmailClient.ts new file mode 100644 index 0000000..168c15b --- /dev/null +++ b/src/scripts/email/EmailClient.ts @@ -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; + 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 { + 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 + ): Promise { + 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 { + return this.sendEmailNotification({ + type: 'comment', + reimbursementId, + comment, + commentByUserId, + isPrivate + }); + } + + /** + * Send submission confirmation + */ + static async notifySubmission(reimbursementId: string): Promise { + return this.sendEmailNotification({ + type: 'submission', + reimbursementId + }); + } + + /** + * Send rejection notification with reason + */ + static async notifyRejection( + reimbursementId: string, + rejectionReason: string, + previousStatus?: string, + changedByUserId?: string + ): Promise { + return this.sendEmailNotification({ + type: 'status_change', + reimbursementId, + newStatus: 'rejected', + previousStatus, + changedByUserId, + additionalContext: { rejectionReason } + }); + } + + /** + * Send test email + */ + static async sendTestEmail(email: string): Promise { + return this.sendEmailNotification({ + type: 'test', + reimbursementId: 'test', // Required but not used for test emails + additionalContext: { email } + }); + } +} \ No newline at end of file diff --git a/src/scripts/email/EmailService.ts b/src/scripts/email/EmailService.ts new file mode 100644 index 0000000..713be7d --- /dev/null +++ b/src/scripts/email/EmailService.ts @@ -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; +} + +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 '; + 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 { + 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 = ` + + + + + + ${subject} + + +
+

IEEE UCSD Reimbursement Update

+
+ +
+

Status Update

+

Hello ${user.name},

+

Your reimbursement request "${reimbursement.title}" has been updated.

+ +
+
+ Status Change: + ${statusText} +
+ + ${previousStatus !== newStatus ? ` +
+ Changed from: ${this.getStatusText(previousStatus)} โ†’ ${statusText} +
+ ` : ''} + + ${changedBy ? ` +
+ Updated by: ${changedBy} +
+ ` : ''} +
+ + ${comment ? ` +
+

Additional Note:

+

${comment}

+
+ ` : ''} + +
+

Reimbursement Details

+ + + + + + + + + + + + + + + + + +
Amount:$${reimbursement.total_amount.toFixed(2)}
Date of Purchase:${new Date(reimbursement.date_of_purchase).toLocaleDateString()}
Department:${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}
Payment Method:${reimbursement.payment_method}
+
+ +
+

Next Steps:

+

+ ${this.getNextStepsText(newStatus)} +

+
+
+ +
+

This is an automated notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${this.replyToEmail}

+
+ + + `; + + 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 { + 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 = ` + + + + + + ${subject} + + +
+

IEEE UCSD Reimbursement Comment

+
+ +
+

New Comment Added

+

Hello ${user.name},

+

A new comment has been added to your reimbursement request "${reimbursement.title}".

+ +
+
+ Comment by: ${commentBy} +
+
+

${comment}

+
+
+ +
+

Reimbursement Details

+ + + + + + + + + + + + + +
Status: + + ${this.getStatusText(reimbursement.status)} + +
Amount:$${reimbursement.total_amount.toFixed(2)}
Date of Purchase:${new Date(reimbursement.date_of_purchase).toLocaleDateString()}
+
+
+ +
+

This is an automated notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${this.replyToEmail}

+
+ + + `; + + 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 { + try { + const { user, reimbursement } = data; + + const subject = `Reimbursement Submitted: ${reimbursement.title}`; + + const html = ` + + + + + + ${subject} + + +
+

โœ… Reimbursement Submitted Successfully

+
+ +
+

Submission Confirmed

+

Hello ${user.name},

+

Your reimbursement request has been successfully submitted and is now under review.

+ +
+

Reimbursement Details

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Title:${reimbursement.title}
Amount:$${reimbursement.total_amount.toFixed(2)}
Date of Purchase:${new Date(reimbursement.date_of_purchase).toLocaleDateString()}
Department:${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}
Payment Method:${reimbursement.payment_method}
Status: + + Submitted + +
+
+ +
+

What happens next?

+
    +
  • Your receipts will be reviewed by our team
  • +
  • You'll receive email updates as the status changes
  • +
  • Once approved, payment will be processed
  • +
  • Typical processing time is 1-2 weeks
  • +
+
+
+ +
+

This is an automated notification from IEEE UCSD Reimbursement System.

+

If you have any questions, please contact us at ${this.replyToEmail}

+
+ + + `; + + 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.'; + } + } +} \ No newline at end of file diff --git a/src/scripts/email/README.md b/src/scripts/email/README.md new file mode 100644 index 0000000..9378e81 --- /dev/null +++ b/src/scripts/email/README.md @@ -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 " +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 \ No newline at end of file diff --git a/src/scripts/email/ReimbursementEmailNotifications.ts b/src/scripts/email/ReimbursementEmailNotifications.ts new file mode 100644 index 0000000..6317beb --- /dev/null +++ b/src/scripts/email/ReimbursementEmailNotifications.ts @@ -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 { + try { + // Get reimbursement details + const reimbursement = await this.get.getOne('reimbursement', reimbursementId); + if (!reimbursement) { + console.error('Reimbursement not found:', reimbursementId); + return false; + } + + // Get submitter user details + const user = await this.get.getOne('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('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 { + try { + // Don't send emails for private comments (for now) + if (isPrivate) { + return true; + } + + // Get reimbursement details + const reimbursement = await this.get.getOne('reimbursement', reimbursementId); + if (!reimbursement) { + console.error('Reimbursement not found:', reimbursementId); + return false; + } + + // Get submitter user details + const user = await this.get.getOne('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('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 { + try { + // Get reimbursement details + const reimbursement = await this.get.getOne('reimbursement', reimbursementId); + if (!reimbursement) { + console.error('Reimbursement not found:', reimbursementId); + return false; + } + + // Get submitter user details + const user = await this.get.getOne('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('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 + ): Promise { + 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 { + 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 { + 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; + } + } +} \ No newline at end of file