diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f48d858 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Add your environment variables here +GOOGLE_API_KEY=GOOGLE_API_KEY +MANTIS_API_KEY=MANTIS_API_KEY +MANTIS_API_ENDPOINT=https://styletech.mantishub.io/api/rest +DATABASE_URL="postgresql://sts-sls-utility:MY_SECURE_PASSWORD@localhost:5432/sts-sls-utility?schema=public" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f1d913c..998ab1b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,9 @@ yarn-error.log* *.sln # local .env files +.env .env.local* + +/postgres + +docker-compose.yml \ No newline at end of file diff --git a/docker-compose-example.yml b/docker-compose-example.yml new file mode 100644 index 0000000..71c5a9c --- /dev/null +++ b/docker-compose-example.yml @@ -0,0 +1,17 @@ +services: + postgres_db: + image: postgres:latest # Use the latest official PostgreSQL image. Consider pinning to a specific version (e.g., postgres:15) for production. + container_name: sts_sls_utility_postgres # A friendly name for the container + restart: unless-stopped # Automatically restart the container unless it was manually stopped + environment: + POSTGRES_USER: sts-sls-utility # Sets the default username as requested + POSTGRES_PASSWORD: MY_RANDOM_PASSWORD # Replace with a secure password + POSTGRES_DB: sts-sls-utility + volumes: + # Mounts the host directory './postgres' into the container's data directory + # This ensures data persists even if the container is removed and recreated. + - ./postgres:/var/lib/postgresql/data + ports: + # Maps port 5432 on your host machine to port 5432 inside the container + # You can change the host port if 5432 is already in use (e.g., "5433:5432") + - "5432:5432" \ No newline at end of file diff --git a/package.json b/package.json index 79d9f35..45c41f1 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,22 @@ "private": true, "scripts": { "test": "echo \"No test specified\" && exit 0", - "dev": "quasar dev -m ssr", + "dev": "prisma db push && quasar dev -m ssr", "build": "quasar build -m ssr", "postinstall": "quasar prepare" }, "dependencies": { "@google/genai": "^0.9.0", + "@prisma/client": "^6.6.0", "@quasar/extras": "^1.16.4", "axios": "^1.8.4", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", "dotenv": "^16.5.0", + "mailparser": "^3.7.2", + "marked": "^15.0.9", + "node-cron": "^3.0.3", + "node-imap": "^0.9.6", "pdfkit": "^0.17.0", "pdfmake": "^0.2.18", "quasar": "^2.16.0", @@ -28,7 +33,8 @@ "devDependencies": { "@quasar/app-vite": "^2.1.0", "autoprefixer": "^10.4.2", - "postcss": "^8.4.14" + "postcss": "^8.4.14", + "prisma": "^6.6.0" }, "engines": { "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b920f5..bafe07c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@google/genai': specifier: ^0.9.0 version: 0.9.0 + '@prisma/client': + specifier: ^6.6.0 + version: 6.6.0(prisma@6.6.0) '@quasar/extras': specifier: ^1.16.4 version: 1.16.17 @@ -26,6 +29,18 @@ importers: dotenv: specifier: ^16.5.0 version: 16.5.0 + mailparser: + specifier: ^3.7.2 + version: 3.7.2 + marked: + specifier: ^15.0.9 + version: 15.0.9 + node-cron: + specifier: ^3.0.3 + version: 3.0.3 + node-imap: + specifier: ^0.9.6 + version: 0.9.6 pdfkit: specifier: ^0.17.0 version: 0.17.0 @@ -51,6 +66,9 @@ importers: postcss: specifier: ^8.4.14 version: 8.5.3 + prisma: + specifier: ^6.6.0 + version: 6.6.0 packages: @@ -273,6 +291,36 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@prisma/client@6.6.0': + resolution: {integrity: sha512-vfp73YT/BHsWWOAuthKQ/1lBgESSqYqAWZEYyTdGXyFAHpmewwWL2Iz6ErIzkj4aHbuc6/cGSsE6ZY+pBO04Cg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.6.0': + resolution: {integrity: sha512-d8FlXRHsx72RbN8nA2QCRORNv5AcUnPXgtPvwhXmYkQSMF/j9cKaJg+9VcUzBRXGy9QBckNzEQDEJZdEOZ+ubA==} + + '@prisma/debug@6.6.0': + resolution: {integrity: sha512-DL6n4IKlW5k2LEXzpN60SQ1kP/F6fqaCgU/McgaYsxSf43GZ8lwtmXLke9efS+L1uGmrhtBUP4npV/QKF8s2ZQ==} + + '@prisma/engines-version@6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a': + resolution: {integrity: sha512-JzRaQ5Em1fuEcbR3nUsMNYaIYrOT1iMheenjCvzZblJcjv/3JIuxXN7RCNT5i6lRkLodW5ojCGhR7n5yvnNKrw==} + + '@prisma/engines@6.6.0': + resolution: {integrity: sha512-nC0IV4NHh7500cozD1fBoTwTD1ydJERndreIjpZr/S3mno3P6tm8qnXmIND5SwUkibNeSJMpgl4gAnlqJ/gVlg==} + + '@prisma/fetch-engine@6.6.0': + resolution: {integrity: sha512-Ohfo8gKp05LFLZaBlPUApM0M7k43a0jmo86YY35u1/4t+vuQH9mRGU7jGwVzGFY3v+9edeb/cowb1oG4buM1yw==} + + '@prisma/get-platform@6.6.0': + resolution: {integrity: sha512-3qCwmnT4Jh5WCGUrkWcc6VZaw0JY7eWN175/pcb5Z6FiLZZ3ygY93UX0WuV41bG51a6JN/oBH0uywJ90Y+V5eA==} + '@quasar/app-vite@2.2.0': resolution: {integrity: sha512-MvCfJrCbxUYvoGaK5jPq0h0hjO8mbxYOWngf+dIKrxhwb+1h5ERh6aVYEUuCtMIwTMEVfPkCez4DIfZIoReuDw==} engines: {node: ^30 || ^28 || ^26 || ^24 || ^22 || ^20 || ^18, npm: '>= 6.14.12', yarn: '>= 1.17.3'} @@ -421,6 +469,9 @@ packages: cpu: [x64] os: [win32] + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} @@ -842,6 +893,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + default-browser-id@5.0.0: resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} engines: {node: '>=18'} @@ -888,6 +943,19 @@ packages: dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -937,6 +1005,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -960,6 +1032,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + esbuild@0.25.3: resolution: {integrity: sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==} engines: {node: '>=18'} @@ -1149,11 +1226,22 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + html-minifier-terser@7.2.0: resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} engines: {node: ^14.13.1 || >=16.0.0} hasBin: true + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -1304,9 +1392,24 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.3.6: + resolution: {integrity: sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + linebreak@1.1.0: resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -1323,6 +1426,17 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + mailparser@3.7.2: + resolution: {integrity: sha512-iI0p2TCcIodR1qGiRoDBBwboSSff50vQAWytM5JRggLfABa4hHYCf3YVujtuzV454xrOP352VsAPIzviqMTo4Q==} + + mailsplit@5.4.2: + resolution: {integrity: sha512-4cczG/3Iu3pyl8JgQ76dKkisurZTmxMrA4dj/e8d2jKYcFTZ7MxOzg1gTioTDMPuFXwTrVuN/gxhkrO7wLg7qA==} + + marked@15.0.9: + resolution: {integrity: sha512-9AW/bn9DxQeZVjR52l5jsc0W2pwuhP04QaQewPvylil12Cfr2GBfWmgp6mu8i9Jy8UlBjqDZ9uMTDuJ8QOGZJA==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1417,6 +1531,10 @@ packages: resolution: {integrity: sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==} engines: {node: '>=10'} + node-cron@3.0.3: + resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} + engines: {node: '>=6.0.0'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1430,9 +1548,17 @@ packages: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-imap@0.9.6: + resolution: {integrity: sha512-pYQ2AtjQwrSvILq8EYInv3E3svrJwrTOxzW7uBGpP//AkCs/pMdO+O6KEgUlSchh/0/N0MSWs5io3xZhxJ9yLg==} + engines: {node: '>=0.8.0'} + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nodemailer@6.9.16: + resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -1493,6 +1619,9 @@ packages: param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1521,6 +1650,9 @@ packages: resolution: {integrity: sha512-Fe+GnMS8EVZu5rci/CDaQ+xmUoHvx8P+rvIlrwSYM6A5c7Aik8G6lpJbddhjBE2jXGjv6WcUCFCB06uZbjxkMw==} engines: {node: '>=18'} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1550,6 +1682,16 @@ packages: engines: {node: '>=10'} hasBin: true + prisma@6.6.0: + resolution: {integrity: sha512-SYCUykz+1cnl6Ugd8VUvtTQq5+j1Q7C0CtzKPjQ8JyA2ALh0EEJkMCS+KgdnvKW1lrxjtjCyJSHOOT236mENYg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1567,6 +1709,10 @@ packages: pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -1796,10 +1942,17 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + selfsigned@2.4.1: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} + semver@5.3.0: + resolution: {integrity: sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==} + hasBin: true + semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -1961,6 +2114,10 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tlds@1.255.0: + resolution: {integrity: sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -2002,6 +2159,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -2028,6 +2188,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + utf7@1.0.2: + resolution: {integrity: sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2035,6 +2198,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -2344,6 +2511,38 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@prisma/client@6.6.0(prisma@6.6.0)': + optionalDependencies: + prisma: 6.6.0 + + '@prisma/config@6.6.0': + dependencies: + esbuild: 0.25.3 + esbuild-register: 3.6.0(esbuild@0.25.3) + transitivePeerDependencies: + - supports-color + + '@prisma/debug@6.6.0': {} + + '@prisma/engines-version@6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a': {} + + '@prisma/engines@6.6.0': + dependencies: + '@prisma/debug': 6.6.0 + '@prisma/engines-version': 6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a + '@prisma/fetch-engine': 6.6.0 + '@prisma/get-platform': 6.6.0 + + '@prisma/fetch-engine@6.6.0': + dependencies: + '@prisma/debug': 6.6.0 + '@prisma/engines-version': 6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a + '@prisma/get-platform': 6.6.0 + + '@prisma/get-platform@6.6.0': + dependencies: + '@prisma/debug': 6.6.0 + '@quasar/app-vite@2.2.0(@types/node@22.14.1)(quasar@2.18.1)(rollup@4.40.0)(terser@5.39.0)(vue-router@4.5.0(vue@3.5.13))(vue@3.5.13)': dependencies: '@quasar/render-ssr-error': 1.0.3 @@ -2479,6 +2678,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.0': optional: true + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@swc/helpers@0.5.17': dependencies: tslib: 2.8.1 @@ -2954,6 +3158,8 @@ snapshots: deep-extend@0.6.0: {} + deepmerge@4.3.1: {} + default-browser-id@5.0.0: {} default-browser@5.2.1: @@ -2991,6 +3197,24 @@ snapshots: dfa@1.2.0: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -3034,6 +3258,8 @@ snapshots: encodeurl@2.0.0: {} + encoding-japanese@2.2.0: {} + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -3055,6 +3281,13 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild-register@3.6.0(esbuild@0.25.3): + dependencies: + debug: 4.4.0 + esbuild: 0.25.3 + transitivePeerDependencies: + - supports-color + esbuild@0.25.3: optionalDependencies: '@esbuild/aix-ppc64': 0.25.3 @@ -3313,6 +3546,8 @@ snapshots: dependencies: function-bind: 1.1.2 + he@1.2.0: {} + html-minifier-terser@7.2.0: dependencies: camel-case: 4.1.2 @@ -3323,6 +3558,21 @@ snapshots: relateurl: 0.2.7 terser: 5.39.0 + 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 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -3473,11 +3723,28 @@ snapshots: dependencies: readable-stream: 2.3.8 + leac@0.6.0: {} + + libbase64@1.3.0: {} + + libmime@5.3.6: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + linebreak@1.1.0: dependencies: base64-js: 0.0.8 unicode-trie: 2.0.0 + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lodash@4.17.21: {} log-symbols@4.1.0: @@ -3495,6 +3762,27 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + mailparser@3.7.2: + dependencies: + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.6.3 + libmime: 5.3.6 + linkify-it: 5.0.0 + mailsplit: 5.4.2 + nodemailer: 6.9.16 + punycode.js: 2.3.1 + tlds: 1.255.0 + + mailsplit@5.4.2: + dependencies: + libbase64: 1.3.0 + libmime: 5.3.6 + libqp: 2.1.1 + + marked@15.0.9: {} + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -3561,14 +3849,25 @@ snapshots: dependencies: semver: 7.7.1 + node-cron@3.0.3: + dependencies: + uuid: 8.3.2 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 node-forge@1.3.1: {} + node-imap@0.9.6: + dependencies: + readable-stream: 3.6.2 + utf7: 1.0.2 + node-releases@2.0.19: {} + nodemailer@6.9.16: {} + normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -3632,6 +3931,11 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + parseurl@1.3.3: {} pascal-case@3.1.2: @@ -3665,6 +3969,8 @@ snapshots: iconv-lite: 0.6.3 xmldoc: 1.3.0 + peberminta@0.9.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3702,6 +4008,15 @@ snapshots: tar-fs: 2.1.2 tunnel-agent: 0.6.0 + prisma@6.6.0: + dependencies: + '@prisma/config': 6.6.0 + '@prisma/engines': 6.6.0 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - supports-color + process-nextick-args@2.0.1: {} process@0.11.10: {} @@ -3718,6 +4033,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 + punycode.js@2.3.1: {} + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -3941,11 +4258,17 @@ snapshots: sax@1.4.1: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + selfsigned@2.4.1: dependencies: '@types/node-forge': 1.3.11 node-forge: 1.3.1 + semver@5.3.0: {} + semver@7.7.1: {} send@0.19.0: @@ -4152,6 +4475,8 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tlds@1.255.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -4181,6 +4506,8 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + uc.micro@2.1.0: {} + ufo@1.6.1: {} undici-types@6.21.0: {} @@ -4205,10 +4532,16 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + utf7@1.0.2: + dependencies: + semver: 5.3.0 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} + uuid@8.3.2: {} + uuid@9.0.1: {} varint@6.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd6a2fd..4bc88d5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ onlyBuiltDependencies: + - '@prisma/client' - better-sqlite3 - esbuild - sqlite3 diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a16bad4 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,101 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Form { + id Int @id @default(autoincrement()) + title String + description String? + createdAt DateTime @default(now()) @map("created_at") + categories Category[] + responses Response[] + + @@map("forms") // Map to the 'forms' table +} + +model Category { + id Int @id @default(autoincrement()) + formId Int @map("form_id") + name String + sortOrder Int @default(0) @map("sort_order") + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + fields Field[] + + @@map("categories") // Map to the 'categories' table +} + +enum FieldType { + text + number + date + textarea + boolean +} + +model Field { + id Int @id @default(autoincrement()) + categoryId Int @map("category_id") + label String + type FieldType // Using Prisma Enum based on CHECK constraint + description String? + sortOrder Int @map("sort_order") + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + responseValues ResponseValue[] + + @@map("fields") // Map to the 'fields' table +} + +model Response { + id Int @id @default(autoincrement()) + formId Int @map("form_id") + submittedAt DateTime @default(now()) @map("submitted_at") + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + responseValues ResponseValue[] + + @@map("responses") // Map to the 'responses' table +} + +model ResponseValue { + id Int @id @default(autoincrement()) + responseId Int @map("response_id") + fieldId Int @map("field_id") + value String? + response Response @relation(fields: [responseId], references: [id], onDelete: Cascade) + field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade) + + @@map("response_values") // Map to the 'response_values' table +} + +model MantisSummary { + id Int @id @default(autoincrement()) + summaryDate DateTime @unique @db.Date @map("summary_date") + summaryText String @map("summary_text") + generatedAt DateTime @default(now()) @map("generated_at") + + @@map("mantis_summaries") // Map to the 'mantis_summaries' table +} + +model EmailSummary { + id Int @id @default(autoincrement()) + summaryDate DateTime @unique @db.Date + summaryText String + generatedAt DateTime @default(now()) +} + +model Setting { + key String @id + value String + + @@map("settings") // Map to the 'settings' table +} diff --git a/quasar.config.js b/quasar.config.js index 1a6dfd7..69684c3 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -73,7 +73,7 @@ export default defineConfig((/* ctx */) => { // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework framework: { config: { - dark: "auto" + dark: true }, // iconSet: 'material-icons', // Quasar icon set diff --git a/src-ssr/database.js b/src-ssr/database.js index 574eb5d..129e485 100644 --- a/src-ssr/database.js +++ b/src-ssr/database.js @@ -1,110 +1,14 @@ -import Database from 'better-sqlite3'; -import { join } from 'path'; -import { fileURLToPath } from 'url'; -import fs from 'fs'; // Needed to check if db file exists +import { PrismaClient } from '@prisma/client'; -// Determine the database path relative to this file -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const dbPath = join(__dirname, 'forms.db'); +// Instantiate Prisma Client +const prisma = new PrismaClient(); -let db = null; +// Export the Prisma Client instance for use in other modules +export default prisma; -export function initializeDatabase() { - if (db) { - return db; - } +// --- Old better-sqlite3 code removed --- +// No need for initializeDatabase, getDb, closeDatabase, etc. +// Prisma Client manages the connection pool. - try { - // Check if the directory exists, create if not (better-sqlite3 might need this) - const dbDir = join(__dirname); - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); - } - - // better-sqlite3 constructor opens/creates the database file - db = new Database(dbPath, { verbose: console.log }); // Enable verbose logging - - console.log('Connected to the SQLite database using better-sqlite3.'); - - // Ensure WAL mode is enabled for better concurrency - db.pragma('journal_mode = WAL'); - - // Create tables if they don't exist (run sequentially) - db.exec(` - CREATE TABLE IF NOT EXISTS forms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - createdAt DATETIME DEFAULT CURRENT_TIMESTAMP - ); - `); - db.exec(` - CREATE TABLE IF NOT EXISTS categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - formId INTEGER NOT NULL, - name TEXT NOT NULL, - sortOrder INTEGER DEFAULT 0, - FOREIGN KEY (formId) REFERENCES forms (id) ON DELETE CASCADE - ); - `); - db.exec(` - CREATE TABLE IF NOT EXISTS fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - categoryId INTEGER NOT NULL, - label TEXT NOT NULL, - type TEXT NOT NULL CHECK(type IN ('text', 'number', 'date', 'textarea', 'boolean')), - description TEXT, - sortOrder INTEGER NOT NULL, - FOREIGN KEY (categoryId) REFERENCES categories(id) ON DELETE CASCADE - ); - `); - db.exec(` - CREATE TABLE IF NOT EXISTS responses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - formId INTEGER NOT NULL, - submittedAt DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (formId) REFERENCES forms (id) ON DELETE CASCADE - ); - `); - db.exec(` - CREATE TABLE IF NOT EXISTS response_values ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - responseId INTEGER NOT NULL, - fieldId INTEGER NOT NULL, - value TEXT, - FOREIGN KEY (responseId) REFERENCES responses (id) ON DELETE CASCADE, - FOREIGN KEY (fieldId) REFERENCES fields (id) ON DELETE CASCADE - ); - `); - - console.log('Database tables ensured.'); - return db; - } catch (err) { - console.error('Error initializing database with better-sqlite3:', err.message); - throw err; // Re-throw the error - } -} - -export function getDb() { - if (!db) { - // Try to initialize if not already done (e.g., during hot reload) - try { - initializeDatabase(); - } catch (err) { - throw new Error('Database not initialized and initialization failed.'); - } - if (!db) { // Check again after trying to initialize - throw new Error('Database not initialized. Call initializeDatabase first.'); - } - } - return db; -} - -// Optional: Add a function to close the database gracefully on server shutdown -export function closeDatabase() { - if (db) { - db.close(); - db = null; - console.log('Database connection closed.'); - } -} +// --- Settings Functions removed --- +// Settings can now be accessed via prisma.setting.findUnique, prisma.setting.upsert, etc. diff --git a/src-ssr/routes/api.js b/src-ssr/routes/api.js index efc94f3..004f572 100644 --- a/src-ssr/routes/api.js +++ b/src-ssr/routes/api.js @@ -1,467 +1,404 @@ import { Router } from 'express'; -import { getDb } from '../database.js'; -import PDFDocument from 'pdfkit'; // Import pdfkit -import axios from 'axios'; // Added for Mantis -import { GoogleGenAI } from '@google/genai'; // Added for GenAI -import * as fs from 'fs'; // Added for reading prompt file -import * as path from 'path'; // Added for path manipulation +import prisma from '../database.js'; // Import Prisma client +import PDFDocument from 'pdfkit'; +import { join } from 'path'; +import { generateTodaysSummary } from '../services/mantisSummarizer.js'; // Keep mantisSummarizer import +import { generateAndStoreEmailSummary } from '../services/emailSummarizer.js'; // Import email summarizer function +import { FieldType } from '@prisma/client'; // Import generated FieldType enum const router = Router(); const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); -import { join } from 'path'; -// --- Environment Variables (Ensure these are set in your .env file) --- -const { - MANTIS_API_KEY, - MANTIS_API_ENDPOINT, - GOOGLE_API_KEY -} = process.env; - -// --- Mantis Summarizer Setup --- -const promptFilePath = join(__dirname, 'prompt.txt'); // Path relative to this file - -const ai = GOOGLE_API_KEY ? new GoogleGenAI({ // Check if API key exists - apiKey: GOOGLE_API_KEY, -}) : null; - -const usernameMap = { - 'credmore': 'Cameron Redmore', - 'dgibson': 'Dane Gibson', - 'egzibovskis': 'Ed Gzibovskis', - 'ascotney': 'Amanda Scotney', - 'gclough': 'Garry Clough', - 'slee': 'Sarah Lee', - 'dwalker': 'Dave Walker', - 'askaith': 'Amy Skaith', - 'dpotter': 'Danny Potter', - 'msmart': 'Michael Smart', - // Add other usernames as needed +// Helper function for consistent error handling +const handlePrismaError = (res, err, context) => { + console.error(`Error ${context}:`, err.message); + // Basic error handling, can be expanded (e.g., check for Prisma-specific error codes) + if (err.code === 'P2025') { // Prisma code for record not found + return res.status(404).json({ error: `${context}: Record not found` }); + } + res.status(500).json({ error: `Failed to ${context}: ${err.message}` }); }; -async function getMantisTickets() { - if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) { - throw new Error("Mantis API endpoint or key not configured in environment variables."); - } - const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`; - const headers = { - 'Authorization': `${MANTIS_API_KEY}`, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }; - - try { - const response = await axios.get(url, { headers }); - - const tickets = response.data.issues.filter((ticket) => { - const ticketDate = new Date(ticket.updated_at); - const thresholdDate = new Date(); - const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ... - - // Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days - const daysToSubtract = currentDay === 1 ? 4 : 2; - thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); - thresholdDate.setHours(0, 0, 0, 0); // Start of the day - - return ticketDate >= thresholdDate; - }).map((ticket) => { - return { - id: ticket.id, - summary: ticket.summary, - description: ticket.description, - created_at: ticket.created_at, - updated_at: ticket.updated_at, - reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access - notes: (ticket.notes ? ticket.notes.filter((note) => { - const noteDate = new Date(note.created_at); - const thresholdDate = new Date(); - const currentDay = thresholdDate.getDay(); - const daysToSubtract = currentDay === 1 ? 4 : 2; - thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); - thresholdDate.setHours(0, 0, 0, 0); // Start of the day - return noteDate >= thresholdDate; - }) : []).map((note) => { - const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access - return { - reporter, - created_at: note.created_at, - text: note.text, - }; - }), - }; - }); - - return tickets; - } catch (error) { - console.error("Error fetching Mantis tickets:", error.message); - // Check if it's an Axios error and provide more details - if (axios.isAxiosError(error)) { - console.error("Axios error details:", error.response?.status, error.response?.data); - throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`); - } - throw new Error(`Failed to fetch Mantis tickets: ${error.message}`); - } -} - // --- Forms API --- // // GET /api/forms - List all forms -router.get('/forms', (req, res) => { +router.get('/forms', async (req, res) => { try { - const db = getDb(); - const forms = db.prepare('SELECT id, title, description, createdAt FROM forms ORDER BY createdAt DESC').all(); + const forms = await prisma.form.findMany({ + orderBy: { + createdAt: 'desc', + }, + select: { // Select only necessary fields + id: true, + title: true, + description: true, + createdAt: true, + } + }); res.json(forms); } catch (err) { - console.error('Error fetching forms:', err.message); - res.status(500).json({ error: 'Failed to fetch forms' }); + handlePrismaError(res, err, 'fetch forms'); } }); // POST /api/forms - Create a new form -router.post('/forms', (req, res) => { +router.post('/forms', async (req, res) => { const { title, description, categories } = req.body; if (!title) { return res.status(400).json({ error: 'Form title is required' }); } - const db = getDb(); - const insertForm = db.prepare('INSERT INTO forms (title, description) VALUES (?, ?)'); - const insertCategory = db.prepare('INSERT INTO categories (formId, name, sortOrder) VALUES (?, ?, ?)'); - const insertField = db.prepare('INSERT INTO fields (categoryId, label, type, description, sortOrder) VALUES (?, ?, ?, ?, ?)'); - - const createTransaction = db.transaction((formData) => { - const { title, description, categories } = formData; - const formResult = insertForm.run(title, description); - const formId = formResult.lastInsertRowid; - - if (categories && categories.length > 0) { - for (const [catIndex, category] of categories.entries()) { - if (!category.name) throw new Error('Category name is required'); - - const catResult = insertCategory.run(formId, category.name, catIndex); - const categoryId = catResult.lastInsertRowid; - - if (category.fields && category.fields.length > 0) { - for (const [fieldIndex, field] of category.fields.entries()) { - if (!field.label || !field.type) { - throw new Error('Field label and type are required'); - } - const validTypes = ['text', 'number', 'date', 'textarea', 'boolean']; - if (!validTypes.includes(field.type)) { - throw new Error(`Invalid field type: ${field.type}`); - } - insertField.run(categoryId, field.label, field.type, field.description || null, fieldIndex); - } - } - } - } - return { id: formId, title, description }; - }); - try { - const resultData = createTransaction({ title, description, categories }); - res.status(201).json(resultData); + const newForm = await prisma.form.create({ + data: { + title, + description, + categories: { + create: categories?.map((category, catIndex) => ({ + name: category.name, + sortOrder: catIndex, + fields: { + create: category.fields?.map((field, fieldIndex) => { + // Validate field type against Prisma Enum + if (!Object.values(FieldType).includes(field.type)) { + throw new Error(`Invalid field type: ${field.type}`); + } + if (!field.label) { + throw new Error('Field label is required'); + } + return { + label: field.label, + type: field.type, + description: field.description || null, + sortOrder: fieldIndex, + }; + }) || [], + }, + })) || [], + }, + }, + select: { // Return basic form info + id: true, + title: true, + description: true, + } + }); + res.status(201).json(newForm); } catch (err) { - console.error('Error creating form:', err.message); - res.status(500).json({ error: `Failed to create form: ${err.message}` }); + handlePrismaError(res, err, 'create form'); } }); // GET /api/forms/:id - Get a specific form with its structure -router.get('/forms/:id', (req, res) => { +router.get('/forms/:id', async (req, res) => { const { id } = req.params; + const formId = parseInt(id, 10); + + if (isNaN(formId)) { + return res.status(400).json({ error: 'Invalid form ID' }); + } + try { - const db = getDb(); - const form = db.prepare('SELECT id, title, description FROM forms WHERE id = ?').get(id); + const form = await prisma.form.findUnique({ + where: { id: formId }, + include: { + categories: { + orderBy: { sortOrder: 'asc' }, + include: { + fields: { + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + }, + }); + if (!form) { return res.status(404).json({ error: 'Form not found' }); } - - const categories = db.prepare(` - SELECT c.id, c.name, c.sortOrder - FROM categories c - WHERE c.formId = ? - ORDER BY c.sortOrder - `).all(id); - - const getFieldsStmt = db.prepare(` - SELECT f.id, f.label, f.type, f.description, f.sortOrder - FROM fields f - WHERE f.categoryId = ? - ORDER BY f.sortOrder - `); - - for (const category of categories) { - category.fields = getFieldsStmt.all(category.id); - } - - form.categories = categories; res.json(form); } catch (err) { - console.error(`Error fetching form ${id}:`, err.message); - res.status(500).json({ error: 'Failed to fetch form details' }); + handlePrismaError(res, err, `fetch form ${formId}`); } }); // DELETE /api/forms/:id - Delete a specific form and all related data -router.delete('/forms/:id', (req, res) => { - const formId = req.params.id; // Corrected destructuring +router.delete('/forms/:id', async (req, res) => { + const { id } = req.params; + const formId = parseInt(id, 10); - const db = getDb(); - const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?'); - const deleteValuesStmt = db.prepare('DELETE FROM response_values WHERE responseId IN (SELECT id FROM responses WHERE formId = ?)'); - const deleteResponsesStmt = db.prepare('DELETE FROM responses WHERE formId = ?'); - const deleteFieldsStmt = db.prepare('DELETE FROM fields WHERE categoryId IN (SELECT id FROM categories WHERE formId = ?)'); - const deleteCategoriesStmt = db.prepare('DELETE FROM categories WHERE formId = ?'); - const deleteFormStmt = db.prepare('DELETE FROM forms WHERE id = ?'); - - const deleteTransaction = db.transaction((id) => { - const form = checkFormStmt.get(id); - if (!form) { - const err = new Error('Form not found'); - err.statusCode = 404; - throw err; - } - - // Delete in order of dependency: values -> responses -> fields -> categories -> form - deleteValuesStmt.run(id); - deleteResponsesStmt.run(id); - deleteFieldsStmt.run(id); - deleteCategoriesStmt.run(id); - deleteFormStmt.run(id); - - return { message: `Form ${id} and all related data deleted successfully.` }; - }); - - try { - const resultData = deleteTransaction(formId); - res.status(200).json(resultData); - } catch (err) { - console.error(`Error deleting form ${formId}:`, err.message); - const statusCode = err.statusCode || 500; - res.status(statusCode).json({ error: `Failed to delete form: ${err.message}` }); - } -}); - -// --- Responses API --- // - -// POST /api/forms/:id/responses - Submit a response for a form -router.post('/forms/:id/responses', (req, res) => { - const { id: formId } = req.params; - const { values } = req.body; - - if (!values || typeof values !== 'object' || Object.keys(values).length === 0) { - return res.status(400).json({ error: 'Response values are required' }); + if (isNaN(formId)) { + return res.status(400).json({ error: 'Invalid form ID' }); } - const db = getDb(); - const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?'); - const checkFieldStmt = db.prepare('SELECT f.id FROM fields f JOIN categories c ON f.categoryId = c.id WHERE f.id = ? AND c.formId = ?'); - const insertResponseStmt = db.prepare('INSERT INTO responses (formId) VALUES (?)'); - const insertValueStmt = db.prepare('INSERT INTO response_values (responseId, fieldId, value) VALUES (?, ?, ?)'); - - const submitTransaction = db.transaction((formIdParam, responseValues) => { - const form = checkFormStmt.get(formIdParam); - if (!form) { - const err = new Error('Form not found'); - err.statusCode = 404; - throw err; - } - - const responseResult = insertResponseStmt.run(formIdParam); - const responseId = responseResult.lastInsertRowid; - - for (const [fieldIdStr, value] of Object.entries(responseValues)) { - const fieldId = parseInt(fieldIdStr, 10); - const field = checkFieldStmt.get(fieldId, formIdParam); - if (!field) { - console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formIdParam}`); - continue; - } - const valueToStore = (value === null || typeof value === 'undefined') ? null : String(value); - insertValueStmt.run(responseId, fieldId, valueToStore); - } - return { responseId }; - }); - try { - const resultData = submitTransaction(formId, values); - res.status(201).json(resultData); + // Prisma automatically handles cascading deletes based on schema relations (onDelete: Cascade) + const deletedForm = await prisma.form.delete({ + where: { id: formId }, + }); + res.status(200).json({ message: `Form ${formId} and all related data deleted successfully.` }); } catch (err) { - console.error(`Error submitting response for form ${formId}:`, err.message); - const statusCode = err.statusCode || 500; - res.status(statusCode).json({ error: `Failed to submit response: ${err.message}` }); - } -}); - -// GET /api/forms/:id/responses - Get all responses for a form -router.get('/forms/:id/responses', (req, res) => { - const { id: formId } = req.params; - try { - const db = getDb(); - const formExists = db.prepare('SELECT id FROM forms WHERE id = ?').get(formId); - if (!formExists) { - return res.status(404).json({ error: 'Form not found' }); - } - - const responses = db.prepare(` - SELECT r.id as responseId, r.submittedAt, - rv.fieldId, f.label as fieldLabel, f.type as fieldType, rv.value - FROM responses r - JOIN response_values rv ON r.id = rv.responseId - JOIN fields f ON rv.fieldId = f.id - JOIN categories c ON f.categoryId = c.id - WHERE r.formId = ? - ORDER BY r.submittedAt DESC, r.id, c.sortOrder, f.sortOrder - `).all(formId); - - const groupedResponses = responses.reduce((acc, row) => { - const { responseId, submittedAt, fieldId, fieldLabel, fieldType, value } = row; - if (!acc[responseId]) { - acc[responseId] = { - id: responseId, - submittedAt, - values: {} - }; - } - acc[responseId].values[fieldId] = { label: fieldLabel, type: fieldType, value }; - return acc; - }, {}); - - res.json(Object.values(groupedResponses)); - } catch (err) { - console.error(`Error fetching responses for form ${formId}:`, err.message); - res.status(500).json({ error: 'Failed to fetch responses' }); + handlePrismaError(res, err, `delete form ${formId}`); } }); // PUT /api/forms/:id - Update an existing form -router.put('/forms/:id', (req, res) => { - const { id: formId } = req.params; +router.put('/forms/:id', async (req, res) => { + const { id } = req.params; + const formId = parseInt(id, 10); const { title, description, categories } = req.body; + if (isNaN(formId)) { + return res.status(400).json({ error: 'Invalid form ID' }); + } if (!title) { return res.status(400).json({ error: 'Form title is required' }); } - const db = getDb(); - const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?'); - const updateFormStmt = db.prepare('UPDATE forms SET title = ?, description = ? WHERE id = ?'); - const deleteFieldsStmt = db.prepare('DELETE FROM fields WHERE categoryId IN (SELECT id FROM categories WHERE formId = ?)'); - const deleteCategoriesStmt = db.prepare('DELETE FROM categories WHERE formId = ?'); - const insertCategoryStmt = db.prepare('INSERT INTO categories (formId, name, sortOrder) VALUES (?, ?, ?)'); - const insertFieldStmt = db.prepare('INSERT INTO fields (categoryId, label, type, description, sortOrder) VALUES (?, ?, ?, ?, ?)'); - - const updateTransaction = db.transaction((formData) => { - const { formId, title, description, categories } = formData; - - // 1. Check if form exists - const existingForm = checkFormStmt.get(formId); - if (!existingForm) { - const err = new Error('Form not found'); - err.statusCode = 404; - throw err; - } - - // 2. Delete existing categories and fields for this form - deleteFieldsStmt.run(formId); - deleteCategoriesStmt.run(formId); - - // 3. Update form details - updateFormStmt.run(title, description, formId); - - // 4. Re-insert categories and fields - if (categories && categories.length > 0) { - for (const [catIndex, category] of categories.entries()) { - if (!category.name) throw new Error('Category name is required'); - - const catResult = insertCategoryStmt.run(formId, category.name, catIndex); - const categoryId = catResult.lastInsertRowid; - - if (category.fields && category.fields.length > 0) { - for (const [fieldIndex, field] of category.fields.entries()) { - if (!field.label || !field.type) { - throw new Error('Field label and type are required'); - } - const validTypes = ['text', 'number', 'date', 'textarea', 'boolean']; - if (!validTypes.includes(field.type)) { - throw new Error(`Invalid field type: ${field.type}`); - } - insertFieldStmt.run(categoryId, field.label, field.type, field.description || null, fieldIndex); - } - } - } - } - // Return the updated form ID and title (or potentially the full updated form structure) - return { id: formId, title, description }; - }); - try { - const resultData = updateTransaction({ formId, title, description, categories }); - // Optionally fetch the full updated form structure here if needed for the response - res.status(200).json(resultData); // Send back basic info for now + // Use a transaction to ensure atomicity: delete old structure, update form, create new structure + const result = await prisma.$transaction(async (tx) => { + // 1. Check if form exists (optional, delete/update will fail if not found anyway) + const existingForm = await tx.form.findUnique({ where: { id: formId } }); + if (!existingForm) { + throw { code: 'P2025' }; // Simulate Prisma not found error + } + + // 2. Delete existing categories (fields and response values cascade) + await tx.category.deleteMany({ where: { formId: formId } }); + + // 3. Update form details and recreate categories/fields in one go + const updatedForm = await tx.form.update({ + where: { id: formId }, + data: { + title, + description, + categories: { + create: categories?.map((category, catIndex) => ({ + name: category.name, + sortOrder: catIndex, + fields: { + create: category.fields?.map((field, fieldIndex) => { + if (!Object.values(FieldType).includes(field.type)) { + throw new Error(`Invalid field type: ${field.type}`); + } + if (!field.label) { + throw new Error('Field label is required'); + } + return { + label: field.label, + type: field.type, + description: field.description || null, + sortOrder: fieldIndex, + }; + }) || [], + }, + })) || [], + }, + }, + select: { // Return basic form info + id: true, + title: true, + description: true, + } + }); + return updatedForm; + }); + + res.status(200).json(result); } catch (err) { - console.error(`Error updating form ${formId}:`, err.message); - const statusCode = err.statusCode || 500; - res.status(statusCode).json({ error: `Failed to update form: ${err.message}` }); + handlePrismaError(res, err, `update form ${formId}`); } }); -router.get('/responses/:responseId/export/pdf', async (req, res) => { - const { responseId } = req.params; + +// --- Responses API --- // + +// POST /api/forms/:id/responses - Submit a response for a form +router.post('/forms/:id/responses', async (req, res) => { + const { id } = req.params; + const formId = parseInt(id, 10); + const { values } = req.body; // values is expected to be { fieldId: value, ... } + + if (isNaN(formId)) { + return res.status(400).json({ error: 'Invalid form ID' }); + } + if (!values || typeof values !== 'object' || Object.keys(values).length === 0) { + return res.status(400).json({ error: 'Response values are required' }); + } try { - const db = getDb(); + // Use transaction to ensure response and values are created together + const result = await prisma.$transaction(async (tx) => { + // 1. Verify form exists + const form = await tx.form.findUnique({ where: { id: formId }, select: { id: true } }); + if (!form) { + throw { code: 'P2025' }; // Simulate Prisma not found error + } - // 1. Fetch the response and its associated form ID - const response = db.prepare(` - SELECT r.id, r.formId, r.submittedAt, f.title as formTitle - FROM responses r - JOIN forms f ON r.formId = f.id - WHERE r.id = ? - `).get(responseId); + // 2. Create the response record + const newResponse = await tx.response.create({ + data: { + formId: formId, + }, + select: { id: true } + }); - if (!response) { + // 3. Prepare response values data + const responseValuesData = []; + const fieldIds = Object.keys(values).map(k => parseInt(k, 10)); + + // Optional: Verify all field IDs belong to the form (more robust) + const validFields = await tx.field.findMany({ + where: { + id: { in: fieldIds }, + category: { formId: formId } + }, + select: { id: true } + }); + const validFieldIds = new Set(validFields.map(f => f.id)); + + for (const fieldIdStr in values) { + const fieldId = parseInt(fieldIdStr, 10); + if (validFieldIds.has(fieldId)) { + const value = values[fieldIdStr]; + responseValuesData.push({ + responseId: newResponse.id, + fieldId: fieldId, + value: (value === null || typeof value === 'undefined') ? null : String(value), + }); + } else { + console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formId}`); + // Decide whether to throw an error or just skip invalid fields + // throw new Error(`Field ${fieldId} does not belong to form ${formId}`); + } + } + + // 4. Create all response values + if (responseValuesData.length > 0) { + await tx.responseValue.createMany({ + data: responseValuesData, + }); + } + + return { responseId: newResponse.id }; + }); + + res.status(201).json(result); + } catch (err) { + handlePrismaError(res, err, `submit response for form ${formId}`); + } +}); + +// GET /api/forms/:id/responses - Get all responses for a form +router.get('/forms/:id/responses', async (req, res) => { + const { id } = req.params; + const formId = parseInt(id, 10); + + if (isNaN(formId)) { + return res.status(400).json({ error: 'Invalid form ID' }); + } + + try { + // 1. Check if form exists + const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } }); + if (!formExists) { + return res.status(404).json({ error: 'Form not found' }); + } + + // 2. Fetch responses with their values and related field info + const responses = await prisma.response.findMany({ + where: { formId: formId }, + orderBy: { submittedAt: 'desc' }, + include: { + responseValues: { + include: { + field: { + select: { label: true, type: true, category: { select: { sortOrder: true } }, sortOrder: true } // Include sort orders + } + } + } + } + }); + + // 3. Group data similar to the old structure for frontend compatibility + const groupedResponses = responses.map(response => ({ + id: response.id, + submittedAt: response.submittedAt, + values: response.responseValues + .sort((a, b) => { + // Sort by category order, then field order + const catSort = a.field.category.sortOrder - b.field.category.sortOrder; + if (catSort !== 0) return catSort; + return a.field.sortOrder - b.field.sortOrder; + }) + .reduce((acc, rv) => { + acc[rv.fieldId] = { + label: rv.field.label, + type: rv.field.type, + value: rv.value + }; + return acc; + }, {}) + })); + + res.json(groupedResponses); + } catch (err) { + handlePrismaError(res, err, `fetch responses for form ${formId}`); + } +}); + + +// GET /responses/:responseId/export/pdf - Export response as PDF +router.get('/responses/:responseId/export/pdf', async (req, res) => { + const { responseId: responseIdStr } = req.params; + const responseId = parseInt(responseIdStr, 10); + + if (isNaN(responseId)) { + return res.status(400).json({ error: 'Invalid response ID' }); + } + + try { + // 1. Fetch the response, form title, form structure, and values in one go + const responseData = await prisma.response.findUnique({ + where: { id: responseId }, + include: { + form: { + select: { + title: true, + categories: { + orderBy: { sortOrder: 'asc' }, + include: { + fields: { + orderBy: { sortOrder: 'asc' }, + select: { id: true, label: true, type: true, description: true } + } + } + } + } + }, + responseValues: { + select: { fieldId: true, value: true } + } + } + }); + + if (!responseData) { return res.status(404).json({ error: 'Response not found' }); } - const formId = response.formId; - const formTitle = response.formTitle; - - // 2. Fetch the form structure (categories and fields) - const categories = db.prepare(` - SELECT c.id, c.name - FROM categories c - WHERE c.formId = ? - ORDER BY c.sortOrder - `).all(formId); - - const getFieldsStmt = db.prepare(` - SELECT f.id, f.label, f.type, f.description - FROM fields f - WHERE f.categoryId = ? - ORDER BY f.sortOrder - `); - - for (const category of categories) { - category.fields = getFieldsStmt.all(category.id); - } - - // 3. Fetch the values for this specific response - const valuesResult = db.prepare(` - SELECT fieldId, value - FROM response_values - WHERE responseId = ? - `).all(responseId); - - const responseValues = valuesResult.reduce((acc, row) => { - acc[row.fieldId] = (row.value === null || typeof row.value === 'undefined') ? '' : String(row.value); + const formTitle = responseData.form.title; + const categories = responseData.form.categories; + const responseValues = responseData.responseValues.reduce((acc, rv) => { + acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value); return acc; }, {}); - // 4. Generate PDF using pdfkit - const doc = new PDFDocument({ margin: 50, size: 'A4' }); // Set size to A4 + // 4. Generate PDF using pdfkit (logic remains largely the same) + const doc = new PDFDocument({ margin: 50, size: 'A4' }); + const fontsDir = join(__dirname, '../../public/fonts'); doc.registerFont('Roboto-Bold', join(fontsDir, 'Roboto-Bold.ttf')); doc.registerFont('Roboto-SemiBold', join(fontsDir, 'Roboto-SemiBold.ttf')); @@ -469,17 +406,14 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => { doc.registerFont('Roboto-Regular', join(fontsDir, 'Roboto-Regular.ttf')); res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `inline; filename=response_${responseId}_${formTitle.replace(/[\s\\/]/g, '_') || 'form'}.pdf`); // Use inline to preview + res.setHeader('Content-Disposition', `inline; filename=response_${responseId}_${formTitle.replace(/[\s\\/]/g, '_') || 'form'}.pdf`); doc.pipe(res); - // --- PDF Content --- - - // Title + // --- PDF Content (remains the same as before) --- doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' }); doc.moveDown(); - // Iterate through categories and fields for (const category of categories) { if (category.name) { doc.fontSize(14).font('Roboto-Bold').text(category.name); @@ -487,111 +421,215 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => { } for (const field of category.fields) { - const value = responseValues[field.id] || ''; // Get the value for this field - - // Field Label - doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false }); // Use continued: false to reset position potentially - // Optional: Add description + const value = responseValues[field.id] || ''; + doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false }); if (field.description) { doc.fontSize(9).font('Roboto-Italics').text(field.description); } doc.moveDown(0.2); - - // Field Value (mimic input) doc.fontSize(11).font('Roboto-Regular'); if (field.type === 'textarea') { - // Draw a box and put text inside for textarea - const textHeight = doc.heightOfString(value, { width: 500 }); // Estimate height - doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke(); // Draw rectangle - doc.text(value, doc.x + 5, doc.y + 5, { width: 490 }); // Add text inside with padding - doc.y += Math.max(textHeight + 10, 30) + 10; // Move below the box + const textHeight = doc.heightOfString(value, { width: 500 }); + doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke(); + doc.text(value, doc.x + 5, doc.y + 5, { width: 490 }); + doc.y += Math.max(textHeight + 10, 30) + 10; } else if (field.type === 'date') { - // Format date as DD/MM/YYYY let formattedDate = ''; if (value) { try { - const dateObj = new Date(value + 'T00:00:00'); // Add time part to avoid timezone issues with just YYYY-MM-DD + const dateObj = new Date(value + 'T00:00:00'); if (!isNaN(dateObj.getTime())) { const day = String(dateObj.getDate()).padStart(2, '0'); - const month = String(dateObj.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const year = dateObj.getFullYear(); formattedDate = `${day}/${month}/${year}`; } else { - formattedDate = value; // Keep original if invalid + formattedDate = value; } } catch (e) { console.error('Error formatting date:', value, e); - formattedDate = value; // Keep original on error + formattedDate = value; } } - doc.text(formattedDate || ' '); // Add space if empty - doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath - doc.moveDown(1.5); // Space between fields + doc.text(formattedDate || ' '); + doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); + doc.moveDown(1.5); } else if (field.type === 'boolean') { - // Display boolean as Yes/No const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' '); doc.text(displayValue); - doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath - doc.moveDown(1.5); // Space between fields + doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); + doc.moveDown(1.5); } else { - // Simple line for other types - doc.text(value || ' '); // Add space if empty to ensure line moves - doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath - doc.moveDown(1.5); // Space between fields + doc.text(value || ' '); + doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); + doc.moveDown(1.5); } } - doc.moveDown(1); // Space between categories + doc.moveDown(1); } - - // --- Finalize PDF --- doc.end(); } catch (err) { console.error(`Error generating PDF for response ${responseId}:`, err.message); if (!res.headersSent) { - res.status(500).json({ error: 'Failed to generate PDF' }); + // Use the helper function + handlePrismaError(res, err, `generate PDF for response ${responseId}`); } else { console.error("Headers already sent, could not send JSON error for PDF generation failure."); - // Ensure the stream is ended if an error occurs after piping res.end(); } } }); -// --- Mantis Summary API --- // -router.get('/mantis-summary', async (req, res) => { - if (!ai) { - return res.status(500).json({ error: 'Google AI API key not configured.' }); +// --- Mantis Summary API Route --- // + +// GET /api/mantis-summary/today - Get today's summary specifically +router.get('/mantis-summary/today', async (req, res) => { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); // Set to start of day UTC for comparison + + const todaySummary = await prisma.mantisSummary.findUnique({ + where: { summaryDate: today }, + select: { summaryDate: true, summaryText: true, generatedAt: true } + }); + + if (todaySummary) { + res.json(todaySummary); + } else { + res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` }); + } + } catch (error) { + handlePrismaError(res, error, 'fetch today\'s Mantis summary'); } - if (!fs.existsSync(promptFilePath)) { - return res.status(500).json({ error: `Prompt file not found at ${promptFilePath}` }); +}); + +// GET /api/mantis-summaries - Get ALL summaries from the DB, with pagination +router.get('/mantis-summaries', async (req, res) => { + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 10; + const skip = (page - 1) * limit; + + try { + const [summaries, totalItems] = await prisma.$transaction([ + prisma.mantisSummary.findMany({ + orderBy: { summaryDate: 'desc' }, + take: limit, + skip: skip, + select: { id: true, summaryDate: true, summaryText: true, generatedAt: true } + }), + prisma.mantisSummary.count() + ]); + + res.json({ summaries, total: totalItems }); + } catch (error) { + handlePrismaError(res, error, 'fetch paginated Mantis summaries'); + } +}); + +// POST /api/mantis-summaries/generate - Trigger summary generation +router.post('/mantis-summaries/generate', async (req, res) => { + try { + // Trigger generation asynchronously, don't wait for it + generateTodaysSummary() + .then(() => { + console.log('Summary generation process finished successfully (async).'); + }) + .catch(error => { + console.error('Background summary generation failed:', error); + }); + + res.status(202).json({ message: 'Summary generation started.' }); + } catch (error) { + handlePrismaError(res, error, 'initiate Mantis summary generation'); + } +}); + +// --- Email Summary API Routes --- // + +// GET /api/email-summaries - Get ALL email summaries from the DB, with pagination +router.get('/email-summaries', async (req, res) => { + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 10; + const skip = (page - 1) * limit; + + try { + const [summaries, totalItems] = await prisma.$transaction([ + prisma.emailSummary.findMany({ // Use emailSummary model + orderBy: { summaryDate: 'desc' }, + take: limit, + skip: skip, + select: { id: true, summaryDate: true, summaryText: true, generatedAt: true } + }), + prisma.emailSummary.count() // Count emailSummary model + ]); + + res.json({ summaries, total: totalItems }); + } catch (error) { + handlePrismaError(res, error, 'fetch paginated Email summaries'); + } +}); + +// POST /api/email-summaries/generate - Trigger email summary generation +router.post('/email-summaries/generate', async (req, res) => { + try { + // Trigger generation asynchronously, don't wait for it + generateAndStoreEmailSummary() // Use the email summarizer function + .then(() => { + console.log('Email summary generation process finished successfully (async).'); + }) + .catch(error => { + console.error('Background email summary generation failed:', error); + }); + + res.status(202).json({ message: 'Email summary generation started.' }); + } catch (error) { + handlePrismaError(res, error, 'initiate Email summary generation'); + } +}); + + +// --- Settings API --- // + +// GET /api/settings/:key - Get a specific setting value +router.get('/settings/:key', async (req, res) => { + const { key } = req.params; + try { + const setting = await prisma.setting.findUnique({ + where: { key: key }, + select: { value: true } + }); + + if (setting !== null) { + res.json({ key, value: setting.value }); + } else { + res.json({ key, value: '' }); // Return empty value if not found + } + } catch (err) { + handlePrismaError(res, err, `fetch setting '${key}'`); + } +}); + +// PUT /api/settings/:key - Update or create a specific setting +router.put('/settings/:key', async (req, res) => { + const { key } = req.params; + const { value } = req.body; + + if (typeof value === 'undefined') { + return res.status(400).json({ error: 'Setting value is required in the request body' }); } try { - // Read the prompt from the file - let promptTemplate = fs.readFileSync(promptFilePath, 'utf8'); - const tickets = await getMantisTickets(); - - if (tickets.length === 0) { - return res.json({ summary: "No Mantis tickets updated recently." }); - } - - let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]); - prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2)); - - // Use the specific model and configuration from your original script - const model = ai.getGenerativeModel({ model: "gemini-2.5-flash-exp" }); // Or your specific model like "gemini-2.0-flash-exp" if available - const result = await model.generateContent(prompt); - const response = await result.response; - const summaryText = response.text(); - - - res.json({ summary: summaryText }); - - } catch (error) { - console.error("Error generating Mantis summary:", error); - res.status(500).json({ error: `Failed to generate summary: ${error.message}` }); + const upsertedSetting = await prisma.setting.upsert({ + where: { key: key }, + update: { value: String(value) }, + create: { key: key, value: String(value) }, + select: { key: true, value: true } // Select to return the updated/created value + }); + res.status(200).json(upsertedSetting); + } catch (err) { + handlePrismaError(res, err, `update setting '${key}'`); } }); diff --git a/src-ssr/server.js b/src-ssr/server.js index 0038d4e..0dab099 100644 --- a/src-ssr/server.js +++ b/src-ssr/server.js @@ -19,9 +19,10 @@ import { defineSsrRenderPreloadTag } from '#q-app/wrappers' -// Import database initialization and close function -import { initializeDatabase, closeDatabase } from './database.js'; +import prisma from './database.js'; // Import the prisma client instance import apiRoutes from './routes/api.js'; +import cron from 'node-cron'; +import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; /** * Create your webserver and return its instance. @@ -35,12 +36,31 @@ export const create = defineSsrCreate((/* { ... } */) => { // Initialize the database (now synchronous) try { - initializeDatabase(); - console.log('Database initialized successfully.'); + console.log('Prisma Client is ready.'); // Log Prisma readiness + + // Schedule the Mantis summary task after DB initialization + // Run daily at 1:00 AM server time (adjust as needed) + cron.schedule('0 1 * * *', async () => { + console.log('Running scheduled Mantis summary task...'); + try { + await generateAndStoreMantisSummary(); + console.log('Scheduled Mantis summary task completed.'); + } catch (error) { + console.error('Error running scheduled Mantis summary task:', error); + } + }, { + scheduled: true, + timezone: "Europe/London" // Example: Set to your server's timezone + }); + console.log('Mantis summary cron job scheduled.'); + + // Optional: Run once immediately on server start if needed + generateAndStoreMantisSummary().catch(err => console.error('Initial Mantis summary failed:', err)); + } catch (error) { - console.error('Failed to initialize database:', error); + console.error('Error during server setup:', error); // Optionally handle the error more gracefully, e.g., prevent server start - process.exit(1); // Exit if DB connection fails + process.exit(1); // Exit if setup fails } // attackers can use this header to detect apps running Express @@ -91,9 +111,14 @@ export const listen = defineSsrListen(({ app, devHttpsApp, port }) => { * * Can be async: defineSsrClose(async ({ ... }) => { ... }) */ -export const close = defineSsrClose(({ listenResult }) => { +export const close = defineSsrClose(async ({ listenResult }) => { // Close the database connection when the server shuts down - closeDatabase(); + try { + await prisma.$disconnect(); + console.log('Prisma Client disconnected.'); + } catch (e) { + console.error('Error disconnecting Prisma Client:', e); + } return listenResult.close() }) diff --git a/src-ssr/services/emailSummarizer.js b/src-ssr/services/emailSummarizer.js new file mode 100644 index 0000000..dd00d64 --- /dev/null +++ b/src-ssr/services/emailSummarizer.js @@ -0,0 +1,199 @@ +import Imap from 'node-imap'; +import { simpleParser } from 'mailparser'; +import { GoogleGenAI } from '@google/genai'; +import prisma from '../database.js'; + +// --- Environment Variables --- +const { GOOGLE_API_KEY } = process.env; // Added + +// --- AI Setup --- +const ai = GOOGLE_API_KEY ? new GoogleGenAI({ + apiKey: GOOGLE_API_KEY, +}) : null; // Added + +export async function fetchAndFormatEmails() { + return new Promise((resolve, reject) => { + const imapConfig = { + user: process.env.OUTLOOK_EMAIL_ADDRESS, + password: process.env.OUTLOOK_APP_PASSWORD, + host: 'outlook.office365.com', + port: 993, + tls: true, + tlsOptions: { rejectUnauthorized: false } // Adjust as needed for your environment + }; + + const imap = new Imap(imapConfig); + const emailsJson = []; + + function openInbox(cb) { + // Note: IMAP uses '/' as hierarchy separator, adjust if your server uses something else + imap.openBox('SLSNotifications/Reports/Backups', false, cb); + } + + imap.once('ready', () => { + openInbox((err, box) => { + if (err) { + imap.end(); + return reject(new Error(`Error opening mailbox: ${err.message}`)); + } + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const searchCriteria = [['SINCE', yesterday.toISOString().split('T')[0]]]; // Search since midnight yesterday + const fetchOptions = { bodies: ['HEADER.FIELDS (SUBJECT DATE)', 'TEXT'], struct: true }; + + imap.search(searchCriteria, (searchErr, results) => { + if (searchErr) { + imap.end(); + return reject(new Error(`Error searching emails: ${searchErr.message}`)); + } + + if (results.length === 0) { + console.log('No emails found from the last 24 hours.'); + imap.end(); + return resolve([]); + } + + const f = imap.fetch(results, fetchOptions); + let processedCount = 0; + + f.on('message', (msg, seqno) => { + let header = ''; + let body = ''; + + msg.on('body', (stream, info) => { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + }); + stream.once('end', () => { + if (info.which === 'TEXT') { + body = buffer; + } else { + // Assuming HEADER.FIELDS (SUBJECT DATE) comes as one chunk + header = buffer; + } + }); + }); + + msg.once('attributes', (attrs) => { + // Attributes might contain date if not fetched via header + }); + + msg.once('end', async () => { + try { + // Use mailparser to handle potential encoding issues and structure + const mail = await simpleParser(`Subject: ${header.match(/Subject: (.*)/i)?.[1] || ''}\nDate: ${header.match(/Date: (.*)/i)?.[1] || ''}\n\n${body}`); + emailsJson.push({ + title: mail.subject || 'No Subject', + time: mail.date ? mail.date.toISOString() : 'No Date', + body: mail.text || mail.html || 'No Body Content' // Prefer text, fallback to html, then empty + }); + } catch (parseErr) { + console.error(`Error parsing email seqno ${seqno}:`, parseErr); + // Decide if you want to reject or just skip this email + } + + processedCount++; + if (processedCount === results.length) { + // This check might be slightly inaccurate if errors occur, + // but it's a common pattern. Consider refining with promises. + } + }); + }); + + f.once('error', (fetchErr) => { + console.error('Fetch error: ' + fetchErr); + // Don't reject here immediately, might still get some emails + }); + + f.once('end', () => { + console.log('Done fetching all messages!'); + imap.end(); + }); + }); + }); + }); + + imap.once('error', (err) => { + reject(new Error(`IMAP Connection Error: ${err.message}`)); + }); + + imap.once('end', () => { + console.log('IMAP Connection ended.'); + resolve(emailsJson); // Resolve with the collected emails + }); + + imap.connect(); + }); +} + +// --- Email Summary Logic (New Function) --- +export async function generateAndStoreEmailSummary() { + console.log('Attempting to generate and store Email summary...'); + if (!ai) { + console.error('Google AI API key not configured. Skipping email summary generation.'); + return; + } + + try { + // Get the prompt from the database settings using Prisma + const setting = await prisma.setting.findUnique({ + where: { key: 'emailPrompt' }, // Use 'emailPrompt' as the key + select: { value: true } + }); + const promptTemplate = setting?.value; + + if (!promptTemplate) { + console.error('Email prompt not found in database settings (key: emailPrompt). Skipping summary generation.'); + return; + } + + const emails = await fetchAndFormatEmails(); + + let summaryText; + if (emails.length === 0) { + summaryText = "No relevant emails found in the last 24 hours."; + console.log('No recent emails found for summary.'); + } else { + console.log(`Found ${emails.length} recent emails. Generating summary...`); + // Replace placeholder in the prompt template + // Ensure your prompt template uses $EMAIL_DATA + let prompt = promptTemplate.replaceAll("$EMAIL_DATA", JSON.stringify(emails, null, 2)); + + // Call the AI model (adjust model name and config as needed) + const response = await ai.models.generateContent({ + "model": "gemini-2.5-preview-04-17", + "contents": prompt, + config: { + temperature: 0 // Adjust temperature as needed + } + }); + + summaryText = response.text; + console.log('Email summary generated successfully by AI.'); + } + + // Store the summary in the database using Prisma upsert + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency + + await prisma.emailSummary.upsert({ + where: { summaryDate: today }, + update: { + summaryText: summaryText, + // generatedAt is updated automatically by @default(now()) + }, + create: { + summaryDate: today, + summaryText: summaryText, + }, + }); + console.log(`Email summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`); + + } catch (error) { + console.error("Error during Email summary generation/storage:", error); + // Re-throw or handle as appropriate for your application + throw error; + } +} diff --git a/src-ssr/services/mantisSummarizer.js b/src-ssr/services/mantisSummarizer.js new file mode 100644 index 0000000..7d8dd27 --- /dev/null +++ b/src-ssr/services/mantisSummarizer.js @@ -0,0 +1,171 @@ +import axios from 'axios'; +import { GoogleGenAI } from '@google/genai'; +import prisma from '../database.js'; // Import Prisma client + +// --- Environment Variables --- +const { + MANTIS_API_KEY, + MANTIS_API_ENDPOINT, + GOOGLE_API_KEY +} = process.env; + +// --- Mantis Summarizer Setup --- +const ai = GOOGLE_API_KEY ? new GoogleGenAI({ + apiKey: GOOGLE_API_KEY, +}) : null; + +const usernameMap = { + 'credmore': 'Cameron Redmore', + 'dgibson': 'Dane Gibson', + 'egzibovskis': 'Ed Gzibovskis', + 'ascotney': 'Amanda Scotney', + 'gclough': 'Garry Clough', + 'slee': 'Sarah Lee', + 'dwalker': 'Dave Walker', + 'askaith': 'Amy Skaith', + 'dpotter': 'Danny Potter', + 'msmart': 'Michael Smart', + // Add other usernames as needed +}; + +async function getMantisTickets() { + if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) { + throw new Error("Mantis API endpoint or key not configured in environment variables."); + } + const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`; + const headers = { + 'Authorization': `${MANTIS_API_KEY}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; + + try { + const response = await axios.get(url, { headers }); + + const tickets = response.data.issues.filter((ticket) => { + const ticketDate = new Date(ticket.updated_at); + const thresholdDate = new Date(); + const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ... + + // Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days + const daysToSubtract = currentDay === 1 ? 4 : 2; + thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); + thresholdDate.setHours(0, 0, 0, 0); // Start of the day + + return ticketDate >= thresholdDate; + }).map((ticket) => { + return { + id: ticket.id, + summary: ticket.summary, + description: ticket.description, + created_at: ticket.created_at, + updated_at: ticket.updated_at, + reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access + notes: (ticket.notes ? ticket.notes.filter((note) => { + const noteDate = new Date(note.created_at); + const thresholdDate = new Date(); + const currentDay = thresholdDate.getDay(); + const daysToSubtract = currentDay === 1 ? 4 : 2; + thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); + thresholdDate.setHours(0, 0, 0, 0); // Start of the day + return noteDate >= thresholdDate; + }) : []).map((note) => { + const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access + return { + reporter, + created_at: note.created_at, + text: note.text, + }; + }), + }; + }); + + return tickets; + } catch (error) { + console.error("Error fetching Mantis tickets:", error.message); + // Check if it's an Axios error and provide more details + if (axios.isAxiosError(error)) { + console.error("Axios error details:", error.response?.status, error.response?.data); + throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`); + } + throw new Error(`Failed to fetch Mantis tickets: ${error.message}`); + } +} + +// --- Mantis Summary Logic (Exported) --- // + +export async function generateAndStoreMantisSummary() { + console.log('Attempting to generate and store Mantis summary...'); + if (!ai) { + console.error('Google AI API key not configured. Skipping summary generation.'); + return; + } + + try { + // Get the prompt from the database settings using Prisma + const setting = await prisma.setting.findUnique({ + where: { key: 'mantisPrompt' }, + select: { value: true } + }); + const promptTemplate = setting?.value; + + if (!promptTemplate) { + console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.'); + return; + } + + const tickets = await getMantisTickets(); + + let summaryText; + if (tickets.length === 0) { + summaryText = "No Mantis tickets updated recently."; + console.log('No recent Mantis tickets found.'); + } else { + console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`); + let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]); + prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2)); + + const response = await ai.models.generateContent({ + "model": "gemini-2.5-flash-preview-04-17", + "contents": prompt, + config: { + temperature: 0 + } + }); + + summaryText = response.text; + console.log('Mantis summary generated successfully by AI.'); + } + + // Store the summary in the database using Prisma upsert + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency + + await prisma.mantisSummary.upsert({ + where: { summaryDate: today }, + update: { + summaryText: summaryText, + // generatedAt is updated automatically by @default(now()) + }, + create: { + summaryDate: today, + summaryText: summaryText, + }, + }); + console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`); + + } catch (error) { + console.error("Error during Mantis summary generation/storage:", error); + } +} + +export async function generateTodaysSummary() { + console.log('Triggering Mantis summary generation via generateTodaysSummary...'); + try { + await generateAndStoreMantisSummary(); + return { success: true, message: 'Summary generation process initiated.' }; + } catch (error) { + console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error); + throw new Error('Failed to initiate Mantis summary generation.'); + } +} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 1e8bf72..470a235 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -48,6 +48,53 @@ Create a new form + + + + + + + Mantis Summaries + View daily summaries + + + + + + + + + Email Summaries + View email summaries + + + + + + + + + + Settings + Manage application settings + + + diff --git a/src/pages/EmailSummariesPage.vue b/src/pages/EmailSummariesPage.vue new file mode 100644 index 0000000..aef0d6c --- /dev/null +++ b/src/pages/EmailSummariesPage.vue @@ -0,0 +1,201 @@ + + + + + Email Summaries + + + + + + + + + + + {{ generationError }} + + + + + + Loading summaries... + + + + + + + + {{ error }} + + + + + + + {{ formatDate(summary.summaryDate) }} + Generated: {{ formatDateTime(summary.generatedAt) }} + + + + + + + + + + + No summaries found. + + + + + + + + + diff --git a/src/pages/MantisSummariesPage.vue b/src/pages/MantisSummariesPage.vue new file mode 100644 index 0000000..f237421 --- /dev/null +++ b/src/pages/MantisSummariesPage.vue @@ -0,0 +1,197 @@ + + + + + Mantis Summaries + + + + + + + + + + + {{ generationError }} + + + + + + Loading summaries... + + + + + + + + {{ error }} + + + + + + + {{ formatDate(summary.summaryDate) }} + Generated: {{ formatDateTime(summary.generatedAt) }} + + + + + + + + + + + No summaries found. + + + + + + + + + diff --git a/src/pages/SettingsPage.vue b/src/pages/SettingsPage.vue new file mode 100644 index 0000000..ff0e066 --- /dev/null +++ b/src/pages/SettingsPage.vue @@ -0,0 +1,163 @@ + + + + Settings + + + + Mantis Summary Prompt + + Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders. + + + + + + + + + + + Email Summary Prompt + + Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array. + + + + + + + + + + + + + + + diff --git a/src/router/routes.js b/src/router/routes.js index c22d363..ecb0426 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -8,7 +8,10 @@ const routes = [ { path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') }, { path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true }, { path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true }, - { path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true } + { path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true }, + { path: 'mantis-summaries', name: 'mantisSummaries', component: () => import('pages/MantisSummariesPage.vue') }, + { path: 'email-summaries', name: 'emailSummaries', component: () => import('pages/EmailSummariesPage.vue') }, + { path: 'settings', name: 'settings', component: () => import('pages/SettingsPage.vue') } ] },