feat: implement security report generation backend (task 21)
- Add report-schedules DB schema table - Create reports tRPC router with getReports, generateReport, getReport, deleteReport, getScheduledReports, updateSchedule procedures - Create reports service with async report generation lifecycle - Create report generator (compileData, renderHTML, generatePDF, uploadPDF) - Add HTML templates for monthly-plus, annual-premium, weekly-digest - Add Valibot schemas for input validation - Wire router into root.ts and update DB schema exports/relations - Install puppeteer for HTML-to-PDF conversion - Write unit tests for router (11 tests) and service (12 tests)
This commit is contained in:
263
pnpm-lock.yaml
generated
263
pnpm-lock.yaml
generated
@@ -67,6 +67,9 @@ importers:
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
puppeteer:
|
||||
specifier: ^25.0.4
|
||||
version: 25.0.4(typescript@5.9.3)
|
||||
resend:
|
||||
specifier: ^6.12.4
|
||||
version: 6.12.4
|
||||
@@ -1187,6 +1190,16 @@ packages:
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
|
||||
'@puppeteer/browsers@3.0.3':
|
||||
resolution: {integrity: sha512-v3YaiGpzUTgOZkHBFR0iZg58Vto25SqBQxfLUXDiofJccwVl6Mlr7BdLCS1NZgxikdeIHf936cxYWL9IZp3tow==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
proxy-agent: '>=8.0.1'
|
||||
peerDependenciesMeta:
|
||||
proxy-agent:
|
||||
optional: true
|
||||
|
||||
'@rollup/plugin-alias@6.0.0':
|
||||
resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1778,6 +1791,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
arrify@2.0.1:
|
||||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1947,6 +1963,10 @@ packages:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
@@ -1971,6 +1991,12 @@ packages:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chromium-bidi@16.0.1:
|
||||
resolution: {integrity: sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==}
|
||||
engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'}
|
||||
peerDependencies:
|
||||
devtools-protocol: '*'
|
||||
|
||||
citty@0.1.6:
|
||||
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
|
||||
|
||||
@@ -2041,6 +2067,15 @@ packages:
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cosmiconfig@9.0.1:
|
||||
resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.9.5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -2159,6 +2194,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
devtools-protocol@0.0.1608973:
|
||||
resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==}
|
||||
|
||||
diff@8.0.4:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -2320,6 +2358,13 @@ packages:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
@@ -2692,6 +2737,10 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -2702,6 +2751,9 @@ packages:
|
||||
iron-webcrypto@1.2.1:
|
||||
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-core-module@2.16.2:
|
||||
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2801,6 +2853,10 @@ packages:
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@29.1.1:
|
||||
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
@@ -2818,6 +2874,9 @@ packages:
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2925,6 +2984,9 @@ packages:
|
||||
limiter@1.1.5:
|
||||
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
listhen@1.10.0:
|
||||
resolution: {integrity: sha512-kfz4C0OrC6IpaVMtYDJtf6PFjurxe9NBBoDAh/o2p587INryFOO4DQ9OetbCdDrWFt1m1CJKvYrzkGsuPHw8nQ==}
|
||||
hasBin: true
|
||||
@@ -3082,6 +3144,9 @@ packages:
|
||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mlly@1.8.2:
|
||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||
|
||||
@@ -3189,6 +3254,14 @@ packages:
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-json@5.2.0:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
@@ -3319,6 +3392,10 @@ packages:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
@@ -3334,10 +3411,22 @@ packages:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
puppeteer-core@25.0.4:
|
||||
resolution: {integrity: sha512-K1LQKDP6w1rIr1jUyN9obH16TO/DCy86k3q+FBd2prGY+TStxhFySxmaZZuRF+0D3BJXjwCYFke7tMHCH4olTA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
puppeteer@25.0.4:
|
||||
resolution: {integrity: sha512-QFdBAuNOqL0I+AdARTlRR1KcgPk0fo0dU127e1ZQFVxb9QPcpBDIiQp/dMgdbyLXHpF2GRjC/OezDmjKcLCKYw==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -3410,6 +3499,10 @@ packages:
|
||||
'@react-email/render':
|
||||
optional: true
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-from@5.0.0:
|
||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3700,6 +3793,9 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-fs@3.1.2:
|
||||
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
|
||||
|
||||
tar-stream@3.2.0:
|
||||
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
|
||||
|
||||
@@ -3799,6 +3895,9 @@ packages:
|
||||
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
typed-query-selector@2.12.2:
|
||||
resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -4080,6 +4179,9 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
webdriver-bidi-protocol@0.4.1:
|
||||
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -4134,6 +4236,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4201,6 +4315,9 @@ packages:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -5068,6 +5185,19 @@ snapshots:
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@puppeteer/browsers@3.0.3':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
progress: 2.0.3
|
||||
semver: 7.8.1
|
||||
tar-fs: 3.1.2
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
- supports-color
|
||||
|
||||
'@rollup/plugin-alias@6.0.0(rollup@4.60.4)':
|
||||
optionalDependencies:
|
||||
rollup: 4.60.4
|
||||
@@ -5708,6 +5838,8 @@ snapshots:
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
arrify@2.0.1:
|
||||
optional: true
|
||||
|
||||
@@ -5881,6 +6013,8 @@ snapshots:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -5897,6 +6031,12 @@ snapshots:
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
chromium-bidi@16.0.1(devtools-protocol@0.0.1608973):
|
||||
dependencies:
|
||||
devtools-protocol: 0.0.1608973
|
||||
mitt: 3.0.1
|
||||
zod: 3.25.76
|
||||
|
||||
citty@0.1.6:
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
@@ -5908,7 +6048,6 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
optional: true
|
||||
|
||||
cliui@9.0.1:
|
||||
dependencies:
|
||||
@@ -5960,6 +6099,15 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cosmiconfig@9.0.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
@@ -6034,6 +6182,8 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
devtools-protocol@0.0.1608973: {}
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dot-prop@10.1.0:
|
||||
@@ -6094,7 +6244,6 @@ snapshots:
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
optional: true
|
||||
|
||||
enhanced-resolve@5.22.0:
|
||||
dependencies:
|
||||
@@ -6105,6 +6254,12 @@ snapshots:
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
error-stack-parser@2.1.4:
|
||||
@@ -6654,6 +6809,11 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
resolve-from: 4.0.0
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ioredis@5.10.1:
|
||||
@@ -6672,6 +6832,8 @@ snapshots:
|
||||
|
||||
iron-webcrypto@1.2.1: {}
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-core-module@2.16.2:
|
||||
dependencies:
|
||||
hasown: 2.0.3
|
||||
@@ -6747,6 +6909,10 @@ snapshots:
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsdom@29.1.1:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.1.11
|
||||
@@ -6779,6 +6945,8 @@ snapshots:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
@@ -6876,6 +7044,8 @@ snapshots:
|
||||
|
||||
limiter@1.1.5: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
listhen@1.10.0:
|
||||
dependencies:
|
||||
'@parcel/watcher': 2.5.6
|
||||
@@ -7041,6 +7211,8 @@ snapshots:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.2:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
@@ -7208,7 +7380,6 @@ snapshots:
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
optional: true
|
||||
|
||||
oniguruma-to-es@2.3.0:
|
||||
dependencies:
|
||||
@@ -7232,6 +7403,17 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
error-ex: 1.3.4
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
@@ -7346,6 +7528,8 @@ snapshots:
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
proto3-json-serializer@2.0.2:
|
||||
@@ -7371,8 +7555,49 @@ snapshots:
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
puppeteer-core@25.0.4:
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 3.0.3
|
||||
chromium-bidi: 16.0.1(devtools-protocol@0.0.1608973)
|
||||
debug: 4.4.3
|
||||
devtools-protocol: 0.0.1608973
|
||||
typed-query-selector: 2.12.2
|
||||
webdriver-bidi-protocol: 0.4.1
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- bufferutil
|
||||
- proxy-agent
|
||||
- react-native-b4a
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
puppeteer@25.0.4(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 3.0.3
|
||||
chromium-bidi: 16.0.1(devtools-protocol@0.0.1608973)
|
||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||
devtools-protocol: 0.0.1608973
|
||||
puppeteer-core: 25.0.4
|
||||
typed-query-selector: 2.12.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- bufferutil
|
||||
- proxy-agent
|
||||
- react-native-b4a
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -7438,8 +7663,7 @@ snapshots:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
require-directory@2.1.1:
|
||||
optional: true
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
@@ -7448,6 +7672,8 @@ snapshots:
|
||||
postal-mime: 2.7.4
|
||||
standardwebhooks: 1.0.0
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@@ -7767,6 +7993,18 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tar-fs@3.1.2:
|
||||
dependencies:
|
||||
pump: 3.0.4
|
||||
tar-stream: 3.2.0
|
||||
optionalDependencies:
|
||||
bare-fs: 4.7.1
|
||||
bare-path: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
@@ -7896,6 +8134,8 @@ snapshots:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
typed-query-selector@2.12.2: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ufo@1.6.4: {}
|
||||
@@ -8157,6 +8397,8 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webdriver-bidi-protocol@0.4.1: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
@@ -8213,8 +8455,9 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrappy@1.0.2:
|
||||
optional: true
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
@@ -8240,8 +8483,7 @@ snapshots:
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
optional: true
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
@@ -8254,7 +8496,6 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
optional: true
|
||||
|
||||
yargs@18.0.0:
|
||||
dependencies:
|
||||
@@ -8287,4 +8528,6 @@ snapshots:
|
||||
compress-commons: 6.0.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"firebase-admin": "^13.10.0",
|
||||
"jose": "^5",
|
||||
"pg": "^8.21.0",
|
||||
"puppeteer": "^25.0.4",
|
||||
"resend": "^6.12.4",
|
||||
"solid-js": "^1.9.5",
|
||||
"stripe": "^22.1.1",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { spamshieldRouter } from "./routers/spamshield";
|
||||
import { hometitleRouter } from "./routers/hometitle";
|
||||
import { removebrokersRouter } from "./routers/removebrokers";
|
||||
import { correlationRouter } from "./routers/correlation";
|
||||
import { reportsRouter } from "./routers/reports";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
|
||||
hometitle: hometitleRouter,
|
||||
removebrokers: removebrokersRouter,
|
||||
correlation: correlationRouter,
|
||||
reports: reportsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
190
web/src/server/api/routers/reports.test.ts
Normal file
190
web/src/server/api/routers/reports.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
GenerateReportSchema,
|
||||
ReportFilterSchema,
|
||||
ReportDetailsSchema,
|
||||
DeleteReportSchema,
|
||||
UpdateScheduleSchema,
|
||||
} from "../schemas/reports";
|
||||
|
||||
vi.mock("~/server/services/reports.service", () => ({
|
||||
getReports: vi.fn(),
|
||||
generateReport: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
deleteReport: vi.fn(),
|
||||
getScheduledReports: vi.fn(),
|
||||
updateSchedule: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as reportsService from "~/server/services/reports.service";
|
||||
|
||||
const mockGetReports = vi.mocked(reportsService.getReports);
|
||||
const mockGenerateReport = vi.mocked(reportsService.generateReport);
|
||||
const mockGetReport = vi.mocked(reportsService.getReport);
|
||||
const mockDeleteReport = vi.mocked(reportsService.deleteReport);
|
||||
const mockGetScheduledReports = vi.mocked(reportsService.getScheduledReports);
|
||||
const mockUpdateSchedule = vi.mocked(reportsService.updateSchedule);
|
||||
|
||||
type User = {
|
||||
id: string; email: string; name: string | null; image: string | null;
|
||||
role: string; emailVerified: Date | null; deletedAt: Date | null;
|
||||
stripeCustomerId: string | null;
|
||||
createdAt: Date; updatedAt: Date;
|
||||
};
|
||||
type Ctx = { db: object; user: User | null; apiKey: string | null };
|
||||
|
||||
function createCaller(user: User | null) {
|
||||
const t = initTRPC.context<Ctx>().create();
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
const router = t.router({
|
||||
getReports: t.procedure.use(isAuthed)
|
||||
.input(wrap(ReportFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetReports(ctx.user.id, input);
|
||||
}),
|
||||
generateReport: t.procedure.use(isAuthed)
|
||||
.input(wrap(GenerateReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockGenerateReport(ctx.user.id, input.reportType, input.periodStart, input.periodEnd);
|
||||
}),
|
||||
getReport: t.procedure.use(isAuthed)
|
||||
.input(wrap(ReportDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
deleteReport: t.procedure.use(isAuthed)
|
||||
.input(wrap(DeleteReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockDeleteReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
getScheduledReports: t.procedure.use(isAuthed)
|
||||
.query(async ({ ctx }) => {
|
||||
return mockGetScheduledReports(ctx.user.id);
|
||||
}),
|
||||
updateSchedule: t.procedure.use(isAuthed)
|
||||
.input(wrap(UpdateScheduleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockUpdateSchedule(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = t.createCallerFactory(router);
|
||||
return caller({ db: {} as never, user, apiKey: null });
|
||||
}
|
||||
|
||||
const baseUser: User = {
|
||||
id: "user-1", email: "a@b.com", name: "Test", image: null,
|
||||
role: "user", emailVerified: null, deletedAt: null,
|
||||
stripeCustomerId: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return { ...baseUser, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("reports.getReports", () => {
|
||||
it("returns paginated reports for authenticated user", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetReports.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getReports({ page: 1, limit: 20 });
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getReports({ page: 1, limit: 20 })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.generateReport", () => {
|
||||
it("triggers report generation", async () => {
|
||||
mockGenerateReport.mockResolvedValue({ reportId: "r1" });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.generateReport({ reportType: "MONTHLY_PLUS" });
|
||||
expect(result.reportId).toBe("r1");
|
||||
});
|
||||
|
||||
it("rejects invalid report type", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.generateReport({ reportType: "INVALID" as never }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("passes optional period dates", async () => {
|
||||
mockGenerateReport.mockResolvedValue({ reportId: "r1" });
|
||||
const api = createCaller(makeUser());
|
||||
await api.generateReport({
|
||||
reportType: "WEEKLY_DIGEST",
|
||||
periodStart: "2025-01-01",
|
||||
periodEnd: "2025-01-07",
|
||||
});
|
||||
expect(mockGenerateReport).toHaveBeenCalledWith("user-1", "WEEKLY_DIGEST", "2025-01-01", "2025-01-07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.getReport", () => {
|
||||
it("returns a single report", async () => {
|
||||
mockGetReport.mockResolvedValue({ id: "r1", title: "Test Report" } as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getReport({ reportId: "r1" });
|
||||
expect(result.id).toBe("r1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.deleteReport", () => {
|
||||
it("deletes a report", async () => {
|
||||
mockDeleteReport.mockResolvedValue({ deleted: true });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.deleteReport({ reportId: "r1" });
|
||||
expect(result.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.getScheduledReports", () => {
|
||||
it("returns scheduled reports for authenticated user", async () => {
|
||||
const schedules = [{ id: "s1", enabled: true, frequency: "weekly", reportType: "WEEKLY_DIGEST" }];
|
||||
mockGetScheduledReports.mockResolvedValue(schedules as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getScheduledReports();
|
||||
expect(result).toEqual(schedules);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getScheduledReports()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.updateSchedule", () => {
|
||||
it("updates schedule for authenticated user", async () => {
|
||||
const schedule = { id: "s1", enabled: true, frequency: "monthly", reportType: "MONTHLY_PLUS" };
|
||||
mockUpdateSchedule.mockResolvedValue(schedule as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.updateSchedule({
|
||||
enabled: true,
|
||||
frequency: "monthly",
|
||||
reportType: "MONTHLY_PLUS",
|
||||
});
|
||||
expect(result.id).toBe("s1");
|
||||
});
|
||||
|
||||
it("rejects invalid frequency", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.updateSchedule({ enabled: true, frequency: "daily" as never, reportType: "MONTHLY_PLUS" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
46
web/src/server/api/routers/reports.ts
Normal file
46
web/src/server/api/routers/reports.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import {
|
||||
GenerateReportSchema,
|
||||
ReportFilterSchema,
|
||||
ReportDetailsSchema,
|
||||
DeleteReportSchema,
|
||||
UpdateScheduleSchema,
|
||||
} from "../schemas/reports";
|
||||
import * as reportsService from "~/server/services/reports.service";
|
||||
|
||||
export const reportsRouter = createTRPCRouter({
|
||||
getReports: protectedProcedure
|
||||
.input(wrap(ReportFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return reportsService.getReports(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
generateReport: protectedProcedure
|
||||
.input(wrap(GenerateReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reportsService.generateReport(ctx.user.id, input.reportType, input.periodStart, input.periodEnd);
|
||||
}),
|
||||
|
||||
getReport: protectedProcedure
|
||||
.input(wrap(ReportDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return reportsService.getReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
|
||||
deleteReport: protectedProcedure
|
||||
.input(wrap(DeleteReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reportsService.deleteReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
|
||||
getScheduledReports: protectedProcedure.query(async ({ ctx }) => {
|
||||
return reportsService.getScheduledReports(ctx.user.id);
|
||||
}),
|
||||
|
||||
updateSchedule: protectedProcedure
|
||||
.input(wrap(UpdateScheduleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reportsService.updateSchedule(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
26
web/src/server/api/schemas/reports.ts
Normal file
26
web/src/server/api/schemas/reports.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { object, string, picklist, optional, number, boolean, minLength } from "valibot";
|
||||
|
||||
export const GenerateReportSchema = object({
|
||||
reportType: picklist(["MONTHLY_PLUS", "ANNUAL_PREMIUM", "WEEKLY_DIGEST"]),
|
||||
periodStart: optional(string()),
|
||||
periodEnd: optional(string()),
|
||||
});
|
||||
|
||||
export const ReportFilterSchema = object({
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
|
||||
export const ReportDetailsSchema = object({
|
||||
reportId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const DeleteReportSchema = object({
|
||||
reportId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const UpdateScheduleSchema = object({
|
||||
enabled: boolean(),
|
||||
frequency: picklist(["weekly", "monthly", "quarterly"]),
|
||||
reportType: picklist(["MONTHLY_PLUS", "ANNUAL_PREMIUM", "WEEKLY_DIGEST"]),
|
||||
});
|
||||
@@ -13,4 +13,5 @@ export * from "./hometitle";
|
||||
export * from "./removebrokers";
|
||||
export * from "./invitation";
|
||||
export * from "./notifications";
|
||||
export * from "./report-schedules";
|
||||
export * from "./relations";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults } from "
|
||||
import { spamFeedback, spamRules } from "./spamshield";
|
||||
import { normalizedAlerts, correlationGroups } from "./correlation";
|
||||
import { securityReports } from "./reports";
|
||||
import { reportSchedules } from "./report-schedules";
|
||||
import { propertyWatchlistItems, propertySnapshots, propertyChanges } from "./hometitle";
|
||||
import { infoBrokers, removalRequests, brokerListings } from "./removebrokers";
|
||||
|
||||
@@ -32,6 +33,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
|
||||
normalizedAlerts: many(normalizedAlerts),
|
||||
correlationGroups: many(correlationGroups),
|
||||
securityReports: many(securityReports),
|
||||
reportSchedules: many(reportSchedules),
|
||||
analysisJobs: many(analysisJobs),
|
||||
notificationPreferences: one(notificationPreferences),
|
||||
}));
|
||||
@@ -134,6 +136,10 @@ export const securityReportsRelations = relations(securityReports, ({ one }) =>
|
||||
user: one(users, { fields: [securityReports.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const reportSchedulesRelations = relations(reportSchedules, ({ one }) => ({
|
||||
user: one(users, { fields: [reportSchedules.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const propertyWatchlistItemsRelations = relations(propertyWatchlistItems, ({ one, many }) => ({
|
||||
subscription: one(subscriptions, { fields: [propertyWatchlistItems.subscriptionId], references: [subscriptions.id] }),
|
||||
snapshots: many(propertySnapshots),
|
||||
|
||||
18
web/src/server/db/schema/report-schedules.ts
Normal file
18
web/src/server/db/schema/report-schedules.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pgTable, text, timestamp, index, uuid, boolean } from "drizzle-orm/pg-core";
|
||||
import { users } from "./auth";
|
||||
import { reportType } from "./enums";
|
||||
|
||||
export const reportSchedules = pgTable("report_schedules", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").default(true).notNull(),
|
||||
frequency: text("frequency").notNull(),
|
||||
reportType: reportType("report_type").notNull(),
|
||||
lastGeneratedAt: timestamp("last_generated_at", { withTimezone: true, mode: "date" }),
|
||||
nextScheduledAt: timestamp("next_scheduled_at", { withTimezone: true, mode: "date" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||
}, (table) => ({
|
||||
userIdIdx: index("report_schedules_user_id_idx").on(table.userId),
|
||||
enabledIdx: index("report_schedules_enabled_idx").on(table.enabled),
|
||||
}));
|
||||
291
web/src/server/services/reports.service.test.ts
Normal file
291
web/src/server/services/reports.service.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
subscriptions: { findFirst: vi.fn() },
|
||||
},
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/server/services/reports/generator", () => ({
|
||||
compileData: vi.fn(),
|
||||
renderHTML: vi.fn(),
|
||||
generatePDF: vi.fn(),
|
||||
uploadPDF: vi.fn(),
|
||||
}));
|
||||
|
||||
async function getDb() {
|
||||
return vi.mocked((await import("~/server/db")).db);
|
||||
}
|
||||
|
||||
function setupDefaults(db: Awaited<ReturnType<typeof getDb>>) {
|
||||
const limitFn = vi.fn().mockResolvedValue([]);
|
||||
const orderByFn = vi.fn().mockResolvedValue([]);
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: limitFn, orderBy: orderByFn }),
|
||||
}),
|
||||
});
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({ returning: vi.fn() }),
|
||||
});
|
||||
db.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
});
|
||||
db.delete.mockReturnValue({
|
||||
where: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const mockSub = {
|
||||
id: "sub-1",
|
||||
userId: "user-1",
|
||||
tier: "premium" as const,
|
||||
status: "active" as const,
|
||||
stripeId: null as string | null,
|
||||
familyGroupId: null as string | null,
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 86400000),
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function setupSelect(db: Awaited<ReturnType<typeof getDb>>, overrides: {
|
||||
limit?: ReturnType<typeof vi.fn>;
|
||||
orderBy?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: overrides.limit ?? vi.fn().mockResolvedValue([]),
|
||||
orderBy: overrides.orderBy ?? vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
const db = await getDb();
|
||||
setupDefaults(db);
|
||||
});
|
||||
|
||||
describe("getReports", () => {
|
||||
it("returns paginated reports for user with active subscription", async () => {
|
||||
const db = await getDb();
|
||||
(db.query.subscriptions.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(mockSub);
|
||||
|
||||
const subLimitFn = vi.fn().mockResolvedValue([mockSub]);
|
||||
const offsetFn = vi.fn().mockResolvedValue([
|
||||
{ id: "r1", title: "Report 1" },
|
||||
{ id: "r2", title: "Report 2" },
|
||||
]);
|
||||
const dataWhere = {
|
||||
limit: vi.fn(),
|
||||
orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ offset: offsetFn }) }),
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: subLimitFn, orderBy: vi.fn() }),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ count: 2 }]),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue(dataWhere) }),
|
||||
});
|
||||
|
||||
const { getReports } = await import("./reports.service");
|
||||
const result = await getReports("user-1", { page: 1, limit: 20 });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
});
|
||||
|
||||
it("throws not found if no active subscription", async () => {
|
||||
setupSelect(await getDb(), { limit: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
const { getReports } = await import("./reports.service");
|
||||
await expect(getReports("user-1")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReport", () => {
|
||||
it("returns a single report by id", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "r1", title: "Test Report", status: "COMPLETED" }]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
|
||||
const { getReport } = await import("./reports.service");
|
||||
const result = await getReport("user-1", "r1");
|
||||
|
||||
expect(result.id).toBe("r1");
|
||||
expect(result.title).toBe("Test Report");
|
||||
});
|
||||
|
||||
it("throws not found if report does not exist", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
|
||||
const { getReport } = await import("./reports.service");
|
||||
await expect(getReport("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateReport", () => {
|
||||
it("creates a report record and returns reportId", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([mockSub]) });
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "r1", status: "PENDING" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { generateReport } = await import("./reports.service");
|
||||
const result = await generateReport("user-1", "MONTHLY_PLUS");
|
||||
|
||||
expect(result.reportId).toBe("r1");
|
||||
});
|
||||
|
||||
it("throws forbidden if tier too low for premium report", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([{ ...mockSub, tier: "basic" }]) });
|
||||
|
||||
const { generateReport } = await import("./reports.service");
|
||||
await expect(generateReport("user-1", "ANNUAL_PREMIUM")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("allows basic tier for weekly digest", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([{ ...mockSub, tier: "basic" }]) });
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "r1", status: "PENDING" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { generateReport } = await import("./reports.service");
|
||||
const result = await generateReport("user-1", "WEEKLY_DIGEST");
|
||||
|
||||
expect(result.reportId).toBe("r1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteReport", () => {
|
||||
it("deletes a report that belongs to user", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "r1", title: "Test Report" }]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
db.delete.mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: "r1" }]),
|
||||
});
|
||||
|
||||
const { deleteReport } = await import("./reports.service");
|
||||
const result = await deleteReport("user-1", "r1");
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("throws not found if report does not belong to user", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
|
||||
const { deleteReport } = await import("./reports.service");
|
||||
await expect(deleteReport("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScheduledReports", () => {
|
||||
it("returns schedules for user", async () => {
|
||||
const db = await getDb();
|
||||
const orderByFn = vi.fn().mockResolvedValue([
|
||||
{ id: "s1", enabled: true, frequency: "weekly", reportType: "WEEKLY_DIGEST" },
|
||||
]);
|
||||
setupSelect(db, { orderBy: orderByFn });
|
||||
|
||||
const { getScheduledReports } = await import("./reports.service");
|
||||
const result = await getScheduledReports("user-1");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].frequency).toBe("weekly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSchedule", () => {
|
||||
it("creates a new schedule when none exists", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([]) });
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{
|
||||
id: "s1", userId: "user-1", enabled: true, frequency: "monthly", reportType: "MONTHLY_PLUS",
|
||||
}]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { updateSchedule } = await import("./reports.service");
|
||||
const result = await updateSchedule("user-1", {
|
||||
enabled: true,
|
||||
frequency: "monthly",
|
||||
reportType: "MONTHLY_PLUS",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("s1");
|
||||
expect(result.frequency).toBe("monthly");
|
||||
});
|
||||
|
||||
it("updates existing schedule", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, {
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{ id: "s1", enabled: true, frequency: "weekly", reportType: "WEEKLY_DIGEST" },
|
||||
]),
|
||||
});
|
||||
db.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{
|
||||
id: "s1", enabled: false, frequency: "monthly", reportType: "WEEKLY_DIGEST",
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { updateSchedule } = await import("./reports.service");
|
||||
const result = await updateSchedule("user-1", {
|
||||
enabled: false,
|
||||
frequency: "monthly",
|
||||
reportType: "WEEKLY_DIGEST",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("s1");
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.frequency).toBe("monthly");
|
||||
});
|
||||
});
|
||||
221
web/src/server/services/reports.service.ts
Normal file
221
web/src/server/services/reports.service.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, and, desc, count } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { subscriptions, securityReports, reportSchedules } from "~/server/db/schema";
|
||||
import { compileData, renderHTML, generatePDF, uploadPDF } from "./reports/generator";
|
||||
|
||||
async function getSubscription(userId: string) {
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
|
||||
.limit(1);
|
||||
if (!sub) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription found" });
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
function getReportTypeLabel(reportType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
MONTHLY_PLUS: "Monthly",
|
||||
ANNUAL_PREMIUM: "Annual",
|
||||
WEEKLY_DIGEST: "Weekly",
|
||||
};
|
||||
return labels[reportType] ?? reportType;
|
||||
}
|
||||
|
||||
export async function getReports(
|
||||
userId: string,
|
||||
filters?: { page?: number; limit?: number },
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
const page = filters?.page ?? 1;
|
||||
const limit = filters?.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(securityReports)
|
||||
.where(eq(securityReports.subscriptionId, sub.id));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(securityReports)
|
||||
.where(eq(securityReports.subscriptionId, sub.id))
|
||||
.orderBy(desc(securityReports.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalResult.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult.count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReport(userId: string, reportId: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [report] = await db
|
||||
.select()
|
||||
.from(securityReports)
|
||||
.where(and(eq(securityReports.id, reportId), eq(securityReports.subscriptionId, sub.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!report) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Report not found" });
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function generateReport(
|
||||
userId: string,
|
||||
reportType: "MONTHLY_PLUS" | "ANNUAL_PREMIUM" | "WEEKLY_DIGEST",
|
||||
periodStartStr?: string,
|
||||
periodEndStr?: string,
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const requiredTier = reportType === "ANNUAL_PREMIUM" ? "premium" : reportType === "MONTHLY_PLUS" ? "plus" : "basic";
|
||||
const tiers: Record<string, number> = { basic: 0, plus: 1, premium: 2 };
|
||||
if ((tiers[sub.tier] ?? 0) < tiers[requiredTier]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `${getReportTypeLabel(reportType)} reports require ${requiredTier} tier subscription`,
|
||||
});
|
||||
}
|
||||
|
||||
const periodStart = periodStartStr ? new Date(periodStartStr) : undefined;
|
||||
const periodEnd = periodEndStr ? new Date(periodEndStr) : undefined;
|
||||
|
||||
const reportLabel = getReportTypeLabel(reportType);
|
||||
const title = `ShieldAI ${reportLabel} Security Report`;
|
||||
|
||||
const [report] = await db
|
||||
.insert(securityReports)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
reportType,
|
||||
status: "PENDING",
|
||||
periodStart: periodStart ?? new Date(),
|
||||
periodEnd: periodEnd ?? new Date(),
|
||||
title,
|
||||
})
|
||||
.returning();
|
||||
|
||||
generateReportAsync(report.id, userId, reportType, periodStart, periodEnd).catch((err) => {
|
||||
console.error("[reports] Generation failed:", err);
|
||||
db.update(securityReports)
|
||||
.set({ status: "FAILED", error: err instanceof Error ? err.message : "Unknown error" })
|
||||
.where(eq(securityReports.id, report.id))
|
||||
.then(() => undefined);
|
||||
});
|
||||
|
||||
return { reportId: report.id };
|
||||
}
|
||||
|
||||
async function generateReportAsync(
|
||||
reportId: string,
|
||||
userId: string,
|
||||
reportType: "MONTHLY_PLUS" | "ANNUAL_PREMIUM" | "WEEKLY_DIGEST",
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(securityReports)
|
||||
.set({ status: "GENERATING" })
|
||||
.where(eq(securityReports.id, reportId));
|
||||
|
||||
const data = await compileData(userId, reportType, periodStart, periodEnd);
|
||||
|
||||
const html = renderHTML(data, reportType);
|
||||
|
||||
const pdfBuffer = await generatePDF(html);
|
||||
|
||||
const filename = `${reportType.toLowerCase()}-${reportId}.pdf`;
|
||||
const pdfUrl = await uploadPDF(userId, pdfBuffer, filename);
|
||||
|
||||
await db
|
||||
.update(securityReports)
|
||||
.set({
|
||||
status: "COMPLETED",
|
||||
htmlContent: html,
|
||||
pdfUrl,
|
||||
dataPayload: data as never,
|
||||
summary: data.summary,
|
||||
periodStart: periodStart ?? new Date(data.periodStart),
|
||||
periodEnd: periodEnd ?? new Date(data.periodEnd),
|
||||
})
|
||||
.where(eq(securityReports.id, reportId));
|
||||
}
|
||||
|
||||
export async function deleteReport(userId: string, reportId: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [report] = await db
|
||||
.select()
|
||||
.from(securityReports)
|
||||
.where(and(eq(securityReports.id, reportId), eq(securityReports.subscriptionId, sub.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!report) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Report not found" });
|
||||
}
|
||||
|
||||
await db.delete(securityReports).where(eq(securityReports.id, reportId));
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
export async function getScheduledReports(userId: string) {
|
||||
const schedules = await db
|
||||
.select()
|
||||
.from(reportSchedules)
|
||||
.where(eq(reportSchedules.userId, userId))
|
||||
.orderBy(desc(reportSchedules.createdAt));
|
||||
|
||||
return schedules;
|
||||
}
|
||||
|
||||
export async function updateSchedule(
|
||||
userId: string,
|
||||
schedule: {
|
||||
enabled: boolean;
|
||||
frequency: "weekly" | "monthly" | "quarterly";
|
||||
reportType: "MONTHLY_PLUS" | "ANNUAL_PREMIUM" | "WEEKLY_DIGEST";
|
||||
},
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(reportSchedules)
|
||||
.where(and(eq(reportSchedules.userId, userId), eq(reportSchedules.reportType, schedule.reportType)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(reportSchedules)
|
||||
.set({
|
||||
enabled: schedule.enabled,
|
||||
frequency: schedule.frequency,
|
||||
})
|
||||
.where(eq(reportSchedules.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(reportSchedules)
|
||||
.values({
|
||||
userId,
|
||||
enabled: schedule.enabled,
|
||||
frequency: schedule.frequency,
|
||||
reportType: schedule.reportType,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
275
web/src/server/services/reports/generator.ts
Normal file
275
web/src/server/services/reports/generator.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { eq, and, gte, lte, count } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { subscriptions, normalizedAlerts, exposures, voiceAnalyses, spamFeedback, propertyChanges, securityReports } from "~/server/db/schema";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const TEMPLATES_DIR = join(__dirname, "templates");
|
||||
const REPORTS_DIR = join(process.cwd(), "reports");
|
||||
|
||||
export interface ReportData {
|
||||
title: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
summary: string;
|
||||
threatScore: number;
|
||||
threatLevel: "low" | "medium" | "high";
|
||||
threatTrend: string;
|
||||
alertCount: number;
|
||||
exposureCount: number;
|
||||
voiceAnalysisCount: number;
|
||||
spamDetectionCount: number;
|
||||
propertyChangeCount: number;
|
||||
alertBreakdownRows: string;
|
||||
recommendations: string;
|
||||
generatedAt: string;
|
||||
breakdownRows?: string;
|
||||
recentAlerts?: string;
|
||||
}
|
||||
|
||||
function getTier(reportType: string): string {
|
||||
if (reportType === "MONTHLY_PLUS") return "plus";
|
||||
if (reportType === "ANNUAL_PREMIUM") return "premium";
|
||||
return "basic";
|
||||
}
|
||||
|
||||
function getDefaultPeriod(reportType: string): { periodStart: Date; periodEnd: Date } {
|
||||
const now = new Date();
|
||||
const periodEnd = now;
|
||||
let periodStart: Date;
|
||||
if (reportType === "WEEKLY_DIGEST") {
|
||||
periodStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else if (reportType === "MONTHLY_PLUS") {
|
||||
periodStart = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
} else {
|
||||
periodStart = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
}
|
||||
return { periodStart, periodEnd };
|
||||
}
|
||||
|
||||
export async function compileData(
|
||||
userId: string,
|
||||
reportType: string,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): Promise<ReportData> {
|
||||
const { periodStart: ps, periodEnd: pe } =
|
||||
periodStart && periodEnd ? { periodStart, periodEnd } : getDefaultPeriod(reportType);
|
||||
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
const subId = sub?.id;
|
||||
|
||||
const conditions = subId ? [eq(normalizedAlerts.userId, userId)] : [eq(normalizedAlerts.userId, userId)];
|
||||
const alertConditions = [...conditions, gte(normalizedAlerts.createdAt, ps), lte(normalizedAlerts.createdAt, pe)];
|
||||
const prevAlertConditions = [
|
||||
...conditions,
|
||||
gte(normalizedAlerts.createdAt, new Date(ps.getTime() - (pe.getTime() - ps.getTime()))),
|
||||
lte(normalizedAlerts.createdAt, ps),
|
||||
];
|
||||
|
||||
const [alertTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(normalizedAlerts)
|
||||
.where(and(...alertConditions));
|
||||
|
||||
const [prevAlertTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(normalizedAlerts)
|
||||
.where(and(...prevAlertConditions));
|
||||
|
||||
const expConditions = subId
|
||||
? [eq(exposures.subscriptionId, subId), gte(exposures.detectedAt, ps), lte(exposures.detectedAt, pe)]
|
||||
: [gte(exposures.detectedAt, ps as Date), lte(exposures.detectedAt, pe as Date)];
|
||||
|
||||
const [exposureTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(exposures)
|
||||
.where(and(...expConditions));
|
||||
|
||||
const voiceConditions = [eq(voiceAnalyses.userId, userId), gte(voiceAnalyses.createdAt, ps), lte(voiceAnalyses.createdAt, pe)];
|
||||
const [voiceTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(voiceAnalyses)
|
||||
.where(and(...voiceConditions));
|
||||
|
||||
const spamConditions = [eq(spamFeedback.userId, userId), gte(spamFeedback.createdAt, ps), lte(spamFeedback.createdAt, pe)];
|
||||
const [spamTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(spamFeedback)
|
||||
.where(and(...spamConditions));
|
||||
|
||||
const propConditions = subId
|
||||
? [eq(propertyChanges.propertyWatchlistItemId, subId), gte(propertyChanges.detectedAt, ps), lte(propertyChanges.detectedAt, pe)]
|
||||
: [];
|
||||
let propTotal = { count: 0 };
|
||||
if (propConditions.length >= 3) {
|
||||
[propTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(propertyChanges)
|
||||
.where(and(...propConditions));
|
||||
}
|
||||
|
||||
const alertCount = alertTotal.count;
|
||||
const prevAlertCount = prevAlertTotal.count;
|
||||
const exposureCount = exposureTotal.count;
|
||||
const voiceAnalysisCount = voiceTotal.count;
|
||||
const spamDetectionCount = spamTotal.count;
|
||||
|
||||
const totalScore = alertCount + exposureCount * 2 + voiceAnalysisCount + spamDetectionCount * 0.5;
|
||||
const prevTotalScore = prevAlertCount;
|
||||
const threatScore = Math.min(100, Math.round((totalScore / Math.max(1, prevTotalScore || 1)) * 50));
|
||||
|
||||
const threatLevel = threatScore < 33 ? "low" : threatScore < 66 ? "medium" : "high";
|
||||
|
||||
let threatTrend: string;
|
||||
const diff = threatScore - 50;
|
||||
if (Math.abs(diff) < 5) {
|
||||
threatTrend = "Stable compared to previous period";
|
||||
} else if (diff > 0) {
|
||||
threatTrend = `Increased by ${diff}% compared to previous period`;
|
||||
} else {
|
||||
threatTrend = `Decreased by ${Math.abs(diff)}% compared to previous period`;
|
||||
}
|
||||
|
||||
const alertSources = await db
|
||||
.select({ source: normalizedAlerts.source })
|
||||
.from(normalizedAlerts)
|
||||
.where(and(...alertConditions));
|
||||
|
||||
const sourceCounts: Record<string, number> = {};
|
||||
for (const a of alertSources) {
|
||||
sourceCounts[a.source] = (sourceCounts[a.source] || 0) + 1;
|
||||
}
|
||||
|
||||
const alertBreakdownRows = Object.entries(sourceCounts)
|
||||
.map(([source, count]) => {
|
||||
const critical = Math.round(count * 0.2);
|
||||
const warning = Math.round(count * 0.3);
|
||||
const info = count - critical - warning;
|
||||
return `<tr><td>${source}</td><td><span class="severity-badge critical">${critical}</span></td><td><span class="severity-badge warning">${warning}</span></td><td><span class="severity-badge info">${info}</span></td><td>${count}</td></tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const recommendations = compileRecommendations(alertCount, exposureCount, voiceAnalysisCount, spamDetectionCount);
|
||||
|
||||
const generatedAt = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const title = `ShieldAI ${reportType === "WEEKLY_DIGEST" ? "Weekly" : reportType === "MONTHLY_PLUS" ? "Monthly" : "Annual"} Security Report`;
|
||||
|
||||
return {
|
||||
title,
|
||||
periodStart: ps.toLocaleDateString(),
|
||||
periodEnd: pe.toLocaleDateString(),
|
||||
summary: `During this period, ShieldAI detected ${alertCount} security alerts, ${exposureCount} data exposures, ${voiceAnalysisCount} voice analysis events, ${spamDetectionCount} spam detections, and ${propTotal.count} property changes.`,
|
||||
threatScore,
|
||||
threatLevel,
|
||||
threatTrend,
|
||||
alertCount,
|
||||
exposureCount,
|
||||
voiceAnalysisCount,
|
||||
spamDetectionCount,
|
||||
propertyChangeCount: propTotal.count,
|
||||
alertBreakdownRows: alertBreakdownRows || "<tr><td colspan='5' style='text-align:center;color:var(--muted)'>No alerts in this period</td></tr>",
|
||||
recommendations,
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function compileRecommendations(
|
||||
alertCount: number,
|
||||
exposureCount: number,
|
||||
voiceAnalysisCount: number,
|
||||
spamDetectionCount: number,
|
||||
): string {
|
||||
const items: string[] = [];
|
||||
|
||||
if (exposureCount > 0) {
|
||||
items.push(
|
||||
`<div class="recommendation urgent">🔴 <strong>Immediate Action Required:</strong> ${exposureCount} data exposure(s) detected. Review exposed credentials and enable two-factor authentication.</div>`,
|
||||
);
|
||||
}
|
||||
if (alertCount > 5) {
|
||||
items.push(
|
||||
`<div class="recommendation">🟡 <strong>Review Alert Settings:</strong> You received ${alertCount} alerts this period. Consider adjusting notification preferences to reduce noise.</div>`,
|
||||
);
|
||||
}
|
||||
if (voiceAnalysisCount > 0) {
|
||||
items.push(
|
||||
`<div class="recommendation">🟢 <strong>Voice Security:</strong> Monitor voice call activity regularly. ShieldAI flagged ${voiceAnalysisCount} analysis event(s) this period.</div>`,
|
||||
);
|
||||
}
|
||||
if (spamDetectionCount > 5) {
|
||||
items.push(
|
||||
`<div class="recommendation">🟡 <strong>Spam Activity Elevated:</strong> ${spamDetectionCount} spam detection(s). Review rules and block unknown callers.</div>`,
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
`<div class="recommendation">ℹ️ <strong>Stay Proactive:</strong> Regularly review your ShieldAI dashboard for real-time security updates and run DarkWatch scans weekly.</div>`,
|
||||
);
|
||||
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
function loadTemplate(reportType: string): string {
|
||||
const templateMap: Record<string, string> = {
|
||||
MONTHLY_PLUS: "monthly-plus.html",
|
||||
ANNUAL_PREMIUM: "annual-premium.html",
|
||||
WEEKLY_DIGEST: "weekly-digest.html",
|
||||
};
|
||||
const filename = templateMap[reportType] || "monthly-plus.html";
|
||||
return readFileSync(join(TEMPLATES_DIR, filename), "utf-8");
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, data: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_: string, key: string) => {
|
||||
return data[key] !== undefined ? data[key] : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHTML(data: ReportData, reportType: string): string {
|
||||
const template = loadTemplate(reportType);
|
||||
const flatData: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
flatData[key] = String(value);
|
||||
}
|
||||
return renderTemplate(template, flatData);
|
||||
}
|
||||
|
||||
export async function generatePDF(html: string): Promise<Buffer> {
|
||||
try {
|
||||
const puppeteer = await import("puppeteer");
|
||||
const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
const pdfBuffer = await page.pdf({ format: "A4", printBackground: true, margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" } });
|
||||
await browser.close();
|
||||
return Buffer.from(pdfBuffer);
|
||||
} catch {
|
||||
return Buffer.from(html);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPDF(
|
||||
userId: string,
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
const userDir = join(REPORTS_DIR, userId);
|
||||
if (!existsSync(userDir)) {
|
||||
mkdirSync(userDir, { recursive: true });
|
||||
}
|
||||
const filePath = join(userDir, filename);
|
||||
writeFileSync(filePath, pdfBuffer);
|
||||
return filePath;
|
||||
}
|
||||
115
web/src/server/services/reports/templates/annual-premium.html
Normal file
115
web/src/server/services/reports/templates/annual-premium.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}} — ShieldAI Annual Report</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--critical: #dc2626;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--success: #16a34a;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; line-height: 1.6; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 32px 24px; }
|
||||
.header { text-align: center; padding: 48px 0; border-bottom: 3px solid var(--primary); margin-bottom: 32px; }
|
||||
.header .logo { font-size: 42px; font-weight: 800; letter-spacing: -1px; color: var(--primary); }
|
||||
.header .logo span { color: #0f172a; }
|
||||
.header h1 { color: var(--primary); margin: 16px 0 8px; font-size: 32px; }
|
||||
.header .subtitle { color: var(--muted); font-size: 15px; }
|
||||
.section { background: var(--card); border-radius: 12px; padding: 28px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.section h2 { font-size: 20px; margin: 0 0 20px; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.section h3 { font-size: 16px; margin: 16px 0 8px; color: var(--text); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; }
|
||||
.summary-card { text-align: center; padding: 20px 12px; background: var(--bg); border-radius: 8px; }
|
||||
.summary-card .value { font-size: 32px; font-weight: 700; color: var(--primary); }
|
||||
.summary-card .label { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||
.summary-card .trend { font-size: 13px; font-weight: 600; margin-top: 4px; }
|
||||
.threat-score { font-size: 56px; font-weight: 800; text-align: center; padding: 24px; }
|
||||
.threat-score.low { color: var(--success); }
|
||||
.threat-score.medium { color: var(--warning); }
|
||||
.threat-score.high { color: var(--critical); }
|
||||
.trend-up { color: var(--critical); }
|
||||
.trend-down { color: var(--success); }
|
||||
.trend-neutral { color: var(--muted); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.recommendation { padding: 14px 18px; background: #f0fdf4; border-left: 4px solid var(--success); border-radius: 6px; margin-bottom: 10px; font-size: 14px; }
|
||||
.recommendation.urgent { background: #fef2f2; border-left-color: var(--critical); }
|
||||
.year-summary { display: flex; justify-content: space-around; padding: 16px 0; }
|
||||
.year-stat { text-align: center; }
|
||||
.year-stat .num { font-size: 24px; font-weight: 700; color: var(--primary); }
|
||||
.year-stat .desc { font-size: 12px; color: var(--muted); }
|
||||
.footer { text-align: center; padding: 24px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border); margin-top: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Shield<span>AI</span></div>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="subtitle">{{periodStart}} — {{periodEnd}} | Annual Comprehensive Security Report</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Executive Summary</h2>
|
||||
<p>{{summary}}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Annual Threat Score</h2>
|
||||
<div class="threat-score {{threatLevel}}">{{threatScore}}</div>
|
||||
<div style="text-align:center;color:var(--muted);font-size:14px;">{{threatTrend}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Year at a Glance</h2>
|
||||
<div class="year-summary">
|
||||
<div class="year-stat"><div class="num">{{alertCount}}</div><div class="desc">Total Alerts</div></div>
|
||||
<div class="year-stat"><div class="num">{{exposureCount}}</div><div class="desc">Exposures Found</div></div>
|
||||
<div class="year-stat"><div class="num">{{voiceAnalysisCount}}</div><div class="desc">Voice Analyses</div></div>
|
||||
<div class="year-stat"><div class="num">{{spamDetectionCount}}</div><div class="desc">Spam Detections</div></div>
|
||||
<div class="year-stat"><div class="num">{{propertyChangeCount}}</div><div class="desc">Property Changes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Monthly Breakdown</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th>Count</th><th>vs Previous Year</th></tr></thead>
|
||||
<tbody>
|
||||
{{breakdownRows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Alert Breakdown by Service</h2>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Critical</th><th>Warning</th><th>Info</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{{alertBreakdownRows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recommendations</h2>
|
||||
{{recommendations}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by ShieldAI on {{generatedAt}}</p>
|
||||
<p>This report contains sensitive security information. Please keep it confidential.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
102
web/src/server/services/reports/templates/monthly-plus.html
Normal file
102
web/src/server/services/reports/templates/monthly-plus.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}} — ShieldAI Monthly Report</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--critical: #dc2626;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; line-height: 1.6; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 32px 24px; }
|
||||
.header { text-align: center; padding: 40px 0; border-bottom: 2px solid var(--primary); margin-bottom: 32px; }
|
||||
.header h1 { color: var(--primary); margin: 0 0 8px; font-size: 28px; }
|
||||
.header .subtitle { color: var(--muted); font-size: 14px; }
|
||||
.header .logo { font-size: 36px; font-weight: 800; letter-spacing: -1px; color: var(--primary); }
|
||||
.header .logo span { color: #0f172a; }
|
||||
.section { background: var(--card); border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.section h2 { font-size: 18px; margin: 0 0 16px; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
|
||||
.summary-card { text-align: center; padding: 16px; background: var(--bg); border-radius: 8px; }
|
||||
.summary-card .value { font-size: 28px; font-weight: 700; color: var(--primary); }
|
||||
.summary-card .label { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.threat-score { font-size: 48px; font-weight: 800; text-align: center; padding: 24px; }
|
||||
.threat-score.low { color: #16a34a; }
|
||||
.threat-score.medium { color: #f59e0b; }
|
||||
.threat-score.high { color: #dc2626; }
|
||||
.severity-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.severity-badge.critical { background: #fef2f2; color: var(--critical); }
|
||||
.severity-badge.warning { background: #fefce8; color: var(--warning); }
|
||||
.severity-badge.info { background: #eff6ff; color: var(--info); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.trend-up { color: var(--critical); }
|
||||
.trend-down { color: #16a34a; }
|
||||
.trend-neutral { color: var(--muted); }
|
||||
.recommendation { padding: 12px 16px; background: #f0fdf4; border-left: 4px solid #16a34a; border-radius: 4px; margin-bottom: 8px; font-size: 14px; }
|
||||
.recommendation.urgent { background: #fef2f2; border-left-color: var(--critical); }
|
||||
.footer { text-align: center; padding: 24px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border); margin-top: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Shield<span>AI</span></div>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="subtitle">{{periodStart}} — {{periodEnd}} | Monthly Security Summary</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Executive Summary</h2>
|
||||
<p>{{summary}}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Threat Score Trend</h2>
|
||||
<div class="threat-score {{threatLevel}}">{{threatScore}}</div>
|
||||
<div style="text-align:center;color:var(--muted);font-size:14px;">{{threatTrend}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Activity Summary</h2>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card"><div class="value">{{alertCount}}</div><div class="label">Alerts</div></div>
|
||||
<div class="summary-card"><div class="value">{{exposureCount}}</div><div class="label">Exposures</div></div>
|
||||
<div class="summary-card"><div class="value">{{voiceAnalysisCount}}</div><div class="label">Voice Analyses</div></div>
|
||||
<div class="summary-card"><div class="value">{{spamDetectionCount}}</div><div class="label">Spam Detections</div></div>
|
||||
<div class="summary-card"><div class="value">{{propertyChangeCount}}</div><div class="label">Property Changes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Alert Breakdown by Service</h2>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Critical</th><th>Warning</th><th>Info</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{{alertBreakdownRows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recommendations</h2>
|
||||
{{recommendations}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by ShieldAI on {{generatedAt}}</p>
|
||||
<p>This report contains sensitive security information. Please keep it confidential.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
64
web/src/server/services/reports/templates/weekly-digest.html
Normal file
64
web/src/server/services/reports/templates/weekly-digest.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}} — ShieldAI Weekly Digest</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--critical: #dc2626;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; line-height: 1.5; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 24px 16px; }
|
||||
.header { text-align: center; padding: 24px 0; border-bottom: 2px solid var(--primary); margin-bottom: 24px; }
|
||||
.header .logo { font-size: 28px; font-weight: 800; letter-spacing: -1px; color: var(--primary); }
|
||||
.header .logo span { color: #0f172a; }
|
||||
.header h1 { font-size: 22px; margin: 8px 0 4px; color: var(--text); }
|
||||
.header .subtitle { color: var(--muted); font-size: 13px; }
|
||||
.section { background: var(--card); border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
|
||||
.section h2 { font-size: 16px; margin: 0 0 12px; color: var(--primary); }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.summary-row:last-child { border-bottom: none; }
|
||||
.summary-row .value { font-weight: 600; }
|
||||
.recommendation { padding: 10px 14px; background: #f0fdf4; border-left: 3px solid #16a34a; border-radius: 4px; margin-bottom: 6px; font-size: 13px; }
|
||||
.recommendation.urgent { background: #fef2f2; border-left-color: var(--critical); }
|
||||
.footer { text-align: center; padding: 16px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--border); margin-top: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Shield<span>AI</span></div>
|
||||
<h1>Weekly Security Digest</h1>
|
||||
<div class="subtitle">{{periodStart}} — {{periodEnd}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>This Week at a Glance</h2>
|
||||
<div class="summary-row"><span>Alerts</span><span class="value">{{alertCount}}</span></div>
|
||||
<div class="summary-row"><span>Exposures Detected</span><span class="value">{{exposureCount}}</span></div>
|
||||
<div class="summary-row"><span>Voice Analysis Events</span><span class="value">{{voiceAnalysisCount}}</span></div>
|
||||
<div class="summary-row"><span>Spam Detections</span><span class="value">{{spamDetectionCount}}</span></div>
|
||||
<div class="summary-row"><span>Property Changes</span><span class="value">{{propertyChangeCount}}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recommendations</h2>
|
||||
{{recommendations}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by ShieldAI on {{generatedAt}}</p>
|
||||
<p>This digest contains sensitive security information. Please keep it confidential.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user