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:
2026-05-25 17:08:43 -04:00
parent 4f7882a10d
commit 659ab9b71a
16 changed files with 3102 additions and 10 deletions

1491
bun.lock Normal file

File diff suppressed because it is too large Load Diff

263
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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",

View File

@@ -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;

View 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();
});
});

View 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);
}),
});

View 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"]),
});

View File

@@ -13,4 +13,5 @@ export * from "./hometitle";
export * from "./removebrokers";
export * from "./invitation";
export * from "./notifications";
export * from "./report-schedules";
export * from "./relations";

View File

@@ -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),

View 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),
}));

View 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");
});
});

View 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;
}

View 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;
}

View 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>

View 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>

View 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>