Compare commits
568 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2379f3a878 | ||
| 0232b9de7a | |||
|
|
2e98bc3fc7 | ||
| ec2f143218 | |||
|
|
f3e233bf38 | ||
| 1696aeb54f | |||
|
|
e45804776d | ||
| 5879328b67 | |||
|
|
4618d11849 | ||
| 4022768c03 | |||
|
|
7fc57b4b10 | ||
| 99f5d52d17 | |||
|
|
e22b5ec02d | ||
| cf476e7afc | |||
|
|
7b7a8d0f35 | ||
| 795b3d0b28 | |||
| d2efca8339 | |||
|
|
c579f141f8 | ||
| 9cb03c1ede | |||
|
|
c14bef4448 | ||
| 7c0e5450db | |||
|
|
8e85493872 | ||
| 327d3d4fbc | |||
|
|
bdb2e274cc | ||
| cd46f1d4c2 | |||
|
|
6da4b5e9d0 | ||
| 941626004e | |||
|
|
67cfe39249 | ||
| c24103d9a0 | |||
|
|
3e85f839fe | ||
| 63a0dde0f8 | |||
|
|
94f45d9726 | ||
| 136a9ce3f3 | |||
|
|
e65151c3df | ||
| 3d91d59b9c | |||
|
|
822d6d1c3c | ||
| a24e28f52f | |||
| 8dbfa62768 | |||
|
|
da4e0c9136 | ||
| dd3cbeb65d | |||
| e6d383103c | |||
|
|
a14816c8ee | ||
|
|
08b220e29c | ||
|
|
d41a3f1887 | ||
| 1f6cdc62d7 | |||
|
|
978c63bacd | ||
| 544eb7ae3c | |||
|
|
f6839f6e14 | ||
| 3fac29436a | |||
|
|
56f45c9301 | ||
| 83460abce4 | |||
|
|
1b084b2ba4 | ||
| 0ea034bdc8 | |||
|
|
fc9e27078a | ||
| fb8cbe8007 | |||
| f49f786c23 | |||
|
|
dd31141d4e | ||
| 8073094760 | |||
|
|
33a1e146ab | ||
| 4f8216db77 | |||
|
|
42d605d19f | ||
| 749350df7f | |||
|
|
ac085100fe | ||
| ce4ecd1268 | |||
|
|
a57cfc396b | ||
| 987badbf8d | |||
|
|
d38fcd21c1 | ||
| 6e36cc3b07 | |||
|
|
62a8a8bf4b | ||
| 96038cfcf4 | |||
|
|
981214fdd0 | ||
| 92b0138108 | |||
|
|
27f0255240 | ||
| 4e06dde9e1 | |||
|
|
b9a0e5b82c | ||
| bb7fe8dc2c | |||
|
|
81f1f2250b | ||
| c6c90bb615 | |||
|
|
60489a626b | ||
| 3c63e1ecbb | |||
|
|
acbcb39cbe | ||
| a87a0b6af1 | |||
|
|
abdc3cb6db | ||
| 7a1bd50119 | |||
|
|
87d75d0571 | ||
| faf2900c28 | |||
|
|
5258efc179 | ||
| 2a5cc5bb51 | |||
|
|
8eaee2844f | ||
| 440a19c3a7 | |||
| 4ae6d84240 | |||
|
|
5870e5c614 | ||
| 2e7ebbd9ed | |||
|
|
dc3fa21359 | ||
| 11aeac5edd | |||
|
|
f6c0c082bc | ||
| 4e22213cd1 | |||
|
|
9815eb3686 | ||
| 2bf4a7c1e6 | |||
|
|
5eed3f51f4 | ||
| d250932c05 | |||
|
|
7d1f964574 | ||
| 3b69e58de3 | |||
|
|
5211aadd22 | ||
| a997d1d0b0 | |||
| cf5f77c58e | |||
|
|
cf0f5bb820 | ||
| 503e7084da | |||
|
|
d8aa19ac40 | ||
| dcd9452b8c | |||
|
|
6d468544e2 | ||
| 2913c7aa09 | |||
|
|
77f9cb6081 | ||
| 2f1d73ca12 | |||
|
|
402e2617ca | ||
| e14c19c112 | |||
|
|
ea46f66c7a | ||
| a42ee5a461 | |||
|
|
71710c8316 | ||
| 1480a73ab0 | |||
|
|
b3efa3c756 | ||
| fb8fd57bb6 | |||
|
|
0a90d9d590 | ||
| 6ab473f5f0 | |||
|
|
c46efe1474 | ||
| 25d6b76f6d | |||
|
|
9ffcc9d65d | ||
| 1285702210 | |||
|
|
d38b751b40 | ||
| e122d55ced | |||
|
|
af9992f773 | ||
| 3912139273 | |||
| b5f7f5e4d1 | |||
|
|
5173059621 | ||
| ebceb0e2e3 | |||
| e75054b1ab | |||
|
|
639313485a | ||
| 4a04e478c4 | |||
|
|
1814469eb4 | ||
| b777430ff7 | |||
|
|
23830c0d4e | ||
| ef42fee982 | |||
|
|
65cb54500c | ||
| 664ad291be | |||
|
|
ff912b9055 | ||
| ec32027bd4 | |||
|
|
59f773639b | ||
| dd2be5eecf | |||
|
|
a94bfbd3e9 | ||
| 338bbc9440 | |||
|
|
60aad04642 | ||
| 7f2aff9a24 | |||
|
|
689320e7d2 | ||
| e457bbf046 | |||
| 68cdbb6066 | |||
|
|
cea6be7145 | ||
| 74a5ca6331 | |||
|
|
62470e7661 | ||
| 2b517683fd | |||
|
|
5d06d1ba09 | ||
| 46c1e56b14 | |||
|
|
78a9b80010 | ||
| d356d9dfb6 | |||
|
|
ab63f83f50 | ||
| b546a55eaf | |||
|
|
dfa53a93dd | ||
| f30464cd0e | |||
|
|
2d2fa3c2c8 | ||
| 58cb391f4b | |||
|
|
0ebe2f0806 | ||
| 7867abc5bc | |||
|
|
cc4c8e2839 | ||
| 33ee2eeac9 | |||
|
|
e0b13f26fb | ||
| eee7f36756 | |||
|
|
622c919733 | ||
| c7f6b6369a | |||
|
|
879d956003 | ||
| 27eaac7ea8 | |||
|
|
93618c57e5 | ||
| 7f043ef704 | |||
|
|
62e35deddc | ||
| 59f6f43d03 | |||
|
|
e675c1a73c | ||
| 3c19084a0a | |||
|
|
e2049c6b9f | ||
| a3839c2f0d | |||
|
|
c1df3d7b1b | ||
| 94782f030d | |||
|
|
1c25b79251 | ||
| 0b0fa8294d | |||
|
|
f49f3a75fb | ||
| 8f14044ae6 | |||
|
|
55e1e425f4 | ||
| 68b16ad2e8 | |||
|
|
6a28934692 | ||
| 78c4a5fee6 | |||
|
|
1ce5f481a8 | ||
|
|
e0120d38fd | ||
| 6b2079ef2c | |||
|
|
0478e176d5 | ||
| 47f7f97cd9 | |||
|
|
b0719d1e39 | ||
| 0039ac3752 | |||
|
|
3c8316f4f7 | ||
| 2564df1c64 | |||
|
|
696c547238 | ||
| 38165bdb9a | |||
|
|
6139dca072 | ||
| 68bfaa50e6 | |||
|
|
9c42621f74 | ||
| 1b98282202 | |||
|
|
b6731b220c | ||
| 3507d455e8 | |||
|
|
92b2adf8e8 | ||
| d6c7452256 | |||
|
|
d812b681dd | ||
| b4306a6092 | |||
|
|
57fdd159d5 | ||
| 4a747ca042 | |||
|
|
e0bf96824c | ||
| e86e09703e | |||
|
|
275741c79e | ||
| 3a40249ddb | |||
|
|
4c70905950 | ||
| 0b4884ff2a | |||
|
|
e4acab77c8 | ||
| 4e20b1b430 | |||
|
|
15747ac942 | ||
| e5fa89ef17 | |||
|
|
2c65da31e9 | ||
| eeec6af905 | |||
|
|
e7d03951b9 | ||
| af8816e0af | |||
|
|
64f6427e1a | ||
| c9b7a75429 | |||
|
|
0490f6922e | ||
| 057c4c9174 | |||
|
|
a9e56bc707 | ||
| e5d09c73b7 | |||
|
|
6e1298b825 | ||
| fc8e43437a | |||
|
|
cb453aa949 | ||
| 2651bd16ae | |||
|
|
91e0f0c46f | ||
| e6986d512b | |||
|
|
8f9c21675c | ||
| 7fb22cdd20 | |||
|
|
780291303d | ||
| 4f607f7d2f | |||
|
|
208227b3ed | ||
| bf1c7d4adf | |||
|
|
a7a30cf983 | ||
| 0bc0676b33 | |||
|
|
73484d3eb4 | ||
| b3253d5bbc | |||
|
|
54f3769e90 | ||
| bad6f74ee6 | |||
|
|
bcf16168b6 | ||
| 498fbd9e0e | |||
|
|
007ff8e538 | ||
| 1fc70e3915 | |||
|
|
d891e47e02 | ||
| 08c39afde4 | |||
|
|
c579543b8a | ||
| 0d84137786 | |||
|
|
20ee30c4b4 | ||
| 93612137e3 | |||
|
|
6e70f08e3c | ||
| 459f5f7976 | |||
|
|
a2e6331ddd | ||
| 13cd30bec9 | |||
|
|
baeb9488c6 | ||
| 0cba0f987e | |||
|
|
958a79997d | ||
| 8fb1c96f93 | |||
| 6e6fe80c7f | |||
|
|
d1554050bd | ||
|
|
b1fae270bb | ||
|
|
c852483e18 | ||
| 2e01ad5bc9 | |||
|
|
26763c7183 | ||
| f0c5c2c45b | |||
|
|
034bb60fd5 | ||
| d4b389cb79 | |||
|
|
a71fb81468 | ||
| 9bee0a013b | |||
|
|
8bcb4311b3 | ||
| 9fd15f3a50 | |||
|
|
e3c876c7be | ||
| 32dcf3b89e | |||
| 7066b937f6 | |||
|
|
8553ea8811 | ||
| 19885a50f7 | |||
|
|
ce82034b9d | ||
| 4528da2934 | |||
|
|
146d4c1351 | ||
| 88625706f4 | |||
|
|
e395faed30 | ||
| e8f8399896 | |||
|
|
ac0115af2b | ||
| f24b15f19b | |||
|
|
e64426bd84 | ||
| 0ec4cd68d2 | |||
|
|
840516d2a3 | ||
| 59355c3eef | |||
| d024935fe9 | |||
|
|
5a5470634e | ||
| 392231ad63 | |||
|
|
4b1c896621 | ||
| 720920a51c | |||
|
|
460adb9506 | ||
| 7aa1f756a9 | |||
|
|
c484a8ca9b | ||
| 28d2c9f4ec | |||
|
|
ee253e9449 | ||
| b6c15e53d0 | |||
|
|
722162c2c3 | ||
| 02a76fe996 | |||
|
|
0ebb03a7ab | ||
| 748ac9e049 | |||
|
|
495edd621c | ||
| 4ffca19db6 | |||
|
|
717427c5d7 | ||
| cc438a0e36 | |||
|
|
a32a0b62fc | ||
| 342f72b713 | |||
|
|
91254d18f3 | ||
| 40580dbf15 | |||
| 7f1d74c047 | |||
|
|
ecec686347 | ||
| 86de680080 | |||
|
|
0371947065 | ||
| 296698758c | |||
|
|
18c1161587 | ||
| 0010396780 | |||
|
|
d4557e13fb | ||
| 3e41130c69 | |||
|
|
d9034563d6 | ||
| 5836a75157 | |||
|
|
790008ae0d | ||
|
|
b5b91eb968 | ||
| 38eb810e7a | |||
|
|
458588a6e7 | ||
| 0b4113417f | |||
|
|
b59d2a9533 | ||
| 6740b35f8a | |||
|
|
92ad82a012 | ||
| 672e4ca597 | |||
|
|
e4d70a9b37 | ||
| c30f1c4162 | |||
|
|
44062a9f5b | ||
| 17fac8cf86 | |||
|
|
9fa8553486 | ||
|
|
f5b0b3b543 | ||
| e3ed5c7e63 | |||
|
|
ae0040e092 | ||
| 1f3f99d430 | |||
|
|
7be72f1758 | ||
| 0967c7a33d | |||
| 1f1c0fa6f3 | |||
|
|
728b1a20d3 | ||
| f248f7cbd0 | |||
|
|
0ad9bb16c2 | ||
| 510787bc5b | |||
|
|
9f696e7676 | ||
|
|
a77105316f | ||
| cadacb63f5 | |||
|
|
62592f707e | ||
| 023e48d99a | |||
|
|
99efca0371 | ||
| 1448950b81 | |||
|
|
a811fdac63 | ||
| 1201fe4d3c | |||
|
|
ba9228c9cb | ||
| b392b82c25 | |||
|
|
87825d13d6 | ||
| 21a6a796cf | |||
|
|
ecd0a73bc8 | ||
|
|
39d61dc7ad | ||
|
|
43491359d9 | ||
| 5ed2cea7e9 | |||
|
|
cbb16a8d52 | ||
| 70e94a6ce0 | |||
|
|
b61a00003a | ||
| 52dba6f890 | |||
| 4242678aab | |||
|
|
b2e086d5ba | ||
| 07a9787570 | |||
|
|
4bf5dc3d58 | ||
| be3d269928 | |||
|
|
80a53fae94 | ||
| e15d2b6c2f | |||
|
|
7a52bf499e | ||
| 2489ec8d2d | |||
|
|
4a4f349805 | ||
| 517a268307 | |||
|
|
a94b2a97b1 | ||
| 542cdfbb82 | |||
|
|
262062f468 | ||
| 0a14193371 | |||
|
|
7f665f5117 | ||
| 2782a8fb3b | |||
|
|
c182ef6d30 | ||
| fdb3b76cbd | |||
|
|
01e7c843cb | ||
| a0dbefbfa0 | |||
|
|
ab3fc318a0 | ||
| e658b35e43 | |||
|
|
67e106162a | ||
| b7f3182fd6 | |||
|
|
ac60072d88 | ||
| 9390f38bf6 | |||
|
|
236d5518c9 | ||
| fd52a79a72 | |||
|
|
f72819e343 | ||
| 1af8be3f15 | |||
|
|
28d03f4e21 | ||
| 2e72ee81dd | |||
|
|
ba67ace190 | ||
|
|
50782c30e5 | ||
| 4a2ff8afc5 | |||
|
|
7a1c14ce89 | ||
| 6fafc3d089 | |||
|
|
4316866bce | ||
| 356c1a1894 | |||
|
|
2a310648ca | ||
| 8592633c22 | |||
|
|
0a9cdb8709 | ||
| 0d21e098f8 | |||
| b6799ed167 | |||
|
|
be5bda169e | ||
| 4ede403356 | |||
| 5d31605b80 | |||
| ddd4ad024e | |||
|
|
4e927f48bd | ||
| af5644d17a | |||
|
|
016c0a883a | ||
| c6a5f889b4 | |||
|
|
c895ecdb28 | ||
| 05e3f8a61c | |||
|
|
f79a2abc65 | ||
| a726c270bb | |||
|
|
8a4965c45b | ||
| 93497bf7c7 | |||
|
|
20584af729 | ||
| be9f452656 | |||
| ef4b8e58fe | |||
|
|
a42f7d7007 | ||
| 768d02b9ed | |||
|
|
c4742959e4 | ||
| 97c54c0c5c | |||
| 7cc50907d1 | |||
|
|
b4199f7c48 | ||
| dda36f7bc5 | |||
| 27810bbb36 | |||
|
|
7a1421d5c2 | ||
| 1b52478f97 | |||
| fe8b000737 | |||
|
|
d2babbe3b0 | ||
|
|
684d81db2a | ||
| 59ffa65562 | |||
| 0c0dd852ac | |||
|
|
cde766872e | ||
| 604b543c12 | |||
| fd67fe2941 | |||
|
|
582035b60e | ||
| 44e7670a89 | |||
| 2abfb3ed6e | |||
|
|
219de4a25c | ||
| 1540d5051f | |||
| 9c978c26fa | |||
|
|
adb109d8e9 | ||
| c668c8785f | |||
|
|
695bbb61b9 | ||
| 877c971833 | |||
| ed3af07aab | |||
|
|
dd4b34edfa | ||
| 91fa2f0516 | |||
|
|
aefd57e57b | ||
| 2ca4eb47ac | |||
| a4fe30da22 | |||
|
|
abab7fd25e | ||
| 53dd26d2d9 | |||
| ab3da0336c | |||
|
|
ed6d6349a2 | ||
| d4db2a709a | |||
| 508583809b | |||
|
|
6b1f7e7590 | ||
| 07bb31f4fb | |||
| a42fb76da8 | |||
|
|
08c320423c | ||
| d2498065ed | |||
| 56dc96f418 | |||
|
|
4e9aa0efc3 | ||
| e5e4b1316c | |||
| e8d511b4de | |||
|
|
c4bbf5c251 | ||
| 32a9e6732b | |||
| e7c076e2ed | |||
|
|
dbe8e72efe | ||
| 38bd193042 | |||
|
|
57215e2778 | ||
| 2c1de24e9a | |||
| c8baff7aac | |||
| de3f21a7ec | |||
|
|
c6adbf79e7 | ||
| 7399a27600 | |||
|
|
68aadcaa4e | ||
| 971d2c3fa7 | |||
|
|
daaacfde5e | ||
| 7ac8fe1d29 | |||
| a2462dfb6b | |||
|
|
a911224fb4 | ||
|
|
bf4bcef890 | ||
| ac6cd2e0a1 | |||
| eea03880c1 | |||
|
|
7fc263691f | ||
| c0912d36d5 | |||
| 612c2b5943 | |||
|
|
8e787ddcf0 | ||
| 11c52d284c | |||
|
|
b528bd3651 | ||
| 4c5ceb1bd6 | |||
| bcc4ad64dc | |||
|
|
d520980322 | ||
| d79955aaa0 | |||
| e66027dc8e | |||
|
|
027df989a4 | ||
| d4d69caaf7 | |||
| 03b5af39e1 | |||
|
|
8a86333f86 | ||
| f173f805ea | |||
| d3b0996ad5 | |||
|
|
b939262f0c | ||
| 9437f3d6c6 | |||
| f1e028d498 | |||
|
|
5274650aea | ||
| de5a9a565b | |||
| 10a379c5e3 | |||
| a6a484d432 | |||
|
|
4b0a172c35 | ||
| e8c894d5cf | |||
| 6c8fd4b126 | |||
|
|
a1f52544d0 | ||
| 2334359756 | |||
| 406954ca06 | |||
|
|
95d441be98 | ||
| 186ed484b7 | |||
|
|
3669958e9d | ||
| 5f3daf0539 | |||
| ae7afaaf97 | |||
|
|
3ae7b9e0d4 | ||
| 921c48fc57 | |||
|
|
2571864b91 | ||
| 065d0c746a | |||
| 395f6c21a2 | |||
|
|
aec56dfc23 | ||
| a12a0e5207 | |||
| e337bd67b1 | |||
|
|
a8f5b4e51a | ||
| d0ce8021d6 | |||
| efbb162880 | |||
|
|
e353ce8a81 | ||
| b5cbf271b8 | |||
|
|
2041b4ac3c | ||
| e547363a65 | |||
| bddaf765fc |
16
.claude/hooks.json
Normal file
16
.claude/hooks.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://claude.ai/schemas/hooks.json",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const cmd = process.argv[1] || ''; const isTest = /\\b(npm\\s+(run\\s+)?test|vitest|jest)\\b/i.test(cmd); const isWindows = process.platform === 'win32'; const inContainer = process.env.REMOTE_CONTAINERS === 'true' || process.env.DEVCONTAINER === 'true'; if (isTest && isWindows && !inContainer) { console.error('BLOCKED: Tests must run on Linux. Use Dev Container (Reopen in Container) or WSL.'); process.exit(1); }\" -- \"$CLAUDE_TOOL_INPUT\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
106
.claude/settings.local.json
Normal file
106
.claude/settings.local.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Bash(podman --version:*)",
|
||||
"Bash(podman ps:*)",
|
||||
"Bash(podman machine start:*)",
|
||||
"Bash(podman compose:*)",
|
||||
"Bash(podman pull:*)",
|
||||
"Bash(podman images:*)",
|
||||
"Bash(podman stop:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(podman rm:*)",
|
||||
"Bash(podman run:*)",
|
||||
"Bash(podman start:*)",
|
||||
"Bash(podman exec:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(PGPASSWORD=postgres psql:*)",
|
||||
"Bash(npm search:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(cmd.exe:*)",
|
||||
"Bash(npm run test:integration:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(done)",
|
||||
"Bash(podman info:*)",
|
||||
"Bash(podman machine:*)",
|
||||
"Bash(podman system connection:*)",
|
||||
"Bash(podman inspect:*)",
|
||||
"Bash(python -m json.tool:*)",
|
||||
"Bash(claude mcp status)",
|
||||
"Bash(powershell.exe -Command \"claude mcp status\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp list\")",
|
||||
"Bash(powershell.exe -Command \"claude --version\")",
|
||||
"Bash(powershell.exe -Command \"claude config\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp get gitea-projectium\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp add --help\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp add -t stdio -s user filesystem -- D:\\\\nodejs\\\\npx.cmd -y @modelcontextprotocol/server-filesystem D:\\\\gitea\\\\flyer-crawler.projectium.com\\\\flyer-crawler.projectium.com\")",
|
||||
"Bash(powershell.exe -Command \"claude mcp add -t stdio -s user fetch -- D:\\\\nodejs\\\\npx.cmd -y @modelcontextprotocol/server-fetch\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List files in src/hooks using filesystem MCP'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List all podman containers'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print --allowedTools ''mcp__gitea-projectium__*''\")",
|
||||
"Bash(powershell.exe -Command \"echo ''Fetch the homepage of https://gitea.projectium.com and summarize it'' | claude --print --allowedTools ''mcp__fetch__*''\")",
|
||||
"Bash(dir \"C:\\\\Users\\\\games3\\\\.claude\")",
|
||||
"Bash(dir:*)",
|
||||
"Bash(D:nodejsnpx.cmd -y @modelcontextprotocol/server-fetch --help)",
|
||||
"Bash(cmd /c \"dir /o-d C:\\\\Users\\\\games3\\\\.claude\\\\debug 2>nul | head -10\")",
|
||||
"mcp__memory__read_graph",
|
||||
"mcp__memory__create_entities",
|
||||
"mcp__memory__search_nodes",
|
||||
"mcp__memory__delete_entities",
|
||||
"mcp__sequential-thinking__sequentialthinking",
|
||||
"mcp__filesystem__list_directory",
|
||||
"mcp__filesystem__read_multiple_files",
|
||||
"mcp__filesystem__directory_tree",
|
||||
"mcp__filesystem__read_text_file",
|
||||
"Bash(wc:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(git grep:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(git add:*)",
|
||||
"mcp__filesystem__write_file",
|
||||
"mcp__podman__container_list",
|
||||
"Bash(podman cp:*)",
|
||||
"mcp__podman__container_inspect",
|
||||
"mcp__podman__network_list",
|
||||
"Bash(podman network connect:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(set NODE_ENV=test)",
|
||||
"Bash(podman-compose:*)",
|
||||
"Bash(timeout 60 podman machine start:*)",
|
||||
"Bash(podman build:*)",
|
||||
"Bash(podman network rm:*)",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run typecheck:*)",
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(npm run test:unit:*)",
|
||||
"mcp__filesystem__move_file",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(podman image inspect:*)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(xargs -I {} sh -c 'if ! grep -q \"\"vi.mock.*apiClient\"\" \"\"{}\"\"; then echo \"\"{}\"\"; fi')",
|
||||
"Bash(MSYS_NO_PATHCONV=1 podman exec:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(\"/c/Users/games3/.local/bin/uvx.exe\" markitdown-mcp --help)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(timeout 1800 podman exec flyer-crawler-dev npm run test:unit:*)",
|
||||
"mcp__filesystem__edit_file",
|
||||
"Bash(timeout 300 tail:*)",
|
||||
"mcp__filesystem__list_allowed_directories",
|
||||
"mcp__memory__add_observations",
|
||||
"Bash(ssh:*)",
|
||||
"mcp__redis__list",
|
||||
"Read(//d/gitea/bugsink-mcp/**)",
|
||||
"Bash(d:/nodejs/npm.cmd install)",
|
||||
"Bash(node node_modules/vitest/vitest.mjs run:*)",
|
||||
"Bash(npm run test:e2e:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
96
.devcontainer/devcontainer.json
Normal file
96
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
// ============================================================================
|
||||
// VS CODE DEV CONTAINER CONFIGURATION
|
||||
// ============================================================================
|
||||
// This file configures VS Code's Dev Containers extension to provide a
|
||||
// consistent, fully-configured development environment.
|
||||
//
|
||||
// Features:
|
||||
// - Automatic PostgreSQL + Redis startup with healthchecks
|
||||
// - Automatic npm install
|
||||
// - Automatic database schema initialization and seeding
|
||||
// - Pre-configured VS Code extensions (ESLint, Prettier)
|
||||
// - Podman support for Windows users
|
||||
//
|
||||
// Usage:
|
||||
// 1. Install the "Dev Containers" extension in VS Code
|
||||
// 2. Open this project folder
|
||||
// 3. Click "Reopen in Container" when prompted (or use Command Palette)
|
||||
// 4. Wait for container build and initialization
|
||||
// 5. Development server starts automatically
|
||||
// ============================================================================
|
||||
|
||||
"name": "Flyer Crawler Dev (Ubuntu 22.04)",
|
||||
|
||||
// Use Docker Compose for multi-container setup
|
||||
"dockerComposeFile": ["../compose.dev.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/app",
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
// Code quality
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
// TypeScript
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
// Database
|
||||
"mtxr.sqltools",
|
||||
"mtxr.sqltools-driver-pg",
|
||||
// Utilities
|
||||
"eamodio.gitlens",
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"typescript.preferences.importModuleSpecifier": "relative"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Run as root (required for npm global installs)
|
||||
"remoteUser": "root",
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle Commands
|
||||
// ============================================================================
|
||||
|
||||
// initializeCommand: Runs on the HOST before the container is created.
|
||||
// Starts Podman machine on Windows (no-op if already running or using Docker).
|
||||
"initializeCommand": "powershell -Command \"podman machine start; exit 0\"",
|
||||
|
||||
// postCreateCommand: Runs ONCE when the container is first created.
|
||||
// This is where we do full initialization: npm install + database setup.
|
||||
"postCreateCommand": "chmod +x scripts/docker-init.sh && ./scripts/docker-init.sh",
|
||||
|
||||
// postAttachCommand: Runs EVERY TIME VS Code attaches to the container.
|
||||
// Starts the development server automatically.
|
||||
"postAttachCommand": "npm run dev:container",
|
||||
|
||||
// ============================================================================
|
||||
// Port Forwarding
|
||||
// ============================================================================
|
||||
// Automatically forward these ports from the container to the host
|
||||
"forwardPorts": [3000, 3001],
|
||||
|
||||
// Labels for forwarded ports in VS Code's Ports panel
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Frontend (Vite)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3001": {
|
||||
"label": "Backend API",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Features
|
||||
// ============================================================================
|
||||
// Additional dev container features (optional)
|
||||
"features": {}
|
||||
}
|
||||
114
.env.example
Normal file
114
.env.example
Normal file
@@ -0,0 +1,114 @@
|
||||
# .env.example
|
||||
# ============================================================================
|
||||
# ENVIRONMENT VARIABLES TEMPLATE
|
||||
# ============================================================================
|
||||
# Copy this file to .env and fill in your values.
|
||||
# For local development with Docker/Podman, these defaults should work out of the box.
|
||||
#
|
||||
# IMPORTANT: Never commit .env files with real credentials to version control!
|
||||
# ============================================================================
|
||||
|
||||
# ===================
|
||||
# Database Configuration
|
||||
# ===================
|
||||
# PostgreSQL connection settings
|
||||
# For container development, use the service name "postgres"
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=flyer_crawler_dev
|
||||
|
||||
# ===================
|
||||
# Redis Configuration
|
||||
# ===================
|
||||
# Redis URL for caching and job queues
|
||||
# For container development, use the service name "redis"
|
||||
REDIS_URL=redis://redis:6379
|
||||
# Optional: Redis password (leave empty if not required)
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# ===================
|
||||
# Application Settings
|
||||
# ===================
|
||||
NODE_ENV=development
|
||||
# Frontend URL for CORS and email links
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ===================
|
||||
# Authentication
|
||||
# ===================
|
||||
# REQUIRED: Secret key for signing JWT tokens (generate a random 64+ character string)
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
||||
# OAuth Providers (Optional - enable social login)
|
||||
# Google OAuth - https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# GitHub OAuth - https://github.com/settings/developers
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# ===================
|
||||
# AI/ML Services
|
||||
# ===================
|
||||
# REQUIRED: Google Gemini API key for flyer OCR processing
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
|
||||
# ===================
|
||||
# External APIs
|
||||
# ===================
|
||||
# Optional: Google Maps API key for geocoding store addresses
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# ===================
|
||||
# Email Configuration (Optional)
|
||||
# ===================
|
||||
# SMTP settings for sending emails (deal notifications, password reset)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# ===================
|
||||
# Worker Configuration (Optional)
|
||||
# ===================
|
||||
# Concurrency settings for background job workers
|
||||
WORKER_CONCURRENCY=1
|
||||
EMAIL_WORKER_CONCURRENCY=10
|
||||
ANALYTICS_WORKER_CONCURRENCY=1
|
||||
CLEANUP_WORKER_CONCURRENCY=10
|
||||
|
||||
# Worker lock duration in milliseconds (default: 2 minutes)
|
||||
WORKER_LOCK_DURATION=120000
|
||||
|
||||
# ===================
|
||||
# Error Tracking (ADR-015)
|
||||
# ===================
|
||||
# Sentry-compatible error tracking via Bugsink (self-hosted)
|
||||
# DSNs are created in Bugsink UI at http://localhost:8000 (dev) or your production URL
|
||||
# Backend DSN - for Express/Node.js errors
|
||||
SENTRY_DSN=
|
||||
# Frontend DSN - for React/browser errors (uses VITE_ prefix)
|
||||
VITE_SENTRY_DSN=
|
||||
# Environment name for error grouping (defaults to NODE_ENV)
|
||||
SENTRY_ENVIRONMENT=development
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
# Enable/disable error tracking (default: true)
|
||||
SENTRY_ENABLED=true
|
||||
VITE_SENTRY_ENABLED=true
|
||||
# Enable debug mode for SDK troubleshooting (default: false)
|
||||
SENTRY_DEBUG=false
|
||||
VITE_SENTRY_DEBUG=false
|
||||
|
||||
# ===================
|
||||
# Source Maps Upload (ADR-015)
|
||||
# ===================
|
||||
# Auth token for uploading source maps to Bugsink
|
||||
# Create at: https://bugsink.projectium.com (Settings > API Keys)
|
||||
# Required for de-minified stack traces in error reports
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# URL of your Bugsink instance (for source map uploads)
|
||||
SENTRY_URL=https://bugsink.projectium.com
|
||||
6
.env.test
Normal file
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
||||
DB_HOST=10.89.0.4
|
||||
DB_USER=flyer
|
||||
DB_PASSWORD=flyer
|
||||
DB_NAME=flyer_crawler_test
|
||||
REDIS_URL=redis://redis:6379
|
||||
NODE_ENV=test
|
||||
@@ -47,11 +47,24 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Bump Minor Version and Push
|
||||
run: |
|
||||
# Configure git for the commit.
|
||||
git config --global user.name 'Gitea Actions'
|
||||
git config --global user.email 'actions@gitea.projectium.com'
|
||||
|
||||
# Bump the minor version number. This creates a new commit and a new tag.
|
||||
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||
npm version minor -m "ci: Bump version to %s for production release [skip ci]"
|
||||
|
||||
# Push the new commit and the new tag back to the main branch.
|
||||
git push --follow-tags
|
||||
|
||||
- name: Check for Production Database Schema Changes
|
||||
env:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
run: |
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
@@ -61,9 +74,10 @@ jobs:
|
||||
echo "--- Checking for production schema changes ---"
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A || echo "none")
|
||||
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
|
||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
||||
if [ "$DEPLOYED_HASH" = "none" ] || [ -z "$DEPLOYED_HASH" ]; then
|
||||
if [ -z "$DEPLOYED_HASH" ]; then
|
||||
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
|
||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
||||
@@ -73,16 +87,33 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build React Application for Production
|
||||
# Source Maps (ADR-015): If SENTRY_AUTH_TOKEN is set, the @sentry/vite-plugin will:
|
||||
# 1. Generate hidden source maps during build
|
||||
# 2. Upload them to Bugsink for error de-minification
|
||||
# 3. Delete the .map files after upload (so they're not publicly accessible)
|
||||
run: |
|
||||
if [ -z "${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}" ]; then
|
||||
echo "ERROR: The VITE_GOOGLE_GENAI_API_KEY secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source map upload is optional - warn if not configured
|
||||
if [ -z "${{ secrets.SENTRY_AUTH_TOKEN }}" ]; then
|
||||
echo "WARNING: SENTRY_AUTH_TOKEN not set. Source maps will NOT be uploaded to Bugsink."
|
||||
echo " Errors will show minified stack traces. To fix, add SENTRY_AUTH_TOKEN to Gitea secrets."
|
||||
fi
|
||||
|
||||
GITEA_SERVER_URL="https://gitea.projectium.com"
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
|
||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
|
||||
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
|
||||
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||
VITE_SENTRY_DSN="${{ secrets.VITE_SENTRY_DSN }}" \
|
||||
VITE_SENTRY_ENVIRONMENT="production" \
|
||||
VITE_SENTRY_ENABLED="true" \
|
||||
SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
|
||||
SENTRY_URL="https://bugsink.projectium.com" \
|
||||
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
|
||||
|
||||
- name: Deploy Application to Production Server
|
||||
@@ -99,10 +130,11 @@ jobs:
|
||||
env:
|
||||
# --- Production Secrets Injection ---
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
REDIS_URL: 'redis://localhost:6379'
|
||||
# Explicitly use database 0 for production (test uses database 1)
|
||||
REDIS_URL: 'redis://localhost:6379/0'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }}
|
||||
FRONTEND_URL: 'https://flyer-crawler.projectium.com'
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
@@ -114,6 +146,15 @@ jobs:
|
||||
SMTP_USER: ''
|
||||
SMTP_PASS: ''
|
||||
SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com'
|
||||
# OAuth Providers
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
|
||||
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
|
||||
# Sentry/Bugsink Error Tracking (ADR-015)
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_ENVIRONMENT: 'production'
|
||||
SENTRY_ENABLED: 'true'
|
||||
run: |
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
|
||||
@@ -123,6 +164,10 @@ jobs:
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
@@ -139,7 +184,7 @@ jobs:
|
||||
else
|
||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||
fi
|
||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save
|
||||
echo "Production backend server reloaded successfully."
|
||||
else
|
||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||
@@ -148,7 +193,12 @@ jobs:
|
||||
echo "Updating schema hash in production database..."
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
|
||||
"CREATE TABLE IF NOT EXISTS public.schema_info (
|
||||
environment VARCHAR(50) PRIMARY KEY,
|
||||
schema_hash VARCHAR(64) NOT NULL,
|
||||
deployed_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
|
||||
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||
|
||||
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||
@@ -161,7 +211,17 @@ jobs:
|
||||
- name: Show PM2 Environment for Production
|
||||
run: |
|
||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
|
||||
sleep 5
|
||||
pm2 describe flyer-crawler-api || echo "Could not find production pm2 process."
|
||||
pm2 logs flyer-crawler-api --lines 20 --nostream || echo "Could not find production pm2 process."
|
||||
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."
|
||||
sleep 5 # Wait a few seconds for the app to start and log its output.
|
||||
|
||||
# Resolve the PM2 ID dynamically to ensure we target the correct process
|
||||
PM2_ID=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.pm_id : ''); } catch(e) { console.log(''); }")
|
||||
|
||||
if [ -n "$PM2_ID" ]; then
|
||||
echo "Found process ID: $PM2_ID"
|
||||
pm2 describe "$PM2_ID" || echo "Failed to describe process $PM2_ID"
|
||||
pm2 logs "$PM2_ID" --lines 20 --nostream || echo "Failed to get logs for $PM2_ID"
|
||||
pm2 env "$PM2_ID" || echo "Failed to get env for $PM2_ID"
|
||||
else
|
||||
echo "Could not find process 'flyer-crawler-api' in pm2 list."
|
||||
pm2 list # Fallback to listing everything to help debug
|
||||
fi
|
||||
|
||||
@@ -90,10 +90,29 @@ jobs:
|
||||
# integration test suite can launch its own, fresh server instance.
|
||||
# '|| true' ensures the workflow doesn't fail if the process isn't running.
|
||||
run: |
|
||||
pm2 stop flyer-crawler-api-test || true
|
||||
pm2 stop flyer-crawler-worker-test || true
|
||||
pm2 delete flyer-crawler-api-test || true
|
||||
pm2 delete flyer-crawler-worker-test || true
|
||||
echo "--- Stopping and deleting all test processes ---"
|
||||
# Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
|
||||
# This is safer than 'pm2 delete all' and more robust than naming each process individually.
|
||||
# It prevents the accumulation of duplicate processes from previous test runs.
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
|
||||
|
||||
- name: Flush Redis Test Database Before Tests
|
||||
# CRITICAL: Clear Redis database 1 (test database) to remove stale BullMQ jobs.
|
||||
# This prevents old jobs with outdated error messages from polluting test results.
|
||||
# NOTE: We use database 1 for tests to isolate from production (database 0).
|
||||
env:
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||
run: |
|
||||
echo "--- Flushing Redis database 1 (test database) to remove stale jobs ---"
|
||||
if [ -z "$REDIS_PASSWORD" ]; then
|
||||
echo "⚠️ REDIS_PASSWORD_TEST not set, attempting flush without password..."
|
||||
redis-cli -n 1 FLUSHDB || echo "Redis flush failed (no password)"
|
||||
else
|
||||
redis-cli -a "$REDIS_PASSWORD" -n 1 FLUSHDB 2>/dev/null && echo "✅ Redis database 1 (test) flushed successfully." || echo "⚠️ Redis flush failed"
|
||||
fi
|
||||
# Verify the flush worked by checking key count on database 1
|
||||
KEY_COUNT=$(redis-cli -a "$REDIS_PASSWORD" -n 1 DBSIZE 2>/dev/null | grep -oE '[0-9]+' || echo "unknown")
|
||||
echo "Redis database 1 key count after flush: $KEY_COUNT"
|
||||
|
||||
- name: Run All Tests and Generate Merged Coverage Report
|
||||
# This single step runs both unit and integration tests, then merges their
|
||||
@@ -102,26 +121,41 @@ jobs:
|
||||
env:
|
||||
# --- Database credentials for the test suite ---
|
||||
# These are injected from Gitea secrets into the runner's environment.
|
||||
# CRITICAL: Use TEST-specific credentials that have CREATE privileges on the public schema.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_NAME: 'flyer-crawler-test' # Explicitly set for tests
|
||||
DB_USER: ${{ secrets.DB_USER_TEST }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_TEST }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }}
|
||||
|
||||
# --- Redis credentials for the test suite ---
|
||||
REDIS_URL: 'redis://localhost:6379'
|
||||
# CRITICAL: Use Redis database 1 to isolate tests from production (which uses db 0).
|
||||
# This prevents the production worker from picking up test jobs.
|
||||
REDIS_URL: 'redis://localhost:6379/1'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||
|
||||
# --- Integration test specific variables ---
|
||||
FRONTEND_URL: 'http://localhost:3000'
|
||||
FRONTEND_URL: 'https://example.com'
|
||||
VITE_API_BASE_URL: 'http://localhost:3001/api'
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||
|
||||
# --- Storage path for flyer images ---
|
||||
# CRITICAL: Use an absolute path in the test runner's working directory for file storage.
|
||||
# This ensures tests can read processed files to verify their contents (e.g., EXIF stripping).
|
||||
# Without this, multer and flyerProcessingService default to /var/www/.../flyer-images.
|
||||
# NOTE: We use ${{ github.workspace }} which resolves to the checkout directory.
|
||||
STORAGE_PATH: '${{ github.workspace }}/flyer-images'
|
||||
|
||||
# --- JWT Secret for Passport authentication in tests ---
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
|
||||
# --- V8 Coverage for Server Process ---
|
||||
# This variable tells the Node.js process (our server, started by globalSetup)
|
||||
# where to output its raw V8 coverage data.
|
||||
NODE_V8_COVERAGE: '.coverage/tmp/integration-server'
|
||||
|
||||
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
||||
# This is crucial for memory-intensive tasks like running tests and coverage.
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
NODE_OPTIONS: '--max-old-space-size=8192 --trace-warnings --unhandled-rejections=strict'
|
||||
|
||||
run: |
|
||||
# Fail-fast check to ensure secrets are configured in Gitea for testing.
|
||||
@@ -136,10 +170,49 @@ jobs:
|
||||
# Run unit and integration tests as separate steps.
|
||||
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
|
||||
echo "--- Running Unit Tests ---"
|
||||
npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||
# npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||
npm run test:unit -- --coverage \
|
||||
--coverage.exclude='**/*.test.ts' \
|
||||
--coverage.exclude='**/tests/**' \
|
||||
--coverage.exclude='**/mocks/**' \
|
||||
--coverage.exclude='src/components/icons/**' \
|
||||
--coverage.exclude='src/db/**' \
|
||||
--coverage.exclude='src/lib/**' \
|
||||
--coverage.exclude='src/types/**' \
|
||||
--coverage.exclude='**/index.tsx' \
|
||||
--coverage.exclude='**/vite-env.d.ts' \
|
||||
--coverage.exclude='**/vitest.setup.ts' \
|
||||
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
|
||||
|
||||
echo "--- Running Integration Tests ---"
|
||||
npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||
npm run test:integration -- --coverage \
|
||||
--coverage.exclude='**/*.test.ts' \
|
||||
--coverage.exclude='**/tests/**' \
|
||||
--coverage.exclude='**/mocks/**' \
|
||||
--coverage.exclude='src/components/icons/**' \
|
||||
--coverage.exclude='src/db/**' \
|
||||
--coverage.exclude='src/lib/**' \
|
||||
--coverage.exclude='src/types/**' \
|
||||
--coverage.exclude='**/index.tsx' \
|
||||
--coverage.exclude='**/vite-env.d.ts' \
|
||||
--coverage.exclude='**/vitest.setup.ts' \
|
||||
--reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
|
||||
|
||||
echo "--- Running E2E Tests ---"
|
||||
# Run E2E tests using the dedicated E2E config.
|
||||
# E2E uses port 3098, integration uses 3099 to avoid conflicts.
|
||||
npx vitest run --config vitest.config.e2e.ts --coverage \
|
||||
--coverage.exclude='**/*.test.ts' \
|
||||
--coverage.exclude='**/tests/**' \
|
||||
--coverage.exclude='**/mocks/**' \
|
||||
--coverage.exclude='src/components/icons/**' \
|
||||
--coverage.exclude='src/db/**' \
|
||||
--coverage.exclude='src/lib/**' \
|
||||
--coverage.exclude='src/types/**' \
|
||||
--coverage.exclude='**/index.tsx' \
|
||||
--coverage.exclude='**/vite-env.d.ts' \
|
||||
--coverage.exclude='**/vitest.setup.ts' \
|
||||
--reporter=verbose --no-file-parallelism || true
|
||||
|
||||
# Re-enable secret masking for subsequent steps.
|
||||
echo "::secret-masking::"
|
||||
@@ -155,6 +228,7 @@ jobs:
|
||||
echo "Checking for source coverage files..."
|
||||
ls -l .coverage/unit/coverage-final.json
|
||||
ls -l .coverage/integration/coverage-final.json
|
||||
ls -l .coverage/e2e/coverage-final.json || echo "E2E coverage file not found"
|
||||
|
||||
# --- V8 Coverage Processing for Backend Server ---
|
||||
# The integration tests start the server, which generates raw V8 coverage data.
|
||||
@@ -167,7 +241,19 @@ jobs:
|
||||
# Run c8: read raw files from the temp dir, and output an Istanbul JSON report.
|
||||
# We only generate the 'json' report here because it's all nyc needs for merging.
|
||||
echo "Server coverage report about to be generated..."
|
||||
npx c8 report --reporter=json --temp-directory .coverage/tmp/integration-server --reports-dir .coverage/integration-server
|
||||
npx c8 report \
|
||||
--include='src/**' \
|
||||
--exclude='**/*.test.ts' \
|
||||
--exclude='**/*.test.tsx' \
|
||||
--exclude='**/tests/**' \
|
||||
--exclude='**/mocks/**' \
|
||||
--exclude='hostexecutor/**' \
|
||||
--exclude='scripts/**' \
|
||||
--exclude='*.config.js' \
|
||||
--exclude='*.config.ts' \
|
||||
--reporter=json \
|
||||
--temp-directory .coverage/tmp/integration-server \
|
||||
--reports-dir .coverage/integration-server
|
||||
echo "Server coverage report generated. Verifying existence:"
|
||||
ls -l .coverage/integration-server/coverage-final.json
|
||||
|
||||
@@ -186,6 +272,7 @@ jobs:
|
||||
# We give them unique names to be safe, though it's not strictly necessary.
|
||||
cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json"
|
||||
cp .coverage/integration/coverage-final.json "$NYC_SOURCE_DIR/integration-coverage.json"
|
||||
cp .coverage/e2e/coverage-final.json "$NYC_SOURCE_DIR/e2e-coverage.json" || echo "E2E coverage file not found, skipping."
|
||||
# This file might not exist if integration tests fail early, so we add `|| true`
|
||||
cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping."
|
||||
echo "Copied coverage files to source directory. Contents:"
|
||||
@@ -205,7 +292,19 @@ jobs:
|
||||
--reporter=text \
|
||||
--reporter=html \
|
||||
--report-dir .coverage/ \
|
||||
--temp-dir "$NYC_SOURCE_DIR"
|
||||
--temp-dir "$NYC_SOURCE_DIR" \
|
||||
--include "src/**" \
|
||||
--exclude "**/*.test.ts" \
|
||||
--exclude "**/*.test.tsx" \
|
||||
--exclude "**/tests/**" \
|
||||
--exclude "**/mocks/**" \
|
||||
--exclude "**/index.tsx" \
|
||||
--exclude "**/vite-env.d.ts" \
|
||||
--exclude "**/vitest.setup.ts" \
|
||||
--exclude "hostexecutor/**" \
|
||||
--exclude "scripts/**" \
|
||||
--exclude "*.config.js" \
|
||||
--exclude "*.config.ts"
|
||||
|
||||
# Re-enable secret masking for subsequent steps.
|
||||
echo "::secret-masking::"
|
||||
@@ -218,16 +317,6 @@ jobs:
|
||||
if: always() # This step runs even if the previous test or coverage steps failed.
|
||||
run: echo "Skipping test artifact cleanup on runner; this is handled on the server."
|
||||
|
||||
- name: Deploy Coverage Report to Public URL
|
||||
if: always()
|
||||
run: |
|
||||
TARGET_DIR="/var/www/flyer-crawler-test.projectium.com/coverage"
|
||||
echo "Deploying HTML coverage report to $TARGET_DIR..."
|
||||
mkdir -p "$TARGET_DIR"
|
||||
rm -rf "$TARGET_DIR"/*
|
||||
cp -r .coverage/* "$TARGET_DIR/"
|
||||
echo "✅ Coverage report deployed to https://flyer-crawler-test.projectium.com/coverage"
|
||||
|
||||
- name: Archive Code Coverage Report
|
||||
# This action saves the generated HTML coverage report as a downloadable artifact.
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -240,10 +329,11 @@ jobs:
|
||||
- name: Check for Test Database Schema Changes
|
||||
env:
|
||||
# Use test database credentials for this check.
|
||||
# CRITICAL: Use TEST-specific credentials that have CREATE privileges on the public schema.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # This is used by psql
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }} # This is used by the application
|
||||
DB_USER: ${{ secrets.DB_USER_TEST }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_TEST }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }}
|
||||
run: |
|
||||
# Fail-fast check to ensure secrets are configured in Gitea.
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
@@ -256,18 +346,19 @@ jobs:
|
||||
# We normalize line endings to ensure the hash is consistent across different OS environments.
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||
|
||||
# Query the production database to get the hash of the deployed schema.
|
||||
# The `psql` command requires PGPASSWORD to be set.
|
||||
# `\t` sets tuples-only mode and `\A` unaligns output to get just the raw value.
|
||||
# The `|| echo "none"` ensures the command doesn't fail if the table or row doesn't exist yet.
|
||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A || echo "none")
|
||||
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
|
||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A)
|
||||
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
||||
|
||||
# Check if the hash is "none" (command failed) OR if it's an empty string (table exists but is empty).
|
||||
if [ "$DEPLOYED_HASH" = "none" ] || [ -z "$DEPLOYED_HASH" ]; then
|
||||
if [ -z "$DEPLOYED_HASH" ]; then
|
||||
echo "WARNING: No schema hash found in the test database."
|
||||
echo "This is expected for a first-time deployment. The hash will be set after a successful deployment."
|
||||
echo "--- Debug: Dumping schema_info table ---"
|
||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=0 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -P pager=off -c "SELECT * FROM public.schema_info;" || true
|
||||
echo "----------------------------------------"
|
||||
# We allow the deployment to continue, but a manual schema update is required.
|
||||
# You could choose to fail here by adding `exit 1`.
|
||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||
@@ -283,6 +374,11 @@ jobs:
|
||||
# We set the environment variable directly in the command line for this step.
|
||||
# This maps the Gitea secret to the environment variable the application expects.
|
||||
# We also generate and inject the application version, commit URL, and commit message.
|
||||
#
|
||||
# Source Maps (ADR-015): If SENTRY_AUTH_TOKEN is set, the @sentry/vite-plugin will:
|
||||
# 1. Generate hidden source maps during build
|
||||
# 2. Upload them to Bugsink for error de-minification
|
||||
# 3. Delete the .map files after upload (so they're not publicly accessible)
|
||||
run: |
|
||||
# Fail-fast check for the build-time secret.
|
||||
if [ -z "${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}" ]; then
|
||||
@@ -290,11 +386,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source map upload is optional - warn if not configured
|
||||
if [ -z "${{ secrets.SENTRY_AUTH_TOKEN }}" ]; then
|
||||
echo "WARNING: SENTRY_AUTH_TOKEN not set. Source maps will NOT be uploaded to Bugsink."
|
||||
echo " Errors will show minified stack traces. To fix, add SENTRY_AUTH_TOKEN to Gitea secrets."
|
||||
fi
|
||||
|
||||
GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=%s)
|
||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \
|
||||
# Sanitize commit message to prevent shell injection or build breaks (removes quotes, backticks, backslashes, $)
|
||||
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s | tr -d '"`\\$')
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
|
||||
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||
VITE_SENTRY_DSN="${{ secrets.VITE_SENTRY_DSN_TEST }}" \
|
||||
VITE_SENTRY_ENVIRONMENT="test" \
|
||||
VITE_SENTRY_ENABLED="true" \
|
||||
SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
|
||||
SENTRY_URL="https://bugsink.projectium.com" \
|
||||
VITE_API_BASE_URL="https://flyer-crawler-test.projectium.com/api" VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} npm run build
|
||||
|
||||
- name: Deploy Application to Test Server
|
||||
@@ -315,6 +424,17 @@ jobs:
|
||||
rsync -avz dist/ "$APP_PATH"
|
||||
echo "Application deployment complete."
|
||||
|
||||
- name: Deploy Coverage Report to Public URL
|
||||
if: always()
|
||||
run: |
|
||||
TARGET_DIR="/var/www/flyer-crawler-test.projectium.com/coverage"
|
||||
echo "Deploying HTML coverage report to $TARGET_DIR..."
|
||||
mkdir -p "$TARGET_DIR"
|
||||
rm -rf "$TARGET_DIR"/*
|
||||
# The merged nyc report is generated in the .coverage directory. We copy its contents.
|
||||
cp -r .coverage/* "$TARGET_DIR/"
|
||||
echo "✅ Coverage report deployed to https://flyer-crawler-test.projectium.com/coverage"
|
||||
|
||||
- name: Install Backend Dependencies and Restart Test Server
|
||||
env:
|
||||
# --- Test Secrets Injection ---
|
||||
@@ -322,18 +442,19 @@ jobs:
|
||||
# Your Node.js application will read these directly from `process.env`.
|
||||
|
||||
# Database Credentials
|
||||
# CRITICAL: Use TEST-specific credentials that have CREATE privileges on the public schema.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_USER: ${{ secrets.DB_USER_TEST }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_TEST }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }}
|
||||
|
||||
# Redis Credentials
|
||||
REDIS_URL: 'redis://localhost:6379'
|
||||
# Redis Credentials (use database 1 to isolate from production)
|
||||
REDIS_URL: 'redis://localhost:6379/1'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||
|
||||
# Application Secrets
|
||||
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }}
|
||||
FRONTEND_URL: 'https://example.com'
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
|
||||
@@ -344,21 +465,38 @@ jobs:
|
||||
SMTP_USER: '' # Using MailHog, no auth needed
|
||||
SMTP_PASS: '' # Using MailHog, no auth needed
|
||||
SMTP_FROM_EMAIL: 'noreply@flyer-crawler-test.projectium.com'
|
||||
# Sentry/Bugsink Error Tracking (ADR-015)
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN_TEST }}
|
||||
SENTRY_ENVIRONMENT: 'test'
|
||||
SENTRY_ENABLED: 'true'
|
||||
|
||||
run: |
|
||||
# Fail-fast check to ensure secrets are configured in Gitea.
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
echo "ERROR: One or more test database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_TEST) are not set in Gitea repository settings."
|
||||
MISSING_SECRETS=""
|
||||
if [ -z "$DB_HOST" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_HOST"; fi
|
||||
if [ -z "$DB_USER" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_USER"; fi
|
||||
if [ -z "$DB_PASSWORD" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_PASSWORD"; fi
|
||||
if [ -z "$DB_NAME" ]; then MISSING_SECRETS="${MISSING_SECRETS} DB_NAME"; fi
|
||||
if [ -z "$JWT_SECRET" ]; then MISSING_SECRETS="${MISSING_SECRETS} JWT_SECRET"; fi
|
||||
|
||||
if [ ! -z "$MISSING_SECRETS" ]; then
|
||||
echo "ERROR: The following required secrets are missing in Gitea:${MISSING_SECRETS}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing production dependencies and restarting test server..."
|
||||
cd /var/www/flyer-crawler-test.projectium.com
|
||||
npm install --omit=dev # Install only production dependencies
|
||||
# Use `startOrReload` with the ecosystem file. This is the standard, idempotent way to deploy.
|
||||
# It will START the process if it's not running, or RELOAD it if it is.
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# Use `startOrReload` with the TEST ecosystem file. This starts test-specific processes
|
||||
# (flyer-crawler-api-test, flyer-crawler-worker-test, flyer-crawler-analytics-worker-test)
|
||||
# that run separately from production processes.
|
||||
# We also add `&& pm2 save` to persist the process list across server reboots.
|
||||
pm2 startOrReload ecosystem.config.cjs --env test && pm2 save
|
||||
pm2 startOrReload ecosystem-test.config.cjs --update-env && pm2 save
|
||||
echo "Test backend server reloaded successfully."
|
||||
|
||||
# After a successful deployment, update the schema hash in the database.
|
||||
@@ -366,7 +504,12 @@ jobs:
|
||||
echo "Updating schema hash in test database..."
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW())
|
||||
"CREATE TABLE IF NOT EXISTS public.schema_info (
|
||||
environment VARCHAR(50) PRIMARY KEY,
|
||||
schema_hash VARCHAR(64) NOT NULL,
|
||||
deployed_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW())
|
||||
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||
|
||||
# Verify the hash was updated
|
||||
@@ -388,7 +531,17 @@ jobs:
|
||||
run: |
|
||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api-test ---"
|
||||
# After a reload, the server restarts. We'll show the last 20 lines of the log to see the startup messages.
|
||||
sleep 5 # Wait a few seconds for the app to start and log its output.
|
||||
pm2 describe flyer-crawler-api-test || echo "Could not find test pm2 process."
|
||||
pm2 logs flyer-crawler-api-test --lines 20 --nostream || echo "Could not find test pm2 process."
|
||||
pm2 env flyer-crawler-api-test || echo "Could not find test pm2 process."
|
||||
sleep 5
|
||||
|
||||
# Resolve the PM2 ID dynamically to ensure we target the correct process
|
||||
PM2_ID=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api-test'); console.log(app ? app.pm2_env.pm_id : ''); } catch(e) { console.log(''); }")
|
||||
|
||||
if [ -n "$PM2_ID" ]; then
|
||||
echo "Found process ID: $PM2_ID"
|
||||
pm2 describe "$PM2_ID" || echo "Failed to describe process $PM2_ID"
|
||||
pm2 logs "$PM2_ID" --lines 20 --nostream || echo "Failed to get logs for $PM2_ID"
|
||||
pm2 env "$PM2_ID" || echo "Failed to get env for $PM2_ID"
|
||||
else
|
||||
echo "Could not find process 'flyer-crawler-api-test' in pm2 list."
|
||||
pm2 list # Fallback to listing everything to help debug
|
||||
fi
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
# Use production database credentials for this entire job.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_PORT: ${{ secrets.DB_PORT }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_NAME: ${{ secrets.DB_NAME_PROD }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
|
||||
steps:
|
||||
- name: Validate Secrets
|
||||
@@ -60,4 +60,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: database-backup
|
||||
path: ${{ env.backup_filename }}
|
||||
path: ${{ env.backup_filename }}
|
||||
|
||||
@@ -23,9 +23,9 @@ jobs:
|
||||
env:
|
||||
# Use production database credentials for this entire job.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Used by psql
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }} # Used by the application
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -144,4 +144,4 @@ jobs:
|
||||
find "$APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete
|
||||
find "$APP_PATH/flyer-images/icons" -type f -name '*-test-flyer-image.*' -delete
|
||||
find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory not found, skipping."
|
||||
echo "✅ Flyer asset directories cleared."
|
||||
echo "✅ Flyer asset directories cleared."
|
||||
|
||||
@@ -23,9 +23,9 @@ jobs:
|
||||
env:
|
||||
# Use test database credentials for this entire job.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Used by psql
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }} # Used by the application
|
||||
DB_USER: ${{ secrets.DB_USER_TEST }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_TEST }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -130,4 +130,4 @@ jobs:
|
||||
find "$APP_PATH/flyer-images" -mindepth 1 -type f -delete
|
||||
find "$APP_PATH/flyer-images/icons" -mindepth 1 -type f -delete
|
||||
find "$APP_PATH/flyer-images/archive" -mindepth 1 -type f -delete || echo "Archive directory not found, skipping."
|
||||
echo "✅ Test flyer asset directories cleared."
|
||||
echo "✅ Test flyer asset directories cleared."
|
||||
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
env:
|
||||
# Use production database credentials for this entire job.
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
BACKUP_DIR: "/var/www/backups" # Define a dedicated directory for backups
|
||||
BACKUP_DIR: '/var/www/backups' # Define a dedicated directory for backups
|
||||
|
||||
steps:
|
||||
- name: Validate Secrets and Inputs
|
||||
@@ -92,4 +92,4 @@ jobs:
|
||||
echo "Restarting application server..."
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
||||
echo "✅ Application server restarted."
|
||||
echo "✅ Application server restarted."
|
||||
|
||||
186
.gitea/workflows/manual-deploy-major.yml
Normal file
186
.gitea/workflows/manual-deploy-major.yml
Normal file
@@ -0,0 +1,186 @@
|
||||
# .gitea/workflows/manual-deploy-major.yml
|
||||
#
|
||||
# This workflow provides a MANUAL trigger to perform a MAJOR version bump
|
||||
# and deploy the application to the PRODUCTION environment.
|
||||
name: Manual - Deploy Major Version to Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirmation:
|
||||
description: 'Type "deploy-major-to-prod" to confirm you want to deploy a new major version.'
|
||||
required: true
|
||||
default: 'do-not-run'
|
||||
force_reload:
|
||||
description: 'Force PM2 reload even if version matches (true/false).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
deploy-production-major:
|
||||
runs-on: projectium.com
|
||||
|
||||
steps:
|
||||
- name: Verify Confirmation Phrase
|
||||
run: |
|
||||
if [ "${{ gitea.event.inputs.confirmation }}" != "deploy-major-to-prod" ]; then
|
||||
echo "ERROR: Confirmation phrase did not match. Aborting deployment."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Confirmation accepted. Proceeding with major version production deployment."
|
||||
|
||||
- name: Checkout Code from 'main' branch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: 'main' # Explicitly check out the main branch for production deployment
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Bump Major Version and Push
|
||||
run: |
|
||||
# Configure git for the commit.
|
||||
git config --global user.name 'Gitea Actions'
|
||||
git config --global user.email 'actions@gitea.projectium.com'
|
||||
|
||||
# Bump the major version number. This creates a new commit and a new tag.
|
||||
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||
npm version major -m "ci: Bump version to %s for major release [skip ci]"
|
||||
|
||||
# Push the new commit and the new tag back to the main branch.
|
||||
git push --follow-tags
|
||||
|
||||
- name: Check for Production Database Schema Changes
|
||||
env:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
run: |
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
|
||||
exit 1
|
||||
fi
|
||||
echo "--- Checking for production schema changes ---"
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
echo "Current Git Schema Hash: $CURRENT_HASH"
|
||||
# The psql command will now fail the step if the query errors (e.g., column missing), preventing deployment on a bad schema.
|
||||
DEPLOYED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||
echo "Deployed DB Schema Hash: $DEPLOYED_HASH"
|
||||
if [ -z "$DEPLOYED_HASH" ]; then
|
||||
echo "WARNING: No schema hash found in the production database. This is expected for a first-time deployment."
|
||||
elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then
|
||||
echo "ERROR: Database schema mismatch detected! A manual database migration is required."
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Schema is up to date. No changes detected."
|
||||
fi
|
||||
|
||||
- name: Build React Application for Production
|
||||
run: |
|
||||
if [ -z "${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}" ]; then
|
||||
echo "ERROR: The VITE_GOOGLE_GENAI_API_KEY secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
GITEA_SERVER_URL="https://gitea.projectium.com"
|
||||
COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \
|
||||
VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \
|
||||
VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \
|
||||
VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build
|
||||
|
||||
- name: Deploy Application to Production Server
|
||||
run: |
|
||||
echo "Deploying application files to /var/www/flyer-crawler.projectium.com..."
|
||||
APP_PATH="/var/www/flyer-crawler.projectium.com"
|
||||
mkdir -p "$APP_PATH"
|
||||
mkdir -p "$APP_PATH/flyer-images/icons" "$APP_PATH/flyer-images/archive"
|
||||
rsync -avz --delete --exclude 'node_modules' --exclude '.git' --exclude 'dist' --exclude 'flyer-images' ./ "$APP_PATH/"
|
||||
rsync -avz dist/ "$APP_PATH"
|
||||
echo "Application deployment complete."
|
||||
|
||||
- name: Install Backend Dependencies and Restart Production Server
|
||||
env:
|
||||
# --- Production Secrets Injection ---
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
# Explicitly use database 0 for production (test uses database 1)
|
||||
REDIS_URL: 'redis://localhost:6379/0'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }}
|
||||
FRONTEND_URL: 'https://flyer-crawler.projectium.com'
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
SMTP_HOST: 'localhost'
|
||||
SMTP_PORT: '1025'
|
||||
SMTP_SECURE: 'false'
|
||||
SMTP_USER: ''
|
||||
SMTP_PASS: ''
|
||||
SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com'
|
||||
run: |
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing production dependencies and restarting server..."
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Cleanup Errored Processes ---
|
||||
echo "Cleaning up errored or stopped PM2 processes..."
|
||||
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.pm2_env.status === 'errored' || p.pm2_env.status === 'stopped') { console.log('Deleting ' + p.pm2_env.status + ' process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id); } } }); } catch (e) { console.error('Error cleaning up processes:', e); }"
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Deployed Package Version: $NEW_VERSION"
|
||||
|
||||
# Get the running version from PM2 for the main API process
|
||||
# We use a small node script to parse the JSON output from pm2 jlist
|
||||
RUNNING_VERSION=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.version : ''); } catch(e) { console.log(''); }")
|
||||
echo "Running PM2 Version: $RUNNING_VERSION"
|
||||
|
||||
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ] || [ "$NEW_VERSION" != "$RUNNING_VERSION" ] || [ -z "$RUNNING_VERSION" ]; then
|
||||
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ]; then
|
||||
echo "Force reload triggered by manual input. Reloading PM2..."
|
||||
else
|
||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||
fi
|
||||
pm2 startOrReload ecosystem.config.cjs --env production --update-env && pm2 save
|
||||
echo "Production backend server reloaded successfully."
|
||||
else
|
||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||
fi
|
||||
|
||||
echo "Updating schema hash in production database..."
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \
|
||||
"INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW())
|
||||
ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();"
|
||||
|
||||
UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A)
|
||||
if [ "$CURRENT_HASH" = "$UPDATED_HASH" ]; then
|
||||
echo "✅ Schema hash successfully updated in the database to: $UPDATED_HASH"
|
||||
else
|
||||
echo "ERROR: Failed to update schema hash in the database."
|
||||
fi
|
||||
|
||||
- name: Show PM2 Environment for Production
|
||||
run: |
|
||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
|
||||
sleep 5
|
||||
pm2 describe flyer-crawler-api || echo "Could not find production pm2 process."
|
||||
pm2 logs flyer-crawler-api --lines 20 --nostream || echo "Could not find production pm2 process."
|
||||
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."
|
||||
167
.gitea/workflows/manual-redis-flush-prod.yml
Normal file
167
.gitea/workflows/manual-redis-flush-prod.yml
Normal file
@@ -0,0 +1,167 @@
|
||||
# .gitea/workflows/manual-redis-flush-prod.yml
|
||||
#
|
||||
# DANGER: This workflow is DESTRUCTIVE and intended for manual execution only.
|
||||
# It will completely FLUSH the PRODUCTION Redis database (db 0).
|
||||
# This will clear all BullMQ queues, sessions, caches, and any other Redis data.
|
||||
#
|
||||
name: Manual - Flush Production Redis
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirmation:
|
||||
description: 'DANGER: This will FLUSH production Redis. Type "flush-production-redis" to confirm.'
|
||||
required: true
|
||||
default: 'do-not-run'
|
||||
flush_type:
|
||||
description: 'What to flush?'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- 'queues-only'
|
||||
- 'entire-database'
|
||||
default: 'queues-only'
|
||||
|
||||
jobs:
|
||||
flush-redis:
|
||||
runs-on: projectium.com # This job runs on your self-hosted Gitea runner.
|
||||
|
||||
env:
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Validate Secrets
|
||||
run: |
|
||||
if [ -z "$REDIS_PASSWORD" ]; then
|
||||
echo "ERROR: REDIS_PASSWORD_PROD secret is not set in Gitea repository settings."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Redis password secret is present."
|
||||
|
||||
- name: Verify Confirmation Phrase
|
||||
run: |
|
||||
if [ "${{ gitea.event.inputs.confirmation }}" != "flush-production-redis" ]; then
|
||||
echo "ERROR: Confirmation phrase did not match. Aborting Redis flush."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Confirmation accepted. Proceeding with Redis flush."
|
||||
|
||||
- name: Show Current Redis State
|
||||
run: |
|
||||
echo "--- Current Redis Database 0 (Production) State ---"
|
||||
redis-cli -a "$REDIS_PASSWORD" -n 0 INFO keyspace 2>/dev/null || echo "Could not get keyspace info"
|
||||
echo ""
|
||||
echo "--- Key Count ---"
|
||||
KEY_COUNT=$(redis-cli -a "$REDIS_PASSWORD" -n 0 DBSIZE 2>/dev/null | grep -oE '[0-9]+' || echo "unknown")
|
||||
echo "Production Redis (db 0) key count: $KEY_COUNT"
|
||||
echo ""
|
||||
echo "--- BullMQ Queue Keys ---"
|
||||
redis-cli -a "$REDIS_PASSWORD" -n 0 KEYS "bull:*" 2>/dev/null | head -20 || echo "No BullMQ keys found"
|
||||
|
||||
- name: 🚨 FINAL WARNING & PAUSE 🚨
|
||||
run: |
|
||||
echo "*********************************************************************"
|
||||
echo "WARNING: YOU ARE ABOUT TO FLUSH PRODUCTION REDIS DATA."
|
||||
echo "Flush type: ${{ gitea.event.inputs.flush_type }}"
|
||||
echo ""
|
||||
if [ "${{ gitea.event.inputs.flush_type }}" = "entire-database" ]; then
|
||||
echo "This will DELETE ALL Redis data including sessions, caches, and queues!"
|
||||
else
|
||||
echo "This will DELETE ALL BullMQ queue data (pending jobs, failed jobs, etc.)"
|
||||
fi
|
||||
echo ""
|
||||
echo "This action is IRREVERSIBLE. Press Ctrl+C in the runner terminal NOW to cancel."
|
||||
echo "Sleeping for 10 seconds..."
|
||||
echo "*********************************************************************"
|
||||
sleep 10
|
||||
|
||||
- name: Flush BullMQ Queues Only
|
||||
if: ${{ gitea.event.inputs.flush_type == 'queues-only' }}
|
||||
env:
|
||||
REDIS_URL: 'redis://localhost:6379/0'
|
||||
run: |
|
||||
echo "--- Obliterating BullMQ queues using Node.js ---"
|
||||
node -e "
|
||||
const { Queue } = require('bullmq');
|
||||
const IORedis = require('ioredis');
|
||||
|
||||
const connection = new IORedis(process.env.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
});
|
||||
|
||||
const queueNames = [
|
||||
'flyer-processing',
|
||||
'email-sending',
|
||||
'analytics-reporting',
|
||||
'weekly-analytics-reporting',
|
||||
'file-cleanup',
|
||||
'token-cleanup'
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for (const name of queueNames) {
|
||||
try {
|
||||
const queue = new Queue(name, { connection });
|
||||
const counts = await queue.getJobCounts();
|
||||
console.log('Queue \"' + name + '\" before obliterate:', JSON.stringify(counts));
|
||||
await queue.obliterate({ force: true });
|
||||
console.log('✅ Obliterated queue: ' + name);
|
||||
await queue.close();
|
||||
} catch (err) {
|
||||
console.error('⚠️ Failed to obliterate queue ' + name + ':', err.message);
|
||||
}
|
||||
}
|
||||
await connection.quit();
|
||||
console.log('✅ All BullMQ queues obliterated.');
|
||||
})();
|
||||
"
|
||||
|
||||
- name: Flush Entire Redis Database
|
||||
if: ${{ gitea.event.inputs.flush_type == 'entire-database' }}
|
||||
run: |
|
||||
echo "--- Flushing entire Redis database 0 (production) ---"
|
||||
redis-cli -a "$REDIS_PASSWORD" -n 0 FLUSHDB 2>/dev/null && echo "✅ Redis database 0 flushed successfully." || echo "❌ Redis flush failed"
|
||||
|
||||
- name: Verify Flush Results
|
||||
run: |
|
||||
echo "--- Redis Database 0 (Production) State After Flush ---"
|
||||
KEY_COUNT=$(redis-cli -a "$REDIS_PASSWORD" -n 0 DBSIZE 2>/dev/null | grep -oE '[0-9]+' || echo "unknown")
|
||||
echo "Production Redis (db 0) key count after flush: $KEY_COUNT"
|
||||
echo ""
|
||||
echo "--- Remaining BullMQ Queue Keys ---"
|
||||
BULL_KEYS=$(redis-cli -a "$REDIS_PASSWORD" -n 0 KEYS "bull:*" 2>/dev/null | wc -l || echo "0")
|
||||
echo "BullMQ key count: $BULL_KEYS"
|
||||
|
||||
if [ "${{ gitea.event.inputs.flush_type }}" = "queues-only" ] && [ "$BULL_KEYS" -gt 0 ]; then
|
||||
echo "⚠️ Warning: Some BullMQ keys may still exist. This can happen if new jobs were added during the flush."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "PRODUCTION REDIS FLUSH COMPLETE"
|
||||
echo "=========================================="
|
||||
echo "Flush type: ${{ gitea.event.inputs.flush_type }}"
|
||||
echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "NOTE: If you flushed queues, any pending jobs (flyer processing,"
|
||||
echo "emails, analytics, etc.) have been permanently deleted."
|
||||
echo ""
|
||||
echo "The production workers will automatically start processing"
|
||||
echo "new jobs as they are added to the queues."
|
||||
echo "=========================================="
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -11,6 +11,18 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
.coverage
|
||||
|
||||
# Test artifacts - flyer-images/ is a runtime directory
|
||||
# Test fixtures are stored in src/tests/assets/ instead
|
||||
flyer-images/
|
||||
test-output.txt
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -22,3 +34,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Thumbs.db
|
||||
.claude
|
||||
nul
|
||||
tmpclaude*
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
FORCE_COLOR=0 npx lint-staged --quiet
|
||||
4
.lintstagedrc.json
Normal file
4
.lintstagedrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{js,jsx,ts,tsx}": ["eslint --fix --no-color", "prettier --write"],
|
||||
"*.{json,md,css,html,yml,yaml}": ["prettier --write"]
|
||||
}
|
||||
5
.nycrc.json
Normal file
5
.nycrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"text": {
|
||||
"maxCols": 200
|
||||
}
|
||||
}
|
||||
41
.prettierignore
Normal file
41
.prettierignore
Normal file
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
.coverage/
|
||||
|
||||
# IDE and editor configs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Lock files (managed by package managers)
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Git directory
|
||||
.git/
|
||||
.gitea/
|
||||
|
||||
# Test artifacts
|
||||
__snapshots__/
|
||||
@@ -6,4 +6,4 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
}
|
||||
|
||||
110
AUTHENTICATION.md
Normal file
110
AUTHENTICATION.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Authentication Setup
|
||||
|
||||
Flyer Crawler supports OAuth authentication via Google and GitHub. This guide walks through configuring both providers.
|
||||
|
||||
---
|
||||
|
||||
## Google OAuth
|
||||
|
||||
### Step 1: Create OAuth Credentials
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project (or select an existing one)
|
||||
3. Navigate to **APIs & Services > Credentials**
|
||||
4. Click **Create Credentials > OAuth client ID**
|
||||
5. Select **Web application** as the application type
|
||||
|
||||
### Step 2: Configure Authorized Redirect URIs
|
||||
|
||||
Add the callback URL where Google will redirect users after authentication:
|
||||
|
||||
| Environment | Redirect URI |
|
||||
| ----------- | -------------------------------------------------- |
|
||||
| Development | `http://localhost:3001/api/auth/google/callback` |
|
||||
| Production | `https://your-domain.com/api/auth/google/callback` |
|
||||
|
||||
### Step 3: Save Credentials
|
||||
|
||||
After clicking **Create**, you'll receive:
|
||||
|
||||
- **Client ID**
|
||||
- **Client Secret**
|
||||
|
||||
Store these securely as environment variables:
|
||||
|
||||
- `GOOGLE_CLIENT_ID`
|
||||
- `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
---
|
||||
|
||||
## GitHub OAuth
|
||||
|
||||
### Step 1: Create OAuth App
|
||||
|
||||
1. Go to your [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Navigate to **OAuth Apps**
|
||||
3. Click **New OAuth App**
|
||||
|
||||
### Step 2: Fill in Application Details
|
||||
|
||||
| Field | Value |
|
||||
| -------------------------- | ---------------------------------------------------- |
|
||||
| Application name | Flyer Crawler (or your preferred name) |
|
||||
| Homepage URL | `http://localhost:5173` (dev) or your production URL |
|
||||
| Authorization callback URL | `http://localhost:3001/api/auth/github/callback` |
|
||||
|
||||
### Step 3: Save GitHub Credentials
|
||||
|
||||
After clicking **Register application**, you'll receive:
|
||||
|
||||
- **Client ID**
|
||||
- **Client Secret**
|
||||
|
||||
Store these securely as environment variables:
|
||||
|
||||
- `GITHUB_CLIENT_ID`
|
||||
- `GITHUB_CLIENT_SECRET`
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Summary
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret |
|
||||
| `JWT_SECRET` | Secret for signing authentication tokens |
|
||||
|
||||
---
|
||||
|
||||
## Production Considerations
|
||||
|
||||
When deploying to production:
|
||||
|
||||
1. **Update redirect URIs** in both Google Cloud Console and GitHub OAuth settings to use your production domain
|
||||
2. **Use HTTPS** for all callback URLs in production
|
||||
3. **Store secrets securely** using your CI/CD platform's secrets management (e.g., Gitea repository secrets)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "redirect_uri_mismatch" Error
|
||||
|
||||
The callback URL in your OAuth provider settings doesn't match what the application is sending. Verify:
|
||||
|
||||
- The URL is exactly correct (no trailing slashes, correct port)
|
||||
- You're using the right environment (dev vs production URLs)
|
||||
|
||||
### "invalid_client" Error
|
||||
|
||||
The Client ID or Client Secret is incorrect. Double-check your environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Installation Guide](INSTALL.md) - Local development setup
|
||||
- [Deployment Guide](DEPLOYMENT.md) - Production deployment
|
||||
378
CLAUDE-MCP.md
Normal file
378
CLAUDE-MCP.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Claude Code MCP Configuration Guide
|
||||
|
||||
This document explains how to configure MCP (Model Context Protocol) servers for Claude Code, covering both the CLI and VS Code extension.
|
||||
|
||||
## The Two Config Files
|
||||
|
||||
Claude Code uses **two separate configuration files** for MCP servers. They must be kept in sync manually.
|
||||
|
||||
| File | Used By | Notes |
|
||||
| ------------------------- | ----------------------------- | ------------------------------------------- |
|
||||
| `~/.claude.json` | Claude CLI (`claude` command) | Requires `"type": "stdio"` in each server |
|
||||
| `~/.claude/settings.json` | VS Code Extension | Simpler format, supports `"disabled": true` |
|
||||
|
||||
**Important:** Changes to one file do NOT automatically sync to the other!
|
||||
|
||||
## File Locations (Windows)
|
||||
|
||||
```text
|
||||
C:\Users\<username>\.claude.json # CLI config
|
||||
C:\Users\<username>\.claude\settings.json # VS Code extension config
|
||||
```
|
||||
|
||||
## Config Format Differences
|
||||
|
||||
### VS Code Extension Format (`~/.claude/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "path/to/executable",
|
||||
"args": ["arg1", "arg2"],
|
||||
"env": {
|
||||
"ENV_VAR": "value"
|
||||
},
|
||||
"disabled": true // Optional - disable without removing
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Format (`~/.claude.json`)
|
||||
|
||||
The CLI config is a larger file with many settings. The `mcpServers` section is nested within it:
|
||||
|
||||
```json
|
||||
{
|
||||
"numStartups": 14,
|
||||
"installMethod": "global",
|
||||
// ... other settings ...
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"type": "stdio", // REQUIRED for CLI
|
||||
"command": "path/to/executable",
|
||||
"args": ["arg1", "arg2"],
|
||||
"env": {
|
||||
"ENV_VAR": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
// ... more settings ...
|
||||
}
|
||||
```
|
||||
|
||||
**Key difference:** CLI format requires `"type": "stdio"` in each server definition.
|
||||
|
||||
## Common MCP Server Examples
|
||||
|
||||
### Memory (Knowledge Graph)
|
||||
|
||||
```json
|
||||
// VS Code format
|
||||
"memory": {
|
||||
"command": "D:\\nodejs\\npx.cmd",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"]
|
||||
}
|
||||
|
||||
// CLI format
|
||||
"memory": {
|
||||
"type": "stdio",
|
||||
"command": "D:\\nodejs\\npx.cmd",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Filesystem
|
||||
|
||||
```json
|
||||
// VS Code format
|
||||
"filesystem": {
|
||||
"command": "d:\\nodejs\\node.exe",
|
||||
"args": [
|
||||
"c:\\Users\\<user>\\AppData\\Roaming\\npm\\node_modules\\@modelcontextprotocol\\server-filesystem\\dist\\index.js",
|
||||
"d:\\path\\to\\project"
|
||||
]
|
||||
}
|
||||
|
||||
// CLI format
|
||||
"filesystem": {
|
||||
"type": "stdio",
|
||||
"command": "d:\\nodejs\\node.exe",
|
||||
"args": [
|
||||
"c:\\Users\\<user>\\AppData\\Roaming\\npm\\node_modules\\@modelcontextprotocol\\server-filesystem\\dist\\index.js",
|
||||
"d:\\path\\to\\project"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Podman/Docker
|
||||
|
||||
```json
|
||||
// VS Code format
|
||||
"podman": {
|
||||
"command": "D:\\nodejs\\npx.cmd",
|
||||
"args": ["-y", "podman-mcp-server@latest"],
|
||||
"env": {
|
||||
"DOCKER_HOST": "npipe:////./pipe/podman-machine-default"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gitea
|
||||
|
||||
```json
|
||||
// VS Code format
|
||||
"gitea-myserver": {
|
||||
"command": "d:\\gitea-mcp\\gitea-mcp.exe",
|
||||
"args": ["run", "-t", "stdio"],
|
||||
"env": {
|
||||
"GITEA_HOST": "https://gitea.example.com",
|
||||
"GITEA_ACCESS_TOKEN": "your-token-here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Redis
|
||||
|
||||
```json
|
||||
// VS Code format
|
||||
"redis": {
|
||||
"command": "D:\\nodejs\\npx.cmd",
|
||||
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"]
|
||||
}
|
||||
```
|
||||
|
||||
### Bugsink (Error Tracking)
|
||||
|
||||
**Important:** Bugsink has a different API than Sentry. Use `bugsink-mcp`, NOT `sentry-selfhosted-mcp`.
|
||||
|
||||
**Note:** The `bugsink-mcp` npm package is NOT published. You must clone and build from source:
|
||||
|
||||
```bash
|
||||
# Clone and build bugsink-mcp
|
||||
git clone https://github.com/j-shelfwood/bugsink-mcp.git d:\gitea\bugsink-mcp
|
||||
cd d:\gitea\bugsink-mcp
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
```json
|
||||
// VS Code format (using locally built version)
|
||||
"bugsink": {
|
||||
"command": "d:\\nodejs\\node.exe",
|
||||
"args": ["d:\\gitea\\bugsink-mcp\\dist\\index.js"],
|
||||
"env": {
|
||||
"BUGSINK_URL": "https://bugsink.example.com",
|
||||
"BUGSINK_TOKEN": "your-api-token"
|
||||
}
|
||||
}
|
||||
|
||||
// CLI format
|
||||
"bugsink": {
|
||||
"type": "stdio",
|
||||
"command": "d:\\nodejs\\node.exe",
|
||||
"args": ["d:\\gitea\\bugsink-mcp\\dist\\index.js"],
|
||||
"env": {
|
||||
"BUGSINK_URL": "https://bugsink.example.com",
|
||||
"BUGSINK_TOKEN": "your-api-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- GitHub: <https://github.com/j-shelfwood/bugsink-mcp>
|
||||
- Get token from Bugsink UI: Settings > API Tokens
|
||||
- **Do NOT use npx** - the package is not on npm
|
||||
|
||||
### Sentry (Cloud or Self-hosted)
|
||||
|
||||
For actual Sentry instances (not Bugsink), use:
|
||||
|
||||
```json
|
||||
"sentry": {
|
||||
"command": "D:\\nodejs\\npx.cmd",
|
||||
"args": ["-y", "@sentry/mcp-server"],
|
||||
"env": {
|
||||
"SENTRY_AUTH_TOKEN": "your-sentry-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Not Loading
|
||||
|
||||
1. **Check both config files** - Make sure the server is defined in both `~/.claude.json` AND `~/.claude/settings.json`
|
||||
|
||||
2. **Verify server order** - Servers load sequentially. Broken/slow servers can block others. Put important servers first.
|
||||
|
||||
3. **Check for timeout** - Each server has 30 seconds to connect. Slow npx downloads can cause timeouts.
|
||||
|
||||
4. **Fully restart VS Code** - Window reload is not enough. Close all VS Code windows and reopen.
|
||||
|
||||
### Verifying Configuration
|
||||
|
||||
**For CLI:**
|
||||
|
||||
```bash
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
**For VS Code:**
|
||||
|
||||
1. Open VS Code
|
||||
2. View → Output
|
||||
3. Select "Claude" from the dropdown
|
||||
4. Look for MCP server connection logs
|
||||
|
||||
### Common Errors
|
||||
|
||||
| Error | Cause | Solution |
|
||||
| ------------------------------------ | ----------------------------- | --------------------------------------------------------------------------- |
|
||||
| `Connection timed out after 30000ms` | Server took too long to start | Move server earlier in config, or use pre-installed packages instead of npx |
|
||||
| `npm error 404 Not Found` | Package doesn't exist | Check package name spelling |
|
||||
| `The system cannot find the path` | Wrong executable path | Verify the command path exists |
|
||||
| `Connection closed` | Server crashed on startup | Check server logs, verify environment variables |
|
||||
|
||||
### Disabling Problem Servers
|
||||
|
||||
In `~/.claude/settings.json`, add `"disabled": true`:
|
||||
|
||||
```json
|
||||
"problem-server": {
|
||||
"command": "...",
|
||||
"args": ["..."],
|
||||
"disabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The CLI config (`~/.claude.json`) does not support the `disabled` flag. You must remove the server entirely from that file.
|
||||
|
||||
## Adding a New MCP Server
|
||||
|
||||
1. **Install/clone the MCP server** (if not using npx)
|
||||
|
||||
2. **Add to VS Code config** (`~/.claude/settings.json`):
|
||||
|
||||
```json
|
||||
"new-server": {
|
||||
"command": "path/to/command",
|
||||
"args": ["arg1", "arg2"],
|
||||
"env": { "VAR": "value" }
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add to CLI config** (`~/.claude.json`) - find the `mcpServers` section:
|
||||
|
||||
```json
|
||||
"new-server": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/command",
|
||||
"args": ["arg1", "arg2"],
|
||||
"env": { "VAR": "value" }
|
||||
}
|
||||
```
|
||||
|
||||
4. **Fully restart VS Code**
|
||||
|
||||
5. **Verify with `claude mcp list`**
|
||||
|
||||
## Quick Reference: Available MCP Servers
|
||||
|
||||
| Server | Package/Repo | Purpose |
|
||||
| ------------------- | -------------------------------------------------- | --------------------------- |
|
||||
| memory | `@modelcontextprotocol/server-memory` | Knowledge graph persistence |
|
||||
| filesystem | `@modelcontextprotocol/server-filesystem` | File system access |
|
||||
| redis | `@modelcontextprotocol/server-redis` | Redis cache inspection |
|
||||
| postgres | `@modelcontextprotocol/server-postgres` | PostgreSQL queries |
|
||||
| sequential-thinking | `@modelcontextprotocol/server-sequential-thinking` | Step-by-step reasoning |
|
||||
| podman | `podman-mcp-server` | Container management |
|
||||
| gitea | `gitea-mcp` (binary) | Gitea API access |
|
||||
| bugsink | `j-shelfwood/bugsink-mcp` (build from source) | Error tracking for Bugsink |
|
||||
| sentry | `@sentry/mcp-server` | Error tracking for Sentry |
|
||||
| playwright | `@anthropics/mcp-server-playwright` | Browser automation |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep configs in sync** - When you change one file, update the other
|
||||
|
||||
2. **Order servers by importance** - Put essential servers (memory, filesystem) first
|
||||
|
||||
3. **Disable instead of delete** - Use `"disabled": true` in settings.json to troubleshoot
|
||||
|
||||
4. **Use node.exe directly** - For faster startup, install packages globally and use `node.exe` instead of `npx`
|
||||
|
||||
5. **Store sensitive data in memory** - Use the memory MCP to store API tokens and config for future sessions
|
||||
|
||||
---
|
||||
|
||||
## Future: MCP Launchpad
|
||||
|
||||
**Project:** <https://github.com/kenneth-liao/mcp-launchpad>
|
||||
|
||||
MCP Launchpad is a CLI tool that wraps multiple MCP servers into a single interface. Worth revisiting when:
|
||||
|
||||
- [ ] Windows support is stable (currently experimental)
|
||||
- [ ] Available as an MCP server itself (currently Bash-based)
|
||||
|
||||
**Why it's interesting:**
|
||||
|
||||
| Benefit | Description |
|
||||
| ---------------------- | -------------------------------------------------------------- |
|
||||
| Single config file | No more syncing `~/.claude.json` and `~/.claude/settings.json` |
|
||||
| Project-level configs | Drop `mcp.json` in any project for instant MCP setup |
|
||||
| Context window savings | One MCP server in context instead of 10+, reducing token usage |
|
||||
| Persistent daemon | Keeps server connections alive for faster repeated calls |
|
||||
| Tool search | Find tools across all servers with `mcpl search` |
|
||||
|
||||
**Current limitations:**
|
||||
|
||||
- Experimental Windows support
|
||||
- Requires Python 3.13+ and uv
|
||||
- Claude calls tools via Bash instead of native MCP integration
|
||||
- Different mental model (runtime discovery vs startup loading)
|
||||
|
||||
---
|
||||
|
||||
## Future: Graphiti (Advanced Knowledge Graph)
|
||||
|
||||
**Project:** <https://github.com/getzep/graphiti>
|
||||
|
||||
Graphiti provides temporal-aware knowledge graphs - it tracks not just facts, but _when_ they became true/outdated. Much more powerful than simple memory MCP, but requires significant infrastructure.
|
||||
|
||||
**Ideal setup:** Run on a Linux server, connect via HTTP from Windows:
|
||||
|
||||
```json
|
||||
// Windows client config (settings.json)
|
||||
"graphiti": {
|
||||
"type": "sse",
|
||||
"url": "http://linux-server:8000/mcp/"
|
||||
}
|
||||
```
|
||||
|
||||
**Linux server setup:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/getzep/graphiti.git
|
||||
cd graphiti/mcp_server
|
||||
docker compose up -d # Starts FalkorDB + MCP server on port 8000
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Docker on Linux server
|
||||
- OpenAI API key (for embeddings)
|
||||
- Port 8000 open on LAN
|
||||
|
||||
**Benefits of remote deployment:**
|
||||
|
||||
- Heavy lifting (Neo4j/FalkorDB + embeddings) offloaded to Linux
|
||||
- Always-on server, Windows connects/disconnects freely
|
||||
- Multiple machines can share the same knowledge graph
|
||||
- Avoids Windows Docker/WSL2 complexity
|
||||
|
||||
---
|
||||
|
||||
\_Last updated: January 2026
|
||||
606
CLAUDE.md
Normal file
606
CLAUDE.md
Normal file
@@ -0,0 +1,606 @@
|
||||
# Claude Code Project Instructions
|
||||
|
||||
## Session Startup Checklist
|
||||
|
||||
**IMPORTANT**: At the start of every session, perform these steps:
|
||||
|
||||
1. **Check Memory First** - Use `mcp__memory__read_graph` or `mcp__memory__search_nodes` to recall:
|
||||
- Project-specific configurations and credentials
|
||||
- Previous work context and decisions
|
||||
- Infrastructure details (URLs, ports, access patterns)
|
||||
- Known issues and their solutions
|
||||
|
||||
2. **Review Recent Git History** - Check `git log --oneline -10` to understand recent changes
|
||||
|
||||
3. **Check Container Status** - Use `mcp__podman__container_list` to see what's running
|
||||
|
||||
---
|
||||
|
||||
## Project Instructions
|
||||
|
||||
### Things to Remember
|
||||
|
||||
Before writing any code:
|
||||
|
||||
1. State how you will verify this change works (test, bash command, browser check, etc.)
|
||||
|
||||
2. Write the test or verification step first
|
||||
|
||||
3. Then implement the code
|
||||
|
||||
4. Run verification and iterate until it passes
|
||||
|
||||
## Git Bash / MSYS Path Conversion Issue (Windows Host)
|
||||
|
||||
**CRITICAL ISSUE**: Git Bash on Windows automatically converts Unix-style paths to Windows paths, which breaks Podman/Docker commands.
|
||||
|
||||
### Problem Examples:
|
||||
|
||||
```bash
|
||||
# This FAILS in Git Bash:
|
||||
podman exec container /usr/local/bin/script.sh
|
||||
# Git Bash converts to: C:/Program Files/Git/usr/local/bin/script.sh
|
||||
|
||||
# This FAILS in Git Bash:
|
||||
podman exec container bash -c "cat /tmp/file.sql"
|
||||
# Git Bash converts /tmp to C:/Users/user/AppData/Local/Temp
|
||||
```
|
||||
|
||||
### Solutions:
|
||||
|
||||
1. **Use `sh -c` instead of `bash -c`** for single-quoted commands:
|
||||
|
||||
```bash
|
||||
podman exec container sh -c '/usr/local/bin/script.sh'
|
||||
```
|
||||
|
||||
2. **Use double slashes** to escape path conversion:
|
||||
|
||||
```bash
|
||||
podman exec container //usr//local//bin//script.sh
|
||||
```
|
||||
|
||||
3. **Set MSYS_NO_PATHCONV** environment variable:
|
||||
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 podman exec container /usr/local/bin/script.sh
|
||||
```
|
||||
|
||||
4. **Use Windows paths with forward slashes** when referencing host files:
|
||||
```bash
|
||||
podman cp "d:/path/to/file" container:/tmp/file
|
||||
```
|
||||
|
||||
**ALWAYS use one of these workarounds when running Bash commands on Windows that involve Unix paths inside containers.**
|
||||
|
||||
## Communication Style: Ask Before Assuming
|
||||
|
||||
**IMPORTANT**: When helping with tasks, **ask clarifying questions before making assumptions**. Do not assume:
|
||||
|
||||
- What steps the user has or hasn't completed
|
||||
- What the user already knows or has configured
|
||||
- What external services (OAuth providers, APIs, etc.) are already set up
|
||||
- What secrets or credentials have already been created
|
||||
|
||||
Instead, ask the user to confirm the current state before providing instructions or making recommendations. This prevents wasted effort and respects the user's existing work.
|
||||
|
||||
## Platform Requirement: Linux Only
|
||||
|
||||
**CRITICAL**: This application is designed to run **exclusively on Linux**. See [ADR-014](docs/adr/0014-containerization-and-deployment-strategy.md) for full details.
|
||||
|
||||
### Environment Terminology
|
||||
|
||||
- **Dev Container** (or just "dev"): The containerized Linux development environment (`flyer-crawler-dev`). This is where all development and testing should occur.
|
||||
- **Host**: The Windows machine running Podman/Docker and VS Code.
|
||||
|
||||
When instructions say "run in dev" or "run in the dev container", they mean executing commands inside the `flyer-crawler-dev` container.
|
||||
|
||||
### Test Execution Rules
|
||||
|
||||
1. **ALL tests MUST be executed in the dev container** - the Linux container environment
|
||||
2. **NEVER run tests directly on Windows host** - test results from Windows are unreliable
|
||||
3. **Always use the dev container for testing** when developing on Windows
|
||||
4. **TypeScript type-check MUST run in dev container** - `npm run type-check` on Windows does not reliably detect errors
|
||||
|
||||
See [docs/TESTING.md](docs/TESTING.md) for comprehensive testing documentation.
|
||||
|
||||
### How to Run Tests Correctly
|
||||
|
||||
```bash
|
||||
# If on Windows, first open VS Code and "Reopen in Container"
|
||||
# Then run tests inside the dev container:
|
||||
npm test # Run all unit tests
|
||||
npm run test:unit # Run unit tests only
|
||||
npm run test:integration # Run integration tests (requires DB/Redis)
|
||||
```
|
||||
|
||||
### Running Tests via Podman (from Windows host)
|
||||
|
||||
**Note:** This project has 2900+ unit tests. For AI-assisted development, pipe output to a file for easier processing.
|
||||
|
||||
The command to run unit tests in the dev container via podman:
|
||||
|
||||
```bash
|
||||
# Basic (output to terminal)
|
||||
podman exec -it flyer-crawler-dev npm run test:unit
|
||||
|
||||
# Recommended for AI processing: pipe to file
|
||||
podman exec -it flyer-crawler-dev npm run test:unit 2>&1 | tee test-results.txt
|
||||
```
|
||||
|
||||
The command to run integration tests in the dev container via podman:
|
||||
|
||||
```bash
|
||||
podman exec -it flyer-crawler-dev npm run test:integration
|
||||
```
|
||||
|
||||
For running specific test files:
|
||||
|
||||
```bash
|
||||
podman exec -it flyer-crawler-dev npm test -- --run src/hooks/useAuth.test.tsx
|
||||
```
|
||||
|
||||
### Why Linux Only?
|
||||
|
||||
- Path separators: Code uses POSIX-style paths (`/`) which may break on Windows
|
||||
- Shell scripts in `scripts/` directory are Linux-only
|
||||
- External dependencies like `pdftocairo` assume Linux installation paths
|
||||
- Unix-style file permissions are assumed throughout
|
||||
|
||||
### Test Result Interpretation
|
||||
|
||||
- Tests that **pass on Windows but fail on Linux** = **BROKEN tests** (must be fixed)
|
||||
- Tests that **fail on Windows but pass on Linux** = **PASSING tests** (acceptable)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Open project in VS Code
|
||||
2. Use "Reopen in Container" (Dev Containers extension required) to enter the dev environment
|
||||
3. Wait for dev container initialization to complete
|
||||
4. Run `npm test` to verify the dev environment is working
|
||||
5. Make changes and run tests inside the dev container
|
||||
|
||||
## Code Change Verification
|
||||
|
||||
After making any code changes, **always run a type-check** to catch TypeScript errors before committing:
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
This prevents linting/type errors from being introduced into the codebase.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `npm test` | Run all unit tests |
|
||||
| `npm run test:unit` | Run unit tests only |
|
||||
| `npm run test:integration` | Run integration tests |
|
||||
| `npm run dev:container` | Start dev server (container) |
|
||||
| `npm run build` | Build for production |
|
||||
| `npm run type-check` | Run TypeScript type checking |
|
||||
|
||||
## Database Schema Files
|
||||
|
||||
**CRITICAL**: The database schema files must be kept in sync with each other. When making schema changes:
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------ | ----------------------------------------------------------- |
|
||||
| `sql/master_schema_rollup.sql` | Complete schema used by test database setup and reference |
|
||||
| `sql/initial_schema.sql` | Base schema without seed data, used as standalone reference |
|
||||
| `sql/migrations/*.sql` | Incremental migrations for production database updates |
|
||||
|
||||
**Maintenance Rules:**
|
||||
|
||||
1. **Keep `master_schema_rollup.sql` and `initial_schema.sql` in sync** - These files should contain the same table definitions
|
||||
2. **When adding columns via migration**, also add them to both `master_schema_rollup.sql` and `initial_schema.sql`
|
||||
3. **Migrations are for production deployments** - They use `ALTER TABLE` to add columns incrementally
|
||||
4. **Schema files are for fresh installs** - They define the complete table structure
|
||||
5. **Test database uses `master_schema_rollup.sql`** - If schema files are out of sync with migrations, tests will fail
|
||||
|
||||
**Example:** When `002_expiry_tracking.sql` adds `purchase_date` to `pantry_items`, that column must also exist in the `CREATE TABLE` statements in both `master_schema_rollup.sql` and `initial_schema.sql`.
|
||||
|
||||
## Known Integration Test Issues and Solutions
|
||||
|
||||
This section documents common test issues encountered in integration tests, their root causes, and solutions. These patterns recur frequently.
|
||||
|
||||
### 1. Vitest globalSetup Runs in Separate Node.js Context
|
||||
|
||||
**Problem:** Vitest's `globalSetup` runs in a completely separate Node.js context from test files. This means:
|
||||
|
||||
- Singletons created in globalSetup are NOT the same instances as those in test files
|
||||
- `global`, `globalThis`, and `process` are all isolated between contexts
|
||||
- `vi.spyOn()` on module exports doesn't work cross-context
|
||||
- Dependency injection via setter methods fails across contexts
|
||||
|
||||
**Affected Tests:** Any test trying to inject mocks into BullMQ worker services (e.g., AI failure tests, DB failure tests)
|
||||
|
||||
**Solution Options:**
|
||||
|
||||
1. Mark tests as `.todo()` until an API-based mock injection mechanism is implemented
|
||||
2. Create test-only API endpoints that allow setting mock behaviors via HTTP
|
||||
3. Use file-based or Redis-based mock flags that services check at runtime
|
||||
|
||||
**Example of affected code pattern:**
|
||||
|
||||
```typescript
|
||||
// This DOES NOT work - different module instances
|
||||
const { flyerProcessingService } = await import('../../services/workers.server');
|
||||
flyerProcessingService._getAiProcessor()._setExtractAndValidateData(mockFn);
|
||||
// The worker uses a different flyerProcessingService instance!
|
||||
```
|
||||
|
||||
### 2. BullMQ Cleanup Queue Deleting Files Before Test Verification
|
||||
|
||||
**Problem:** The cleanup worker runs in the globalSetup context and processes cleanup jobs even when tests spy on `cleanupQueue.add()`. The spy intercepts calls in the test context, but jobs already queued run in the worker's context.
|
||||
|
||||
**Affected Tests:** EXIF/PNG metadata stripping tests that need to verify file contents before deletion
|
||||
|
||||
**Solution:** Drain and pause the cleanup queue before the test:
|
||||
|
||||
```typescript
|
||||
const { cleanupQueue } = await import('../../services/queues.server');
|
||||
await cleanupQueue.drain(); // Remove existing jobs
|
||||
await cleanupQueue.pause(); // Prevent new jobs from processing
|
||||
// ... run test ...
|
||||
await cleanupQueue.resume(); // Restore normal operation
|
||||
```
|
||||
|
||||
### 3. Cache Invalidation After Direct Database Inserts
|
||||
|
||||
**Problem:** Tests that insert data directly via SQL (bypassing the service layer) don't trigger cache invalidation. Subsequent API calls return stale cached data.
|
||||
|
||||
**Affected Tests:** Any test using `pool.query()` to insert flyers, stores, or other cached entities
|
||||
|
||||
**Solution:** Manually invalidate the cache after direct inserts:
|
||||
|
||||
```typescript
|
||||
await pool.query('INSERT INTO flyers ...');
|
||||
await cacheService.invalidateFlyers(); // Clear stale cache
|
||||
```
|
||||
|
||||
### 4. Unique Filenames Required for Test Isolation
|
||||
|
||||
**Problem:** Multer generates predictable filenames in test environments, causing race conditions when multiple tests upload files concurrently or in sequence.
|
||||
|
||||
**Affected Tests:** Flyer processing tests, file upload tests
|
||||
|
||||
**Solution:** Always use unique filenames with timestamps:
|
||||
|
||||
```typescript
|
||||
// In multer.middleware.ts
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}-${sanitizedOriginalName}`);
|
||||
```
|
||||
|
||||
### 5. Response Format Mismatches
|
||||
|
||||
**Problem:** API response formats may change, causing tests to fail when expecting old formats.
|
||||
|
||||
**Common Issues:**
|
||||
|
||||
- `response.body.data.jobId` vs `response.body.data.job.id`
|
||||
- Nested objects vs flat response structures
|
||||
- Type coercion (string vs number for IDs)
|
||||
|
||||
**Solution:** Always log response bodies during debugging and update test assertions to match actual API contracts.
|
||||
|
||||
### 6. External Service Availability
|
||||
|
||||
**Problem:** Tests depending on external services (PM2, Redis health checks) fail when those services aren't available in the test environment.
|
||||
|
||||
**Solution:** Use try/catch with graceful degradation or mock the external service checks.
|
||||
|
||||
## Secrets and Environment Variables
|
||||
|
||||
**CRITICAL**: This project uses **Gitea CI/CD secrets** for all sensitive configuration. There is NO `/etc/flyer-crawler/environment` file or similar local config file on the server.
|
||||
|
||||
### Server Directory Structure
|
||||
|
||||
| Path | Environment | Notes |
|
||||
| --------------------------------------------- | ----------- | ------------------------------------------------ |
|
||||
| `/var/www/flyer-crawler.projectium.com/` | Production | NO `.env` file - secrets injected via CI/CD only |
|
||||
| `/var/www/flyer-crawler-test.projectium.com/` | Test | Has `.env.test` file for test-specific config |
|
||||
|
||||
### How Secrets Work
|
||||
|
||||
1. **Gitea Secrets**: All secrets are stored in Gitea repository settings (Settings → Secrets)
|
||||
2. **CI/CD Injection**: Secrets are injected during deployment via `.gitea/workflows/deploy-to-prod.yml` and `deploy-to-test.yml`
|
||||
3. **PM2 Environment**: The CI/CD workflow passes secrets to PM2 via environment variables, which are then available to the application
|
||||
|
||||
### Key Files for Configuration
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------- | ---------------------------------------------------- |
|
||||
| `src/config/env.ts` | Centralized config with Zod schema validation |
|
||||
| `ecosystem.config.cjs` | PM2 process config - reads from `process.env` |
|
||||
| `.gitea/workflows/deploy-to-prod.yml` | Production deployment with secret injection |
|
||||
| `.gitea/workflows/deploy-to-test.yml` | Test deployment with secret injection |
|
||||
| `.env.example` | Template showing all available environment variables |
|
||||
| `.env.test` | Test environment overrides (only on test server) |
|
||||
|
||||
### Adding New Secrets
|
||||
|
||||
To add a new secret (e.g., `SENTRY_DSN`):
|
||||
|
||||
1. Add the secret to Gitea repository settings
|
||||
2. Update the relevant workflow file (e.g., `deploy-to-prod.yml`) to inject it:
|
||||
|
||||
```yaml
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
```
|
||||
|
||||
3. Update `ecosystem.config.cjs` to read it from `process.env`
|
||||
4. Update `src/config/env.ts` schema if validation is needed
|
||||
5. Update `.env.example` to document the new variable
|
||||
|
||||
### Current Gitea Secrets
|
||||
|
||||
**Shared (used by both environments):**
|
||||
|
||||
- `DB_HOST` - Database host (shared PostgreSQL server)
|
||||
- `JWT_SECRET` - Authentication
|
||||
- `GOOGLE_MAPS_API_KEY` - Google Maps
|
||||
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` - Google OAuth
|
||||
- `GH_CLIENT_ID`, `GH_CLIENT_SECRET` - GitHub OAuth
|
||||
- `SENTRY_AUTH_TOKEN` - Bugsink API token for source map uploads (create at Settings > API Keys in Bugsink)
|
||||
|
||||
**Production-specific:**
|
||||
|
||||
- `DB_USER_PROD`, `DB_PASSWORD_PROD` - Production database credentials (`flyer_crawler_prod`)
|
||||
- `DB_DATABASE_PROD` - Production database name (`flyer-crawler`)
|
||||
- `REDIS_PASSWORD_PROD` - Redis password (uses database 0)
|
||||
- `VITE_GOOGLE_GENAI_API_KEY` - Gemini API key for production
|
||||
- `SENTRY_DSN`, `VITE_SENTRY_DSN` - Bugsink error tracking DSNs (production projects)
|
||||
|
||||
**Test-specific:**
|
||||
|
||||
- `DB_USER_TEST`, `DB_PASSWORD_TEST` - Test database credentials (`flyer_crawler_test`)
|
||||
- `DB_DATABASE_TEST` - Test database name (`flyer-crawler-test`)
|
||||
- `REDIS_PASSWORD_TEST` - Redis password (uses database 1 for isolation)
|
||||
- `VITE_GOOGLE_GENAI_API_KEY_TEST` - Gemini API key for test
|
||||
- `SENTRY_DSN_TEST`, `VITE_SENTRY_DSN_TEST` - Bugsink error tracking DSNs (test projects)
|
||||
|
||||
### Test Environment
|
||||
|
||||
The test environment (`flyer-crawler-test.projectium.com`) uses **both** Gitea CI/CD secrets and a local `.env.test` file:
|
||||
|
||||
- **Gitea secrets**: Injected during deployment via `.gitea/workflows/deploy-to-test.yml`
|
||||
- **`.env.test` file**: Located at `/var/www/flyer-crawler-test.projectium.com/.env.test` for local overrides
|
||||
- **Redis database 1**: Isolates test job queues from production (which uses database 0)
|
||||
- **PM2 process names**: Suffixed with `-test` (e.g., `flyer-crawler-api-test`)
|
||||
|
||||
### Database User Setup (Test Environment)
|
||||
|
||||
**CRITICAL**: The test database requires specific PostgreSQL permissions to be configured manually. Schema ownership alone is NOT sufficient - explicit privileges must be granted.
|
||||
|
||||
**Database Users:**
|
||||
|
||||
| User | Database | Purpose |
|
||||
| -------------------- | -------------------- | ---------- |
|
||||
| `flyer_crawler_prod` | `flyer-crawler-prod` | Production |
|
||||
| `flyer_crawler_test` | `flyer-crawler-test` | Testing |
|
||||
|
||||
**Required Setup Commands** (run as `postgres` superuser):
|
||||
|
||||
```bash
|
||||
# Connect as postgres superuser
|
||||
sudo -u postgres psql
|
||||
|
||||
# Create the test database and user (if not exists)
|
||||
CREATE DATABASE "flyer-crawler-test";
|
||||
CREATE USER flyer_crawler_test WITH PASSWORD 'your-password-here';
|
||||
|
||||
# Grant ownership and privileges
|
||||
ALTER DATABASE "flyer-crawler-test" OWNER TO flyer_crawler_test;
|
||||
\c "flyer-crawler-test"
|
||||
ALTER SCHEMA public OWNER TO flyer_crawler_test;
|
||||
GRANT CREATE, USAGE ON SCHEMA public TO flyer_crawler_test;
|
||||
|
||||
# Create required extension (must be done by superuser)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
```
|
||||
|
||||
**Why These Steps Are Necessary:**
|
||||
|
||||
1. **Schema ownership alone is insufficient** - PostgreSQL requires explicit `GRANT CREATE, USAGE` privileges even when the user owns the schema
|
||||
2. **uuid-ossp extension** - Required by the application for UUID generation; must be created by a superuser before the app can use it
|
||||
3. **Separate users for prod/test** - Prevents accidental cross-environment data access; each environment has its own credentials in Gitea secrets
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
# Check schema privileges (should show 'UC' for flyer_crawler_test)
|
||||
psql -d "flyer-crawler-test" -c "\dn+ public"
|
||||
|
||||
# Expected output:
|
||||
# Name | Owner | Access privileges
|
||||
# -------+--------------------+------------------------------------------
|
||||
# public | flyer_crawler_test | flyer_crawler_test=UC/flyer_crawler_test
|
||||
```
|
||||
|
||||
### Dev Container Environment
|
||||
|
||||
The dev container runs its own **local Bugsink instance** - it does NOT connect to the production Bugsink server:
|
||||
|
||||
- **Local Bugsink**: Runs at `http://localhost:8000` inside the container
|
||||
- **Pre-configured DSNs**: Set in `compose.dev.yml`, pointing to local instance
|
||||
- **Admin credentials**: `admin@localhost` / `admin`
|
||||
- **Isolated**: Dev errors stay local, don't pollute production/test dashboards
|
||||
- **No Gitea secrets needed**: Everything is self-contained in the container
|
||||
|
||||
---
|
||||
|
||||
## MCP Servers
|
||||
|
||||
The following MCP servers are configured for this project:
|
||||
|
||||
| Server | Purpose |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| gitea-projectium | Gitea API for gitea.projectium.com |
|
||||
| gitea-torbonium | Gitea API for gitea.torbonium.com |
|
||||
| podman | Container management |
|
||||
| filesystem | File system access |
|
||||
| fetch | Web fetching |
|
||||
| markitdown | Convert documents to markdown |
|
||||
| sequential-thinking | Step-by-step reasoning |
|
||||
| memory | Knowledge graph persistence |
|
||||
| postgres | Direct database queries (localhost:5432) |
|
||||
| playwright | Browser automation and testing |
|
||||
| redis | Redis cache inspection (localhost:6379) |
|
||||
| sentry-selfhosted-mcp | Error tracking via Bugsink (localhost:8000) |
|
||||
|
||||
**Note:** MCP servers work in both **Claude CLI** and **Claude Code VS Code extension** (as of January 2026).
|
||||
|
||||
### Sentry/Bugsink MCP Server Setup (ADR-015)
|
||||
|
||||
To enable Claude Code to query and analyze application errors from Bugsink:
|
||||
|
||||
1. **Install the MCP server**:
|
||||
|
||||
```bash
|
||||
# Clone the sentry-selfhosted-mcp repository
|
||||
git clone https://github.com/ddfourtwo/sentry-selfhosted-mcp.git
|
||||
cd sentry-selfhosted-mcp
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Configure Claude Code** (add to `.claude/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"sentry-selfhosted-mcp": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/sentry-selfhosted-mcp/dist/index.js"],
|
||||
"env": {
|
||||
"SENTRY_URL": "http://localhost:8000",
|
||||
"SENTRY_AUTH_TOKEN": "<get-from-bugsink-ui>",
|
||||
"SENTRY_ORG_SLUG": "flyer-crawler"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Get the auth token**:
|
||||
- Navigate to Bugsink UI at `http://localhost:8000`
|
||||
- Log in with admin credentials
|
||||
- Go to Settings > API Keys
|
||||
- Create a new API key with read access
|
||||
|
||||
4. **Available capabilities**:
|
||||
- List projects and issues
|
||||
- View detailed error events
|
||||
- Search by error message or stack trace
|
||||
- Update issue status (resolve, ignore)
|
||||
- Add comments to issues
|
||||
|
||||
### SSH Server Access
|
||||
|
||||
Claude Code can execute commands on the production server via SSH:
|
||||
|
||||
```bash
|
||||
# Basic command execution
|
||||
ssh root@projectium.com "command here"
|
||||
|
||||
# Examples:
|
||||
ssh root@projectium.com "systemctl status logstash"
|
||||
ssh root@projectium.com "pm2 list"
|
||||
ssh root@projectium.com "tail -50 /var/www/flyer-crawler.projectium.com/logs/app.log"
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Managing Logstash, PM2, NGINX, Redis services
|
||||
- Viewing server logs
|
||||
- Deploying configuration changes
|
||||
- Checking service status
|
||||
|
||||
**Important:** SSH access requires the host machine to have SSH keys configured for `root@projectium.com`.
|
||||
|
||||
---
|
||||
|
||||
## Logstash Configuration (ADR-050)
|
||||
|
||||
The production server uses **Logstash** to aggregate logs from multiple sources and forward errors to Bugsink for centralized error tracking.
|
||||
|
||||
**Log Sources:**
|
||||
|
||||
- **PostgreSQL function logs** - Structured JSON logs from `fn_log()` helper function
|
||||
- **PM2 worker logs** - Service logs from BullMQ job workers (stdout)
|
||||
- **Redis logs** - Operational logs (INFO level) and errors
|
||||
- **NGINX logs** - Access logs (all requests) and error logs
|
||||
|
||||
### Configuration Location
|
||||
|
||||
**Primary configuration file:**
|
||||
|
||||
- `/etc/logstash/conf.d/bugsink.conf` - Complete Logstash pipeline configuration
|
||||
|
||||
**Related files:**
|
||||
|
||||
- `/etc/postgresql/14/main/conf.d/observability.conf` - PostgreSQL logging configuration
|
||||
- `/var/log/postgresql/*.log` - PostgreSQL log files
|
||||
- `/home/gitea-runner/.pm2/logs/*.log` - PM2 worker logs
|
||||
- `/var/log/redis/redis-server.log` - Redis logs
|
||||
- `/var/log/nginx/access.log` - NGINX access logs
|
||||
- `/var/log/nginx/error.log` - NGINX error logs
|
||||
- `/var/log/logstash/*.log` - Logstash file outputs (operational logs)
|
||||
- `/var/lib/logstash/sincedb_*` - Logstash position tracking files
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Multi-source aggregation**: Collects logs from PostgreSQL, PM2 workers, Redis, and NGINX
|
||||
2. **Environment-based routing**: Automatically detects production vs test environments and routes errors to the correct Bugsink project
|
||||
3. **Structured JSON parsing**: Extracts `fn_log()` function output from PostgreSQL logs and Pino JSON from PM2 workers
|
||||
4. **Sentry-compatible format**: Transforms events to Sentry format with `event_id`, `timestamp`, `level`, `message`, and `extra` context
|
||||
5. **Error filtering**: Only forwards WARNING and ERROR level messages to Bugsink
|
||||
6. **Operational log storage**: Stores non-error logs (Redis INFO, NGINX access, PM2 operational) to `/var/log/logstash/` for analysis
|
||||
7. **Request monitoring**: Categorizes NGINX requests by status code (2xx, 3xx, 4xx, 5xx) and identifies slow requests
|
||||
|
||||
### Common Maintenance Commands
|
||||
|
||||
```bash
|
||||
# Check Logstash status
|
||||
systemctl status logstash
|
||||
|
||||
# Restart Logstash after configuration changes
|
||||
systemctl restart logstash
|
||||
|
||||
# Test configuration syntax
|
||||
/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf
|
||||
|
||||
# View Logstash logs
|
||||
journalctl -u logstash -f
|
||||
|
||||
# Check Logstash stats (events processed, failures)
|
||||
curl -XGET 'localhost:9600/_node/stats/pipelines?pretty' | jq '.pipelines.main.plugins.filters'
|
||||
|
||||
# Monitor PostgreSQL logs being processed
|
||||
tail -f /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
|
||||
|
||||
# View operational log outputs
|
||||
tail -f /var/log/logstash/pm2-workers-$(date +%Y-%m-%d).log
|
||||
tail -f /var/log/logstash/redis-operational-$(date +%Y-%m-%d).log
|
||||
tail -f /var/log/logstash/nginx-access-$(date +%Y-%m-%d).log
|
||||
|
||||
# Check disk usage of log files
|
||||
du -sh /var/log/logstash/
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Issue | Check | Solution |
|
||||
| ------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Errors not appearing in Bugsink | Check Logstash is running | `systemctl status logstash` |
|
||||
| Configuration syntax errors | Test config file | `/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf` |
|
||||
| Grok pattern failures | Check Logstash stats | `curl localhost:9600/_node/stats/pipelines?pretty \| jq '.pipelines.main.plugins.filters'` |
|
||||
| Wrong Bugsink project | Verify environment detection | Check tags in logs match expected environment (production/test) |
|
||||
| Permission denied reading logs | Check Logstash permissions | `groups logstash` should include `postgres`, `adm` groups |
|
||||
| PM2 logs not captured | Check file paths exist | `ls /home/gitea-runner/.pm2/logs/flyer-crawler-worker-*.log` |
|
||||
| NGINX access logs not showing | Check file output directory | `ls -lh /var/log/logstash/nginx-access-*.log` |
|
||||
| High disk usage | Check log rotation | Verify `/etc/logrotate.d/logstash` is configured and running daily |
|
||||
|
||||
**Full setup guide**: See [docs/BARE-METAL-SETUP.md](docs/BARE-METAL-SETUP.md) section "PostgreSQL Function Observability (ADR-050)"
|
||||
|
||||
**Architecture details**: See [docs/adr/0050-postgresql-function-observability.md](docs/adr/0050-postgresql-function-observability.md)
|
||||
223
DATABASE.md
Normal file
223
DATABASE.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Database Setup
|
||||
|
||||
Flyer Crawler uses PostgreSQL with several extensions for full-text search, geographic data, and UUID generation.
|
||||
|
||||
---
|
||||
|
||||
## Required Extensions
|
||||
|
||||
| Extension | Purpose |
|
||||
| ----------- | ------------------------------------------- |
|
||||
| `postgis` | Geographic/spatial data for store locations |
|
||||
| `pg_trgm` | Trigram matching for fuzzy text search |
|
||||
| `uuid-ossp` | UUID generation for primary keys |
|
||||
|
||||
---
|
||||
|
||||
## Database Users
|
||||
|
||||
This project uses **environment-specific database users** to isolate production and test environments:
|
||||
|
||||
| User | Database | Purpose |
|
||||
| -------------------- | -------------------- | ---------- |
|
||||
| `flyer_crawler_prod` | `flyer-crawler-prod` | Production |
|
||||
| `flyer_crawler_test` | `flyer-crawler-test` | Testing |
|
||||
|
||||
---
|
||||
|
||||
## Production Database Setup
|
||||
|
||||
### Step 1: Install PostgreSQL
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
```
|
||||
|
||||
### Step 2: Create Database and User
|
||||
|
||||
Switch to the postgres system user:
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
Run the following SQL commands (replace `'a_very_strong_password'` with a secure password):
|
||||
|
||||
```sql
|
||||
-- Create the production role
|
||||
CREATE ROLE flyer_crawler_prod WITH LOGIN PASSWORD 'a_very_strong_password';
|
||||
|
||||
-- Create the production database
|
||||
CREATE DATABASE "flyer-crawler-prod" WITH OWNER = flyer_crawler_prod;
|
||||
|
||||
-- Connect to the new database
|
||||
\c "flyer-crawler-prod"
|
||||
|
||||
-- Grant schema privileges
|
||||
ALTER SCHEMA public OWNER TO flyer_crawler_prod;
|
||||
GRANT CREATE, USAGE ON SCHEMA public TO flyer_crawler_prod;
|
||||
|
||||
-- Install required extensions (must be done as superuser)
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Exit
|
||||
\q
|
||||
```
|
||||
|
||||
### Step 3: Apply the Schema
|
||||
|
||||
Navigate to your project directory and run:
|
||||
|
||||
```bash
|
||||
psql -U flyer_crawler_prod -d "flyer-crawler-prod" -f sql/master_schema_rollup.sql
|
||||
```
|
||||
|
||||
This creates all tables, functions, triggers, and seeds essential data (categories, master items).
|
||||
|
||||
### Step 4: Seed the Admin Account
|
||||
|
||||
Set the required environment variables and run the seed script:
|
||||
|
||||
```bash
|
||||
export DB_USER=flyer_crawler_prod
|
||||
export DB_PASSWORD=your_password
|
||||
export DB_NAME="flyer-crawler-prod"
|
||||
export DB_HOST=localhost
|
||||
|
||||
npx tsx src/db/seed_admin_account.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Database Setup
|
||||
|
||||
The test database is used by CI/CD pipelines and local test runs.
|
||||
|
||||
### Step 1: Create the Test Database
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Create the test role
|
||||
CREATE ROLE flyer_crawler_test WITH LOGIN PASSWORD 'a_very_strong_password';
|
||||
|
||||
-- Create the test database
|
||||
CREATE DATABASE "flyer-crawler-test" WITH OWNER = flyer_crawler_test;
|
||||
|
||||
-- Connect to the test database
|
||||
\c "flyer-crawler-test"
|
||||
|
||||
-- Grant schema privileges (required for test runner to reset schema)
|
||||
ALTER SCHEMA public OWNER TO flyer_crawler_test;
|
||||
GRANT CREATE, USAGE ON SCHEMA public TO flyer_crawler_test;
|
||||
|
||||
-- Install required extensions
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Exit
|
||||
\q
|
||||
```
|
||||
|
||||
### Step 2: Configure CI/CD Secrets
|
||||
|
||||
Ensure these secrets are set in your Gitea repository settings:
|
||||
|
||||
**Shared:**
|
||||
|
||||
| Secret | Description |
|
||||
| --------- | ------------------------------------- |
|
||||
| `DB_HOST` | Database hostname (e.g., `localhost`) |
|
||||
| `DB_PORT` | Database port (e.g., `5432`) |
|
||||
|
||||
**Production-specific:**
|
||||
|
||||
| Secret | Description |
|
||||
| ------------------ | ----------------------------------------------- |
|
||||
| `DB_USER_PROD` | Production database user (`flyer_crawler_prod`) |
|
||||
| `DB_PASSWORD_PROD` | Production database password |
|
||||
| `DB_DATABASE_PROD` | Production database name (`flyer-crawler-prod`) |
|
||||
|
||||
**Test-specific:**
|
||||
|
||||
| Secret | Description |
|
||||
| ------------------ | ----------------------------------------- |
|
||||
| `DB_USER_TEST` | Test database user (`flyer_crawler_test`) |
|
||||
| `DB_PASSWORD_TEST` | Test database password |
|
||||
| `DB_DATABASE_TEST` | Test database name (`flyer-crawler-test`) |
|
||||
|
||||
---
|
||||
|
||||
## How the Test Pipeline Works
|
||||
|
||||
The CI pipeline uses a permanent test database that gets reset on each test run:
|
||||
|
||||
1. **Setup**: The vitest global setup script connects to `flyer-crawler-test`
|
||||
2. **Schema Reset**: Executes `sql/drop_tables.sql` (`DROP SCHEMA public CASCADE`)
|
||||
3. **Schema Application**: Runs `sql/master_schema_rollup.sql` to build a fresh schema
|
||||
4. **Test Execution**: Tests run against the clean database
|
||||
|
||||
This approach is faster than creating/destroying databases and doesn't require sudo access.
|
||||
|
||||
---
|
||||
|
||||
## Connecting to Production Database
|
||||
|
||||
```bash
|
||||
psql -h localhost -U flyer_crawler_prod -d "flyer-crawler-prod" -W
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking PostGIS Version
|
||||
|
||||
```sql
|
||||
SELECT version();
|
||||
SELECT PostGIS_Full_Version();
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```text
|
||||
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1)
|
||||
POSTGIS="3.2.0 c3e3cc0" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------ | --------------------------------------------------------- |
|
||||
| `sql/master_schema_rollup.sql` | Complete schema with all tables, functions, and seed data |
|
||||
| `sql/drop_tables.sql` | Drops entire schema (used by test runner) |
|
||||
| `sql/schema.sql.txt` | Legacy schema file (reference only) |
|
||||
|
||||
---
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Create a Backup
|
||||
|
||||
```bash
|
||||
pg_dump -U flyer_crawler_prod -d "flyer-crawler-prod" -F c -f backup.dump
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
pg_restore -U flyer_crawler_prod -d "flyer-crawler-prod" -c backup.dump
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Installation Guide](INSTALL.md) - Local development setup
|
||||
- [Deployment Guide](DEPLOYMENT.md) - Production deployment
|
||||
271
DEPLOYMENT.md
Normal file
271
DEPLOYMENT.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Deployment Guide
|
||||
|
||||
This guide covers deploying Flyer Crawler to a production server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ubuntu server (22.04 LTS recommended)
|
||||
- PostgreSQL 14+ with PostGIS extension
|
||||
- Redis
|
||||
- Node.js 20.x
|
||||
- NGINX (reverse proxy)
|
||||
- PM2 (process manager)
|
||||
|
||||
---
|
||||
|
||||
## Server Setup
|
||||
|
||||
### Install Node.js
|
||||
|
||||
```bash
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
### Install PM2
|
||||
|
||||
```bash
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Application Deployment
|
||||
|
||||
### Clone and Install
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd flyer-crawler.projectium.com
|
||||
npm install
|
||||
```
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Start with PM2
|
||||
|
||||
```bash
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
This starts three PM2 processes:
|
||||
|
||||
- `flyer-crawler-api` - Main API server
|
||||
- `flyer-crawler-worker` - Background job worker
|
||||
- `flyer-crawler-analytics-worker` - Analytics processing worker
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (Gitea Secrets)
|
||||
|
||||
For deployments using Gitea CI/CD workflows, configure these as **repository secrets**:
|
||||
|
||||
| Secret | Description |
|
||||
| --------------------------- | ------------------------------------------- |
|
||||
| `DB_HOST` | PostgreSQL server hostname |
|
||||
| `DB_USER` | PostgreSQL username |
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `DB_DATABASE_PROD` | Production database name |
|
||||
| `REDIS_PASSWORD_PROD` | Production Redis password |
|
||||
| `REDIS_PASSWORD_TEST` | Test Redis password |
|
||||
| `JWT_SECRET` | Long, random string for signing auth tokens |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY` | Google Gemini API key |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps Geocoding API key |
|
||||
|
||||
---
|
||||
|
||||
## NGINX Configuration
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
Create a site configuration at `/etc/nginx/sites-available/flyer-crawler.projectium.com`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name flyer-crawler.projectium.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5173;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/flyer-crawler.projectium.com /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### MIME Types Fix for .mjs Files
|
||||
|
||||
If JavaScript modules (`.mjs` files) aren't loading correctly, add the proper MIME type.
|
||||
|
||||
**Option 1**: Edit the site configuration file directly:
|
||||
|
||||
```nginx
|
||||
# Add inside the server block
|
||||
types {
|
||||
application/javascript js mjs;
|
||||
}
|
||||
```
|
||||
|
||||
**Option 2**: Edit `/etc/nginx/mime.types` globally:
|
||||
|
||||
```
|
||||
# Change this line:
|
||||
application/javascript js;
|
||||
|
||||
# To:
|
||||
application/javascript js mjs;
|
||||
```
|
||||
|
||||
After changes:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PM2 Log Management
|
||||
|
||||
Install and configure pm2-logrotate to manage log files:
|
||||
|
||||
```bash
|
||||
pm2 install pm2-logrotate
|
||||
pm2 set pm2-logrotate:max_size 10M
|
||||
pm2 set pm2-logrotate:retain 14
|
||||
pm2 set pm2-logrotate:compress false
|
||||
pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The application respects the Gemini AI service's rate limits. You can adjust the `GEMINI_RPM` (requests per minute) environment variable in production as needed without changing the code.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
The project includes Gitea workflows at `.gitea/workflows/deploy.yml` that:
|
||||
|
||||
1. Run tests against a test database
|
||||
2. Build the application
|
||||
3. Deploy to production on successful builds
|
||||
|
||||
The workflow automatically:
|
||||
|
||||
- Sets up the test database schema before tests
|
||||
- Tears down test data after tests complete
|
||||
- Deploys to the production server
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check PM2 Status
|
||||
|
||||
```bash
|
||||
pm2 status
|
||||
pm2 logs
|
||||
pm2 logs flyer-crawler-api --lines 100
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
pm2 restart all
|
||||
pm2 restart flyer-crawler-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Tracking with Bugsink (ADR-015)
|
||||
|
||||
Bugsink is a self-hosted Sentry-compatible error tracking system. See [docs/adr/0015-application-performance-monitoring-and-error-tracking.md](docs/adr/0015-application-performance-monitoring-and-error-tracking.md) for the full architecture decision.
|
||||
|
||||
### Creating Bugsink Projects and DSNs
|
||||
|
||||
After Bugsink is installed and running, you need to create projects and obtain DSNs:
|
||||
|
||||
1. **Access Bugsink UI**: Navigate to `http://localhost:8000`
|
||||
|
||||
2. **Log in** with your admin credentials
|
||||
|
||||
3. **Create Backend Project**:
|
||||
- Click "Create Project"
|
||||
- Name: `flyer-crawler-backend`
|
||||
- Platform: Node.js
|
||||
- Copy the generated DSN (format: `http://<key>@localhost:8000/<project_id>`)
|
||||
|
||||
4. **Create Frontend Project**:
|
||||
- Click "Create Project"
|
||||
- Name: `flyer-crawler-frontend`
|
||||
- Platform: React
|
||||
- Copy the generated DSN
|
||||
|
||||
5. **Configure Environment Variables**:
|
||||
|
||||
```bash
|
||||
# Backend (server-side)
|
||||
export SENTRY_DSN=http://<backend-key>@localhost:8000/<backend-project-id>
|
||||
|
||||
# Frontend (client-side, exposed to browser)
|
||||
export VITE_SENTRY_DSN=http://<frontend-key>@localhost:8000/<frontend-project-id>
|
||||
|
||||
# Shared settings
|
||||
export SENTRY_ENVIRONMENT=production
|
||||
export VITE_SENTRY_ENVIRONMENT=production
|
||||
export SENTRY_ENABLED=true
|
||||
export VITE_SENTRY_ENABLED=true
|
||||
```
|
||||
|
||||
### Testing Error Tracking
|
||||
|
||||
Verify Bugsink is receiving events:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/test-bugsink.ts
|
||||
```
|
||||
|
||||
This sends test error and info events. Check the Bugsink UI for:
|
||||
|
||||
- `BugsinkTestError` in the backend project
|
||||
- Info message "Test info message from test-bugsink.ts"
|
||||
|
||||
### Sentry SDK v10+ HTTP DSN Limitation
|
||||
|
||||
The Sentry SDK v10+ enforces HTTPS-only DSNs by default. Since Bugsink runs locally over HTTP, our implementation uses the Sentry Store API directly instead of the SDK's built-in transport. This is handled transparently by the `sentry.server.ts` and `sentry.client.ts` modules.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Database Setup](DATABASE.md) - PostgreSQL and PostGIS configuration
|
||||
- [Authentication Setup](AUTHENTICATION.md) - OAuth provider configuration
|
||||
- [Installation Guide](INSTALL.md) - Local development setup
|
||||
- [Bare-Metal Server Setup](docs/BARE-METAL-SETUP.md) - Manual server installation guide
|
||||
337
Dockerfile.dev
Normal file
337
Dockerfile.dev
Normal file
@@ -0,0 +1,337 @@
|
||||
# Dockerfile.dev
|
||||
# ============================================================================
|
||||
# DEVELOPMENT DOCKERFILE
|
||||
# ============================================================================
|
||||
# This Dockerfile creates a development environment that matches production
|
||||
# as closely as possible while providing the tools needed for development.
|
||||
#
|
||||
# Base: Ubuntu 22.04 (LTS) - matches production server
|
||||
# Node: v20.x (LTS) - matches production
|
||||
# Includes: PostgreSQL client, Redis CLI, build tools, Bugsink, Logstash
|
||||
# ============================================================================
|
||||
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Set environment variables to non-interactive to avoid prompts during installation
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ============================================================================
|
||||
# Install System Dependencies
|
||||
# ============================================================================
|
||||
# - curl: for downloading Node.js setup script and health checks
|
||||
# - git: for version control operations
|
||||
# - build-essential: for compiling native Node.js modules (node-gyp)
|
||||
# - python3, python3-pip, python3-venv: for Bugsink
|
||||
# - postgresql-client: for psql CLI (database initialization)
|
||||
# - redis-tools: for redis-cli (health checks)
|
||||
# - gnupg, apt-transport-https: for Elastic APT repository (Logstash)
|
||||
# - openjdk-17-jre-headless: required by Logstash
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
postgresql-client \
|
||||
redis-tools \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
openjdk-17-jre-headless \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ============================================================================
|
||||
# Install Node.js 20.x (LTS)
|
||||
# ============================================================================
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# ============================================================================
|
||||
# Install Logstash (Elastic APT Repository)
|
||||
# ============================================================================
|
||||
# ADR-015: Log aggregation for Pino and Redis logs → Bugsink
|
||||
RUN curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | gpg --dearmor -o /usr/share/keyrings/elastic-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/elastic-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" | tee /etc/apt/sources.list.d/elastic-8.x.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y logstash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ============================================================================
|
||||
# Install Bugsink (Python Package)
|
||||
# ============================================================================
|
||||
# ADR-015: Self-hosted Sentry-compatible error tracking
|
||||
# Create a virtual environment for Bugsink to avoid conflicts
|
||||
RUN python3 -m venv /opt/bugsink \
|
||||
&& /opt/bugsink/bin/pip install --upgrade pip \
|
||||
&& /opt/bugsink/bin/pip install bugsink gunicorn psycopg2-binary
|
||||
|
||||
# Create Bugsink directories and configuration
|
||||
RUN mkdir -p /var/log/bugsink /var/lib/bugsink /opt/bugsink/conf
|
||||
|
||||
# Create Bugsink configuration file (Django settings module)
|
||||
# This file is imported by bugsink-manage via DJANGO_SETTINGS_MODULE
|
||||
# Based on bugsink/conf_templates/docker.py.template but customized for our setup
|
||||
RUN echo 'import os\n\
|
||||
from urllib.parse import urlparse\n\
|
||||
\n\
|
||||
from bugsink.settings.default import *\n\
|
||||
from bugsink.settings.default import DATABASES, SILENCED_SYSTEM_CHECKS\n\
|
||||
from bugsink.conf_utils import deduce_allowed_hosts, deduce_script_name\n\
|
||||
\n\
|
||||
IS_DOCKER = True\n\
|
||||
\n\
|
||||
# Security settings\n\
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")\n\
|
||||
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")\n\
|
||||
\n\
|
||||
# Silence cookie security warnings for dev (no HTTPS)\n\
|
||||
SILENCED_SYSTEM_CHECKS += ["security.W012", "security.W016"]\n\
|
||||
\n\
|
||||
# Database configuration from DATABASE_URL environment variable\n\
|
||||
if os.getenv("DATABASE_URL"):\n\
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")\n\
|
||||
parsed = urlparse(DATABASE_URL)\n\
|
||||
\n\
|
||||
if parsed.scheme in ["postgres", "postgresql"]:\n\
|
||||
DATABASES["default"] = {\n\
|
||||
"ENGINE": "django.db.backends.postgresql",\n\
|
||||
"NAME": parsed.path.lstrip("/"),\n\
|
||||
"USER": parsed.username,\n\
|
||||
"PASSWORD": parsed.password,\n\
|
||||
"HOST": parsed.hostname,\n\
|
||||
"PORT": parsed.port or "5432",\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Snappea (background task runner) settings\n\
|
||||
SNAPPEA = {\n\
|
||||
"TASK_ALWAYS_EAGER": False,\n\
|
||||
"WORKAHOLIC": True,\n\
|
||||
"NUM_WORKERS": 2,\n\
|
||||
"PID_FILE": None,\n\
|
||||
}\n\
|
||||
DATABASES["snappea"]["NAME"] = "/tmp/snappea.sqlite3"\n\
|
||||
\n\
|
||||
# Site settings\n\
|
||||
_PORT = os.getenv("PORT", "8000")\n\
|
||||
BUGSINK = {\n\
|
||||
"BASE_URL": os.getenv("BASE_URL", f"http://localhost:{_PORT}"),\n\
|
||||
"SITE_TITLE": os.getenv("SITE_TITLE", "Flyer Crawler Error Tracking"),\n\
|
||||
"SINGLE_USER": os.getenv("SINGLE_USER", "True").lower() in ("true", "1", "yes"),\n\
|
||||
"SINGLE_TEAM": os.getenv("SINGLE_TEAM", "True").lower() in ("true", "1", "yes"),\n\
|
||||
"PHONEHOME": False,\n\
|
||||
}\n\
|
||||
\n\
|
||||
ALLOWED_HOSTS = deduce_allowed_hosts(BUGSINK["BASE_URL"])\n\
|
||||
\n\
|
||||
# Console email backend for dev\n\
|
||||
EMAIL_BACKEND = "bugsink.email_backends.QuietConsoleEmailBackend"\n\
|
||||
' > /opt/bugsink/conf/bugsink_conf.py
|
||||
|
||||
# Create Bugsink startup script
|
||||
# Uses DATABASE_URL environment variable (standard Docker approach per docs)
|
||||
RUN echo '#!/bin/bash\n\
|
||||
set -e\n\
|
||||
\n\
|
||||
# Build DATABASE_URL from individual env vars for flexibility\n\
|
||||
export DATABASE_URL="postgresql://${BUGSINK_DB_USER:-bugsink}:${BUGSINK_DB_PASSWORD:-bugsink_dev_password}@${BUGSINK_DB_HOST:-postgres}:${BUGSINK_DB_PORT:-5432}/${BUGSINK_DB_NAME:-bugsink}"\n\
|
||||
# SECRET_KEY is required by Bugsink/Django\n\
|
||||
export SECRET_KEY="${BUGSINK_SECRET_KEY:-dev-bugsink-secret-key-minimum-50-characters-for-security}"\n\
|
||||
\n\
|
||||
# Create superuser if not exists (for dev convenience)\n\
|
||||
if [ -n "$BUGSINK_ADMIN_EMAIL" ] && [ -n "$BUGSINK_ADMIN_PASSWORD" ]; then\n\
|
||||
export CREATE_SUPERUSER="${BUGSINK_ADMIN_EMAIL}:${BUGSINK_ADMIN_PASSWORD}"\n\
|
||||
fi\n\
|
||||
\n\
|
||||
# Wait for PostgreSQL to be ready\n\
|
||||
until pg_isready -h ${BUGSINK_DB_HOST:-postgres} -p ${BUGSINK_DB_PORT:-5432} -U ${BUGSINK_DB_USER:-bugsink}; do\n\
|
||||
echo "Waiting for PostgreSQL..."\n\
|
||||
sleep 2\n\
|
||||
done\n\
|
||||
\n\
|
||||
echo "PostgreSQL is ready. Starting Bugsink..."\n\
|
||||
echo "DATABASE_URL: postgresql://${BUGSINK_DB_USER}:***@${BUGSINK_DB_HOST}:${BUGSINK_DB_PORT}/${BUGSINK_DB_NAME}"\n\
|
||||
\n\
|
||||
# Change to config directory so bugsink_conf.py can be found\n\
|
||||
cd /opt/bugsink/conf\n\
|
||||
\n\
|
||||
# Run migrations\n\
|
||||
echo "Running database migrations..."\n\
|
||||
/opt/bugsink/bin/bugsink-manage migrate --noinput\n\
|
||||
\n\
|
||||
# Create superuser if CREATE_SUPERUSER is set (format: email:password)\n\
|
||||
if [ -n "$CREATE_SUPERUSER" ]; then\n\
|
||||
IFS=":" read -r ADMIN_EMAIL ADMIN_PASS <<< "$CREATE_SUPERUSER"\n\
|
||||
/opt/bugsink/bin/bugsink-manage shell -c "\n\
|
||||
from django.contrib.auth import get_user_model\n\
|
||||
User = get_user_model()\n\
|
||||
if not User.objects.filter(email='"'"'$ADMIN_EMAIL'"'"').exists():\n\
|
||||
User.objects.create_superuser('"'"'$ADMIN_EMAIL'"'"', '"'"'$ADMIN_PASS'"'"')\n\
|
||||
print('"'"'Superuser created'"'"')\n\
|
||||
else:\n\
|
||||
print('"'"'Superuser already exists'"'"')\n\
|
||||
" || true\n\
|
||||
fi\n\
|
||||
\n\
|
||||
# Start Bugsink with Gunicorn\n\
|
||||
echo "Starting Gunicorn on port ${BUGSINK_PORT:-8000}..."\n\
|
||||
exec /opt/bugsink/bin/gunicorn \\\n\
|
||||
--bind 0.0.0.0:${BUGSINK_PORT:-8000} \\\n\
|
||||
--workers ${BUGSINK_WORKERS:-2} \\\n\
|
||||
--access-logfile - \\\n\
|
||||
--error-logfile - \\\n\
|
||||
bugsink.wsgi:application\n\
|
||||
' > /usr/local/bin/start-bugsink.sh \
|
||||
&& chmod +x /usr/local/bin/start-bugsink.sh
|
||||
|
||||
# ============================================================================
|
||||
# Create Logstash Pipeline Configuration
|
||||
# ============================================================================
|
||||
# ADR-015: Pino and Redis logs → Bugsink
|
||||
RUN mkdir -p /etc/logstash/conf.d /app/logs
|
||||
|
||||
RUN echo 'input {\n\
|
||||
# Pino application logs\n\
|
||||
file {\n\
|
||||
path => "/app/logs/*.log"\n\
|
||||
codec => json\n\
|
||||
type => "pino"\n\
|
||||
tags => ["app"]\n\
|
||||
start_position => "beginning"\n\
|
||||
sincedb_path => "/var/lib/logstash/sincedb_pino"\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Redis logs\n\
|
||||
file {\n\
|
||||
path => "/var/log/redis/*.log"\n\
|
||||
type => "redis"\n\
|
||||
tags => ["redis"]\n\
|
||||
start_position => "beginning"\n\
|
||||
sincedb_path => "/var/lib/logstash/sincedb_redis"\n\
|
||||
}\n\
|
||||
\n\
|
||||
# PostgreSQL function logs (ADR-050)\n\
|
||||
file {\n\
|
||||
path => "/var/log/postgresql/*.log"\n\
|
||||
type => "postgres"\n\
|
||||
tags => ["postgres", "database"]\n\
|
||||
start_position => "beginning"\n\
|
||||
sincedb_path => "/var/lib/logstash/sincedb_postgres"\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
filter {\n\
|
||||
# Pino error detection (level 50 = error, 60 = fatal)\n\
|
||||
if [type] == "pino" and [level] >= 50 {\n\
|
||||
mutate { add_tag => ["error"] }\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Redis log parsing\n\
|
||||
if [type] == "redis" {\n\
|
||||
grok {\n\
|
||||
match => { "message" => "%%{POSINT:pid}:%%{WORD:role} %%{MONTHDAY} %%{MONTH} %%{TIME} %%{WORD:loglevel} %%{GREEDYDATA:redis_message}" }\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Tag errors (WARNING/ERROR) for Bugsink forwarding\n\
|
||||
if [loglevel] in ["WARNING", "ERROR"] {\n\
|
||||
mutate { add_tag => ["error"] }\n\
|
||||
}\n\
|
||||
# Tag INFO-level operational events (startup, config, persistence)\n\
|
||||
else if [loglevel] == "INFO" {\n\
|
||||
mutate { add_tag => ["redis_operational"] }\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
# PostgreSQL function log parsing (ADR-050)\n\
|
||||
if [type] == "postgres" {\n\
|
||||
# Extract timestamp and process ID from PostgreSQL log prefix\n\
|
||||
# Format: "2026-01-18 10:30:00 PST [12345] user@database "\n\
|
||||
grok {\n\
|
||||
match => { "message" => "%%{TIMESTAMP_ISO8601:pg_timestamp} \\\\[%%{POSINT:pg_pid}\\\\] %%{USERNAME:pg_user}@%%{WORD:pg_database} %%{GREEDYDATA:pg_message}" }\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Check if this is a structured JSON log from fn_log()\n\
|
||||
# fn_log() emits JSON like: {"timestamp":"...","level":"WARNING","source":"postgresql","function":"award_achievement",...}\n\
|
||||
if [pg_message] =~ /^\\{.*"source":"postgresql".*\\}$/ {\n\
|
||||
json {\n\
|
||||
source => "pg_message"\n\
|
||||
target => "fn_log"\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Mark as error if level is WARNING or ERROR\n\
|
||||
if [fn_log][level] in ["WARNING", "ERROR"] {\n\
|
||||
mutate { add_tag => ["error", "db_function"] }\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Also catch native PostgreSQL errors\n\
|
||||
if [pg_message] =~ /^ERROR:/ or [pg_message] =~ /^FATAL:/ {\n\
|
||||
mutate { add_tag => ["error", "postgres_native"] }\n\
|
||||
}\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
output {\n\
|
||||
# Forward errors to Bugsink\n\
|
||||
if "error" in [tags] {\n\
|
||||
http {\n\
|
||||
url => "http://localhost:8000/api/store/"\n\
|
||||
http_method => "post"\n\
|
||||
format => "json"\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Store Redis operational logs (INFO level) to file\n\
|
||||
if "redis_operational" in [tags] {\n\
|
||||
file {\n\
|
||||
path => "/var/log/logstash/redis-operational-%%{+YYYY-MM-dd}.log"\n\
|
||||
codec => json_lines\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Debug output (comment out in production)\n\
|
||||
stdout { codec => rubydebug }\n\
|
||||
}\n\
|
||||
' > /etc/logstash/conf.d/bugsink.conf
|
||||
|
||||
# Create Logstash directories
|
||||
RUN mkdir -p /var/lib/logstash && chown -R logstash:logstash /var/lib/logstash
|
||||
RUN mkdir -p /var/log/logstash && chown -R logstash:logstash /var/log/logstash
|
||||
|
||||
# ============================================================================
|
||||
# Set Working Directory
|
||||
# ============================================================================
|
||||
WORKDIR /app
|
||||
|
||||
# ============================================================================
|
||||
# Environment Configuration
|
||||
# ============================================================================
|
||||
# Default environment variables for development
|
||||
ENV NODE_ENV=development
|
||||
# Increase Node.js memory limit for large builds
|
||||
ENV NODE_OPTIONS='--max-old-space-size=8192'
|
||||
|
||||
# Bugsink defaults (ADR-015)
|
||||
ENV BUGSINK_DB_HOST=postgres
|
||||
ENV BUGSINK_DB_PORT=5432
|
||||
ENV BUGSINK_DB_NAME=bugsink
|
||||
ENV BUGSINK_DB_USER=bugsink
|
||||
ENV BUGSINK_DB_PASSWORD=bugsink_dev_password
|
||||
ENV BUGSINK_PORT=8000
|
||||
ENV BUGSINK_BASE_URL=http://localhost:8000
|
||||
ENV BUGSINK_ADMIN_EMAIL=admin@localhost
|
||||
ENV BUGSINK_ADMIN_PASSWORD=admin
|
||||
|
||||
# ============================================================================
|
||||
# Expose Ports
|
||||
# ============================================================================
|
||||
# 3000 - Vite frontend
|
||||
# 3001 - Express backend
|
||||
# 8000 - Bugsink error tracking
|
||||
EXPOSE 3000 3001 8000
|
||||
|
||||
# ============================================================================
|
||||
# Default Command
|
||||
# ============================================================================
|
||||
# Keep container running so VS Code can attach.
|
||||
# Actual commands (npm run dev, etc.) are run via devcontainer.json.
|
||||
CMD ["bash"]
|
||||
245
IMPLEMENTATION_STATUS.md
Normal file
245
IMPLEMENTATION_STATUS.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Store Address Implementation - Progress Status
|
||||
|
||||
## ✅ COMPLETED (Core Foundation)
|
||||
|
||||
### Phase 1: Database Layer (100%)
|
||||
|
||||
- ✅ **StoreRepository** ([src/services/db/store.db.ts](src/services/db/store.db.ts))
|
||||
- `createStore()`, `getStoreById()`, `getAllStores()`, `updateStore()`, `deleteStore()`, `searchStoresByName()`
|
||||
- Full test coverage: [src/services/db/store.db.test.ts](src/services/db/store.db.test.ts)
|
||||
|
||||
- ✅ **StoreLocationRepository** ([src/services/db/storeLocation.db.ts](src/services/db/storeLocation.db.ts))
|
||||
- `createStoreLocation()`, `getLocationsByStoreId()`, `getStoreWithLocations()`, `getAllStoresWithLocations()`, `deleteStoreLocation()`, `updateStoreLocation()`
|
||||
- Full test coverage: [src/services/db/storeLocation.db.test.ts](src/services/db/storeLocation.db.test.ts)
|
||||
|
||||
- ✅ **Enhanced AddressRepository** ([src/services/db/address.db.ts](src/services/db/address.db.ts))
|
||||
- Added: `searchAddressesByText()`, `getAddressesByStoreId()`
|
||||
|
||||
### Phase 2: TypeScript Types (100%)
|
||||
|
||||
- ✅ Added to [src/types.ts](src/types.ts):
|
||||
- `StoreLocationWithAddress` - Store location with full address data
|
||||
- `StoreWithLocations` - Store with all its locations
|
||||
- `CreateStoreRequest` - API request type for creating stores
|
||||
|
||||
### Phase 3: API Routes (100%)
|
||||
|
||||
- ✅ **store.routes.ts** ([src/routes/store.routes.ts](src/routes/store.routes.ts))
|
||||
- GET /api/stores (list with optional ?includeLocations=true)
|
||||
- GET /api/stores/:id (single store with locations)
|
||||
- POST /api/stores (create with optional address)
|
||||
- PUT /api/stores/:id (update store)
|
||||
- DELETE /api/stores/:id (admin only)
|
||||
- POST /api/stores/:id/locations (add location)
|
||||
- DELETE /api/stores/:id/locations/:locationId
|
||||
- ✅ **store.routes.test.ts** ([src/routes/store.routes.test.ts](src/routes/store.routes.test.ts))
|
||||
- Full test coverage for all endpoints
|
||||
- ✅ **server.ts** - Route registered at /api/stores
|
||||
|
||||
### Phase 4: Database Query Updates (100% - COMPLETE)
|
||||
|
||||
- ✅ **admin.db.ts** ([src/services/db/admin.db.ts](src/services/db/admin.db.ts))
|
||||
- Updated `getUnmatchedFlyerItems()` to include store with locations array
|
||||
- Updated `getFlyersForReview()` to include store with locations array
|
||||
- ✅ **flyer.db.ts** ([src/services/db/flyer.db.ts](src/services/db/flyer.db.ts))
|
||||
- Updated `getFlyers()` to include store with locations array
|
||||
- Updated `getFlyerById()` to include store with locations array
|
||||
- ✅ **deals.db.ts** ([src/services/db/deals.db.ts](src/services/db/deals.db.ts))
|
||||
- Updated `findBestPricesForWatchedItems()` to include store with locations array
|
||||
- ✅ **types.ts** - Updated `WatchedItemDeal` interface to use store object instead of store_name
|
||||
|
||||
### Phase 6: Integration Test Updates (100% - ALL COMPLETE)
|
||||
|
||||
- ✅ **admin.integration.test.ts** - Updated to use `createStoreWithLocation()`
|
||||
- ✅ **flyer.integration.test.ts** - Updated to use `createStoreWithLocation()`
|
||||
- ✅ **price.integration.test.ts** - Updated to use `createStoreWithLocation()`
|
||||
- ✅ **public.routes.integration.test.ts** - Updated to use `createStoreWithLocation()`
|
||||
- ✅ **receipt.integration.test.ts** - Updated to use `createStoreWithLocation()`
|
||||
|
||||
### Test Helpers
|
||||
|
||||
- ✅ **storeHelpers.ts** ([src/tests/utils/storeHelpers.ts](src/tests/utils/storeHelpers.ts))
|
||||
- `createStoreWithLocation()` - Creates normalized store+address+location
|
||||
- `cleanupStoreLocations()` - Bulk cleanup
|
||||
|
||||
### Phase 7: Mock Factories (100% - COMPLETE)
|
||||
|
||||
- ✅ **mockFactories.ts** ([src/tests/utils/mockFactories.ts](src/tests/utils/mockFactories.ts))
|
||||
- Added `createMockStoreLocation()` - Basic store location mock
|
||||
- Added `createMockStoreLocationWithAddress()` - Store location with nested address
|
||||
- Added `createMockStoreWithLocations()` - Full store with array of locations
|
||||
|
||||
### Phase 8: Schema Migration (100% - COMPLETE)
|
||||
|
||||
- ✅ **Architectural Decision**: Made addresses **optional** by design
|
||||
- Stores can exist without any locations
|
||||
- No data migration required
|
||||
- No breaking changes to existing code
|
||||
- Addresses can be added incrementally
|
||||
- ✅ **Implementation Details**:
|
||||
- API accepts `address` as optional field in POST /api/stores
|
||||
- Database queries use `LEFT JOIN` for locations (not `INNER JOIN`)
|
||||
- Frontend shows "No location data" when store has no addresses
|
||||
- All existing stores continue to work without modification
|
||||
|
||||
### Phase 9: Cache Invalidation (100% - COMPLETE)
|
||||
|
||||
- ✅ **cacheService.server.ts** ([src/services/cacheService.server.ts](src/services/cacheService.server.ts))
|
||||
- Added `CACHE_TTL.STORES` and `CACHE_TTL.STORE` constants
|
||||
- Added `CACHE_PREFIX.STORES` and `CACHE_PREFIX.STORE` constants
|
||||
- Added `invalidateStores()` - Invalidates all store cache entries
|
||||
- Added `invalidateStore(storeId)` - Invalidates specific store cache
|
||||
- Added `invalidateStoreLocations(storeId)` - Invalidates store location cache
|
||||
- ✅ **store.routes.ts** ([src/routes/store.routes.ts](src/routes/store.routes.ts))
|
||||
- Integrated cache invalidation in POST /api/stores (create)
|
||||
- Integrated cache invalidation in PUT /api/stores/:id (update)
|
||||
- Integrated cache invalidation in DELETE /api/stores/:id (delete)
|
||||
- Integrated cache invalidation in POST /api/stores/:id/locations (add location)
|
||||
- Integrated cache invalidation in DELETE /api/stores/:id/locations/:locationId (remove location)
|
||||
|
||||
### Phase 5: Frontend Components (100% - COMPLETE)
|
||||
|
||||
- ✅ **API Client Functions** ([src/services/apiClient.ts](src/services/apiClient.ts))
|
||||
- Added 7 API client functions: `getStores()`, `getStoreById()`, `createStore()`, `updateStore()`, `deleteStore()`, `addStoreLocation()`, `deleteStoreLocation()`
|
||||
- ✅ **AdminStoreManager** ([src/pages/admin/components/AdminStoreManager.tsx](src/pages/admin/components/AdminStoreManager.tsx))
|
||||
- Table listing all stores with locations
|
||||
- Create/Edit/Delete functionality with modal forms
|
||||
- Query-based data fetching with cache invalidation
|
||||
- ✅ **StoreForm** ([src/pages/admin/components/StoreForm.tsx](src/pages/admin/components/StoreForm.tsx))
|
||||
- Reusable form for creating and editing stores
|
||||
- Optional address fields for adding locations
|
||||
- Validation and error handling
|
||||
- ✅ **StoreCard** ([src/features/store/StoreCard.tsx](src/features/store/StoreCard.tsx))
|
||||
- Reusable display component for stores
|
||||
- Shows logo, name, and optional location data
|
||||
- Used in flyer/deal listings
|
||||
- ✅ **AdminStoresPage** ([src/pages/admin/AdminStoresPage.tsx](src/pages/admin/AdminStoresPage.tsx))
|
||||
- Full page layout for store management
|
||||
- Route registered at `/admin/stores`
|
||||
- ✅ **AdminPage** - Updated to include "Manage Stores" link
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- ✅ All 3 E2E tests already updated:
|
||||
- [src/tests/e2e/deals-journey.e2e.test.ts](src/tests/e2e/deals-journey.e2e.test.ts)
|
||||
- [src/tests/e2e/budget-journey.e2e.test.ts](src/tests/e2e/budget-journey.e2e.test.ts)
|
||||
- [src/tests/e2e/receipt-journey.e2e.test.ts](src/tests/e2e/receipt-journey.e2e.test.ts)
|
||||
|
||||
---
|
||||
|
||||
## ✅ ALL PHASES COMPLETE
|
||||
|
||||
All planned phases of the store address normalization implementation are now complete.
|
||||
|
||||
---
|
||||
|
||||
## Testing Status
|
||||
|
||||
### Type Checking
|
||||
|
||||
✅ **PASSING** - All TypeScript compilation succeeds
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- ✅ StoreRepository tests (new)
|
||||
- ✅ StoreLocationRepository tests (new)
|
||||
- ⏳ AddressRepository tests (need to add tests for new functions)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- ✅ admin.integration.test.ts (updated)
|
||||
- ✅ flyer.integration.test.ts (updated)
|
||||
- ✅ price.integration.test.ts (updated)
|
||||
- ✅ public.routes.integration.test.ts (updated)
|
||||
- ✅ receipt.integration.test.ts (updated)
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- ✅ All E2E tests passing (already updated)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
1. ✅ **Phase 1: Database Layer** - COMPLETE
|
||||
2. ✅ **Phase 2: TypeScript Types** - COMPLETE
|
||||
3. ✅ **Phase 3: API Routes** - COMPLETE
|
||||
4. ✅ **Phase 4: Update Existing Database Queries** - COMPLETE
|
||||
5. ✅ **Phase 5: Frontend Components** - COMPLETE
|
||||
6. ✅ **Phase 6: Integration Test Updates** - COMPLETE
|
||||
7. ✅ **Phase 7: Update Mock Factories** - COMPLETE
|
||||
8. ✅ **Phase 8: Schema Migration** - COMPLETE (Made addresses optional by design - no migration needed)
|
||||
9. ✅ **Phase 9: Cache Invalidation** - COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## Files Created (New)
|
||||
|
||||
1. `src/services/db/store.db.ts` - Store repository
|
||||
2. `src/services/db/store.db.test.ts` - Store tests (43 tests)
|
||||
3. `src/services/db/storeLocation.db.ts` - Store location repository
|
||||
4. `src/services/db/storeLocation.db.test.ts` - Store location tests (16 tests)
|
||||
5. `src/routes/store.routes.ts` - Store API routes
|
||||
6. `src/routes/store.routes.test.ts` - Store route tests (17 tests)
|
||||
7. `src/tests/utils/storeHelpers.ts` - Test helpers (already existed, used by E2E)
|
||||
8. `src/pages/admin/components/AdminStoreManager.tsx` - Admin store management UI
|
||||
9. `src/pages/admin/components/StoreForm.tsx` - Store create/edit form
|
||||
10. `src/features/store/StoreCard.tsx` - Store display component
|
||||
11. `src/pages/admin/AdminStoresPage.tsx` - Store management page
|
||||
12. `STORE_ADDRESS_IMPLEMENTATION_PLAN.md` - Original plan
|
||||
13. `IMPLEMENTATION_STATUS.md` - This file
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `src/types.ts` - Added StoreLocationWithAddress, StoreWithLocations, CreateStoreRequest; Updated WatchedItemDeal
|
||||
2. `src/services/db/address.db.ts` - Added searchAddressesByText(), getAddressesByStoreId()
|
||||
3. `src/services/db/admin.db.ts` - Updated 2 queries to include store with locations
|
||||
4. `src/services/db/flyer.db.ts` - Updated 2 queries to include store with locations
|
||||
5. `src/services/db/deals.db.ts` - Updated 1 query to include store with locations
|
||||
6. `src/services/apiClient.ts` - Added 7 store management API functions
|
||||
7. `src/pages/admin/AdminPage.tsx` - Added "Manage Stores" link
|
||||
8. `src/App.tsx` - Added AdminStoresPage route at /admin/stores
|
||||
9. `server.ts` - Registered /api/stores route
|
||||
10. `src/tests/integration/admin.integration.test.ts` - Updated to use createStoreWithLocation()
|
||||
11. `src/tests/integration/flyer.integration.test.ts` - Updated to use createStoreWithLocation()
|
||||
12. `src/tests/integration/price.integration.test.ts` - Updated to use createStoreWithLocation()
|
||||
13. `src/tests/integration/public.routes.integration.test.ts` - Updated to use createStoreWithLocation()
|
||||
14. `src/tests/integration/receipt.integration.test.ts` - Updated to use createStoreWithLocation()
|
||||
15. `src/tests/e2e/deals-journey.e2e.test.ts` - Updated (earlier)
|
||||
16. `src/tests/e2e/budget-journey.e2e.test.ts` - Updated (earlier)
|
||||
17. `src/tests/e2e/receipt-journey.e2e.test.ts` - Updated (earlier)
|
||||
18. `src/tests/utils/mockFactories.ts` - Added 3 store-related mock functions
|
||||
19. `src/services/cacheService.server.ts` - Added store cache TTLs, prefixes, and 3 invalidation methods
|
||||
20. `src/routes/store.routes.ts` - Integrated cache invalidation in all 5 mutation endpoints
|
||||
|
||||
---
|
||||
|
||||
## Key Achievement
|
||||
|
||||
**ALL PHASES COMPLETE**. The normalized structure (stores → store_locations → addresses) is now fully integrated:
|
||||
|
||||
- ✅ Database layer with full test coverage (59 tests)
|
||||
- ✅ TypeScript types and interfaces
|
||||
- ✅ REST API with 7 endpoints (17 route tests)
|
||||
- ✅ All E2E tests (3) using normalized structure
|
||||
- ✅ All integration tests (5) using normalized structure
|
||||
- ✅ Test helpers for easy store+address creation
|
||||
- ✅ All database queries returning store data now include addresses (5 queries updated)
|
||||
- ✅ Full admin UI for store management (CRUD operations)
|
||||
- ✅ Store display components for frontend use
|
||||
- ✅ Mock factories for all store-related types (3 new functions)
|
||||
- ✅ Cache invalidation for all store operations (5 endpoints)
|
||||
|
||||
**What's Working:**
|
||||
|
||||
- Stores can be created with or without addresses
|
||||
- Multiple locations per store are supported
|
||||
- Full CRUD operations via API with automatic cache invalidation
|
||||
- Admin can manage stores through web UI at `/admin/stores`
|
||||
- Type-safe throughout the stack
|
||||
- All flyers, deals, and admin queries include full store address information
|
||||
- StoreCard component available for displaying stores in flyer/deal listings
|
||||
- Mock factories available for testing components
|
||||
- Redis cache automatically invalidated on store mutations
|
||||
|
||||
**No breaking changes** - existing code continues to work. Addresses are optional (stores can exist without locations).
|
||||
168
INSTALL.md
Normal file
168
INSTALL.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Installation Guide
|
||||
|
||||
This guide covers setting up a local development environment for Flyer Crawler.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20.x or later
|
||||
- Access to a PostgreSQL database (local or remote)
|
||||
- Redis instance (for session management)
|
||||
- Google Gemini API key
|
||||
- Google Maps API key (for geocoding)
|
||||
|
||||
## Quick Start
|
||||
|
||||
If you already have PostgreSQL and Redis configured:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Environment with Podman (Recommended for Windows)
|
||||
|
||||
This approach uses Podman with an Ubuntu container for a consistent development environment.
|
||||
|
||||
### Step 1: Install Prerequisites on Windows
|
||||
|
||||
1. **Install WSL 2**: Podman on Windows relies on the Windows Subsystem for Linux.
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
|
||||
Run this in an administrator PowerShell.
|
||||
|
||||
2. **Install Podman Desktop**: Download and install [Podman Desktop for Windows](https://podman-desktop.io/).
|
||||
|
||||
### Step 2: Set Up Podman
|
||||
|
||||
1. **Initialize Podman**: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
||||
2. **Start Podman**: Ensure the Podman machine is running from the Podman Desktop interface.
|
||||
|
||||
### Step 3: Set Up the Ubuntu Container
|
||||
|
||||
1. **Pull Ubuntu Image**:
|
||||
|
||||
```bash
|
||||
podman pull ubuntu:latest
|
||||
```
|
||||
|
||||
2. **Create a Podman Volume** (persists node_modules between container restarts):
|
||||
|
||||
```bash
|
||||
podman volume create node_modules_cache
|
||||
```
|
||||
|
||||
3. **Run the Ubuntu Container**:
|
||||
|
||||
Open a terminal in your project's root directory and run:
|
||||
|
||||
```bash
|
||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev \
|
||||
-v "$(pwd):/app" \
|
||||
-v "node_modules_cache:/app/node_modules" \
|
||||
ubuntu:latest
|
||||
```
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------------------------------- | ------------------------------------------------ |
|
||||
| `-p 3001:3001` | Forwards the backend server port |
|
||||
| `-p 5173:5173` | Forwards the Vite frontend server port |
|
||||
| `--name flyer-dev` | Names the container for easy reference |
|
||||
| `-v "...:/app"` | Mounts your project directory into the container |
|
||||
| `-v "node_modules_cache:/app/node_modules"` | Mounts the named volume for node_modules |
|
||||
|
||||
### Step 4: Configure the Ubuntu Environment
|
||||
|
||||
You are now inside the Ubuntu container's shell.
|
||||
|
||||
1. **Update Package Lists**:
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
```
|
||||
|
||||
2. **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
apt-get install -y curl git
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
```
|
||||
|
||||
3. **Navigate to Project Directory**:
|
||||
|
||||
```bash
|
||||
cd /app
|
||||
```
|
||||
|
||||
4. **Install Project Dependencies**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 5: Run the Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Step 6: Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **Backend API**: http://localhost:3001
|
||||
|
||||
### Managing the Container
|
||||
|
||||
| Action | Command |
|
||||
| --------------------- | -------------------------------- |
|
||||
| Stop the container | Press `Ctrl+C`, then type `exit` |
|
||||
| Restart the container | `podman start -a -i flyer-dev` |
|
||||
| Remove the container | `podman rm flyer-dev` |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
This project is configured to run in a CI/CD environment and does not use `.env` files. All configuration must be provided as environment variables.
|
||||
|
||||
For local development, you can export these in your shell or use your IDE's environment configuration:
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------------- | ------------------------------------- |
|
||||
| `DB_HOST` | PostgreSQL server hostname |
|
||||
| `DB_USER` | PostgreSQL username |
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `DB_DATABASE_PROD` | Production database name |
|
||||
| `JWT_SECRET` | Secret string for signing auth tokens |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY` | Google Gemini API key |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps Geocoding API key |
|
||||
| `REDIS_PASSWORD_PROD` | Production Redis password |
|
||||
| `REDIS_PASSWORD_TEST` | Test Redis password |
|
||||
|
||||
---
|
||||
|
||||
## Seeding Development Users
|
||||
|
||||
To create initial test accounts (`admin@example.com` and `user@example.com`):
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
After running, you may need to restart your IDE's TypeScript server to pick up any generated types.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Database Setup](DATABASE.md) - Set up PostgreSQL with required extensions
|
||||
- [Authentication Setup](AUTHENTICATION.md) - Configure OAuth providers
|
||||
- [Deployment Guide](DEPLOYMENT.md) - Deploy to production
|
||||
461
README.md
461
README.md
@@ -1,428 +1,93 @@
|
||||
# Flyer Crawler - Grocery AI Analyzer
|
||||
|
||||
Flyer Crawler is a web application that uses the Google Gemini AI to extract, analyze, and manage data from grocery store flyers. Users can upload flyer images or PDFs, and the application will automatically identify items, prices, and sale dates, storing the structured data in a PostgreSQL database for historical analysis, price tracking, and personalized deal alerts.
|
||||
Flyer Crawler is a web application that uses Google Gemini AI to extract, analyze, and manage data from grocery store flyers. Users can upload flyer images or PDFs, and the application automatically identifies items, prices, and sale dates, storing structured data in a PostgreSQL database for historical analysis, price tracking, and personalized deal alerts.
|
||||
|
||||
We are working on an app to help people save money, by finding good deals that are only advertized in store flyers/ads. So, the primary purpose of the site is to make uploading flyers as easy as possible and as accurate as possible, and to store peoples needs, so sales can be matched to needs.
|
||||
|
||||
## Features
|
||||
|
||||
- **AI-Powered Data Extraction**: Upload PNG, JPG, or PDF flyers to automatically extract store names, sale dates, and a detailed list of items with prices and quantities.
|
||||
- **Bulk Import**: Process multiple flyers at once with a summary report of successes, skips (duplicates), and errors.
|
||||
- **Database Integration**: All extracted data is saved to a PostgreSQL database, enabling long-term persistence and analysis.
|
||||
- **Personalized Watchlist**: Authenticated users can create a "watchlist" of specific grocery items they want to track.
|
||||
- **Active Deal Alerts**: The app highlights current sales on your watched items from all valid flyers in the database.
|
||||
- **Price History Charts**: Visualize the price trends of your watched items over time.
|
||||
- **Shopping List Management**: Users can create multiple shopping lists, add items from flyers or their watchlist, and track purchased items.
|
||||
- **User Authentication & Management**: Secure user sign-up, login, and profile management, including a secure account deletion process.
|
||||
- **Dynamic UI**: A responsive interface with dark mode and a choice between metric/imperial unit systems.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React, TypeScript, Tailwind CSS
|
||||
- **AI**: Google Gemini API (`@google/genai`)
|
||||
- **Backend**: Node.js with Express
|
||||
- **Database**: PostgreSQL
|
||||
- **Authentication**: Passport.js
|
||||
- **UI Components**: Recharts for charts
|
||||
**Our mission**: Help people save money by finding good deals that are only advertised in store flyers. The app makes uploading flyers as easy and accurate as possible, and matches sales to users' needs.
|
||||
|
||||
---
|
||||
|
||||
## Required Secrets & Configuration
|
||||
## Features
|
||||
|
||||
This project is configured to run in a CI/CD environment and does not use `.env` files. All configuration and secrets must be provided as environment variables. For deployments using the included Gitea workflows, these must be configured as **repository secrets** in your Gitea instance.
|
||||
- **AI-Powered Data Extraction**: Upload PNG, JPG, or PDF flyers to automatically extract store names, sale dates, and detailed item lists with prices and quantities
|
||||
- **Bulk Import**: Process multiple flyers at once with summary reports of successes, skips (duplicates), and errors
|
||||
- **Personalized Watchlist**: Create a watchlist of specific grocery items you want to track
|
||||
- **Active Deal Alerts**: See current sales on your watched items from all valid flyers
|
||||
- **Price History Charts**: Visualize price trends of watched items over time
|
||||
- **Shopping List Management**: Create multiple shopping lists, add items from flyers or your watchlist, and track purchased items
|
||||
- **User Authentication**: Secure sign-up, login, profile management, and account deletion
|
||||
- **Dynamic UI**: Responsive interface with dark mode and metric/imperial unit systems
|
||||
|
||||
- **`DB_HOST`, `DB_USER`, `DB_PASSWORD`**: Credentials for your PostgreSQL server. The port is assumed to be `5432`.
|
||||
- **`DB_DATABASE_PROD`**: The name of your production database.
|
||||
- **`REDIS_PASSWORD_PROD`**: The password for your production Redis instance.
|
||||
- **`REDIS_PASSWORD_TEST`**: The password for your test Redis instance.
|
||||
- **`JWT_SECRET`**: A long, random, and secret string for signing authentication tokens.
|
||||
- **`VITE_GOOGLE_GENAI_API_KEY`**: Your Google Gemini API key.
|
||||
- **`GOOGLE_MAPS_API_KEY`**: Your Google Maps Geocoding API key.
|
||||
---
|
||||
|
||||
## Setup and Installation
|
||||
## Tech Stack
|
||||
|
||||
### Step 1: Set Up PostgreSQL Database
|
||||
| Layer | Technology |
|
||||
| -------------- | ----------------------------------- |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| AI | Google Gemini API (`@google/genai`) |
|
||||
| Backend | Node.js, Express |
|
||||
| Database | PostgreSQL with PostGIS |
|
||||
| Authentication | Passport.js (Google, GitHub OAuth) |
|
||||
| Charts | Recharts |
|
||||
|
||||
1. **Set up a PostgreSQL database instance.**
|
||||
2. **Run the Database Schema**:
|
||||
- Connect to your database using a tool like `psql` or DBeaver.
|
||||
- Open `sql/schema.sql.txt`, copy its entire contents, and execute it against your database.
|
||||
- This will create all necessary tables, functions, and relationships.
|
||||
---
|
||||
|
||||
### Step 2: Install Dependencies and Run the Application
|
||||
|
||||
1. **Install Dependencies**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Run the Application**:
|
||||
|
||||
```bash
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
### Step 3: Seed Development Users (Optional)
|
||||
|
||||
To create the initial `admin@example.com` and `user@example.com` accounts, you can run the seed script:
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
```
|
||||
|
||||
After running, you may need to restart your IDE's TypeScript server to pick up the changes.
|
||||
See [INSTALL.md](INSTALL.md) for detailed setup instructions.
|
||||
|
||||
## NGINX mime types issue
|
||||
---
|
||||
|
||||
sudo nano /etc/nginx/mime.types
|
||||
## Documentation
|
||||
|
||||
change
|
||||
| Document | Description |
|
||||
| -------------------------------------- | ---------------------------------------- |
|
||||
| [INSTALL.md](INSTALL.md) | Local development setup with Podman |
|
||||
| [DATABASE.md](DATABASE.md) | PostgreSQL setup, schema, and extensions |
|
||||
| [AUTHENTICATION.md](AUTHENTICATION.md) | OAuth configuration (Google, GitHub) |
|
||||
| [DEPLOYMENT.md](DEPLOYMENT.md) | Production server setup, NGINX, PM2 |
|
||||
|
||||
application/javascript js;
|
||||
---
|
||||
|
||||
TO
|
||||
## Environment Variables
|
||||
|
||||
application/javascript js mjs;
|
||||
This project uses environment variables for configuration (no `.env` files). Key variables:
|
||||
|
||||
RESTART NGINX
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `DB_HOST` | PostgreSQL host |
|
||||
| `DB_USER_PROD`, `DB_PASSWORD_PROD` | Production database credentials |
|
||||
| `DB_USER_TEST`, `DB_PASSWORD_TEST` | Test database credentials |
|
||||
| `DB_DATABASE_PROD`, `DB_DATABASE_TEST` | Database names |
|
||||
| `JWT_SECRET` | Authentication token signing key |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY` | Google Gemini API key |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps Geocoding API key |
|
||||
| `REDIS_PASSWORD_PROD`, `REDIS_PASSWORD_TEST` | Redis passwords |
|
||||
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
See [INSTALL.md](INSTALL.md) for the complete list.
|
||||
|
||||
actually the proper change was to do this in the /etc/nginx/sites-available/flyer-crawler.projectium.com file
|
||||
---
|
||||
|
||||
## for OAuth
|
||||
## Scripts
|
||||
|
||||
1. Get Google OAuth Credentials
|
||||
This is a crucial step that you must do outside the codebase:
|
||||
| Command | Description |
|
||||
| -------------------- | -------------------------------- |
|
||||
| `npm run dev` | Start development server |
|
||||
| `npm run build` | Build for production |
|
||||
| `npm run start:prod` | Start production server with PM2 |
|
||||
| `npm run test` | Run test suite |
|
||||
| `npm run seed` | Seed development user accounts |
|
||||
|
||||
Go to the Google Cloud Console.
|
||||
---
|
||||
|
||||
Create a new project (or select an existing one).
|
||||
## License
|
||||
|
||||
In the navigation menu, go to APIs & Services > Credentials.
|
||||
|
||||
Click Create Credentials > OAuth client ID.
|
||||
|
||||
Select Web application as the application type.
|
||||
|
||||
Under Authorized redirect URIs, click ADD URI and enter the URL where Google will redirect users back to your server. For local development, this will be: http://localhost:3001/api/auth/google/callback.
|
||||
|
||||
Click Create. You will be given a Client ID and a Client Secret.
|
||||
|
||||
2. Get GitHub OAuth Credentials
|
||||
You'll need to obtain a Client ID and Client Secret from GitHub:
|
||||
|
||||
Go to your GitHub profile settings.
|
||||
|
||||
Navigate to Developer settings > OAuth Apps.
|
||||
|
||||
Click New OAuth App.
|
||||
|
||||
Fill in the required fields:
|
||||
|
||||
Application name: A descriptive name for your app (e.g., "Flyer Crawler").
|
||||
Homepage URL: The base URL of your application (e.g., http://localhost:5173 for local development).
|
||||
Authorization callback URL: This is where GitHub will redirect users after they authorize your app. For local development, this will be: <http://localhost:3001/api/auth/github/callback>.
|
||||
Click Register application.
|
||||
|
||||
You will be given a Client ID and a Client Secret.
|
||||
|
||||
## connect to postgres on projectium.com
|
||||
|
||||
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
||||
|
||||
|
||||
## postgis
|
||||
|
||||
flyer-crawler-prod=> SELECT version();
|
||||
version
|
||||
------------------------------------------------------------------------------------------------------------------------------------------
|
||||
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
||||
(1 row)
|
||||
|
||||
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
||||
postgis_full_version
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
||||
(1 row)
|
||||
|
||||
|
||||
## production postgres setup
|
||||
|
||||
Part 1: Production Database Setup
|
||||
This database will be the live, persistent storage for your application.
|
||||
|
||||
Step 1: Install PostgreSQL (if not already installed)
|
||||
First, ensure PostgreSQL is installed on your server.
|
||||
|
||||
bash
|
||||
sudo apt update
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
Step 2: Create the Production Database and User
|
||||
It's best practice to create a dedicated, non-superuser role for your application to connect with.
|
||||
|
||||
Switch to the postgres system user to get superuser access to the database.
|
||||
|
||||
bash
|
||||
sudo -u postgres psql
|
||||
Inside the psql shell, run the following SQL commands. Remember to replace 'a_very_strong_password' with a secure password that you will manage with a secrets tool or in your .env file.
|
||||
|
||||
sql
|
||||
-- Create a new role (user) for your application
|
||||
CREATE ROLE flyer_crawler_user WITH LOGIN PASSWORD 'a_very_strong_password';
|
||||
|
||||
-- Create the production database and assign ownership to the new user
|
||||
CREATE DATABASE "flyer-crawler-prod" WITH OWNER = flyer_crawler_user;
|
||||
|
||||
-- Connect to the new database to install extensions within it.
|
||||
\c "flyer-crawler-prod"
|
||||
|
||||
-- Install the required extensions as a superuser. This only needs to be done once.
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Exit the psql shell
|
||||
|
||||
Step 3: Apply the Master Schema
|
||||
Now, you'll populate your new database with all the tables, functions, and initial data. Your master_schema_rollup.sql file is perfect for this.
|
||||
|
||||
Navigate to your project's root directory on the server.
|
||||
|
||||
Run the following command to execute the master schema script against your new production database. You will be prompted for the password you created in the previous step.
|
||||
|
||||
bash
|
||||
psql -U flyer_crawler_user -d "flyer-crawler-prod" -f sql/master_schema_rollup.sql
|
||||
This single command creates all tables, extensions (pg_trgm, postgis), functions, and triggers, and seeds essential data like categories and master items.
|
||||
|
||||
Step 4: Seed the Admin Account (If Needed)
|
||||
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
||||
|
||||
bash
|
||||
# Set variables for the current session
|
||||
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
||||
# Run the seeding script
|
||||
npx tsx src/db/seed_admin_account.ts
|
||||
Your production database is now ready!
|
||||
|
||||
Part 2: Test Database Setup (for CI/CD)
|
||||
Your Gitea workflow (deploy.yml) already automates the creation and teardown of the test database during the pipeline run. The steps below are for understanding what the workflow does and for manual setup if you ever need to run tests outside the CI pipeline.
|
||||
|
||||
The process your CI pipeline follows is:
|
||||
|
||||
Setup (sql/test_setup.sql):
|
||||
|
||||
As the postgres superuser, it runs sql/test_setup.sql.
|
||||
This creates a temporary role named test_runner.
|
||||
It creates a separate database named "flyer-crawler-test" owned by test_runner.
|
||||
Schema Application (src/tests/setup/global-setup.ts):
|
||||
|
||||
The test runner (vitest) executes the global-setup.ts file.
|
||||
This script connects to the "flyer-crawler-test" database using the temporary credentials.
|
||||
It then runs the same sql/master_schema_rollup.sql file, ensuring your test database has the exact same structure as production.
|
||||
Test Execution:
|
||||
|
||||
Your tests run against this clean, isolated "flyer-crawler-test" database.
|
||||
Teardown (sql/test_teardown.sql):
|
||||
|
||||
After tests complete (whether they pass or fail), the if: always() step in your workflow ensures that sql/test_teardown.sql is executed.
|
||||
This script terminates any lingering connections to the test database, drops the "flyer-crawler-test" database completely, and drops the test_runner role.
|
||||
|
||||
Part 3: Test Database Setup (for CI/CD and Local Testing)
|
||||
Your Gitea workflow and local test runner rely on a permanent test database. This database needs to be created once on your server. The test runner will automatically reset the schema inside it before every test run.
|
||||
|
||||
Step 1: Create the Test Database
|
||||
On your server, switch to the postgres system user to get superuser access.
|
||||
|
||||
bash
|
||||
sudo -u postgres psql
|
||||
Inside the psql shell, create a new database. We will assign ownership to the same flyer_crawler_user that your application uses. This user needs to be the owner to have permission to drop and recreate the schema during testing.
|
||||
|
||||
sql
|
||||
-- Create the test database and assign ownership to your existing application user
|
||||
CREATE DATABASE "flyer-crawler-test" WITH OWNER = flyer_crawler_user;
|
||||
|
||||
-- Connect to the newly created test database
|
||||
\c "flyer-crawler-test"
|
||||
|
||||
-- Install the required extensions as a superuser. This only needs to be done once.
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Connect to the newly created test database
|
||||
\c "flyer-crawler-test"
|
||||
|
||||
-- Grant ownership of the public schema within this database to your application user.
|
||||
-- This is CRITICAL for allowing the test runner to drop and recreate the schema.
|
||||
ALTER SCHEMA public OWNER TO flyer_crawler_user;
|
||||
|
||||
-- Exit the psql shell
|
||||
\q
|
||||
|
||||
Step 2: Configure Gitea Secrets for Testing
|
||||
Your CI pipeline needs to know how to connect to this test database. Ensure the following secrets are set in your Gitea repository settings:
|
||||
|
||||
DB_HOST: The hostname of your database server (e.g., localhost).
|
||||
DB_PORT: The port for your database (e.g., 5432).
|
||||
DB_USER: The user for the database (e.g., flyer_crawler_user).
|
||||
DB_PASSWORD: The password for the database user.
|
||||
The workflow file (.gitea/workflows/deploy.yml) is configured to use these secrets and will automatically connect to the "flyer-crawler-test" database when it runs the npm test command.
|
||||
|
||||
How the Test Workflow Works
|
||||
The CI pipeline no longer uses sudo or creates/destroys the database on each run. Instead, the process is now:
|
||||
|
||||
Setup: The vitest global setup script (src/tests/setup/global-setup.ts) connects to the permanent "flyer-crawler-test" database.
|
||||
|
||||
Schema Reset: It executes sql/drop_tables.sql (which runs DROP SCHEMA public CASCADE) to completely wipe all tables, functions, and triggers.
|
||||
|
||||
Schema Application: It then immediately executes sql/master_schema_rollup.sql to build a fresh, clean schema and seed initial data.
|
||||
|
||||
Test Execution: Your tests run against this clean, isolated schema.
|
||||
|
||||
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
||||
|
||||
|
||||
|
||||
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
||||
[PM2][Module] Installing NPM pm2-logrotate module
|
||||
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
||||
|
||||
added 161 packages in 5s
|
||||
|
||||
21 packages are looking for funding
|
||||
run `npm fund` for details
|
||||
npm notice
|
||||
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
||||
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
||||
npm notice To update run: npm install -g npm@11.6.4
|
||||
npm notice
|
||||
[PM2][Module] Module downloaded
|
||||
[PM2][WARN] Applications pm2-logrotate not running, starting...
|
||||
[PM2] App [pm2-logrotate] launched (1 instances)
|
||||
Module: pm2-logrotate
|
||||
$ pm2 set pm2-logrotate:max_size 10M
|
||||
$ pm2 set pm2-logrotate:retain 30
|
||||
$ pm2 set pm2-logrotate:compress false
|
||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||
$ pm2 set pm2-logrotate:workerInterval 30
|
||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
||||
$ pm2 set pm2-logrotate:rotateModule true
|
||||
Modules configuration. Copy/Paste line to edit values.
|
||||
[PM2][Module] Module successfully installed and launched
|
||||
[PM2][Module] Checkout module options: `$ pm2 conf`
|
||||
┌────┬───────────────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
|
||||
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
|
||||
├────┼───────────────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
|
||||
│ 2 │ flyer-crawler-analytics-worker │ default │ 0.0.0 │ fork │ 3846981 │ 7m │ 5 │ online │ 0% │ 55.8mb │ git… │ disabled │
|
||||
│ 11 │ flyer-crawler-api │ default │ 0.0.0 │ fork │ 3846987 │ 7m │ 0 │ online │ 0% │ 59.0mb │ git… │ disabled │
|
||||
│ 12 │ flyer-crawler-worker │ default │ 0.0.0 │ fork │ 3846988 │ 7m │ 0 │ online │ 0% │ 54.2mb │ git… │ disabled │
|
||||
└────┴───────────────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
|
||||
Module
|
||||
┌────┬──────────────────────────────┬───────────────┬──────────┬──────────┬──────┬──────────┬──────────┬──────────┐
|
||||
│ id │ module │ version │ pid │ status │ ↺ │ cpu │ mem │ user │
|
||||
├────┼──────────────────────────────┼───────────────┼──────────┼──────────┼──────┼──────────┼──────────┼──────────┤
|
||||
│ 13 │ pm2-logrotate │ 3.0.0 │ 3848878 │ online │ 0 │ 0% │ 20.1mb │ git… │
|
||||
└────┴──────────────────────────────┴───────────────┴──────────┴──────────┴──────┴──────────┴──────────┴──────────┘
|
||||
gitea-runner@projectium:~$ pm2 set pm2-logrotate:max_size 10M
|
||||
[PM2] Module pm2-logrotate restarted
|
||||
[PM2] Setting changed
|
||||
Module: pm2-logrotate
|
||||
$ pm2 set pm2-logrotate:max_size 10M
|
||||
$ pm2 set pm2-logrotate:retain 30
|
||||
$ pm2 set pm2-logrotate:compress false
|
||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||
$ pm2 set pm2-logrotate:workerInterval 30
|
||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
||||
$ pm2 set pm2-logrotate:rotateModule true
|
||||
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
||||
[PM2] Module pm2-logrotate restarted
|
||||
[PM2] Setting changed
|
||||
Module: pm2-logrotate
|
||||
$ pm2 set pm2-logrotate:max_size 10M
|
||||
$ pm2 set pm2-logrotate:retain 14
|
||||
$ pm2 set pm2-logrotate:compress false
|
||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||
$ pm2 set pm2-logrotate:workerInterval 30
|
||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
||||
$ pm2 set pm2-logrotate:rotateModule true
|
||||
gitea-runner@projectium:~$
|
||||
|
||||
|
||||
|
||||
|
||||
## dev server setup:
|
||||
|
||||
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
||||
|
||||
1. Install Prerequisites on Windows
|
||||
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
||||
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
||||
|
||||
2. Set Up Podman
|
||||
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
||||
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
||||
|
||||
3. Set Up the Ubuntu Container
|
||||
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
||||
podman pull ubuntu:latest
|
||||
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
||||
podman volume create node_modules_cache
|
||||
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
||||
- Open a terminal in your project's root directory on Windows.
|
||||
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
||||
|
||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com:/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
||||
|
||||
-p 3001:3001: Forwards the backend server port.
|
||||
-p 5173:5173: Forwards the Vite frontend server port.
|
||||
--name flyer-dev: Names the container for easy reference.
|
||||
-v "...:/app": Mounts your project directory into the container at /app.
|
||||
-v "node_modules_cache:/app/node_modules": Mounts the named volume for node_modules.
|
||||
|
||||
4. Configure the Ubuntu Environment
|
||||
You are now inside the Ubuntu container's shell.
|
||||
|
||||
- Update Package Lists:
|
||||
apt-get update
|
||||
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
||||
apt-get install -y curl git
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
- Navigate to Project Directory:
|
||||
cd /app
|
||||
|
||||
- Install Project Dependencies:
|
||||
npm install
|
||||
|
||||
5. Run the Development Server
|
||||
- Start the Application:
|
||||
npm run dev
|
||||
|
||||
6. Accessing the Application
|
||||
- Frontend: Open your browser and go to http://localhost:5173.
|
||||
- Backend: The frontend will make API calls to http://localhost:3001.
|
||||
|
||||
Managing the Environment
|
||||
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
||||
- Restarting the Container:
|
||||
podman start -a -i flyer-dev
|
||||
|
||||
|
||||
|
||||
## for me:
|
||||
|
||||
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
rate limiting
|
||||
|
||||
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
||||
[Add license information here]
|
||||
|
||||
3
README.testing.md
Normal file
3
README.testing.md
Normal file
@@ -0,0 +1,3 @@
|
||||
using powershell on win10 use this command to run the integration tests only in the container
|
||||
|
||||
podman exec -i flyer-crawler-dev npm run test:integration 2>&1 | Tee-Object -FilePath test-output.txt
|
||||
630
README.vscode.md
Normal file
630
README.vscode.md
Normal file
@@ -0,0 +1,630 @@
|
||||
# VS Code Configuration for Flyer Crawler Project
|
||||
|
||||
This document describes the VS Code setup for this project, including MCP (Model Context Protocol) server configurations for both Gemini Code and Claude Code.
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses VS Code with AI coding assistants (Gemini Code and Claude Code) that connect to various MCP servers for enhanced capabilities like container management, repository access, and file system operations.
|
||||
|
||||
## MCP Server Architecture
|
||||
|
||||
MCP (Model Context Protocol) allows AI assistants to interact with external tools and services. Both Gemini Code and Claude Code are configured to use the same set of MCP servers.
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- **Gemini Code**: `%APPDATA%\Code\User\mcp.json`
|
||||
- **Claude Code**: `%USERPROFILE%\.claude\settings.json`
|
||||
|
||||
## Configured MCP Servers
|
||||
|
||||
### 1. Gitea MCP Servers
|
||||
|
||||
Access to multiple Gitea instances for repository management, code search, issue tracking, and CI/CD workflows.
|
||||
|
||||
#### Gitea Projectium (Primary)
|
||||
- **Host**: `https://gitea.projectium.com`
|
||||
- **Purpose**: Main production Gitea server
|
||||
- **Capabilities**:
|
||||
- Repository browsing and code search
|
||||
- Issue and PR management
|
||||
- CI/CD workflow access
|
||||
- Repository cloning and management
|
||||
|
||||
#### Gitea Torbonium
|
||||
- **Host**: `https://gitea.torbonium.com`
|
||||
- **Purpose**: Development/testing Gitea instance
|
||||
- **Capabilities**: Same as Gitea Projectium
|
||||
|
||||
#### Gitea LAN
|
||||
- **Host**: `https://gitea.torbolan.com`
|
||||
- **Purpose**: Local network Gitea instance
|
||||
- **Status**: Disabled (requires token configuration)
|
||||
|
||||
**Executable Location**: `d:\gitea-mcp\gitea-mcp.exe`
|
||||
|
||||
**Configuration Example** (Gemini Code - mcp.json):
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"gitea-projectium": {
|
||||
"command": "d:\\gitea-mcp\\gitea-mcp.exe",
|
||||
"args": ["run", "-t", "stdio"],
|
||||
"env": {
|
||||
"GITEA_HOST": "https://gitea.projectium.com",
|
||||
"GITEA_ACCESS_TOKEN": "your-token-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Example** (Claude Code - settings.json):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea-projectium": {
|
||||
"command": "d:\\gitea-mcp\\gitea-mcp.exe",
|
||||
"args": ["run", "-t", "stdio"],
|
||||
"env": {
|
||||
"GITEA_HOST": "https://gitea.projectium.com",
|
||||
"GITEA_ACCESS_TOKEN": "your-token-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Podman/Docker MCP Server
|
||||
|
||||
Manages local containers via Podman Desktop (using Docker-compatible API).
|
||||
|
||||
- **Purpose**: Container lifecycle management
|
||||
- **Socket**: `npipe:////./pipe/docker_engine` (Windows named pipe)
|
||||
- **Capabilities**:
|
||||
- List, start, stop containers
|
||||
- Execute commands in containers
|
||||
- View container logs
|
||||
- Inspect container status and configuration
|
||||
|
||||
**Current Containers** (for this project):
|
||||
- `flyer-crawler-postgres` - PostgreSQL 15 + PostGIS on port 5432
|
||||
- `flyer-crawler-redis` - Redis on port 6379
|
||||
|
||||
**Configuration** (Gemini Code - mcp.json):
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"podman": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-docker"],
|
||||
"env": {
|
||||
"DOCKER_HOST": "npipe:////./pipe/docker_engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration** (Claude Code):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"podman": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-docker"],
|
||||
"env": {
|
||||
"DOCKER_HOST": "npipe:////./pipe/docker_engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Filesystem MCP Server
|
||||
|
||||
Direct file system access to the project directory.
|
||||
|
||||
- **Purpose**: Read and write files in the project
|
||||
- **Scope**: `D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com`
|
||||
- **Capabilities**:
|
||||
- Read file contents
|
||||
- Write/edit files
|
||||
- List directory contents
|
||||
- Search files
|
||||
|
||||
**Configuration** (Gemini Code - mcp.json):
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"D:\\gitea\\flyer-crawler.projectium.com\\flyer-crawler.projectium.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration** (Claude Code):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"D:\\gitea\\flyer-crawler.projectium.com\\flyer-crawler.projectium.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Fetch MCP Server
|
||||
|
||||
Web request capabilities for documentation lookups and API testing.
|
||||
|
||||
- **Purpose**: Make HTTP requests
|
||||
- **Capabilities**:
|
||||
- Fetch web pages and APIs
|
||||
- Download documentation
|
||||
- Test endpoints
|
||||
|
||||
**Configuration** (Gemini Code - mcp.json):
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"fetch": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration** (Claude Code):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Chrome DevTools MCP Server (Optional)
|
||||
|
||||
Browser automation and debugging capabilities.
|
||||
|
||||
- **Purpose**: Automated browser testing
|
||||
- **Status**: Disabled by default
|
||||
- **Capabilities**:
|
||||
- Browser automation
|
||||
- Screenshot capture
|
||||
- DOM inspection
|
||||
- Network monitoring
|
||||
|
||||
**Configuration** (when enabled):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--headless", "false",
|
||||
"--isolated", "false",
|
||||
"--channel", "stable"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Markitdown MCP Server (Optional)
|
||||
|
||||
Document conversion capabilities.
|
||||
|
||||
- **Purpose**: Convert various document formats to Markdown
|
||||
- **Status**: Disabled by default
|
||||
- **Requires**: Python with `uvx` installed
|
||||
- **Capabilities**:
|
||||
- Convert PDFs to Markdown
|
||||
- Convert Word documents
|
||||
- Convert other document formats
|
||||
|
||||
**Configuration** (when enabled):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"markitdown": {
|
||||
"command": "uvx",
|
||||
"args": ["markitdown-mcp==0.0.1a4"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### For Podman MCP
|
||||
1. **Podman Desktop** installed and running
|
||||
2. Podman machine initialized and started:
|
||||
```powershell
|
||||
podman machine init
|
||||
podman machine start
|
||||
```
|
||||
|
||||
### For Gitea MCP
|
||||
1. **Gitea MCP executable** at `d:\gitea-mcp\gitea-mcp.exe`
|
||||
2. **Gitea Access Tokens** with appropriate permissions:
|
||||
- `repo` - Full repository access
|
||||
- `write:user` - User profile access
|
||||
- `read:organization` - Organization access
|
||||
|
||||
### For Chrome DevTools MCP
|
||||
1. **Chrome browser** installed (stable channel)
|
||||
2. **Node.js 18+** for npx execution
|
||||
|
||||
### For Markitdown MCP
|
||||
1. **Python 3.8+** installed
|
||||
2. **uvx** (universal virtualenv executor):
|
||||
```powershell
|
||||
pip install uvx
|
||||
```
|
||||
|
||||
## Testing MCP Servers
|
||||
|
||||
### Test Podman Connection
|
||||
```powershell
|
||||
podman ps
|
||||
# Should list running containers
|
||||
```
|
||||
|
||||
### Test Gitea API Access
|
||||
```powershell
|
||||
curl -H "Authorization: token YOUR_TOKEN" https://gitea.projectium.com/api/v1/user
|
||||
# Should return your user information
|
||||
```
|
||||
|
||||
### Test Database Container
|
||||
```powershell
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "SELECT version();"
|
||||
# Should return PostgreSQL version
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
### Token Management
|
||||
- **Never commit tokens** to version control
|
||||
- Store tokens in environment variables or secure password managers
|
||||
- Rotate tokens periodically
|
||||
- Use minimal required permissions
|
||||
|
||||
### Access Tokens in Configuration Files
|
||||
The configuration files (`mcp.json` and `settings.json`) contain sensitive access tokens. These files should:
|
||||
- Be added to `.gitignore`
|
||||
- Have restricted file permissions
|
||||
- Be backed up securely
|
||||
- Be updated when tokens are rotated
|
||||
|
||||
### Current Security Setup
|
||||
- `%APPDATA%\Code\User\mcp.json` - Gitea tokens embedded
|
||||
- `%USERPROFILE%\.claude\settings.json` - Gitea tokens embedded
|
||||
- Both files are in user-specific directories with appropriate Windows ACLs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Podman MCP Not Working
|
||||
1. Check Podman machine status:
|
||||
```powershell
|
||||
podman machine list
|
||||
```
|
||||
2. Ensure Podman Desktop is running
|
||||
3. Verify Docker socket is accessible:
|
||||
```powershell
|
||||
podman ps
|
||||
```
|
||||
|
||||
### Gitea MCP Connection Issues
|
||||
1. Verify token has correct permissions
|
||||
2. Check network connectivity to Gitea server:
|
||||
```powershell
|
||||
curl https://gitea.projectium.com/api/v1/version
|
||||
```
|
||||
3. Ensure `gitea-mcp.exe` is not blocked by antivirus/firewall
|
||||
|
||||
### VS Code Extension Issues
|
||||
1. **Reload Window**: Press `Ctrl+Shift+P` → "Developer: Reload Window"
|
||||
2. **Check Extension Logs**: View → Output → Select extension from dropdown
|
||||
3. **Verify JSON Syntax**: Ensure both config files have valid JSON
|
||||
|
||||
### MCP Server Not Loading
|
||||
1. Check config file syntax with JSON validator
|
||||
2. Verify executable paths are correct (use forward slashes or escaped backslashes)
|
||||
3. Ensure required dependencies are installed (Node.js, Python, etc.)
|
||||
4. Check VS Code developer console for errors: Help → Toggle Developer Tools
|
||||
|
||||
## Adding New MCP Servers
|
||||
|
||||
To add a new MCP server to both Gemini Code and Claude Code:
|
||||
|
||||
1. **Install the MCP server** (if it's an npm package):
|
||||
```powershell
|
||||
npm install -g @modelcontextprotocol/server-YOUR-SERVER
|
||||
```
|
||||
|
||||
2. **Add to Gemini Code** (`mcp.json`):
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"your-server-name": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-YOUR-SERVER"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add to Claude Code** (`settings.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"your-server-name": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-YOUR-SERVER"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Reload VS Code**
|
||||
|
||||
## Current Project Integration
|
||||
|
||||
### ADR Implementation Status
|
||||
- **ADR-0002**: Transaction Management ✅ Enforced
|
||||
- **ADR-0003**: Input Validation ✅ Enforced with URL validation
|
||||
|
||||
### Database Setup
|
||||
- PostgreSQL 15 + PostGIS running in container
|
||||
- 63 tables created
|
||||
- URL constraints active:
|
||||
- `flyers_image_url_check` enforces `^https?://.*`
|
||||
- `flyers_icon_url_check` enforces `^https?://.*`
|
||||
|
||||
### Development Workflow
|
||||
1. Start containers: `podman start flyer-crawler-postgres flyer-crawler-redis`
|
||||
2. Use MCP servers to manage development environment
|
||||
3. AI assistants can:
|
||||
- Manage containers via Podman MCP
|
||||
- Access repository via Gitea MCP
|
||||
- Edit files via Filesystem MCP
|
||||
- Fetch documentation via Fetch MCP
|
||||
|
||||
## Resources
|
||||
|
||||
- [Model Context Protocol Documentation](https://modelcontextprotocol.io/)
|
||||
- [Gitea API Documentation](https://docs.gitea.com/api/1.22/)
|
||||
- [Podman Desktop](https://podman-desktop.io/)
|
||||
- [Claude Code Documentation](https://docs.anthropic.com/claude-code)
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regular Tasks
|
||||
- **Monthly**: Rotate Gitea access tokens
|
||||
- **Weekly**: Update MCP server packages:
|
||||
```powershell
|
||||
npm update -g @modelcontextprotocol/server-*
|
||||
```
|
||||
- **As Needed**: Update Gitea MCP executable when new version is released
|
||||
|
||||
### Backup Configuration
|
||||
Recommended to backup these files regularly:
|
||||
- `%APPDATA%\Code\User\mcp.json`
|
||||
- `%USERPROFILE%\.claude\settings.json`
|
||||
|
||||
## Gitea Workflows and CI/CD
|
||||
|
||||
This project uses Gitea Actions for continuous integration and deployment. The workflows are located in `.gitea/workflows/`.
|
||||
|
||||
### Available Workflows
|
||||
|
||||
#### Automated Workflows
|
||||
|
||||
**deploy-to-test.yml** - Automated deployment to test environment
|
||||
- **Trigger**: Automatically on every push to `main` branch
|
||||
- **Runner**: `projectium.com` (self-hosted)
|
||||
- **Process**:
|
||||
1. Version bump (patch) with `[skip ci]` tag
|
||||
2. TypeScript type-check and linting
|
||||
3. Run unit tests + integration tests + E2E tests
|
||||
4. Generate merged coverage report
|
||||
5. Build React frontend for test environment
|
||||
6. Deploy to `flyer-crawler-test.projectium.com`
|
||||
7. Restart PM2 processes for test environment
|
||||
8. Update database schema hash
|
||||
- **Coverage Report**: https://flyer-crawler-test.projectium.com/coverage
|
||||
- **Environment Variables**: Uses test database and Redis credentials
|
||||
|
||||
#### Manual Workflows
|
||||
|
||||
**deploy-to-prod.yml** - Manual deployment to production
|
||||
- **Trigger**: Manual via workflow_dispatch
|
||||
- **Confirmation Required**: Must type "deploy-to-prod"
|
||||
- **Process**:
|
||||
1. Version bump (minor) for production release
|
||||
2. Check database schema hash (fails if mismatch)
|
||||
3. Build React frontend for production
|
||||
4. Deploy to `flyer-crawler.projectium.com`
|
||||
5. Restart PM2 processes (with version check)
|
||||
6. Update production database schema hash
|
||||
- **Optional**: Force PM2 reload even if version matches
|
||||
|
||||
**manual-db-backup.yml** - Database backup workflow
|
||||
- Creates timestamped backup of production database
|
||||
- Stored in `/var/backups/postgres/`
|
||||
|
||||
**manual-db-restore.yml** - Database restore workflow
|
||||
- Restores production database from backup file
|
||||
- Requires confirmation and backup filename
|
||||
|
||||
**manual-db-reset-test.yml** - Reset test database
|
||||
- Drops and recreates test database schema
|
||||
- Used for testing schema migrations
|
||||
|
||||
**manual-db-reset-prod.yml** - Reset production database
|
||||
- **DANGER**: Drops and recreates production database
|
||||
- Requires multiple confirmations
|
||||
|
||||
**manual-deploy-major.yml** - Major version deployment
|
||||
- Similar to deploy-to-prod but bumps major version
|
||||
- For breaking changes or major releases
|
||||
|
||||
### Accessing Workflows via Gitea MCP
|
||||
|
||||
With the Gitea MCP server configured, AI assistants can:
|
||||
- View workflow files
|
||||
- Monitor workflow runs
|
||||
- Check deployment status
|
||||
- Review CI/CD logs
|
||||
- Trigger manual workflows (via API)
|
||||
|
||||
**Example MCP Operations**:
|
||||
```bash
|
||||
# Via Gitea MCP, you can:
|
||||
# - List recent workflow runs
|
||||
# - View workflow logs
|
||||
# - Check deployment status
|
||||
# - Review test results
|
||||
# - Monitor coverage reports
|
||||
```
|
||||
|
||||
### Key Environment Variables for CI/CD
|
||||
|
||||
The workflows use these Gitea repository secrets:
|
||||
|
||||
**Database**:
|
||||
- `DB_HOST` - PostgreSQL host
|
||||
- `DB_USER` - Database user
|
||||
- `DB_PASSWORD` - Database password
|
||||
- `DB_DATABASE_PROD` - Production database name
|
||||
- `DB_DATABASE_TEST` - Test database name
|
||||
|
||||
**Redis**:
|
||||
- `REDIS_PASSWORD_PROD` - Production Redis password
|
||||
- `REDIS_PASSWORD_TEST` - Test Redis password
|
||||
|
||||
**API Keys**:
|
||||
- `VITE_GOOGLE_GENAI_API_KEY` - Production Gemini API key
|
||||
- `VITE_GOOGLE_GENAI_API_KEY_TEST` - Test Gemini API key
|
||||
- `GOOGLE_MAPS_API_KEY` - Google Maps Geocoding API key
|
||||
|
||||
**Authentication**:
|
||||
- `JWT_SECRET` - JWT signing secret
|
||||
|
||||
### Schema Migration Process
|
||||
|
||||
The workflows use a schema hash comparison system:
|
||||
|
||||
1. **Hash Calculation**: SHA-256 hash of `sql/master_schema_rollup.sql`
|
||||
2. **Storage**: Hashes stored in `public.schema_info` table
|
||||
3. **Comparison**: On each deployment, current hash vs deployed hash
|
||||
4. **Protection**: Deployment fails if schemas don't match
|
||||
|
||||
**Manual Migration Steps** (when schema changes):
|
||||
1. Update `sql/master_schema_rollup.sql`
|
||||
2. Run manual migration workflow or:
|
||||
```bash
|
||||
psql -U <user> -d <database> -f sql/master_schema_rollup.sql
|
||||
```
|
||||
3. Deploy will update hash automatically
|
||||
|
||||
### PM2 Process Management
|
||||
|
||||
The workflows manage three PM2 processes per environment:
|
||||
|
||||
**Production** (`ecosystem.config.cjs --env production`):
|
||||
- `flyer-crawler-api` - Express API server
|
||||
- `flyer-crawler-worker` - Background job worker
|
||||
- `flyer-crawler-analytics-worker` - Analytics processor
|
||||
|
||||
**Test** (`ecosystem.config.cjs --env test`):
|
||||
- `flyer-crawler-api-test` - Test Express API server
|
||||
- `flyer-crawler-worker-test` - Test background worker
|
||||
- `flyer-crawler-analytics-worker-test` - Test analytics worker
|
||||
|
||||
**Process Cleanup**:
|
||||
- Workflows automatically delete errored/stopped processes
|
||||
- Version comparison prevents unnecessary reloads
|
||||
- Force reload option available for production
|
||||
|
||||
### Monitoring Deployment via MCP
|
||||
|
||||
Using Gitea MCP, you can monitor deployments in real-time:
|
||||
|
||||
1. **Check Workflow Status**:
|
||||
- View running workflows
|
||||
- See step-by-step progress
|
||||
- Read deployment logs
|
||||
|
||||
2. **PM2 Process Monitoring**:
|
||||
- Workflows output PM2 status after deployment
|
||||
- View process IDs, memory usage, uptime
|
||||
- Check recent logs (last 20 lines)
|
||||
|
||||
3. **Coverage Reports**:
|
||||
- Automatically published to test environment
|
||||
- HTML reports with detailed breakdown
|
||||
- Merged coverage from unit + integration + E2E + server
|
||||
|
||||
### Development Workflow Integration
|
||||
|
||||
**Local Development** → **Push to main** → **Auto-deploy to test** → **Manual deploy to prod**
|
||||
|
||||
1. Develop locally with Podman containers
|
||||
2. Commit and push to `main` branch
|
||||
3. Gitea Actions automatically:
|
||||
- Runs all tests
|
||||
- Generates coverage
|
||||
- Deploys to test environment
|
||||
4. Review test deployment at https://flyer-crawler-test.projectium.com
|
||||
5. Manually trigger production deployment when ready
|
||||
|
||||
### Using MCP for Deployment Tasks
|
||||
|
||||
With the configured MCP servers, you can:
|
||||
|
||||
**Via Gitea MCP**:
|
||||
- Trigger manual workflows
|
||||
- View deployment history
|
||||
- Monitor test results
|
||||
- Access workflow logs
|
||||
|
||||
**Via Podman MCP**:
|
||||
- Inspect container logs (for local testing)
|
||||
- Manage local database containers
|
||||
- Test migrations locally
|
||||
|
||||
**Via Filesystem MCP**:
|
||||
- Review workflow files
|
||||
- Edit deployment scripts
|
||||
- Update ecosystem config
|
||||
|
||||
## Version History
|
||||
|
||||
- **2026-01-07**: Initial MCP configuration for Gemini Code and Claude Code
|
||||
- Added Gitea MCP servers (projectium, torbonium, lan)
|
||||
- Added Podman MCP server
|
||||
- Added Filesystem, Fetch MCP servers
|
||||
- Configured Chrome DevTools and Markitdown (disabled by default)
|
||||
- Documented Gitea workflows and CI/CD pipeline
|
||||
303
READMEv2.md
Normal file
303
READMEv2.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Flyer Crawler - Development Environment Setup
|
||||
|
||||
Quick start guide for getting the development environment running with Podman containers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Windows with WSL 2**: Install WSL 2 by running `wsl --install` in an administrator PowerShell
|
||||
- **Podman Desktop**: Download and install [Podman Desktop for Windows](https://podman-desktop.io/)
|
||||
- **Node.js 20+**: Required for running the application
|
||||
|
||||
## Quick Start - Container Environment
|
||||
|
||||
### 1. Initialize Podman
|
||||
|
||||
```powershell
|
||||
# Start Podman machine (do this once after installing Podman Desktop)
|
||||
podman machine init
|
||||
podman machine start
|
||||
```
|
||||
|
||||
### 2. Start Required Services
|
||||
|
||||
Start PostgreSQL (with PostGIS) and Redis containers:
|
||||
|
||||
```powershell
|
||||
# Navigate to project directory
|
||||
cd D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com
|
||||
|
||||
# Start PostgreSQL with PostGIS
|
||||
podman run -d \
|
||||
--name flyer-crawler-postgres \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=flyer_crawler_dev \
|
||||
-p 5432:5432 \
|
||||
docker.io/postgis/postgis:15-3.3
|
||||
|
||||
# Start Redis
|
||||
podman run -d \
|
||||
--name flyer-crawler-redis \
|
||||
-e REDIS_PASSWORD="" \
|
||||
-p 6379:6379 \
|
||||
docker.io/library/redis:alpine
|
||||
```
|
||||
|
||||
### 3. Wait for PostgreSQL to Initialize
|
||||
|
||||
```powershell
|
||||
# Wait a few seconds, then check if PostgreSQL is ready
|
||||
podman exec flyer-crawler-postgres pg_isready -U postgres
|
||||
# Should output: /var/run/postgresql:5432 - accepting connections
|
||||
```
|
||||
|
||||
### 4. Install Required PostgreSQL Extensions
|
||||
|
||||
```powershell
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
|
||||
```
|
||||
|
||||
### 5. Apply Database Schema
|
||||
|
||||
```powershell
|
||||
# Apply the complete schema with URL constraints enabled
|
||||
podman exec -i flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev < sql/master_schema_rollup.sql
|
||||
```
|
||||
|
||||
### 6. Verify URL Constraints Are Enabled
|
||||
|
||||
```powershell
|
||||
podman exec flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev -c "\d public.flyers" | grep -E "(image_url|icon_url|Check)"
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
image_url | text | | not null |
|
||||
icon_url | text | | not null |
|
||||
Check constraints:
|
||||
"flyers_icon_url_check" CHECK (icon_url ~* '^https?://.*'::text)
|
||||
"flyers_image_url_check" CHECK (image_url ~* '^https?://.*'::text)
|
||||
```
|
||||
|
||||
### 7. Set Environment Variables and Start Application
|
||||
|
||||
```powershell
|
||||
# Set required environment variables
|
||||
$env:NODE_ENV="development"
|
||||
$env:DB_HOST="localhost"
|
||||
$env:DB_USER="postgres"
|
||||
$env:DB_PASSWORD="postgres"
|
||||
$env:DB_NAME="flyer_crawler_dev"
|
||||
$env:REDIS_URL="redis://localhost:6379"
|
||||
$env:PORT="3001"
|
||||
$env:FRONTEND_URL="http://localhost:5173"
|
||||
|
||||
# Install dependencies (first time only)
|
||||
npm install
|
||||
|
||||
# Start the development server (runs both backend and frontend)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at:
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **Backend API**: http://localhost:3001
|
||||
|
||||
## Managing Containers
|
||||
|
||||
### View Running Containers
|
||||
```powershell
|
||||
podman ps
|
||||
```
|
||||
|
||||
### Stop Containers
|
||||
```powershell
|
||||
podman stop flyer-crawler-postgres flyer-crawler-redis
|
||||
```
|
||||
|
||||
### Start Containers (After They've Been Created)
|
||||
```powershell
|
||||
podman start flyer-crawler-postgres flyer-crawler-redis
|
||||
```
|
||||
|
||||
### Remove Containers (Clean Slate)
|
||||
```powershell
|
||||
podman stop flyer-crawler-postgres flyer-crawler-redis
|
||||
podman rm flyer-crawler-postgres flyer-crawler-redis
|
||||
```
|
||||
|
||||
### View Container Logs
|
||||
```powershell
|
||||
podman logs flyer-crawler-postgres
|
||||
podman logs flyer-crawler-redis
|
||||
```
|
||||
|
||||
## Database Management
|
||||
|
||||
### Connect to PostgreSQL
|
||||
```powershell
|
||||
podman exec -it flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev
|
||||
```
|
||||
|
||||
### Reset Database Schema
|
||||
```powershell
|
||||
# Drop all tables
|
||||
podman exec -i flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev < sql/drop_tables.sql
|
||||
|
||||
# Reapply schema
|
||||
podman exec -i flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev < sql/master_schema_rollup.sql
|
||||
```
|
||||
|
||||
### Seed Development Data
|
||||
```powershell
|
||||
npm run db:reset:dev
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Unit Tests
|
||||
```powershell
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**IMPORTANT**: Integration tests require the PostgreSQL and Redis containers to be running.
|
||||
|
||||
```powershell
|
||||
# Make sure containers are running
|
||||
podman ps
|
||||
|
||||
# Run integration tests
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Podman Machine Issues
|
||||
If you get "unable to connect to Podman socket" errors:
|
||||
```powershell
|
||||
podman machine start
|
||||
```
|
||||
|
||||
### PostgreSQL Connection Refused
|
||||
Make sure PostgreSQL is ready:
|
||||
```powershell
|
||||
podman exec flyer-crawler-postgres pg_isready -U postgres
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
If ports 5432 or 6379 are already in use, you can either:
|
||||
1. Stop the conflicting service
|
||||
2. Change the port mapping when creating containers (e.g., `-p 5433:5432`)
|
||||
|
||||
### URL Validation Errors
|
||||
The database now enforces URL constraints. All `image_url` and `icon_url` fields must:
|
||||
- Start with `http://` or `https://`
|
||||
- Match the regex pattern: `^https?://.*`
|
||||
|
||||
Make sure the `FRONTEND_URL` environment variable is set correctly to avoid URL validation errors.
|
||||
|
||||
## ADR Implementation Status
|
||||
|
||||
This development environment implements:
|
||||
|
||||
- **ADR-0002**: Transaction Management ✅
|
||||
- All database operations use the `withTransaction` pattern
|
||||
- Automatic rollback on errors
|
||||
- No connection pool leaks
|
||||
|
||||
- **ADR-0003**: Input Validation ✅
|
||||
- Zod schemas for URL validation
|
||||
- Database constraints enabled
|
||||
- Validation at API boundaries
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Start Containers** (once per development session)
|
||||
```powershell
|
||||
podman start flyer-crawler-postgres flyer-crawler-redis
|
||||
```
|
||||
|
||||
2. **Start Application**
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Make Changes** to code (auto-reloads via `tsx watch`)
|
||||
|
||||
4. **Run Tests** before committing
|
||||
```powershell
|
||||
npm run test:unit
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
5. **Stop Application** (Ctrl+C)
|
||||
|
||||
6. **Stop Containers** (optional, or leave running)
|
||||
```powershell
|
||||
podman stop flyer-crawler-postgres flyer-crawler-redis
|
||||
```
|
||||
|
||||
## PM2 Worker Setup (Production-like)
|
||||
|
||||
To test with PM2 workers locally:
|
||||
|
||||
```powershell
|
||||
# Install PM2 globally (once)
|
||||
npm install -g pm2
|
||||
|
||||
# Start the worker
|
||||
pm2 start npm --name "flyer-crawler-worker" -- run worker:prod
|
||||
|
||||
# View logs
|
||||
pm2 logs flyer-crawler-worker
|
||||
|
||||
# Stop worker
|
||||
pm2 stop flyer-crawler-worker
|
||||
pm2 delete flyer-crawler-worker
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After getting the environment running:
|
||||
|
||||
1. Review [docs/adr/](docs/adr/) for architectural decisions
|
||||
2. Check [sql/master_schema_rollup.sql](sql/master_schema_rollup.sql) for database schema
|
||||
3. Explore [src/routes/](src/routes/) for API endpoints
|
||||
4. Review [src/types.ts](src/types.ts) for TypeScript type definitions
|
||||
|
||||
## Common Environment Variables
|
||||
|
||||
Create these environment variables for development:
|
||||
|
||||
```powershell
|
||||
# Database
|
||||
$env:DB_HOST="localhost"
|
||||
$env:DB_USER="postgres"
|
||||
$env:DB_PASSWORD="postgres"
|
||||
$env:DB_NAME="flyer_crawler_dev"
|
||||
$env:DB_PORT="5432"
|
||||
|
||||
# Redis
|
||||
$env:REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# Application
|
||||
$env:NODE_ENV="development"
|
||||
$env:PORT="3001"
|
||||
$env:FRONTEND_URL="http://localhost:5173"
|
||||
|
||||
# Authentication (generate your own secrets)
|
||||
$env:JWT_SECRET="your-dev-jwt-secret-change-this"
|
||||
$env:SESSION_SECRET="your-dev-session-secret-change-this"
|
||||
|
||||
# AI Services (get your own API keys)
|
||||
$env:VITE_GOOGLE_GENAI_API_KEY="your-google-genai-api-key"
|
||||
$env:GOOGLE_MAPS_API_KEY="your-google-maps-api-key"
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Podman Desktop Documentation](https://podman-desktop.io/docs)
|
||||
- [PostGIS Documentation](https://postgis.net/documentation/)
|
||||
- [Original README.md](README.md) for production setup
|
||||
529
STORE_ADDRESS_IMPLEMENTATION_PLAN.md
Normal file
529
STORE_ADDRESS_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# Store Address Normalization Implementation Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Problem**: The database schema has a properly normalized structure for stores and addresses (`stores` → `store_locations` → `addresses`), but the application code does NOT fully utilize this structure. Currently:
|
||||
|
||||
- TypeScript types exist (`Store`, `Address`, `StoreLocation`) ✅
|
||||
- AddressRepository exists for basic CRUD ✅
|
||||
- E2E tests now create data using normalized structure ✅
|
||||
- **BUT**: No functionality to CREATE/MANAGE stores with addresses in the application
|
||||
- **BUT**: No API endpoints to handle store location data
|
||||
- **BUT**: No frontend forms to input address data when creating stores
|
||||
- **BUT**: Queries don't join stores with their addresses for display
|
||||
|
||||
**Impact**: Users see stores without addresses, making features like "deals near me", "store finder", and location-based features impossible.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ What EXISTS and WORKS:
|
||||
|
||||
1. **Database Schema**: Properly normalized (stores, addresses, store_locations)
|
||||
2. **TypeScript Types** ([src/types.ts](src/types.ts)):
|
||||
- `Store` type (lines 2-9)
|
||||
- `Address` type (lines 712-724)
|
||||
- `StoreLocation` type (lines 704-710)
|
||||
3. **AddressRepository** ([src/services/db/address.db.ts](src/services/db/address.db.ts)):
|
||||
- `getAddressById()`
|
||||
- `upsertAddress()`
|
||||
4. **Test Helpers** ([src/tests/utils/storeHelpers.ts](src/tests/utils/storeHelpers.ts)):
|
||||
- `createStoreWithLocation()` - for test data creation
|
||||
- `cleanupStoreLocations()` - for test cleanup
|
||||
|
||||
### ❌ What's MISSING:
|
||||
|
||||
1. **No StoreRepository/StoreService** - No database layer for stores
|
||||
2. **No StoreLocationRepository** - No functions to link stores to addresses
|
||||
3. **No API endpoints** for:
|
||||
- POST /api/stores - Create store with address
|
||||
- GET /api/stores/:id - Get store with address(es)
|
||||
- PUT /api/stores/:id - Update store details
|
||||
- POST /api/stores/:id/locations - Add location to store
|
||||
- etc.
|
||||
4. **No frontend components** for:
|
||||
- Store creation form (with address fields)
|
||||
- Store editing form
|
||||
- Store location display
|
||||
5. **Queries don't join** - Existing queries (admin.db.ts, flyer.db.ts) join stores but don't include address data
|
||||
6. **No store management UI** - Admin dashboard doesn't have store management
|
||||
|
||||
---
|
||||
|
||||
## Detailed Investigation Findings
|
||||
|
||||
### Places Where Stores Are Used (Need Address Data):
|
||||
|
||||
1. **Flyer Display** ([src/features/flyer/FlyerDisplay.tsx](src/features/flyer/FlyerDisplay.tsx))
|
||||
- Shows store name, but could show "Store @ 123 Main St, Toronto"
|
||||
|
||||
2. **Deal Listings** (deals.db.ts queries)
|
||||
- `deal_store_name` field exists (line 691 in types.ts)
|
||||
- Should show "Milk $4.99 @ Store #123 (456 Oak Ave)"
|
||||
|
||||
3. **Receipt Processing** (receipt.db.ts)
|
||||
- Receipts link to store_id
|
||||
- Could show "Receipt from Store @ 789 Budget St"
|
||||
|
||||
4. **Admin Dashboard** (admin.db.ts)
|
||||
- Joins stores for flyer review (line 720)
|
||||
- Should show store address in admin views
|
||||
|
||||
5. **Flyer Item Analysis** (admin.db.ts line 334)
|
||||
- Joins stores for unmatched items
|
||||
- Address context would help with store identification
|
||||
|
||||
### Test Files That Need Updates:
|
||||
|
||||
**Unit Tests** (may need store+address mocks):
|
||||
|
||||
- src/services/db/flyer.db.test.ts
|
||||
- src/services/db/receipt.db.test.ts
|
||||
- src/services/aiService.server.test.ts
|
||||
- src/features/flyer/\*.test.tsx (various component tests)
|
||||
|
||||
**Integration Tests** (create stores):
|
||||
|
||||
- src/tests/integration/admin.integration.test.ts (line 164: INSERT INTO stores)
|
||||
- src/tests/integration/flyer.integration.test.ts (line 28: INSERT INTO stores)
|
||||
- src/tests/integration/price.integration.test.ts (line 48: INSERT INTO stores)
|
||||
- src/tests/integration/public.routes.integration.test.ts (line 66: INSERT INTO stores)
|
||||
- src/tests/integration/receipt.integration.test.ts (line 252: INSERT INTO stores)
|
||||
|
||||
**E2E Tests** (already fixed):
|
||||
|
||||
- ✅ src/tests/e2e/deals-journey.e2e.test.ts
|
||||
- ✅ src/tests/e2e/budget-journey.e2e.test.ts
|
||||
- ✅ src/tests/e2e/receipt-journey.e2e.test.ts
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan (NO CODE YET - APPROVAL REQUIRED)
|
||||
|
||||
### Phase 1: Database Layer (Foundation)
|
||||
|
||||
#### 1.1 Create StoreRepository ([src/services/db/store.db.ts](src/services/db/store.db.ts))
|
||||
|
||||
Functions needed:
|
||||
|
||||
- `getStoreById(storeId)` - Returns Store (basic)
|
||||
- `getStoreWithLocations(storeId)` - Returns Store + Address[]
|
||||
- `getAllStores()` - Returns Store[] (basic)
|
||||
- `getAllStoresWithLocations()` - Returns Array<Store & {locations: Address[]}>
|
||||
- `createStore(name, logoUrl?, createdBy?)` - Returns storeId
|
||||
- `updateStore(storeId, updates)` - Updates name/logo
|
||||
- `deleteStore(storeId)` - Cascades to store_locations
|
||||
- `searchStoresByName(query)` - For autocomplete
|
||||
|
||||
**Test file**: [src/services/db/store.db.test.ts](src/services/db/store.db.test.ts)
|
||||
|
||||
#### 1.2 Create StoreLocationRepository ([src/services/db/storeLocation.db.ts](src/services/db/storeLocation.db.ts))
|
||||
|
||||
Functions needed:
|
||||
|
||||
- `createStoreLocation(storeId, addressId)` - Links store to address
|
||||
- `getLocationsByStoreId(storeId)` - Returns StoreLocation[] with Address data
|
||||
- `deleteStoreLocation(storeLocationId)` - Unlinks
|
||||
- `updateStoreLocation(storeLocationId, newAddressId)` - Changes address
|
||||
|
||||
**Test file**: [src/services/db/storeLocation.db.test.ts](src/services/db/storeLocation.db.test.ts)
|
||||
|
||||
#### 1.3 Enhance AddressRepository ([src/services/db/address.db.ts](src/services/db/address.db.ts))
|
||||
|
||||
Add functions:
|
||||
|
||||
- `searchAddressesByText(query)` - For autocomplete
|
||||
- `getAddressesByStoreId(storeId)` - Convenience method
|
||||
|
||||
**Files to modify**:
|
||||
|
||||
- [src/services/db/address.db.ts](src/services/db/address.db.ts)
|
||||
- [src/services/db/address.db.test.ts](src/services/db/address.db.test.ts)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: TypeScript Types & Validation
|
||||
|
||||
#### 2.1 Add Extended Types ([src/types.ts](src/types.ts))
|
||||
|
||||
```typescript
|
||||
// Store with address data for API responses
|
||||
export interface StoreWithLocation {
|
||||
...Store;
|
||||
locations: Array<{
|
||||
store_location_id: number;
|
||||
address: Address;
|
||||
}>;
|
||||
}
|
||||
|
||||
// For API requests when creating store
|
||||
export interface CreateStoreRequest {
|
||||
name: string;
|
||||
logo_url?: string;
|
||||
address?: {
|
||||
address_line_1: string;
|
||||
city: string;
|
||||
province_state: string;
|
||||
postal_code: string;
|
||||
country?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Add Zod Validation Schemas
|
||||
|
||||
Create [src/schemas/store.schema.ts](src/schemas/store.schema.ts):
|
||||
|
||||
- `createStoreSchema` - Validates POST /stores body
|
||||
- `updateStoreSchema` - Validates PUT /stores/:id body
|
||||
- `addLocationSchema` - Validates POST /stores/:id/locations body
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: API Routes
|
||||
|
||||
#### 3.1 Create Store Routes ([src/routes/store.routes.ts](src/routes/store.routes.ts))
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `GET /api/stores` - List all stores (with pagination)
|
||||
- Query params: `?includeLocations=true`, `?search=name`
|
||||
- `GET /api/stores/:id` - Get single store with locations
|
||||
- `POST /api/stores` - Create store (optionally with address)
|
||||
- `PUT /api/stores/:id` - Update store name/logo
|
||||
- `DELETE /api/stores/:id` - Delete store (admin only)
|
||||
- `POST /api/stores/:id/locations` - Add location to store
|
||||
- `DELETE /api/stores/:id/locations/:locationId` - Remove location
|
||||
|
||||
**Test file**: [src/routes/store.routes.test.ts](src/routes/store.routes.test.ts)
|
||||
|
||||
**Permissions**:
|
||||
|
||||
- Create/Update/Delete: Admin only
|
||||
- Read: Public (for store listings in flyers/deals)
|
||||
|
||||
#### 3.2 Update Existing Routes to Include Address Data
|
||||
|
||||
**Files to modify**:
|
||||
|
||||
- [src/routes/flyer.routes.ts](src/routes/flyer.routes.ts) - GET /flyers should include store address
|
||||
- [src/routes/deals.routes.ts](src/routes/deals.routes.ts) - GET /deals should include store address
|
||||
- [src/routes/receipt.routes.ts](src/routes/receipt.routes.ts) - GET /receipts/:id should include store address
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Update Database Queries
|
||||
|
||||
#### 4.1 Modify Existing Queries to JOIN Addresses
|
||||
|
||||
**Files to modify**:
|
||||
|
||||
- [src/services/db/admin.db.ts](src/services/db/admin.db.ts)
|
||||
- Line 334: JOIN store_locations and addresses for unmatched items
|
||||
- Line 720: JOIN store_locations and addresses for flyers needing review
|
||||
|
||||
- [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts)
|
||||
- Any query that returns flyers with store data
|
||||
|
||||
- [src/services/db/deals.db.ts](src/services/db/deals.db.ts)
|
||||
- Add address fields to deal queries
|
||||
|
||||
**Pattern to use**:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'store_location_id', sl.store_location_id,
|
||||
'address', row_to_json(a.*)
|
||||
)
|
||||
) FILTER (WHERE sl.store_location_id IS NOT NULL) as locations
|
||||
FROM stores s
|
||||
LEFT JOIN store_locations sl ON s.store_id = sl.store_id
|
||||
LEFT JOIN addresses a ON sl.address_id = a.address_id
|
||||
GROUP BY s.store_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Frontend Components
|
||||
|
||||
#### 5.1 Admin Store Management
|
||||
|
||||
Create [src/pages/admin/components/AdminStoreManager.tsx](src/pages/admin/components/AdminStoreManager.tsx):
|
||||
|
||||
- Table listing all stores with locations
|
||||
- Create store button → opens modal/form
|
||||
- Edit store button → opens modal with store+address data
|
||||
- Delete store button (with confirmation)
|
||||
|
||||
#### 5.2 Store Form Component
|
||||
|
||||
Create [src/features/store/StoreForm.tsx](src/features/store/StoreForm.tsx):
|
||||
|
||||
- Store name input
|
||||
- Logo URL input
|
||||
- Address section:
|
||||
- Address line 1 (required)
|
||||
- City (required)
|
||||
- Province/State (required)
|
||||
- Postal code (required)
|
||||
- Country (default: Canada)
|
||||
- Reusable for create & edit
|
||||
|
||||
#### 5.3 Store Display Components
|
||||
|
||||
Create [src/features/store/StoreCard.tsx](src/features/store/StoreCard.tsx):
|
||||
|
||||
- Shows store name + logo
|
||||
- Shows primary address (if exists)
|
||||
- "View all locations" link (if multiple)
|
||||
|
||||
Update existing components to use StoreCard:
|
||||
|
||||
- Flyer listings
|
||||
- Deal listings
|
||||
- Receipt displays
|
||||
|
||||
#### 5.4 Location Selector Component
|
||||
|
||||
Create [src/features/store/LocationSelector.tsx](src/features/store/LocationSelector.tsx):
|
||||
|
||||
- Dropdown or map view
|
||||
- Filter stores by proximity (future: use lat/long)
|
||||
- Used in "Find deals near me" feature
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Update Integration Tests
|
||||
|
||||
All integration tests that create stores need to use `createStoreWithLocation()`:
|
||||
|
||||
**Files to update** (5 files):
|
||||
|
||||
1. [src/tests/integration/admin.integration.test.ts](src/tests/integration/admin.integration.test.ts) (line 164)
|
||||
2. [src/tests/integration/flyer.integration.test.ts](src/tests/integration/flyer.integration.test.ts) (line 28)
|
||||
3. [src/tests/integration/price.integration.test.ts](src/tests/integration/price.integration.test.ts) (line 48)
|
||||
4. [src/tests/integration/public.routes.integration.test.ts](src/tests/integration/public.routes.integration.test.ts) (line 66)
|
||||
5. [src/tests/integration/receipt.integration.test.ts](src/tests/integration/receipt.integration.test.ts) (line 252)
|
||||
|
||||
**Change pattern**:
|
||||
|
||||
```typescript
|
||||
// OLD:
|
||||
const storeResult = await pool.query('INSERT INTO stores (name) VALUES ($1) RETURNING store_id', [
|
||||
'Test Store',
|
||||
]);
|
||||
|
||||
// NEW:
|
||||
import { createStoreWithLocation } from '../utils/storeHelpers';
|
||||
const store = await createStoreWithLocation(pool, {
|
||||
name: 'Test Store',
|
||||
address: '123 Test St',
|
||||
city: 'Test City',
|
||||
province: 'ON',
|
||||
postalCode: 'M5V 1A1',
|
||||
});
|
||||
const storeId = store.storeId;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Update Unit Tests & Mocks
|
||||
|
||||
#### 7.1 Update Mock Factories
|
||||
|
||||
[src/tests/utils/mockFactories.ts](src/tests/utils/mockFactories.ts) - Add:
|
||||
|
||||
- `createMockStore(overrides?): Store`
|
||||
- `createMockAddress(overrides?): Address`
|
||||
- `createMockStoreLocation(overrides?): StoreLocation`
|
||||
- `createMockStoreWithLocation(overrides?): StoreWithLocation`
|
||||
|
||||
#### 7.2 Update Component Tests
|
||||
|
||||
Files that display stores need updated mocks:
|
||||
|
||||
- [src/features/flyer/FlyerDisplay.test.tsx](src/features/flyer/FlyerDisplay.test.tsx)
|
||||
- [src/features/flyer/FlyerList.test.tsx](src/features/flyer/FlyerList.test.tsx)
|
||||
- Any other components that show store data
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Schema Migration (IF NEEDED)
|
||||
|
||||
**Check**: Do we need to migrate existing data?
|
||||
|
||||
- If production has stores without addresses, we need to handle this
|
||||
- Options:
|
||||
1. Make addresses optional (store can exist without location)
|
||||
2. Create "Unknown Location" placeholder addresses
|
||||
3. Manual data entry for existing stores
|
||||
|
||||
**Migration file**: [sql/migrations/XXX_add_store_locations_data.sql](sql/migrations/XXX_add_store_locations_data.sql) (if needed)
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Documentation & Cache Invalidation
|
||||
|
||||
#### 9.1 Update API Documentation
|
||||
|
||||
- Add store endpoints to API docs
|
||||
- Document request/response formats
|
||||
- Add examples
|
||||
|
||||
#### 9.2 Cache Invalidation
|
||||
|
||||
[src/services/cacheService.server.ts](src/services/cacheService.server.ts):
|
||||
|
||||
- Add `invalidateStores()` method
|
||||
- Add `invalidateStoreLocations(storeId)` method
|
||||
- Call after create/update/delete operations
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files to Create (12 files):
|
||||
|
||||
1. `src/services/db/store.db.ts` - Store repository
|
||||
2. `src/services/db/store.db.test.ts` - Store repository tests
|
||||
3. `src/services/db/storeLocation.db.ts` - StoreLocation repository
|
||||
4. `src/services/db/storeLocation.db.test.ts` - StoreLocation tests
|
||||
5. `src/schemas/store.schema.ts` - Validation schemas
|
||||
6. `src/routes/store.routes.ts` - API endpoints
|
||||
7. `src/routes/store.routes.test.ts` - Route tests
|
||||
8. `src/pages/admin/components/AdminStoreManager.tsx` - Admin UI
|
||||
9. `src/features/store/StoreForm.tsx` - Store creation/edit form
|
||||
10. `src/features/store/StoreCard.tsx` - Display component
|
||||
11. `src/features/store/LocationSelector.tsx` - Location picker
|
||||
12. `STORE_ADDRESS_IMPLEMENTATION_PLAN.md` - This document
|
||||
|
||||
### Files to Modify (20+ files):
|
||||
|
||||
**Database Layer (3)**:
|
||||
|
||||
- `src/services/db/address.db.ts` - Add search functions
|
||||
- `src/services/db/admin.db.ts` - Update JOINs
|
||||
- `src/services/db/flyer.db.ts` - Update JOINs
|
||||
- `src/services/db/deals.db.ts` - Update queries
|
||||
- `src/services/db/receipt.db.ts` - Update queries
|
||||
|
||||
**API Routes (3)**:
|
||||
|
||||
- `src/routes/flyer.routes.ts` - Include address in responses
|
||||
- `src/routes/deals.routes.ts` - Include address in responses
|
||||
- `src/routes/receipt.routes.ts` - Include address in responses
|
||||
|
||||
**Types (1)**:
|
||||
|
||||
- `src/types.ts` - Add StoreWithLocation and CreateStoreRequest types
|
||||
|
||||
**Tests (10+)**:
|
||||
|
||||
- `src/tests/integration/admin.integration.test.ts`
|
||||
- `src/tests/integration/flyer.integration.test.ts`
|
||||
- `src/tests/integration/price.integration.test.ts`
|
||||
- `src/tests/integration/public.routes.integration.test.ts`
|
||||
- `src/tests/integration/receipt.integration.test.ts`
|
||||
- `src/tests/utils/mockFactories.ts`
|
||||
- `src/features/flyer/FlyerDisplay.test.tsx`
|
||||
- `src/features/flyer/FlyerList.test.tsx`
|
||||
- Component tests for new store UI
|
||||
|
||||
**Frontend (2+)**:
|
||||
|
||||
- `src/pages/admin/Dashboard.tsx` - Add store management link
|
||||
- Any components displaying store data
|
||||
|
||||
**Services (1)**:
|
||||
|
||||
- `src/services/cacheService.server.ts` - Add store cache methods
|
||||
|
||||
---
|
||||
|
||||
## Estimated Complexity
|
||||
|
||||
**Low Complexity** (Well-defined, straightforward):
|
||||
|
||||
- Phase 1: Database repositories (patterns exist)
|
||||
- Phase 2: Type definitions (simple)
|
||||
- Phase 6: Update integration tests (mechanical)
|
||||
|
||||
**Medium Complexity** (Requires design decisions):
|
||||
|
||||
- Phase 3: API routes (standard REST)
|
||||
- Phase 4: Update queries (SQL JOINs)
|
||||
- Phase 7: Update mocks (depends on types)
|
||||
- Phase 9: Cache invalidation (pattern exists)
|
||||
|
||||
**High Complexity** (Requires UX design, edge cases):
|
||||
|
||||
- Phase 5: Frontend components (UI/UX decisions)
|
||||
- Phase 8: Data migration (if needed)
|
||||
- Multi-location handling (one store, many addresses)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
**Critical Dependencies**:
|
||||
|
||||
1. Address data quality - garbage in, garbage out
|
||||
2. Google Maps API integration (future) - for geocoding/validation
|
||||
3. Multi-location handling - some stores have 100+ locations
|
||||
|
||||
**Risks**:
|
||||
|
||||
1. **Breaking changes**: Existing queries might break if address data is required
|
||||
2. **Performance**: Joining 3 tables (stores+store_locations+addresses) could be slow
|
||||
3. **Data migration**: Existing production stores have no addresses
|
||||
4. **Scope creep**: "Find stores near me" leads to mapping features
|
||||
|
||||
**Mitigation**:
|
||||
|
||||
- Make addresses OPTIONAL initially
|
||||
- Add database indexes on foreign keys
|
||||
- Use caching aggressively
|
||||
- Implement in phases (can stop after Phase 3 and assess)
|
||||
|
||||
---
|
||||
|
||||
## Questions for Approval
|
||||
|
||||
1. **Scope**: Implement all 9 phases, or start with Phase 1-3 (backend only)?
|
||||
2. **Addresses required**: Should stores REQUIRE an address, or is it optional?
|
||||
3. **Multi-location**: How to handle store chains with many locations?
|
||||
- Option A: One "primary" location
|
||||
- Option B: All locations equal
|
||||
- Option C: User selects location when viewing deals
|
||||
4. **Existing data**: How to handle production stores without addresses?
|
||||
5. **Priority**: Is this blocking other features, or can it wait?
|
||||
6. **Frontend design**: Do we have mockups for store management UI?
|
||||
|
||||
---
|
||||
|
||||
## Approval Checklist
|
||||
|
||||
Before starting implementation, confirm:
|
||||
|
||||
- [ ] Plan reviewed and approved by project lead
|
||||
- [ ] Scope defined (which phases to implement)
|
||||
- [ ] Multi-location strategy decided
|
||||
- [ ] Data migration plan approved (if needed)
|
||||
- [ ] Frontend design approved (if doing Phase 5)
|
||||
- [ ] Testing strategy approved
|
||||
- [ ] Estimated timeline acceptable
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Approval
|
||||
|
||||
1. Create feature branch: `feature/store-address-integration`
|
||||
2. Start with Phase 1.1 (StoreRepository)
|
||||
3. Write tests first (TDD approach)
|
||||
4. Implement phase by phase
|
||||
5. Request code review after each phase
|
||||
6. Merge only after ALL tests pass
|
||||
19
certs/localhost.crt
Normal file
19
certs/localhost.crt
Normal file
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCTCCAfGgAwIBAgIUHhZUK1vmww2wCepWPuVcU6d27hMwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDExODAyMzM0NFoXDTI3MDEx
|
||||
ODAyMzM0NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAuUJGtSZzd+ZpLi+efjrkxJJNfVxVz2VLhknNM2WKeOYx
|
||||
JTK/VaTYq5hrczy6fEUnMhDAJCgEPUFlOK3vn1gFJKNMN8m7arkLVk6PYtrx8CTw
|
||||
w78Q06FLITr6hR0vlJNpN4MsmGxYwUoUpn1j5JdfZF7foxNAZRiwoopf7ZJxltDu
|
||||
PIuFjmVZqdzR8c6vmqIqdawx/V6sL9fizZr+CDH3oTsTUirn2qM+1ibBtPDiBvfX
|
||||
omUsr6MVOcTtvnMvAdy9NfV88qwF7MEWBGCjXkoT1bKCLD8hjn8l7GjRmPcmMFE2
|
||||
GqWEvfJiFkBK0CgSHYEUwzo0UtVNeQr0k0qkDRub6QIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQU5VeD67yFLV0QNYbHaJ6u9cM6UbkwHwYDVR0jBBgwFoAU5VeD67yFLV0QNYbH
|
||||
aJ6u9cM6UbkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABueA
|
||||
8ujAD+yjeP5dTgqQH1G0hlriD5LmlJYnktaLarFU+y+EZlRFwjdORF/vLPwSG+y7
|
||||
CLty/xlmKKQop70QzQ5jtJcsWzUjww8w1sO3AevfZlIF3HNhJmt51ihfvtJ7DVCv
|
||||
CNyMeYO0pBqRKwOuhbG3EtJgyV7MF8J25UEtO4t+GzX3jcKKU4pWP+kyLBVfeDU3
|
||||
MQuigd2LBwBQQFxZdpYpcXVKnAJJlHZIt68ycO1oSBEJO9fIF0CiAlC6ITxjtYtz
|
||||
oCjd6cCLKMJiC6Zg7t1Q17vGl+FdGyQObSsiYsYO9N3CVaeDdpyGCH0Rfa0+oZzu
|
||||
a5U9/l1FHlvpX980bw==
|
||||
-----END CERTIFICATE-----
|
||||
28
certs/localhost.key
Normal file
28
certs/localhost.key
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5Qka1JnN35mku
|
||||
L55+OuTEkk19XFXPZUuGSc0zZYp45jElMr9VpNirmGtzPLp8RScyEMAkKAQ9QWU4
|
||||
re+fWAUko0w3ybtquQtWTo9i2vHwJPDDvxDToUshOvqFHS+Uk2k3gyyYbFjBShSm
|
||||
fWPkl19kXt+jE0BlGLCiil/tknGW0O48i4WOZVmp3NHxzq+aoip1rDH9Xqwv1+LN
|
||||
mv4IMfehOxNSKufaoz7WJsG08OIG99eiZSyvoxU5xO2+cy8B3L019XzyrAXswRYE
|
||||
YKNeShPVsoIsPyGOfyXsaNGY9yYwUTYapYS98mIWQErQKBIdgRTDOjRS1U15CvST
|
||||
SqQNG5vpAgMBAAECggEAAnv0Dw1Mv+rRy4ZyxtObEVPXPRzoxnDDXzHP4E16BTye
|
||||
Fc/4pSBUIAUn2bPvLz0/X8bMOa4dlDcIv7Eu9Pvns8AY70vMaUReA80fmtHVD2xX
|
||||
1PCT0X3InnxRAYKstSIUIGs+aHvV5Z+iJ8F82soOStN1MU56h+JLWElL5deCPHq3
|
||||
tLZT8wM9aOZlNG72kJ71+DlcViahynQj8+VrionOLNjTJ2Jv/ByjM3GMIuSdBrgd
|
||||
Sl4YAcdn6ontjJGoTgI+e+qkBAPwMZxHarNGQgbS0yNVIJe7Lq4zIKHErU/ZSmpD
|
||||
GzhdVNzhrjADNIDzS7G+pxtz+aUxGtmRvOyopy8GAQKBgQDEPp2mRM+uZVVT4e1j
|
||||
pkKO1c3O8j24I5mGKwFqhhNs3qGy051RXZa0+cQNx63GokXQan9DIXzc/Il7Y72E
|
||||
z9bCFbcSWnlP8dBIpWiJm+UmqLXRyY4N8ecNnzL5x+Tuxm5Ij+ixJwXgdz/TLNeO
|
||||
MBzu+Qy738/l/cAYxwcF7mR7AQKBgQDxq1F95HzCxBahRU9OGUO4s3naXqc8xKCC
|
||||
m3vbbI8V0Exse2cuiwtlPPQWzTPabLCJVvCGXNru98sdeOu9FO9yicwZX0knOABK
|
||||
QfPyDeITsh2u0C63+T9DNn6ixI/T68bTs7DHawEYbpS7bR50BnbHbQrrOAo6FSXF
|
||||
yC7+Te+o6QKBgQCXEWSmo/4D0Dn5Usg9l7VQ40GFd3EPmUgLwntal0/I1TFAyiom
|
||||
gpcLReIogXhCmpSHthO1h8fpDfZ/p+4ymRRHYBQH6uHMKugdpEdu9zVVpzYgArp5
|
||||
/afSEqVZJwoSzWoELdQA23toqiPV2oUtDdiYFdw5nDccY1RHPp8nb7amAQKBgQDj
|
||||
f4DhYDxKJMmg21xCiuoDb4DgHoaUYA0xpii8cL9pq4KmBK0nVWFO1kh5Robvsa2m
|
||||
PB+EfNjkaIPepLxWbOTUEAAASoDU2JT9UoTQcl1GaUAkFnpEWfBB14TyuNMkjinH
|
||||
lLpvn72SQFbm8VvfoU4jgfTrZP/LmajLPR1v6/IWMQKBgBh9qvOTax/GugBAWNj3
|
||||
ZvF99rHOx0rfotEdaPcRN66OOiSWILR9yfMsTvwt1V0VEj7OqO9juMRFuIyB57gd
|
||||
Hs/zgbkuggqjr1dW9r22P/UpzpodAEEN2d52RSX8nkMOkH61JXlH2MyRX65kdExA
|
||||
VkTDq6KwomuhrU3z0+r/MSOn
|
||||
-----END PRIVATE KEY-----
|
||||
195
compose.dev.yml
Normal file
195
compose.dev.yml
Normal file
@@ -0,0 +1,195 @@
|
||||
# compose.dev.yml
|
||||
# ============================================================================
|
||||
# DEVELOPMENT DOCKER COMPOSE CONFIGURATION
|
||||
# ============================================================================
|
||||
# This file defines the local development environment using Docker/Podman.
|
||||
#
|
||||
# Services:
|
||||
# - app: Node.js application (API + Frontend + Bugsink + Logstash)
|
||||
# - postgres: PostgreSQL 15 with PostGIS extension
|
||||
# - redis: Redis for caching and job queues
|
||||
#
|
||||
# Usage:
|
||||
# Start all services: podman-compose -f compose.dev.yml up -d
|
||||
# Stop all services: podman-compose -f compose.dev.yml down
|
||||
# View logs: podman-compose -f compose.dev.yml logs -f
|
||||
# Reset everything: podman-compose -f compose.dev.yml down -v
|
||||
#
|
||||
# VS Code Dev Containers:
|
||||
# This file is referenced by .devcontainer/devcontainer.json for seamless
|
||||
# VS Code integration. Open the project in VS Code and use "Reopen in Container".
|
||||
#
|
||||
# Bugsink (ADR-015):
|
||||
# Access error tracking UI at http://localhost:8000
|
||||
# Default login: admin@localhost / admin
|
||||
# ============================================================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===================
|
||||
# Application Service
|
||||
# ===================
|
||||
app:
|
||||
container_name: flyer-crawler-dev
|
||||
# Use pre-built image if available, otherwise build from Dockerfile.dev
|
||||
# To build: podman build -f Dockerfile.dev -t flyer-crawler-dev:latest .
|
||||
image: localhost/flyer-crawler-dev:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
# Mount the current directory to /app in the container
|
||||
- .:/app
|
||||
# Create a volume for node_modules to avoid conflicts with Windows host
|
||||
# and improve performance.
|
||||
- node_modules_data:/app/node_modules
|
||||
# Mount PostgreSQL logs for Logstash access (ADR-050)
|
||||
- postgres_logs:/var/log/postgresql:ro
|
||||
ports:
|
||||
- '3000:3000' # Frontend (Vite default)
|
||||
- '3001:3001' # Backend API
|
||||
- '8000:8000' # Bugsink error tracking (ADR-015)
|
||||
environment:
|
||||
# Core settings
|
||||
- NODE_ENV=development
|
||||
# Database - use service name for Docker networking
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_NAME=flyer_crawler_dev
|
||||
# Redis - use service name for Docker networking
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
# Frontend URL for CORS
|
||||
- FRONTEND_URL=http://localhost:3000
|
||||
# Default JWT secret for development (override in production!)
|
||||
- JWT_SECRET=dev-jwt-secret-change-in-production
|
||||
# Worker settings
|
||||
- WORKER_LOCK_DURATION=120000
|
||||
# Bugsink error tracking (ADR-015)
|
||||
- BUGSINK_DB_HOST=postgres
|
||||
- BUGSINK_DB_PORT=5432
|
||||
- BUGSINK_DB_NAME=bugsink
|
||||
- BUGSINK_DB_USER=bugsink
|
||||
- BUGSINK_DB_PASSWORD=bugsink_dev_password
|
||||
- BUGSINK_PORT=8000
|
||||
- BUGSINK_BASE_URL=http://localhost:8000
|
||||
- BUGSINK_ADMIN_EMAIL=admin@localhost
|
||||
- BUGSINK_ADMIN_PASSWORD=admin
|
||||
- BUGSINK_SECRET_KEY=dev-bugsink-secret-key-minimum-50-characters-for-security
|
||||
# Sentry SDK configuration (points to local Bugsink)
|
||||
- SENTRY_DSN=http://59a58583-e869-7697-f94a-cfa0337676a8@localhost:8000/1
|
||||
- VITE_SENTRY_DSN=http://d5fc5221-4266-ff2f-9af8-5689696072f3@localhost:8000/2
|
||||
- SENTRY_ENVIRONMENT=development
|
||||
- VITE_SENTRY_ENVIRONMENT=development
|
||||
- SENTRY_ENABLED=true
|
||||
- VITE_SENTRY_ENABLED=true
|
||||
- SENTRY_DEBUG=true
|
||||
- VITE_SENTRY_DEBUG=true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# Keep container running so VS Code can attach
|
||||
command: tail -f /dev/null
|
||||
# Healthcheck for the app (once it's running)
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3001/api/health', '||', 'exit', '0']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# ===================
|
||||
# PostgreSQL Database
|
||||
# ===================
|
||||
postgres:
|
||||
image: docker.io/postgis/postgis:15-3.4
|
||||
container_name: flyer-crawler-postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: flyer_crawler_dev
|
||||
# Optimize for development
|
||||
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# Mount init scripts to run on first database creation
|
||||
# Scripts run in alphabetical order: 00-extensions, 01-bugsink
|
||||
- ./sql/00-init-extensions.sql:/docker-entrypoint-initdb.d/00-init-extensions.sql:ro
|
||||
- ./sql/01-init-bugsink.sh:/docker-entrypoint-initdb.d/01-init-bugsink.sh:ro
|
||||
# Mount custom PostgreSQL configuration (ADR-050)
|
||||
- ./docker/postgres/postgresql.conf.override:/etc/postgresql/postgresql.conf.d/custom.conf:ro
|
||||
# Create log volume for Logstash access (ADR-050)
|
||||
- postgres_logs:/var/log/postgresql
|
||||
# Override postgres command to include custom config (ADR-050)
|
||||
command: >
|
||||
postgres
|
||||
-c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
-c hba_file=/var/lib/postgresql/data/pg_hba.conf
|
||||
-c log_min_messages=notice
|
||||
-c client_min_messages=notice
|
||||
-c logging_collector=on
|
||||
-c log_destination=stderr
|
||||
-c log_directory=/var/log/postgresql
|
||||
-c log_filename=postgresql-%Y-%m-%d.log
|
||||
-c log_rotation_age=1d
|
||||
-c log_rotation_size=100MB
|
||||
-c log_truncate_on_rotation=on
|
||||
-c log_line_prefix='%t [%p] %u@%d '
|
||||
-c log_min_duration_statement=1000
|
||||
-c log_statement=none
|
||||
-c log_connections=on
|
||||
-c log_disconnections=on
|
||||
# Healthcheck ensures postgres is ready before app starts
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d flyer_crawler_dev']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
# ===================
|
||||
# Redis Cache/Queue
|
||||
# ===================
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine
|
||||
container_name: flyer-crawler-redis
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
# Healthcheck ensures redis is ready before app starts
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
# Enable persistence for development data
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
# ===================
|
||||
# Named Volumes
|
||||
# ===================
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: flyer-crawler-postgres-data
|
||||
postgres_logs:
|
||||
name: flyer-crawler-postgres-logs
|
||||
redis_data:
|
||||
name: flyer-crawler-redis-data
|
||||
node_modules_data:
|
||||
name: flyer-crawler-node-modules
|
||||
|
||||
# ===================
|
||||
# Network Configuration
|
||||
# ===================
|
||||
# All services are on the default bridge network.
|
||||
# Use service names (postgres, redis) as hostnames.
|
||||
29
docker/postgres/postgresql.conf.override
Normal file
29
docker/postgres/postgresql.conf.override
Normal file
@@ -0,0 +1,29 @@
|
||||
# PostgreSQL Logging Configuration for Database Function Observability (ADR-050)
|
||||
# This file is mounted into the PostgreSQL container to enable structured logging
|
||||
# from database functions via fn_log()
|
||||
|
||||
# Enable logging to files for Logstash pickup
|
||||
logging_collector = on
|
||||
log_destination = 'stderr'
|
||||
log_directory = '/var/log/postgresql'
|
||||
log_filename = 'postgresql-%Y-%m-%d.log'
|
||||
log_rotation_age = 1d
|
||||
log_rotation_size = 100MB
|
||||
log_truncate_on_rotation = on
|
||||
|
||||
# Log level - capture NOTICE and above (includes fn_log WARNING/ERROR)
|
||||
log_min_messages = notice
|
||||
client_min_messages = notice
|
||||
|
||||
# Include useful context in log prefix
|
||||
log_line_prefix = '%t [%p] %u@%d '
|
||||
|
||||
# Capture slow queries from functions (1 second threshold)
|
||||
log_min_duration_statement = 1000
|
||||
|
||||
# Log statement types (off for production, 'all' for debugging)
|
||||
log_statement = 'none'
|
||||
|
||||
# Connection logging (useful for dev, can be disabled in production)
|
||||
log_connections = on
|
||||
log_disconnections = on
|
||||
1961
docs/BARE-METAL-SETUP.md
Normal file
1961
docs/BARE-METAL-SETUP.md
Normal file
File diff suppressed because it is too large
Load Diff
271
docs/BUGSINK-SYNC.md
Normal file
271
docs/BUGSINK-SYNC.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Bugsink to Gitea Issue Synchronization
|
||||
|
||||
This document describes the automated workflow for syncing Bugsink error tracking issues to Gitea tickets.
|
||||
|
||||
## Overview
|
||||
|
||||
The sync system automatically creates Gitea issues from unresolved Bugsink errors, ensuring all application errors are tracked and assignable.
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Runs **only on test/staging server** (not production)
|
||||
- Syncs **all 6 Bugsink projects** (including production errors)
|
||||
- Creates Gitea issues with full error context
|
||||
- Marks synced issues as resolved in Bugsink
|
||||
- Uses Redis db 15 for sync state tracking
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TEST/STAGING SERVER
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ BullMQ Queue ──▶ Sync Worker ──▶ Redis DB 15 │
|
||||
│ (bugsink-sync) (15min) (sync state) │
|
||||
│ │ │
|
||||
└──────────────────────┼───────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ Bugsink │ │ Gitea │
|
||||
│ (read) │ │ (write) │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Bugsink Projects
|
||||
|
||||
| Project Slug | Type | Environment | Label Mapping |
|
||||
| --------------------------------- | -------- | ----------- | ----------------------------------- |
|
||||
| flyer-crawler-backend | Backend | Production | bug:backend + env:production |
|
||||
| flyer-crawler-backend-test | Backend | Test | bug:backend + env:test |
|
||||
| flyer-crawler-frontend | Frontend | Production | bug:frontend + env:production |
|
||||
| flyer-crawler-frontend-test | Frontend | Test | bug:frontend + env:test |
|
||||
| flyer-crawler-infrastructure | Infra | Production | bug:infrastructure + env:production |
|
||||
| flyer-crawler-test-infrastructure | Infra | Test | bug:infrastructure + env:test |
|
||||
|
||||
## Gitea Labels
|
||||
|
||||
| Label | Color | ID |
|
||||
| ------------------ | ------------------ | --- |
|
||||
| bug:frontend | #e11d48 (Red) | 8 |
|
||||
| bug:backend | #ea580c (Orange) | 9 |
|
||||
| bug:infrastructure | #7c3aed (Purple) | 10 |
|
||||
| env:production | #dc2626 (Dark Red) | 11 |
|
||||
| env:test | #2563eb (Blue) | 12 |
|
||||
| env:development | #6b7280 (Gray) | 13 |
|
||||
| source:bugsink | #10b981 (Green) | 14 |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Add these to **test environment only** (`deploy-to-test.yml`):
|
||||
|
||||
```bash
|
||||
# Bugsink API
|
||||
BUGSINK_URL=https://bugsink.projectium.com
|
||||
BUGSINK_API_TOKEN=<from Bugsink Settings > API Keys>
|
||||
|
||||
# Gitea API
|
||||
GITEA_URL=https://gitea.projectium.com
|
||||
GITEA_API_TOKEN=<personal access token with repo scope>
|
||||
GITEA_OWNER=torbo
|
||||
GITEA_REPO=flyer-crawler.projectium.com
|
||||
|
||||
# Sync Control
|
||||
BUGSINK_SYNC_ENABLED=true # Only set true in test env
|
||||
BUGSINK_SYNC_INTERVAL=15 # Minutes between sync runs
|
||||
```
|
||||
|
||||
## Gitea Secrets to Add
|
||||
|
||||
Add these secrets in Gitea repository settings (Settings > Secrets):
|
||||
|
||||
| Secret Name | Value | Environment |
|
||||
| ---------------------- | ---------------------- | ----------- |
|
||||
| `BUGSINK_API_TOKEN` | API token from Bugsink | Test only |
|
||||
| `GITEA_SYNC_TOKEN` | Personal access token | Test only |
|
||||
| `BUGSINK_SYNC_ENABLED` | `true` | Test only |
|
||||
|
||||
## Redis Configuration
|
||||
|
||||
| Database | Purpose |
|
||||
| -------- | ------------------------ |
|
||||
| 0 | BullMQ production queues |
|
||||
| 1 | BullMQ test queues |
|
||||
| 15 | Bugsink sync state |
|
||||
|
||||
**Key Pattern:**
|
||||
|
||||
```
|
||||
bugsink:synced:{issue_uuid}
|
||||
```
|
||||
|
||||
**Value (JSON):**
|
||||
|
||||
```json
|
||||
{
|
||||
"gitea_issue_number": 42,
|
||||
"synced_at": "2026-01-17T10:30:00Z",
|
||||
"project": "flyer-crawler-frontend-test",
|
||||
"title": "[TypeError] t.map is not a function"
|
||||
}
|
||||
```
|
||||
|
||||
## Sync Workflow
|
||||
|
||||
1. **Trigger**: Every 15 minutes (or manual via admin API)
|
||||
2. **Fetch**: List unresolved issues from all 6 Bugsink projects
|
||||
3. **Check**: Skip issues already in Redis sync state
|
||||
4. **Create**: Create Gitea issue with labels and full context
|
||||
5. **Record**: Store sync mapping in Redis db 15
|
||||
6. **Resolve**: Mark issue as resolved in Bugsink
|
||||
|
||||
## Issue Template
|
||||
|
||||
Created Gitea issues follow this format:
|
||||
|
||||
```markdown
|
||||
## Error Details
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ----------------------- |
|
||||
| **Type** | TypeError |
|
||||
| **Message** | t.map is not a function |
|
||||
| **Platform** | javascript |
|
||||
| **Level** | error |
|
||||
|
||||
## Occurrence Statistics
|
||||
|
||||
- **First Seen**: 2026-01-13 18:24:22 UTC
|
||||
- **Last Seen**: 2026-01-16 05:03:02 UTC
|
||||
- **Total Occurrences**: 4
|
||||
|
||||
## Request Context
|
||||
|
||||
- **URL**: GET https://flyer-crawler-test.projectium.com/
|
||||
|
||||
## Stacktrace
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
[Full stacktrace]
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
**Bugsink Issue**: https://bugsink.projectium.com/issues/{id}
|
||||
**Project**: flyer-crawler-frontend-test
|
||||
```
|
||||
|
||||
## Admin Endpoints
|
||||
|
||||
### Manual Sync Trigger
|
||||
|
||||
```bash
|
||||
POST /api/admin/bugsink/sync
|
||||
Authorization: Bearer <admin_jwt>
|
||||
|
||||
# Response
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"synced": 3,
|
||||
"skipped": 12,
|
||||
"failed": 0,
|
||||
"duration_ms": 2340
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sync Status
|
||||
|
||||
```bash
|
||||
GET /api/admin/bugsink/sync/status
|
||||
Authorization: Bearer <admin_jwt>
|
||||
|
||||
# Response
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"last_run": "2026-01-17T10:30:00Z",
|
||||
"next_run": "2026-01-17T10:45:00Z",
|
||||
"total_synced": 47
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------- |
|
||||
| `src/services/bugsinkSync.server.ts` | Core sync logic |
|
||||
| `src/services/bugsinkClient.server.ts` | Bugsink HTTP client |
|
||||
| `src/services/giteaClient.server.ts` | Gitea HTTP client |
|
||||
| `src/types/bugsink.ts` | TypeScript interfaces |
|
||||
| `src/routes/admin/bugsink-sync.ts` | Admin endpoints |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------- | ------------------------- |
|
||||
| `src/services/queues.server.ts` | Add `bugsinkSyncQueue` |
|
||||
| `src/services/workers.server.ts` | Add sync worker |
|
||||
| `src/config/env.ts` | Add bugsink config schema |
|
||||
| `.env.example` | Document new variables |
|
||||
| `.gitea/workflows/deploy-to-test.yml` | Pass secrets |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
- [ ] Add env vars to `env.ts` schema
|
||||
- [ ] Create BugsinkClient service
|
||||
- [ ] Create GiteaClient service
|
||||
- [ ] Add Redis db 15 connection
|
||||
|
||||
### Phase 2: Sync Logic
|
||||
|
||||
- [ ] Create BugsinkSyncService
|
||||
- [ ] Add bugsink-sync queue
|
||||
- [ ] Add sync worker
|
||||
- [ ] Create TypeScript types
|
||||
|
||||
### Phase 3: Integration
|
||||
|
||||
- [ ] Add admin endpoints
|
||||
- [ ] Update deploy-to-test.yml
|
||||
- [ ] Add Gitea secrets
|
||||
- [ ] End-to-end testing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sync not running
|
||||
|
||||
1. Check `BUGSINK_SYNC_ENABLED` is `true`
|
||||
2. Verify worker is running: `GET /api/admin/workers/status`
|
||||
3. Check Bull Board: `/api/admin/jobs`
|
||||
|
||||
### Duplicate issues created
|
||||
|
||||
1. Check Redis db 15 connectivity
|
||||
2. Verify sync state keys exist: `redis-cli -n 15 KEYS "bugsink:*"`
|
||||
|
||||
### Issues not resolving in Bugsink
|
||||
|
||||
1. Verify `BUGSINK_API_TOKEN` has write permissions
|
||||
2. Check worker logs for API errors
|
||||
|
||||
### Missing stacktrace in Gitea issue
|
||||
|
||||
1. Source maps may not be uploaded
|
||||
2. Bugsink API may have returned partial data
|
||||
3. Check worker logs for fetch errors
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR-054: Bugsink-Gitea Sync](./adr/0054-bugsink-gitea-issue-sync.md)
|
||||
- [ADR-006: Background Job Processing](./adr/0006-background-job-processing-and-task-queues.md)
|
||||
- [ADR-015: Error Tracking](./adr/0015-application-performance-monitoring-and-error-tracking.md)
|
||||
460
docs/LOGSTASH-TROUBLESHOOTING.md
Normal file
460
docs/LOGSTASH-TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# Logstash Troubleshooting Runbook
|
||||
|
||||
This runbook provides step-by-step diagnostics and solutions for common Logstash issues in the PostgreSQL observability pipeline (ADR-050).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Symptom | Most Likely Cause | Quick Check |
|
||||
| ------------------------ | ---------------------------- | ------------------------------------- |
|
||||
| No errors in Bugsink | Logstash not running | `systemctl status logstash` |
|
||||
| Events not processed | Grok pattern mismatch | Check filter failures in stats |
|
||||
| Wrong Bugsink project | Environment detection failed | Verify `pg_database` field extraction |
|
||||
| 403 authentication error | Missing/wrong DSN key | Check `X-Sentry-Auth` header |
|
||||
| 500 error from Bugsink | Invalid event format | Verify `event_id` and required fields |
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Steps
|
||||
|
||||
### 1. Verify Logstash is Running
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status logstash
|
||||
|
||||
# If stopped, start it
|
||||
systemctl start logstash
|
||||
|
||||
# View recent logs
|
||||
journalctl -u logstash -n 50 --no-pager
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
|
||||
- Status: `active (running)`
|
||||
- No error messages in recent logs
|
||||
|
||||
---
|
||||
|
||||
### 2. Check Configuration Syntax
|
||||
|
||||
```bash
|
||||
# Test configuration file
|
||||
/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
|
||||
```
|
||||
Configuration OK
|
||||
```
|
||||
|
||||
**If syntax errors:**
|
||||
|
||||
1. Review error message for line number
|
||||
2. Check for missing braces, quotes, or commas
|
||||
3. Verify plugin names are correct (e.g., `json`, `grok`, `uuid`, `http`)
|
||||
|
||||
---
|
||||
|
||||
### 3. Verify PostgreSQL Logs Are Being Read
|
||||
|
||||
```bash
|
||||
# Check if log file exists and has content
|
||||
ls -lh /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
|
||||
|
||||
# Check Logstash can read the file
|
||||
sudo -u logstash cat /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | head -10
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
|
||||
- Log file exists and is not empty
|
||||
- Logstash user can read the file without permission errors
|
||||
|
||||
**If permission denied:**
|
||||
|
||||
```bash
|
||||
# Check Logstash is in postgres group
|
||||
groups logstash
|
||||
|
||||
# Should show: logstash : logstash adm postgres
|
||||
|
||||
# If not, add to group
|
||||
usermod -a -G postgres logstash
|
||||
systemctl restart logstash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Check Logstash Pipeline Stats
|
||||
|
||||
```bash
|
||||
# Get pipeline statistics
|
||||
curl -XGET 'localhost:9600/_node/stats/pipelines?pretty' | jq '.pipelines.main.plugins.filters'
|
||||
```
|
||||
|
||||
**Key metrics to check:**
|
||||
|
||||
1. **Grok filter events:**
|
||||
- `"events.in"` - Total events received
|
||||
- `"events.out"` - Events successfully parsed
|
||||
- `"failures"` - Events that failed to parse
|
||||
|
||||
**If failures > 0:** Grok pattern doesn't match log format. Check PostgreSQL log format.
|
||||
|
||||
2. **JSON filter events:**
|
||||
- `"events.in"` - Events received by JSON parser
|
||||
- `"events.out"` - Successfully parsed JSON
|
||||
|
||||
**If events.in = 0:** Regex check `pg_message =~ /^\{/` is not matching. Verify fn_log() output format.
|
||||
|
||||
3. **UUID filter events:**
|
||||
- Should match number of errors being forwarded
|
||||
|
||||
---
|
||||
|
||||
### 5. Test Grok Pattern Manually
|
||||
|
||||
```bash
|
||||
# Get a sample log line
|
||||
tail -1 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
|
||||
|
||||
# Example expected format:
|
||||
# 2026-01-20 10:30:00 +05 [12345] flyer_crawler_prod@flyer-crawler-prod WARNING: {"level":"WARNING","source":"postgresql",...}
|
||||
```
|
||||
|
||||
**Pattern breakdown:**
|
||||
|
||||
```
|
||||
%{TIMESTAMP_ISO8601:pg_timestamp} # 2026-01-20 10:30:00
|
||||
[+-]%{INT:pg_timezone} # +05
|
||||
\[%{POSINT:pg_pid}\] # [12345]
|
||||
%{DATA:pg_user}@%{DATA:pg_database} # flyer_crawler_prod@flyer-crawler-prod
|
||||
%{WORD:pg_level}: # WARNING:
|
||||
%{GREEDYDATA:pg_message} # (rest of line)
|
||||
```
|
||||
|
||||
**If pattern doesn't match:**
|
||||
|
||||
1. Check PostgreSQL `log_line_prefix` setting in `/etc/postgresql/14/main/conf.d/observability.conf`
|
||||
2. Should be: `log_line_prefix = '%t [%p] %u@%d '`
|
||||
3. Restart PostgreSQL if changed: `systemctl restart postgresql`
|
||||
|
||||
---
|
||||
|
||||
### 6. Verify Environment Detection
|
||||
|
||||
```bash
|
||||
# Check recent PostgreSQL logs for database field
|
||||
tail -20 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep -E "flyer-crawler-(prod|test)"
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
|
||||
- Production database: `flyer_crawler_prod@flyer-crawler-prod`
|
||||
- Test database: `flyer_crawler_test@flyer-crawler-test`
|
||||
|
||||
**If database name doesn't match:**
|
||||
|
||||
- Check database connection string in application
|
||||
- Verify `DB_DATABASE_PROD` and `DB_DATABASE_TEST` Gitea secrets
|
||||
|
||||
---
|
||||
|
||||
### 7. Test Bugsink API Connection
|
||||
|
||||
```bash
|
||||
# Test production endpoint
|
||||
curl -X POST https://bugsink.projectium.com/api/1/store/ \
|
||||
-H "X-Sentry-Auth: Sentry sentry_version=7, sentry_client=test/1.0, sentry_key=911aef02b9a548fa8fabb8a3c81abfe5" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event_id": "12345678901234567890123456789012",
|
||||
"timestamp": "2026-01-20T10:30:00Z",
|
||||
"platform": "other",
|
||||
"level": "error",
|
||||
"logger": "test",
|
||||
"message": "Test error from troubleshooting"
|
||||
}'
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
|
||||
- HTTP 200 OK
|
||||
- Response body: `{"id": "..."}`
|
||||
|
||||
**If 403 Forbidden:**
|
||||
|
||||
- DSN key is wrong in `/etc/logstash/conf.d/bugsink.conf`
|
||||
- Get correct key from Bugsink UI: Settings → Projects → DSN
|
||||
|
||||
**If 500 Internal Server Error:**
|
||||
|
||||
- Missing required fields (event_id, timestamp, level)
|
||||
- Check `mapping` section in Logstash config
|
||||
|
||||
---
|
||||
|
||||
### 8. Monitor Logstash Output in Real-Time
|
||||
|
||||
```bash
|
||||
# Watch Logstash processing logs
|
||||
journalctl -u logstash -f
|
||||
```
|
||||
|
||||
**What to look for:**
|
||||
|
||||
- `"response code => 200"` - Successful forwarding to Bugsink
|
||||
- `"response code => 403"` - Authentication failure
|
||||
- `"response code => 500"` - Invalid event format
|
||||
- Grok parse failures
|
||||
|
||||
---
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: Grok Pattern Parse Failures
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Logstash stats show increasing `"failures"` count
|
||||
- No events reaching Bugsink
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
curl -XGET 'localhost:9600/_node/stats/pipelines?pretty' | jq '.pipelines.main.plugins.filters[] | select(.name == "grok") | .failures'
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check PostgreSQL log format matches expected pattern
|
||||
2. Verify `log_line_prefix` in PostgreSQL config
|
||||
3. Test with sample log line using Grok Debugger (Kibana Dev Tools)
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: JSON Filter Not Parsing fn_log() Output
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Grok parses successfully but JSON filter shows 0 events
|
||||
- `[fn_log]` fields missing in Logstash output
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check if pg_message field contains JSON
|
||||
tail -20 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep "WARNING:" | grep "{"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify `fn_log()` function exists in database:
|
||||
```sql
|
||||
\df fn_log
|
||||
```
|
||||
2. Test `fn_log()` output format:
|
||||
```sql
|
||||
SELECT fn_log('WARNING', 'test', 'Test message', '{"key":"value"}'::jsonb);
|
||||
```
|
||||
3. Check logs show JSON output starting with `{`
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: Events Going to Wrong Bugsink Project
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Production errors appear in test project (or vice versa)
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check database name detection in recent logs
|
||||
tail -50 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep -E "(flyer-crawler-prod|flyer-crawler-test)"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify database names in filter section match actual database names
|
||||
2. Check `pg_database` field is correctly extracted by grok pattern:
|
||||
```bash
|
||||
# Enable debug output in Logstash config temporarily
|
||||
stdout { codec => rubydebug { metadata => true } }
|
||||
```
|
||||
3. Verify environment tagging in filter:
|
||||
- `pg_database == "flyer-crawler-prod"` → adds "production" tag → routes to project 1
|
||||
- `pg_database == "flyer-crawler-test"` → adds "test" tag → routes to project 3
|
||||
|
||||
---
|
||||
|
||||
### Issue 4: 403 Authentication Errors from Bugsink
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Logstash logs show `response code => 403`
|
||||
- Events not appearing in Bugsink
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check Logstash output logs for authentication errors
|
||||
journalctl -u logstash -n 100 | grep "403"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify DSN key in `/etc/logstash/conf.d/bugsink.conf` matches Bugsink project
|
||||
2. Get correct DSN from Bugsink UI:
|
||||
- Navigate to Settings → Projects → Click project
|
||||
- Copy "DSN" value
|
||||
- Extract key: `http://KEY@host/PROJECT_ID` → use KEY
|
||||
3. Update `X-Sentry-Auth` header in Logstash config:
|
||||
```conf
|
||||
"X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=YOUR_KEY_HERE"
|
||||
```
|
||||
4. Restart Logstash: `systemctl restart logstash`
|
||||
|
||||
---
|
||||
|
||||
### Issue 5: 500 Errors from Bugsink
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Logstash logs show `response code => 500`
|
||||
- Bugsink logs show validation errors
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check Bugsink logs for details
|
||||
docker logs bugsink-web 2>&1 | tail -50
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
1. Missing `event_id` field
|
||||
2. Invalid timestamp format
|
||||
3. Missing required Sentry fields
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify `uuid` filter is generating `event_id`:
|
||||
```conf
|
||||
uuid {
|
||||
target => "[@metadata][event_id]"
|
||||
overwrite => true
|
||||
}
|
||||
```
|
||||
2. Check `mapping` section includes all required fields:
|
||||
- `event_id` (UUID)
|
||||
- `timestamp` (ISO 8601)
|
||||
- `platform` (string)
|
||||
- `level` (error/warning/info)
|
||||
- `logger` (string)
|
||||
- `message` (string)
|
||||
|
||||
---
|
||||
|
||||
### Issue 6: High Memory Usage by Logstash
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Server running out of memory
|
||||
- Logstash OOM killed
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check Logstash memory usage
|
||||
ps aux | grep logstash
|
||||
systemctl status logstash
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Limit Logstash heap size in `/etc/logstash/jvm.options`:
|
||||
```
|
||||
-Xms1g
|
||||
-Xmx1g
|
||||
```
|
||||
2. Restart Logstash: `systemctl restart logstash`
|
||||
3. Monitor with: `top -p $(pgrep -f logstash)`
|
||||
|
||||
---
|
||||
|
||||
### Issue 7: Log File Rotation Issues
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Logstash stops processing after log file rotates
|
||||
- Sincedb file pointing to old inode
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Check sincedb file
|
||||
cat /var/lib/logstash/sincedb_postgres
|
||||
|
||||
# Check current log file inode
|
||||
ls -li /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Logstash should automatically detect rotation
|
||||
2. If stuck, delete sincedb file (will reprocess recent logs):
|
||||
```bash
|
||||
systemctl stop logstash
|
||||
rm /var/lib/logstash/sincedb_postgres
|
||||
systemctl start logstash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After making any changes, verify the pipeline is working:
|
||||
|
||||
- [ ] Logstash is running: `systemctl status logstash`
|
||||
- [ ] Configuration is valid: `/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf`
|
||||
- [ ] No grok failures: `curl localhost:9600/_node/stats/pipelines?pretty | jq '.pipelines.main.plugins.filters[] | select(.name == "grok") | .failures'`
|
||||
- [ ] Events being processed: `curl localhost:9600/_node/stats/pipelines?pretty | jq '.pipelines.main.events'`
|
||||
- [ ] Test error appears in Bugsink: Trigger a database function error and check Bugsink UI
|
||||
|
||||
---
|
||||
|
||||
## Test Database Function Error
|
||||
|
||||
To generate a test error for verification:
|
||||
|
||||
```bash
|
||||
# Connect to production database
|
||||
sudo -u postgres psql -d flyer-crawler-prod
|
||||
|
||||
# Trigger an error (achievement not found)
|
||||
SELECT award_achievement('00000000-0000-0000-0000-000000000001'::uuid, 'Nonexistent Badge');
|
||||
\q
|
||||
```
|
||||
|
||||
**Expected flow:**
|
||||
|
||||
1. PostgreSQL logs the error to `/var/log/postgresql/postgresql-YYYY-MM-DD.log`
|
||||
2. Logstash reads and parses the log (within ~30 seconds)
|
||||
3. Error appears in Bugsink project 1 (production)
|
||||
|
||||
**If error doesn't appear:**
|
||||
|
||||
- Check each diagnostic step above
|
||||
- Review Logstash logs: `journalctl -u logstash -f`
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Setup Guide**: [docs/BARE-METAL-SETUP.md](BARE-METAL-SETUP.md) - PostgreSQL Function Observability section
|
||||
- **Architecture**: [docs/adr/0050-postgresql-function-observability.md](adr/0050-postgresql-function-observability.md)
|
||||
- **Configuration Reference**: [CLAUDE.md](../CLAUDE.md) - Logstash Configuration section
|
||||
- **Bugsink MCP Server**: [CLAUDE.md](../CLAUDE.md) - Sentry/Bugsink MCP Server Setup section
|
||||
311
docs/SCHEMA_RELATIONSHIP_ANALYSIS.md
Normal file
311
docs/SCHEMA_RELATIONSHIP_ANALYSIS.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Database Schema Relationship Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document analyzes the database schema to identify missing table relationships and JOINs that aren't properly implemented in the codebase. This analysis was triggered by discovering that `WatchedItemDeal` was using a `store_name` string instead of a proper `store` object with nested locations.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### ✅ CORRECTLY IMPLEMENTED
|
||||
|
||||
#### 1. Store → Store Locations → Addresses (3-table normalization)
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
stores (store_id) → store_locations (store_location_id) → addresses (address_id)
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- [src/services/db/storeLocation.db.ts](src/services/db/storeLocation.db.ts) properly JOINs all three tables
|
||||
- [src/types.ts](src/types.ts) defines `StoreWithLocations` interface with nested address objects
|
||||
- Recent fixes corrected `WatchedItemDeal` to use `store` object instead of `store_name` string
|
||||
|
||||
**Queries:**
|
||||
|
||||
```typescript
|
||||
// From storeLocation.db.ts
|
||||
FROM public.stores s
|
||||
LEFT JOIN public.store_locations sl ON s.store_id = sl.store_id
|
||||
LEFT JOIN public.addresses a ON sl.address_id = a.address_id
|
||||
```
|
||||
|
||||
#### 2. Shopping Trips → Shopping Trip Items
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
shopping_trips (shopping_trip_id) → shopping_trip_items (shopping_trip_item_id) → master_grocery_items
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- [src/services/db/shopping.db.ts:513-518](src/services/db/shopping.db.ts#L513-L518) properly JOINs shopping_trips → shopping_trip_items → master_grocery_items
|
||||
- Uses `json_agg` to nest items array within trip object
|
||||
- [src/types.ts:639-647](src/types.ts#L639-L647) `ShoppingTrip` interface includes nested `items: ShoppingTripItem[]`
|
||||
|
||||
**Queries:**
|
||||
|
||||
```typescript
|
||||
FROM public.shopping_trips st
|
||||
LEFT JOIN public.shopping_trip_items sti ON st.shopping_trip_id = sti.shopping_trip_id
|
||||
LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.master_grocery_item_id
|
||||
```
|
||||
|
||||
#### 3. Receipts → Receipt Items
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
receipts (receipt_id) → receipt_items (receipt_item_id)
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- [src/types.ts:649-662](src/types.ts#L649-L662) `Receipt` interface includes optional `items?: ReceiptItem[]`
|
||||
- Receipt items are fetched separately via repository methods
|
||||
- Proper foreign key relationship maintained
|
||||
|
||||
---
|
||||
|
||||
### ❌ MISSING / INCORRECT IMPLEMENTATIONS
|
||||
|
||||
#### 1. **CRITICAL: Flyers → Flyer Locations → Store Locations (Many-to-Many)**
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||
store_location_id BIGINT NOT NULL REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (flyer_id, store_location_id),
|
||||
...
|
||||
);
|
||||
COMMENT: 'A linking table associating a single flyer with multiple store locations where its deals are valid.'
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- The schema defines a **many-to-many relationship** - a flyer can be valid at multiple store locations
|
||||
- Current implementation in [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts) **IGNORES** the `flyer_locations` table entirely
|
||||
- Queries JOIN `flyers` directly to `stores` via `store_id` foreign key
|
||||
- This means flyers can only be associated with ONE store, not multiple locations
|
||||
|
||||
**Current (Incorrect) Queries:**
|
||||
|
||||
```typescript
|
||||
// From flyer.db.ts:315-362
|
||||
FROM public.flyers f
|
||||
JOIN public.stores s ON f.store_id = s.store_id // ❌ Wrong - ignores flyer_locations
|
||||
```
|
||||
|
||||
**Expected (Correct) Queries:**
|
||||
|
||||
```typescript
|
||||
// Should be:
|
||||
FROM public.flyers f
|
||||
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
|
||||
JOIN public.store_locations sl ON fl.store_location_id = sl.store_location_id
|
||||
JOIN public.stores s ON sl.store_id = s.store_id
|
||||
JOIN public.addresses a ON sl.address_id = a.address_id
|
||||
```
|
||||
|
||||
**TypeScript Type Issues:**
|
||||
|
||||
- [src/types.ts](src/types.ts) `Flyer` interface has `store` object, but it should have `locations: StoreLocation[]` array
|
||||
- Current structure assumes one store per flyer, not multiple locations
|
||||
|
||||
**Files Affected:**
|
||||
|
||||
- [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts) - All flyer queries
|
||||
- [src/types.ts](src/types.ts) - `Flyer` interface definition
|
||||
- Any component displaying flyer locations
|
||||
|
||||
---
|
||||
|
||||
#### 2. **User Submitted Prices → Store Locations (MIGRATED)**
|
||||
|
||||
**Status**: ✅ **FIXED** - Migration created
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||
...
|
||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
**Solution Implemented:**
|
||||
|
||||
- Created migration [sql/migrations/005_add_store_location_to_user_submitted_prices.sql](sql/migrations/005_add_store_location_to_user_submitted_prices.sql)
|
||||
- Added `store_location_id` column to table (NOT NULL after migration)
|
||||
- Migrated existing data: linked each price to first location of its store
|
||||
- Updated TypeScript interface [src/types.ts:270-282](src/types.ts#L270-L282) to include both fields
|
||||
- Kept `store_id` for backward compatibility during transition
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Prices are now specific to individual store locations
|
||||
- "Walmart Toronto" and "Walmart Vancouver" prices are tracked separately
|
||||
- Improves geographic specificity for price comparisons
|
||||
- Enables proximity-based price recommendations
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Application code needs to be updated to use `store_location_id` when creating new prices
|
||||
- Once all code is migrated, can drop the legacy `store_id` column
|
||||
- User-submitted prices feature is not yet implemented in the UI
|
||||
|
||||
---
|
||||
|
||||
#### 3. **Receipts → Store Locations (MIGRATED)**
|
||||
|
||||
**Status**: ✅ **FIXED** - Migration created
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||
...
|
||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE SET NULL,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
**Solution Implemented:**
|
||||
|
||||
- Created migration [sql/migrations/006_add_store_location_to_receipts.sql](sql/migrations/006_add_store_location_to_receipts.sql)
|
||||
- Added `store_location_id` column to table (nullable - receipts may not have matched store)
|
||||
- Migrated existing data: linked each receipt to first location of its store
|
||||
- Updated TypeScript interface [src/types.ts:661-675](src/types.ts#L661-L675) to include both fields
|
||||
- Kept `store_id` for backward compatibility during transition
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Receipts can now be tied to specific store locations
|
||||
- "Loblaws Queen St" and "Loblaws Bloor St" are tracked separately
|
||||
- Enables location-specific shopping pattern analysis
|
||||
- Improves receipt matching accuracy with address data
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Receipt scanning code needs to determine specific store_location_id from OCR text
|
||||
- May require address parsing/matching logic in receipt processing
|
||||
- Once all code is migrated, can drop the legacy `store_id` column
|
||||
- OCR confidence and pattern matching should prefer location-specific data
|
||||
|
||||
---
|
||||
|
||||
#### 4. Item Price History → Store Locations (Already Correct!)
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.item_price_history (
|
||||
...
|
||||
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
**Status:**
|
||||
|
||||
- ✅ **CORRECTLY IMPLEMENTED** - This table already uses `store_location_id`
|
||||
- Properly tracks price history per location
|
||||
- Good example of how other tables should be structured
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Table | Foreign Key | Should Use | Status | Priority |
|
||||
| --------------------- | --------------------------- | ------------------------------------- | --------------- | -------- |
|
||||
| **flyer_locations** | flyer_id, store_location_id | Many-to-many link | ✅ **FIXED** | ✅ Done |
|
||||
| flyers | store_id | ~~store_id~~ Now uses flyer_locations | ✅ **FIXED** | ✅ Done |
|
||||
| user_submitted_prices | store_id | store_location_id | ✅ **MIGRATED** | ✅ Done |
|
||||
| receipts | store_id | store_location_id | ✅ **MIGRATED** | ✅ Done |
|
||||
| item_price_history | store_location_id | ✅ Already correct | ✅ Correct | ✅ Good |
|
||||
| shopping_trips | (no store ref) | N/A | ✅ Correct | ✅ Good |
|
||||
| store_locations | store_id, address_id | ✅ Already correct | ✅ Correct | ✅ Good |
|
||||
|
||||
---
|
||||
|
||||
## Impact Assessment
|
||||
|
||||
### Critical (Must Fix)
|
||||
|
||||
1. **Flyer Locations Many-to-Many**
|
||||
- **Impact:** Flyers can't be associated with multiple store locations
|
||||
- **User Impact:** Users can't see which specific store locations have deals
|
||||
- **Business Logic:** Breaks core assumption that one flyer can be valid at multiple stores
|
||||
- **Fix Complexity:** High - requires schema migration, type changes, query rewrites
|
||||
|
||||
### Medium (Should Consider)
|
||||
|
||||
2. **User Submitted Prices & Receipts**
|
||||
- **Impact:** Loss of location-specific data
|
||||
- **User Impact:** Can't distinguish between different locations of same store chain
|
||||
- **Business Logic:** Reduces accuracy of proximity-based recommendations
|
||||
- **Fix Complexity:** Medium - requires migration and query updates
|
||||
|
||||
---
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### Phase 1: Fix Flyer Locations (Critical)
|
||||
|
||||
1. Create migration to properly use `flyer_locations` table
|
||||
2. Update `Flyer` TypeScript interface to support multiple locations
|
||||
3. Rewrite all flyer queries in [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts)
|
||||
4. Update flyer creation/update endpoints to manage `flyer_locations` entries
|
||||
5. Update frontend components to display multiple locations per flyer
|
||||
6. Update tests to use new structure
|
||||
|
||||
### Phase 2: Consider Store Location Specificity (Optional)
|
||||
|
||||
1. Evaluate if location-specific receipts and prices provide value
|
||||
2. If yes, create migrations to change `store_id` → `store_location_id`
|
||||
3. Update repository queries
|
||||
4. Update TypeScript interfaces
|
||||
5. Update tests
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [ADR-013: Store Address Normalization](../docs/adr/0013-store-address-normalization.md)
|
||||
- [STORE_ADDRESS_IMPLEMENTATION_PLAN.md](../STORE_ADDRESS_IMPLEMENTATION_PLAN.md)
|
||||
- [TESTING.md](../docs/TESTING.md)
|
||||
|
||||
---
|
||||
|
||||
## Analysis Methodology
|
||||
|
||||
This analysis was conducted by:
|
||||
|
||||
1. Extracting all foreign key relationships from [sql/master_schema_rollup.sql](sql/master_schema_rollup.sql)
|
||||
2. Comparing schema relationships against TypeScript interfaces in [src/types.ts](src/types.ts)
|
||||
3. Auditing database queries in [src/services/db/](src/services/db/) for proper JOIN usage
|
||||
4. Identifying gaps where schema relationships exist but aren't used in queries
|
||||
|
||||
Commands used:
|
||||
|
||||
```bash
|
||||
# Extract all foreign keys
|
||||
podman exec -it flyer-crawler-dev bash -c "grep -n 'REFERENCES' sql/master_schema_rollup.sql"
|
||||
|
||||
# Check specific table structures
|
||||
podman exec -it flyer-crawler-dev bash -c "grep -A 15 'CREATE TABLE.*table_name' sql/master_schema_rollup.sql"
|
||||
|
||||
# Verify query patterns
|
||||
podman exec -it flyer-crawler-dev bash -c "grep -n 'JOIN.*table_name' src/services/db/*.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-19
|
||||
**Analyzed By:** Claude Code (via user request after discovering store_name → store bug)
|
||||
252
docs/TESTING.md
Normal file
252
docs/TESTING.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This project has comprehensive test coverage including unit tests, integration tests, and E2E tests. All tests must be run in the **Linux dev container environment** for reliable results.
|
||||
|
||||
## Test Execution Environment
|
||||
|
||||
**CRITICAL**: All tests and type-checking MUST be executed inside the dev container (Linux environment).
|
||||
|
||||
### Why Linux Only?
|
||||
|
||||
- Path separators: Code uses POSIX-style paths (`/`) which may break on Windows
|
||||
- TypeScript compilation works differently on Windows vs Linux
|
||||
- Shell scripts and external dependencies assume Linux
|
||||
- Test results from Windows are **unreliable and should be ignored**
|
||||
|
||||
### Running Tests Correctly
|
||||
|
||||
#### Option 1: Inside Dev Container (Recommended)
|
||||
|
||||
Open VS Code and use "Reopen in Container", then:
|
||||
|
||||
```bash
|
||||
npm test # Run all tests
|
||||
npm run test:unit # Run unit tests only
|
||||
npm run test:integration # Run integration tests
|
||||
npm run type-check # Run TypeScript type checking
|
||||
```
|
||||
|
||||
#### Option 2: Via Podman from Windows Host
|
||||
|
||||
From the Windows host, execute commands in the container:
|
||||
|
||||
```bash
|
||||
# Run unit tests (2900+ tests - pipe to file for AI processing)
|
||||
podman exec -it flyer-crawler-dev npm run test:unit 2>&1 | tee test-results.txt
|
||||
|
||||
# Run integration tests
|
||||
podman exec -it flyer-crawler-dev npm run test:integration
|
||||
|
||||
# Run type checking
|
||||
podman exec -it flyer-crawler-dev npm run type-check
|
||||
|
||||
# Run specific test file
|
||||
podman exec -it flyer-crawler-dev npm test -- --run src/hooks/useAuth.test.tsx
|
||||
```
|
||||
|
||||
## Type Checking
|
||||
|
||||
TypeScript type checking is performed using `tsc --noEmit`.
|
||||
|
||||
### Type Check Command
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### Type Check Validation
|
||||
|
||||
The type-check command will:
|
||||
|
||||
- Exit with code 0 if no errors are found
|
||||
- Exit with non-zero code and print errors if type errors exist
|
||||
- Check all files in the `src/` directory as defined in `tsconfig.json`
|
||||
|
||||
**IMPORTANT**: Type-check on Windows may not show errors reliably. Always verify type-check results by running in the dev container.
|
||||
|
||||
### Verifying Type Check Works
|
||||
|
||||
To verify type-check is working correctly:
|
||||
|
||||
1. Run type-check in dev container: `podman exec -it flyer-crawler-dev npm run type-check`
|
||||
2. Check for output - errors will be displayed with file paths and line numbers
|
||||
3. No output + exit code 0 = no type errors
|
||||
|
||||
Example error output:
|
||||
|
||||
```
|
||||
src/pages/MyDealsPage.tsx:68:31 - error TS2339: Property 'store_name' does not exist on type 'WatchedItemDeal'.
|
||||
|
||||
68 <span>{deal.store_name}</span>
|
||||
~~~~~~~~~~
|
||||
```
|
||||
|
||||
## Pre-Commit Hooks
|
||||
|
||||
The project uses Husky and lint-staged for pre-commit validation:
|
||||
|
||||
```bash
|
||||
# .husky/pre-commit
|
||||
npx lint-staged
|
||||
```
|
||||
|
||||
Lint-staged configuration (`.lintstagedrc.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"*.{js,jsx,ts,tsx}": ["eslint --fix --no-color", "prettier --write"],
|
||||
"*.{json,md,css,html,yml,yaml}": ["prettier --write"]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `--no-color` flag prevents ANSI color codes from breaking file path links in git output.
|
||||
|
||||
## Test Suite Structure
|
||||
|
||||
### Unit Tests (~2900 tests)
|
||||
|
||||
Located throughout `src/` directory alongside source files with `.test.ts` or `.test.tsx` extensions.
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Integration Tests (5 test files)
|
||||
|
||||
Located in `src/tests/integration/`:
|
||||
|
||||
- `admin.integration.test.ts`
|
||||
- `flyer.integration.test.ts`
|
||||
- `price.integration.test.ts`
|
||||
- `public.routes.integration.test.ts`
|
||||
- `receipt.integration.test.ts`
|
||||
|
||||
Requires PostgreSQL and Redis services running.
|
||||
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### E2E Tests (3 test files)
|
||||
|
||||
Located in `src/tests/e2e/`:
|
||||
|
||||
- `deals-journey.e2e.test.ts`
|
||||
- `budget-journey.e2e.test.ts`
|
||||
- `receipt-journey.e2e.test.ts`
|
||||
|
||||
Requires all services (PostgreSQL, Redis, BullMQ workers) running.
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Test Result Interpretation
|
||||
|
||||
- Tests that **pass on Windows but fail on Linux** = **BROKEN tests** (must be fixed)
|
||||
- Tests that **fail on Windows but pass on Linux** = **PASSING tests** (acceptable)
|
||||
- Always use **Linux (dev container) results** as the source of truth
|
||||
|
||||
## Test Helpers
|
||||
|
||||
### Store Test Helpers
|
||||
|
||||
Located in `src/tests/utils/storeHelpers.ts`:
|
||||
|
||||
```typescript
|
||||
// Create a store with a location in one call
|
||||
const store = await createStoreWithLocation({
|
||||
storeName: 'Test Store',
|
||||
address: {
|
||||
address_line_1: '123 Main St',
|
||||
city: 'Toronto',
|
||||
province_state: 'ON',
|
||||
postal_code: 'M1M 1M1',
|
||||
},
|
||||
pool,
|
||||
log,
|
||||
});
|
||||
|
||||
// Cleanup stores and their locations
|
||||
await cleanupStoreLocations([storeId1, storeId2], pool, log);
|
||||
```
|
||||
|
||||
### Mock Factories
|
||||
|
||||
Located in `src/tests/utils/mockFactories.ts`:
|
||||
|
||||
```typescript
|
||||
// Create mock data for tests
|
||||
const mockStore = createMockStore({ name: 'Test Store' });
|
||||
const mockAddress = createMockAddress({ city: 'Toronto' });
|
||||
const mockStoreLocation = createMockStoreLocationWithAddress();
|
||||
const mockStoreWithLocations = createMockStoreWithLocations({
|
||||
locations: [{ address: { city: 'Toronto' } }],
|
||||
});
|
||||
```
|
||||
|
||||
## Known Integration Test Issues
|
||||
|
||||
See `CLAUDE.md` for documentation of common integration test issues and their solutions, including:
|
||||
|
||||
1. Vitest globalSetup context isolation
|
||||
2. BullMQ cleanup queue timing issues
|
||||
3. Cache invalidation after direct database inserts
|
||||
4. Unique filename requirements for file uploads
|
||||
5. Response format mismatches
|
||||
6. External service availability
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests run automatically on:
|
||||
|
||||
- Pre-commit (via Husky hooks)
|
||||
- Pull request creation/update (via Gitea CI/CD)
|
||||
- Merge to main branch (via Gitea CI/CD)
|
||||
|
||||
CI/CD configuration:
|
||||
|
||||
- `.gitea/workflows/deploy-to-prod.yml`
|
||||
- `.gitea/workflows/deploy-to-test.yml`
|
||||
|
||||
## Coverage Reports
|
||||
|
||||
Test coverage is tracked using Vitest's built-in coverage tools.
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
Coverage reports are generated in the `coverage/` directory.
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
```bash
|
||||
# Run tests with verbose output
|
||||
npm test -- --reporter=verbose
|
||||
|
||||
# Run specific test with logging
|
||||
DEBUG=* npm test -- --run src/path/to/test.test.ts
|
||||
```
|
||||
|
||||
### Using Vitest UI
|
||||
|
||||
```bash
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Opens a browser-based test runner with filtering and debugging capabilities.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always run tests in dev container** - never trust Windows test results
|
||||
2. **Run type-check before committing** - catches TypeScript errors early
|
||||
3. **Use test helpers** - `createStoreWithLocation()`, mock factories, etc.
|
||||
4. **Clean up test data** - use cleanup helpers in `afterEach`/`afterAll`
|
||||
5. **Verify cache invalidation** - tests that insert data directly must invalidate cache
|
||||
6. **Use unique filenames** - file upload tests need timestamp-based filenames
|
||||
7. **Check exit codes** - `npm run type-check` returns 0 on success, non-zero on error
|
||||
411
docs/WEBSOCKET_USAGE.md
Normal file
411
docs/WEBSOCKET_USAGE.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# WebSocket Real-Time Notifications - Usage Guide
|
||||
|
||||
This guide shows you how to use the WebSocket real-time notification system in your React components.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Enable Global Notifications
|
||||
|
||||
Add the `NotificationToastHandler` to your root `App.tsx`:
|
||||
|
||||
```tsx
|
||||
// src/App.tsx
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { NotificationToastHandler } from './components/NotificationToastHandler';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
{/* React Hot Toast container */}
|
||||
<Toaster position="top-right" />
|
||||
|
||||
{/* WebSocket notification handler (renders nothing, handles side effects) */}
|
||||
<NotificationToastHandler
|
||||
enabled={true}
|
||||
playSound={false} // Set to true to play notification sounds
|
||||
/>
|
||||
|
||||
{/* Your app routes and components */}
|
||||
<YourAppContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Notification Bell to Header
|
||||
|
||||
```tsx
|
||||
// src/components/Header.tsx
|
||||
import { NotificationBell } from './components/NotificationBell';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function Header() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between p-4">
|
||||
<h1>Flyer Crawler</h1>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notification bell with unread count */}
|
||||
<NotificationBell onClick={() => navigate('/notifications')} showConnectionStatus={true} />
|
||||
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Listen for Notifications in Components
|
||||
|
||||
```tsx
|
||||
// src/pages/DealsPage.tsx
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DealNotificationData } from '../types/websocket';
|
||||
|
||||
function DealsPage() {
|
||||
const [deals, setDeals] = useState([]);
|
||||
|
||||
// Listen for new deal notifications
|
||||
const handleDealNotification = useCallback((data: DealNotificationData) => {
|
||||
console.log('New deals received:', data.deals);
|
||||
|
||||
// Update your deals list
|
||||
setDeals((prev) => [...data.deals, ...prev]);
|
||||
|
||||
// Or refetch from API
|
||||
// refetchDeals();
|
||||
}, []);
|
||||
|
||||
useEventBus('notification:deal', handleDealNotification);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Deals</h1>
|
||||
{/* Render deals */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Available Components
|
||||
|
||||
### `NotificationBell`
|
||||
|
||||
A notification bell icon with unread count and connection status indicator.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `onClick?: () => void` - Callback when bell is clicked
|
||||
- `showConnectionStatus?: boolean` - Show green/red/yellow connection dot (default: `true`)
|
||||
- `className?: string` - Custom CSS classes
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
<NotificationBell
|
||||
onClick={() => navigate('/notifications')}
|
||||
showConnectionStatus={true}
|
||||
className="mr-4"
|
||||
/>
|
||||
```
|
||||
|
||||
### `ConnectionStatus`
|
||||
|
||||
A simple status indicator showing if WebSocket is connected (no bell icon).
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
<ConnectionStatus />
|
||||
```
|
||||
|
||||
### `NotificationToastHandler`
|
||||
|
||||
Global handler that listens for WebSocket events and displays toasts. Should be rendered once at app root.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `enabled?: boolean` - Enable/disable toast notifications (default: `true`)
|
||||
- `playSound?: boolean` - Play sound on notifications (default: `false`)
|
||||
- `soundUrl?: string` - Custom notification sound URL
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
<NotificationToastHandler enabled={true} playSound={true} soundUrl="/custom-sound.mp3" />
|
||||
```
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### `useWebSocket`
|
||||
|
||||
Connect to the WebSocket server and manage connection state.
|
||||
|
||||
**Options:**
|
||||
|
||||
- `autoConnect?: boolean` - Auto-connect on mount (default: `true`)
|
||||
- `maxReconnectAttempts?: number` - Max reconnect attempts (default: `5`)
|
||||
- `reconnectDelay?: number` - Base reconnect delay in ms (default: `1000`)
|
||||
- `onConnect?: () => void` - Callback on connection
|
||||
- `onDisconnect?: () => void` - Callback on disconnect
|
||||
- `onError?: (error: Event) => void` - Callback on error
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `isConnected: boolean` - Connection status
|
||||
- `isConnecting: boolean` - Connecting state
|
||||
- `error: string | null` - Error message if any
|
||||
- `connect: () => void` - Manual connect function
|
||||
- `disconnect: () => void` - Manual disconnect function
|
||||
- `send: (message: WebSocketMessage) => void` - Send message to server
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
const { isConnected, error, connect, disconnect } = useWebSocket({
|
||||
autoConnect: true,
|
||||
maxReconnectAttempts: 3,
|
||||
onConnect: () => console.log('Connected!'),
|
||||
onDisconnect: () => console.log('Disconnected!'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
|
||||
{error && <p>Error: {error}</p>}
|
||||
<button onClick={connect}>Reconnect</button>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### `useEventBus`
|
||||
|
||||
Subscribe to event bus events (used with WebSocket integration).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `event: string` - Event name to listen for
|
||||
- `callback: (data?: T) => void` - Callback function
|
||||
|
||||
**Available Events:**
|
||||
|
||||
- `'notification:deal'` - Deal notifications (`DealNotificationData`)
|
||||
- `'notification:system'` - System messages (`SystemMessageData`)
|
||||
- `'notification:error'` - Error messages (`{ message: string; code?: string }`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
import type { DealNotificationData } from '../types/websocket';
|
||||
|
||||
function MyComponent() {
|
||||
useEventBus<DealNotificationData>('notification:deal', (data) => {
|
||||
console.log('Received deal:', data);
|
||||
});
|
||||
|
||||
return <div>Listening for deals...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Message Types
|
||||
|
||||
### Deal Notification
|
||||
|
||||
```typescript
|
||||
interface DealNotificationData {
|
||||
notification_id?: string;
|
||||
deals: Array<{
|
||||
item_name: string;
|
||||
best_price_in_cents: number;
|
||||
store_name: string;
|
||||
store_id: string;
|
||||
}>;
|
||||
user_id: string;
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
|
||||
### System Message
|
||||
|
||||
```typescript
|
||||
interface SystemMessageData {
|
||||
message: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Notification Handling
|
||||
|
||||
If you don't want to use the default `NotificationToastHandler`, you can create your own:
|
||||
|
||||
```tsx
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
import type { DealNotificationData } from '../types/websocket';
|
||||
|
||||
function CustomNotificationHandler() {
|
||||
const { isConnected } = useWebSocket({ autoConnect: true });
|
||||
|
||||
useEventBus<DealNotificationData>('notification:deal', (data) => {
|
||||
// Custom handling - e.g., update Redux store
|
||||
dispatch(addDeals(data.deals));
|
||||
|
||||
// Show custom UI
|
||||
showCustomNotification(data.message);
|
||||
});
|
||||
|
||||
return null; // Or return your custom UI
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional WebSocket Connection
|
||||
|
||||
```tsx
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
function ConditionalWebSocket() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Only connect if user is logged in
|
||||
useWebSocket({
|
||||
autoConnect: !!user,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Send Messages to Server
|
||||
|
||||
```tsx
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
|
||||
function PingComponent() {
|
||||
const { send, isConnected } = useWebSocket();
|
||||
|
||||
const sendPing = () => {
|
||||
send({
|
||||
type: 'ping',
|
||||
data: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={sendPing} disabled={!isConnected}>
|
||||
Send Ping
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Monitoring
|
||||
|
||||
### Get WebSocket Stats
|
||||
|
||||
Admin users can check WebSocket connection statistics:
|
||||
|
||||
```bash
|
||||
# Get connection stats
|
||||
curl -H "Authorization: Bearer <admin-token>" \
|
||||
http://localhost:3001/api/admin/websocket/stats
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalUsers": 42,
|
||||
"totalConnections": 67
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Dashboard Integration
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function AdminWebSocketStats() {
|
||||
const [stats, setStats] = useState({ totalUsers: 0, totalConnections: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
const response = await fetch('/api/admin/websocket/stats', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const data = await response.json();
|
||||
setStats(data.data);
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 5000); // Poll every 5s
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 border rounded">
|
||||
<h3>WebSocket Stats</h3>
|
||||
<p>Connected Users: {stats.totalUsers}</p>
|
||||
<p>Total Connections: {stats.totalConnections}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
1. **Check JWT Token**: WebSocket requires a valid JWT token in cookies or query string
|
||||
2. **Check Server Logs**: Look for WebSocket connection errors in server logs
|
||||
3. **Check Browser Console**: WebSocket errors are logged to console
|
||||
4. **Verify Path**: WebSocket server is at `ws://localhost:3001/ws` (or `wss://` for HTTPS)
|
||||
|
||||
### Not Receiving Notifications
|
||||
|
||||
1. **Check Connection Status**: Use `<ConnectionStatus />` to verify connection
|
||||
2. **Verify Event Name**: Ensure you're listening to the correct event (`notification:deal`, etc.)
|
||||
3. **Check User ID**: Notifications are sent to specific users - verify JWT user_id matches
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
1. **Connection Leaks**: Ensure components using `useWebSocket` are properly unmounting
|
||||
2. **Event Listeners**: `useEventBus` automatically cleans up, but verify no manual listeners remain
|
||||
3. **Check Stats**: Use `/api/admin/websocket/stats` to monitor connection count
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
|
||||
describe('useWebSocket', () => {
|
||||
it('should connect automatically', () => {
|
||||
const { result } = renderHook(() => useWebSocket({ autoConnect: true }));
|
||||
expect(result.current.isConnecting).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
See [src/tests/integration/websocket.integration.test.ts](../src/tests/integration/websocket.integration.test.ts) for comprehensive integration tests.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR-022: Real-time Notification System](./adr/0022-real-time-notification-system.md)
|
||||
- [ADR-036: Event Bus and Pub/Sub Pattern](./adr/0036-event-bus-and-pub-sub-pattern.md)
|
||||
- [ADR-042: Email and Notification Architecture](./adr/0042-email-and-notification-architecture.md)
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-07
|
||||
|
||||
## Context
|
||||
|
||||
Our application has experienced a recurring pattern of bugs and brittle tests related to error handling, specifically for "resource not found" scenarios. The root causes identified are:
|
||||
@@ -34,10 +36,93 @@ We will adopt a strict, consistent error-handling contract for the service and r
|
||||
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
||||
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
||||
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
||||
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the *exact* error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
||||
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the _exact_ error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
||||
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
||||
|
||||
### Negative
|
||||
|
||||
**Initial Refactoring**: Requires a one-time effort to audit and refactor all existing repository methods to conform to this new standard.
|
||||
**Convention Adherence**: Developers must be aware of and adhere to this convention. This ADR serves as the primary documentation for this pattern.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Custom Error Types
|
||||
|
||||
All custom errors are defined in `src/services/db/errors.db.ts`:
|
||||
|
||||
| Error Class | HTTP Status | PostgreSQL Code | Use Case |
|
||||
| -------------------------------- | ----------- | --------------- | ------------------------------- |
|
||||
| `NotFoundError` | 404 | - | Resource not found |
|
||||
| `UniqueConstraintError` | 409 | 23505 | Duplicate key violation |
|
||||
| `ForeignKeyConstraintError` | 400 | 23503 | Referenced record doesn't exist |
|
||||
| `NotNullConstraintError` | 400 | 23502 | Required field is null |
|
||||
| `CheckConstraintError` | 400 | 23514 | Check constraint violated |
|
||||
| `InvalidTextRepresentationError` | 400 | 22P02 | Invalid data type format |
|
||||
| `NumericValueOutOfRangeError` | 400 | 22003 | Numeric overflow |
|
||||
| `ValidationError` | 400 | - | Request validation failed |
|
||||
| `ForbiddenError` | 403 | - | Access denied |
|
||||
|
||||
### Error Handler Middleware
|
||||
|
||||
The centralized error handler in `src/middleware/errorHandler.ts`:
|
||||
|
||||
1. Catches all errors from route handlers
|
||||
2. Maps custom error types to HTTP status codes
|
||||
3. Logs errors with appropriate severity (warn for 4xx, error for 5xx)
|
||||
4. Returns consistent JSON error responses
|
||||
5. Includes error ID for server errors (for support correlation)
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```typescript
|
||||
// In repository (throws NotFoundError)
|
||||
async function getUserById(id: number): Promise<User> {
|
||||
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new NotFoundError(`User with ID ${id} not found.`);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// In route handler (simple try/catch)
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const user = await getUserById(req.params.id);
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error); // errorHandler maps NotFoundError to 404
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Centralized Error Handler Helper
|
||||
|
||||
The `handleDbError` function in `src/services/db/errors.db.ts` provides centralized PostgreSQL error handling:
|
||||
|
||||
```typescript
|
||||
import { handleDbError } from './errors.db';
|
||||
|
||||
try {
|
||||
await pool.query('INSERT INTO users (email) VALUES ($1)', [email]);
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
logger,
|
||||
'Failed to create user',
|
||||
{ email },
|
||||
{
|
||||
uniqueMessage: 'A user with this email already exists.',
|
||||
defaultMessage: 'Failed to create user.',
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/db/errors.db.ts` - Custom error classes and `handleDbError` utility
|
||||
- `src/middleware/errorHandler.ts` - Centralized Express error handling middleware
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern Standards (extends this ADR)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-07
|
||||
|
||||
## Context
|
||||
|
||||
@@ -10,21 +12,19 @@ Following the standardization of error handling in ADR-001, the next most common
|
||||
|
||||
This manual approach has several drawbacks:
|
||||
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
||||
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application.
|
||||
3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
||||
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application. 3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
||||
|
||||
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
||||
|
||||
* Acquiring a client from the database pool.
|
||||
* Starting a transaction (`BEGIN`).
|
||||
* Executing the `callback` function, passing the transactional client to it.
|
||||
* If the callback succeeds, it will `COMMIT` the transaction.
|
||||
* If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
||||
* In all cases, it will `RELEASE` the client back to the pool.
|
||||
- Acquiring a client from the database pool.
|
||||
- Starting a transaction (`BEGIN`).
|
||||
- Executing the `callback` function, passing the transactional client to it.
|
||||
- If the callback succeeds, it will `COMMIT` the transaction.
|
||||
- If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
||||
- In all cases, it will `RELEASE` the client back to the pool.
|
||||
|
||||
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
||||
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
||||
@@ -40,7 +40,7 @@ async function registerUserAndCreateDefaultList(userData) {
|
||||
const shoppingRepo = new ShoppingRepository(client);
|
||||
|
||||
const newUser = await userRepo.createUser(userData);
|
||||
await shoppingRepo.createShoppingList(newUser.user_id, "My First List");
|
||||
await shoppingRepo.createShoppingList(newUser.user_id, 'My First List');
|
||||
|
||||
return newUser;
|
||||
});
|
||||
@@ -60,3 +60,109 @@ async function registerUserAndCreateDefaultList(userData) {
|
||||
|
||||
**Learning Curve**: Developers will need to learn and adopt the `withTransaction` pattern for all transactional database work.
|
||||
**Refactoring Effort**: Existing methods that manually manage transactions (`createUser`, `createBudget`, etc.) will need to be refactored to use the new pattern.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### The `withTransaction` Helper
|
||||
|
||||
Located in `src/services/db/connection.db.ts`:
|
||||
|
||||
```typescript
|
||||
export async function withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error({ err: error }, 'Transaction failed, rolling back.');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Pattern for Transaction Support
|
||||
|
||||
Repository methods accept an optional `PoolClient` parameter:
|
||||
|
||||
```typescript
|
||||
// Function-based approach
|
||||
export async function createUser(userData: CreateUserInput, client?: PoolClient): Promise<User> {
|
||||
const queryable = client || getPool();
|
||||
const result = await queryable.query<User>(
|
||||
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
|
||||
[userData.email, userData.passwordHash],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
### Transactional Service Example
|
||||
|
||||
```typescript
|
||||
// src/services/authService.ts
|
||||
import { withTransaction } from './db/connection.db';
|
||||
import { createUser, createProfile } from './db';
|
||||
|
||||
export async function registerUserWithProfile(
|
||||
email: string,
|
||||
password: string,
|
||||
profileData: ProfileInput,
|
||||
): Promise<UserWithProfile> {
|
||||
return withTransaction(async (client) => {
|
||||
// All operations use the same transactional client
|
||||
const user = await createUser({ email, password }, client);
|
||||
const profile = await createProfile(
|
||||
{
|
||||
userId: user.user_id,
|
||||
...profileData,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
return { user, profile };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Services Using `withTransaction`
|
||||
|
||||
| Service | Function | Operations |
|
||||
| ------------------------- | ----------------------- | ----------------------------------- |
|
||||
| `authService` | `registerAndLoginUser` | Create user + profile + preferences |
|
||||
| `userService` | `updateUserWithProfile` | Update user + profile atomically |
|
||||
| `flyerPersistenceService` | `saveFlyer` | Create flyer + items + metadata |
|
||||
| `shoppingService` | `createListWithItems` | Create list + initial items |
|
||||
| `gamificationService` | `awardAchievement` | Create achievement + update points |
|
||||
|
||||
### Connection Pool Configuration
|
||||
|
||||
```typescript
|
||||
const poolConfig: PoolConfig = {
|
||||
max: 20, // Max clients in pool
|
||||
idleTimeoutMillis: 30000, // Close idle clients after 30s
|
||||
connectionTimeoutMillis: 2000, // Fail connect after 2s
|
||||
};
|
||||
```
|
||||
|
||||
### Pool Status Monitoring
|
||||
|
||||
```typescript
|
||||
import { getPoolStatus } from './db/connection.db';
|
||||
|
||||
const status = getPoolStatus();
|
||||
// { totalCount: 20, idleCount: 15, waitingCount: 0 }
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/db/connection.db.ts` - `getPool()`, `withTransaction()`, `getPoolStatus()`
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error handling within transactions
|
||||
- [ADR-034](./0034-repository-pattern-standards.md) - Repository patterns for transaction participation
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-07
|
||||
|
||||
## Context
|
||||
|
||||
@@ -20,8 +22,8 @@ We will adopt a schema-based approach for input validation using the `zod` libra
|
||||
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
||||
|
||||
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
||||
* On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
||||
* On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
||||
- On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
||||
- On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
||||
|
||||
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
||||
|
||||
@@ -46,18 +48,18 @@ const getFlyerSchema = z.object({
|
||||
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
||||
|
||||
// 3. Apply the middleware and use an inline cast for the request
|
||||
router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
||||
// Cast 'req' to the inferred type.
|
||||
// This provides full type safety for params, query, and body.
|
||||
const { params } = req as unknown as GetFlyerRequest;
|
||||
router.get('/:id', validateRequest(getFlyerSchema), async (req, res, next) => {
|
||||
// Cast 'req' to the inferred type.
|
||||
// This provides full type safety for params, query, and body.
|
||||
const { params } = req as unknown as GetFlyerRequest;
|
||||
|
||||
try {
|
||||
const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number'
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}));
|
||||
try {
|
||||
const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number'
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Consequences
|
||||
@@ -77,3 +79,140 @@ router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
||||
**New Dependency**: Introduces `zod` as a new project dependency.
|
||||
**Learning Curve**: Developers need to learn the `zod` schema definition syntax.
|
||||
**Refactoring Effort**: Requires a one-time effort to create schemas and refactor all existing routes to use the `validateRequest` middleware.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### The `validateRequest` Middleware
|
||||
|
||||
Located in `src/middleware/validation.middleware.ts`:
|
||||
|
||||
```typescript
|
||||
export const validateRequest =
|
||||
(schema: ZodObject<z.ZodRawShape>) => async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { params, query, body } = await schema.parseAsync({
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
// Merge parsed data back into request
|
||||
Object.keys(req.params).forEach((key) => delete req.params[key]);
|
||||
Object.assign(req.params, params);
|
||||
Object.keys(req.query).forEach((key) => delete req.query[key]);
|
||||
Object.assign(req.query, query);
|
||||
req.body = body;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const validationIssues = error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: issue.path.map((p) => String(p)),
|
||||
}));
|
||||
return next(new ValidationError(validationIssues));
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Common Zod Patterns
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import { requiredString } from '../utils/zodUtils';
|
||||
|
||||
// String that coerces to positive integer (for ID params)
|
||||
const idParam = z.string().pipe(z.coerce.number().int().positive());
|
||||
|
||||
// Pagination query params with defaults
|
||||
const paginationQuery = z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
// Email with sanitization
|
||||
const emailSchema = z.string().trim().toLowerCase().email('A valid email is required.');
|
||||
|
||||
// Password with strength validation
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.superRefine((password, ctx) => {
|
||||
const strength = validatePasswordStrength(password);
|
||||
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
|
||||
});
|
||||
|
||||
// Optional string that converts empty string to undefined
|
||||
const optionalString = z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().trim().optional(),
|
||||
);
|
||||
```
|
||||
|
||||
### Routes Using `validateRequest`
|
||||
|
||||
All API routes use the validation middleware:
|
||||
|
||||
| Router | Schemas Defined | Validated Endpoints |
|
||||
| ------------------------ | --------------- | -------------------------------------------------------------------------------- |
|
||||
| `auth.routes.ts` | 5 | `/register`, `/login`, `/forgot-password`, `/reset-password`, `/change-password` |
|
||||
| `user.routes.ts` | 4 | `/profile`, `/address`, `/preferences`, `/notifications` |
|
||||
| `flyer.routes.ts` | 6 | `GET /:id`, `GET /`, `GET /:id/items`, `DELETE /:id` |
|
||||
| `budget.routes.ts` | 5 | `/`, `/:id`, `/batch`, `/categories` |
|
||||
| `recipe.routes.ts` | 4 | `GET /`, `GET /:id`, `POST /`, `PATCH /:id` |
|
||||
| `admin.routes.ts` | 8 | Various admin endpoints |
|
||||
| `ai.routes.ts` | 3 | `/upload-and-process`, `/analyze`, `/jobs/:jobId/status` |
|
||||
| `gamification.routes.ts` | 3 | `/achievements`, `/leaderboard`, `/points` |
|
||||
|
||||
### Validation Error Response Format
|
||||
|
||||
When validation fails, the `errorHandler` returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "The request data is invalid.",
|
||||
"errors": [
|
||||
{
|
||||
"path": ["body", "email"],
|
||||
"message": "A valid email is required."
|
||||
},
|
||||
{
|
||||
"path": ["body", "password"],
|
||||
"message": "Password must be at least 8 characters long."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
HTTP Status: `400 Bad Request`
|
||||
|
||||
### Zod Utility Functions
|
||||
|
||||
Located in `src/utils/zodUtils.ts`:
|
||||
|
||||
```typescript
|
||||
// String that rejects empty strings
|
||||
export const requiredString = (message?: string) =>
|
||||
z.string().min(1, message || 'This field is required.');
|
||||
|
||||
// Number from string with validation
|
||||
export const numericString = z.string().pipe(z.coerce.number());
|
||||
|
||||
// Boolean from string ('true'/'false')
|
||||
export const booleanString = z.enum(['true', 'false']).transform((v) => v === 'true');
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/middleware/validation.middleware.ts` - The `validateRequest` middleware
|
||||
- `src/services/db/errors.db.ts` - `ValidationError` class definition
|
||||
- `src/middleware/errorHandler.ts` - Error formatting for validation errors
|
||||
- `src/utils/zodUtils.ts` - Reusable Zod schema utilities
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error handling for validation errors
|
||||
- [ADR-032](./0032-rate-limiting-strategy.md) - Rate limiting applied alongside validation
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-07
|
||||
|
||||
## Context
|
||||
|
||||
@@ -20,9 +22,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
||||
|
||||
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
||||
|
||||
* Generate a unique `request_id` for each incoming request.
|
||||
* Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
||||
* Attach this child logger to the `req` object (e.g., `req.log`).
|
||||
- Generate a unique `request_id` for each incoming request.
|
||||
- Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
||||
- Attach this child logger to the `req` object (e.g., `req.log`).
|
||||
|
||||
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
||||
|
||||
@@ -32,9 +34,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
||||
|
||||
**Standardized Logging Practices**:
|
||||
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
||||
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
||||
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
||||
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
||||
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
||||
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
||||
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
||||
|
||||
### Example Usage
|
||||
|
||||
@@ -59,15 +61,15 @@ export const requestLogger = (req, res, next) => {
|
||||
|
||||
// In a route handler:
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
// Use the request-scoped logger
|
||||
req.log.info({ flyerId: req.params.id }, 'Fetching flyer by ID');
|
||||
try {
|
||||
// ... business logic ...
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
// The error itself will be logged with full context by the errorHandler
|
||||
next(error);
|
||||
}
|
||||
// Use the request-scoped logger
|
||||
req.log.info({ flyerId: req.params.id }, 'Fetching flyer by ID');
|
||||
try {
|
||||
// ... business logic ...
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
// The error itself will be logged with full context by the errorHandler
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
@@ -84,3 +86,219 @@ router.get('/:id', async (req, res, next) => {
|
||||
|
||||
**Refactoring Effort**: Requires adding the `requestLogger` middleware and refactoring all routes and services to use `req.log` instead of the global `logger`.
|
||||
**Slight Performance Overhead**: Creating a child logger for every request adds a minor performance cost, though this is negligible for most modern logging libraries.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Logger Configuration
|
||||
|
||||
Located in `src/services/logger.server.ts`:
|
||||
|
||||
```typescript
|
||||
import pino from 'pino';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
|
||||
export const logger = pino({
|
||||
level: isProduction ? 'info' : 'debug',
|
||||
transport:
|
||||
isProduction || isTest
|
||||
? undefined
|
||||
: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'*.body.password',
|
||||
'*.body.newPassword',
|
||||
'*.body.currentPassword',
|
||||
'*.body.confirmPassword',
|
||||
'*.body.refreshToken',
|
||||
'*.body.token',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Request Logger Middleware
|
||||
|
||||
Located in `server.ts`:
|
||||
|
||||
```typescript
|
||||
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
const requestId = randomUUID();
|
||||
const user = req.user as UserProfile | undefined;
|
||||
const start = process.hrtime();
|
||||
|
||||
// Create request-scoped logger
|
||||
req.log = logger.child({
|
||||
request_id: requestId,
|
||||
user_id: user?.user.user_id,
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
req.log.debug({ method: req.method, originalUrl: req.originalUrl }, 'INCOMING');
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = getDurationInMilliseconds(start);
|
||||
const { statusCode, statusMessage } = res;
|
||||
const logDetails = {
|
||||
user_id: (req.user as UserProfile | undefined)?.user.user_id,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
statusCode,
|
||||
statusMessage,
|
||||
duration: duration.toFixed(2),
|
||||
};
|
||||
|
||||
// Include request details for failed requests (for debugging)
|
||||
if (statusCode >= 400) {
|
||||
logDetails.req = { headers: req.headers, body: req.body };
|
||||
}
|
||||
|
||||
if (statusCode >= 500) req.log.error(logDetails, 'Request completed with server error');
|
||||
else if (statusCode >= 400) req.log.warn(logDetails, 'Request completed with client error');
|
||||
else req.log.info(logDetails, 'Request completed successfully');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
app.use(requestLogger);
|
||||
```
|
||||
|
||||
### TypeScript Support
|
||||
|
||||
The `req.log` property is typed via declaration merging in `src/types/express.d.ts`:
|
||||
|
||||
```typescript
|
||||
import { Logger } from 'pino';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
log: Logger;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Sensitive Data Redaction
|
||||
|
||||
The Pino logger automatically redacts sensitive fields:
|
||||
|
||||
```json
|
||||
// Before redaction
|
||||
{
|
||||
"body": {
|
||||
"email": "user@example.com",
|
||||
"password": "secret123",
|
||||
"newPassword": "newsecret456"
|
||||
}
|
||||
}
|
||||
|
||||
// After redaction (in logs)
|
||||
{
|
||||
"body": {
|
||||
"email": "user@example.com",
|
||||
"password": "[REDACTED]",
|
||||
"newPassword": "[REDACTED]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Log Levels by Scenario
|
||||
|
||||
| Level | HTTP Status | Scenario |
|
||||
| ----- | ----------- | -------------------------------------------------- |
|
||||
| DEBUG | Any | Request incoming, internal state, development info |
|
||||
| INFO | 2xx | Successful requests, business events |
|
||||
| WARN | 4xx | Client errors, validation failures, not found |
|
||||
| ERROR | 5xx | Server errors, unhandled exceptions |
|
||||
|
||||
### Service Layer Logging
|
||||
|
||||
Services accept the request-scoped logger as an optional parameter:
|
||||
|
||||
```typescript
|
||||
export async function registerUser(email: string, password: string, reqLog?: Logger) {
|
||||
const log = reqLog || logger; // Fall back to global logger
|
||||
|
||||
log.info({ email }, 'Registering new user');
|
||||
// ... implementation
|
||||
|
||||
log.debug({ userId: user.user_id }, 'User created successfully');
|
||||
return user;
|
||||
}
|
||||
|
||||
// In route handler
|
||||
router.post('/register', async (req, res, next) => {
|
||||
await authService.registerUser(req.body.email, req.body.password, req.log);
|
||||
});
|
||||
```
|
||||
|
||||
### Log Output Format
|
||||
|
||||
**Development** (pino-pretty):
|
||||
|
||||
```text
|
||||
[2026-01-09 12:34:56.789] INFO (request_id=abc123): Request completed successfully
|
||||
method: "GET"
|
||||
originalUrl: "/api/flyers"
|
||||
statusCode: 200
|
||||
duration: "45.23"
|
||||
```
|
||||
|
||||
**Production** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 30,
|
||||
"time": 1704812096789,
|
||||
"request_id": "abc123",
|
||||
"user_id": "user_456",
|
||||
"ip_address": "192.168.1.1",
|
||||
"method": "GET",
|
||||
"originalUrl": "/api/flyers",
|
||||
"statusCode": 200,
|
||||
"duration": "45.23",
|
||||
"msg": "Request completed successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Routes Using `req.log`
|
||||
|
||||
All route files have been migrated to use the request-scoped logger:
|
||||
|
||||
- `src/routes/auth.routes.ts`
|
||||
- `src/routes/user.routes.ts`
|
||||
- `src/routes/flyer.routes.ts`
|
||||
- `src/routes/ai.routes.ts`
|
||||
- `src/routes/admin.routes.ts`
|
||||
- `src/routes/budget.routes.ts`
|
||||
- `src/routes/recipe.routes.ts`
|
||||
- `src/routes/gamification.routes.ts`
|
||||
- `src/routes/personalization.routes.ts`
|
||||
- `src/routes/stats.routes.ts`
|
||||
- `src/routes/health.routes.ts`
|
||||
- `src/routes/system.routes.ts`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/logger.server.ts` - Pino logger configuration
|
||||
- `src/services/logger.client.ts` - Client-side logger (for frontend)
|
||||
- `src/types/express.d.ts` - TypeScript declaration for `req.log`
|
||||
- `server.ts` - Request logger middleware
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error handler uses `req.log` for error logging
|
||||
- [ADR-026](./0026-standardized-client-side-structured-logging.md) - Client-side logging strategy
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# ADR-005: Frontend State Management and Server Cache Strategy
|
||||
|
||||
**Date**: 2025-12-12
|
||||
**Implementation Date**: 2026-01-08
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted and Fully Implemented (Phases 1-8 complete, 100% coverage)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,3 +17,228 @@ We will adopt a dedicated library for managing server state, such as **TanStack
|
||||
|
||||
**Positive**: Leads to a more performant, predictable, and simpler frontend codebase. Standardizes how the client-side communicates with the server and handles loading/error states. Improves user experience through intelligent caching.
|
||||
**Negative**: Introduces a new frontend dependency. Requires a learning curve for developers unfamiliar with the library. Requires refactoring of existing data-fetching logic.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Phase 1: Infrastructure & Core Queries (✅ Complete - 2026-01-08)
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/config/queryClient.ts](../../src/config/queryClient.ts) - Global QueryClient configuration
|
||||
- [src/hooks/queries/useFlyersQuery.ts](../../src/hooks/queries/useFlyersQuery.ts) - Flyers data query
|
||||
- [src/hooks/queries/useWatchedItemsQuery.ts](../../src/hooks/queries/useWatchedItemsQuery.ts) - Watched items query
|
||||
- [src/hooks/queries/useShoppingListsQuery.ts](../../src/hooks/queries/useShoppingListsQuery.ts) - Shopping lists query
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- [src/providers/AppProviders.tsx](../../src/providers/AppProviders.tsx) - Added QueryClientProvider wrapper
|
||||
- [src/providers/FlyersProvider.tsx](../../src/providers/FlyersProvider.tsx) - Refactored to use TanStack Query
|
||||
- [src/providers/UserDataProvider.tsx](../../src/providers/UserDataProvider.tsx) - Refactored to use TanStack Query
|
||||
- [src/services/apiClient.ts](../../src/services/apiClient.ts) - Added pagination params to fetchFlyers
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Removed ~150 lines of custom state management code
|
||||
- ✅ Automatic caching of server data
|
||||
- ✅ Background refetching for stale data
|
||||
- ✅ React Query Devtools available in development
|
||||
- ✅ Automatic data invalidation on user logout
|
||||
- ✅ Better error handling and loading states
|
||||
|
||||
### Phase 2: Remaining Queries (✅ Complete - 2026-01-08)
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/hooks/queries/useMasterItemsQuery.ts](../../src/hooks/queries/useMasterItemsQuery.ts) - Master grocery items query
|
||||
- [src/hooks/queries/useFlyerItemsQuery.ts](../../src/hooks/queries/useFlyerItemsQuery.ts) - Flyer items query
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- [src/providers/MasterItemsProvider.tsx](../../src/providers/MasterItemsProvider.tsx) - Refactored to use TanStack Query
|
||||
- [src/hooks/useFlyerItems.ts](../../src/hooks/useFlyerItems.ts) - Refactored to use TanStack Query
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Removed additional ~50 lines of custom state management code
|
||||
- ✅ Per-flyer item caching (items cached separately for each flyer)
|
||||
- ✅ Longer cache times for infrequently changing data (master items)
|
||||
- ✅ Automatic query disabling when dependencies are not met
|
||||
|
||||
### Phase 3: Mutations (✅ Complete - 2026-01-08)
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/hooks/mutations/useAddWatchedItemMutation.ts](../../src/hooks/mutations/useAddWatchedItemMutation.ts) - Add watched item mutation
|
||||
- [src/hooks/mutations/useRemoveWatchedItemMutation.ts](../../src/hooks/mutations/useRemoveWatchedItemMutation.ts) - Remove watched item mutation
|
||||
- [src/hooks/mutations/useCreateShoppingListMutation.ts](../../src/hooks/mutations/useCreateShoppingListMutation.ts) - Create shopping list mutation
|
||||
- [src/hooks/mutations/useDeleteShoppingListMutation.ts](../../src/hooks/mutations/useDeleteShoppingListMutation.ts) - Delete shopping list mutation
|
||||
- [src/hooks/mutations/useAddShoppingListItemMutation.ts](../../src/hooks/mutations/useAddShoppingListItemMutation.ts) - Add shopping list item mutation
|
||||
- [src/hooks/mutations/useUpdateShoppingListItemMutation.ts](../../src/hooks/mutations/useUpdateShoppingListItemMutation.ts) - Update shopping list item mutation
|
||||
- [src/hooks/mutations/useRemoveShoppingListItemMutation.ts](../../src/hooks/mutations/useRemoveShoppingListItemMutation.ts) - Remove shopping list item mutation
|
||||
- [src/hooks/mutations/index.ts](../../src/hooks/mutations/index.ts) - Barrel export for all mutation hooks
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Standardized mutation pattern across all data modifications
|
||||
- ✅ Automatic cache invalidation after successful mutations
|
||||
- ✅ Built-in success/error notifications
|
||||
- ✅ Consistent error handling
|
||||
- ✅ Full TypeScript type safety
|
||||
- ✅ Comprehensive documentation with usage examples
|
||||
|
||||
**See**: [plans/adr-0005-phase-3-summary.md](../../plans/adr-0005-phase-3-summary.md) for detailed documentation
|
||||
|
||||
### Phase 4: Hook Refactoring (✅ Complete)
|
||||
|
||||
**Goal:** Refactor user-facing hooks to use TanStack Query mutation hooks.
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- [src/hooks/useWatchedItems.tsx](../../src/hooks/useWatchedItems.tsx) - Refactored to use mutation hooks
|
||||
- [src/hooks/useShoppingLists.tsx](../../src/hooks/useShoppingLists.tsx) - Refactored to use mutation hooks
|
||||
- [src/contexts/UserDataContext.ts](../../src/contexts/UserDataContext.ts) - Clean read-only interface (no setters)
|
||||
- [src/providers/UserDataProvider.tsx](../../src/providers/UserDataProvider.tsx) - Uses query hooks, no setter stubs
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Both hooks now use TanStack Query mutations
|
||||
- ✅ Automatic cache invalidation after mutations
|
||||
- ✅ Consistent error handling via mutation hooks
|
||||
- ✅ Clean context interface (read-only server state)
|
||||
- ✅ Backward compatible API for hook consumers
|
||||
|
||||
### Phase 5: Admin Features (✅ Complete)
|
||||
|
||||
**Goal:** Create query hooks for admin features.
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/hooks/queries/useActivityLogQuery.ts](../../src/hooks/queries/useActivityLogQuery.ts) - Activity log with pagination
|
||||
- [src/hooks/queries/useApplicationStatsQuery.ts](../../src/hooks/queries/useApplicationStatsQuery.ts) - Application statistics
|
||||
- [src/hooks/queries/useSuggestedCorrectionsQuery.ts](../../src/hooks/queries/useSuggestedCorrectionsQuery.ts) - Corrections data
|
||||
- [src/hooks/queries/useCategoriesQuery.ts](../../src/hooks/queries/useCategoriesQuery.ts) - Categories (public endpoint)
|
||||
|
||||
**Components Migrated:**
|
||||
|
||||
- [src/pages/admin/ActivityLog.tsx](../../src/pages/admin/ActivityLog.tsx) - Uses useActivityLogQuery
|
||||
- [src/pages/admin/AdminStatsPage.tsx](../../src/pages/admin/AdminStatsPage.tsx) - Uses useApplicationStatsQuery
|
||||
- [src/pages/admin/CorrectionsPage.tsx](../../src/pages/admin/CorrectionsPage.tsx) - Uses useSuggestedCorrectionsQuery, useMasterItemsQuery, useCategoriesQuery
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Automatic caching of admin data
|
||||
- ✅ Parallel fetching (CorrectionsPage fetches 3 queries simultaneously)
|
||||
- ✅ Consistent stale times (30s to 2 min based on data volatility)
|
||||
- ✅ Shared cache across components (useMasterItemsQuery reused)
|
||||
|
||||
### Phase 6: Analytics Features (✅ Complete - 2026-01-10)
|
||||
|
||||
**Goal:** Migrate analytics and deals features.
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/hooks/queries/useBestSalePricesQuery.ts](../../src/hooks/queries/useBestSalePricesQuery.ts) - Best sale prices for watched items
|
||||
- [src/hooks/queries/useFlyerItemsForFlyersQuery.ts](../../src/hooks/queries/useFlyerItemsForFlyersQuery.ts) - Batch fetch items for multiple flyers
|
||||
- [src/hooks/queries/useFlyerItemCountQuery.ts](../../src/hooks/queries/useFlyerItemCountQuery.ts) - Count items across flyers
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- [src/pages/MyDealsPage.tsx](../../src/pages/MyDealsPage.tsx) - Now uses useBestSalePricesQuery
|
||||
- [src/hooks/useActiveDeals.tsx](../../src/hooks/useActiveDeals.tsx) - Refactored to use TanStack Query hooks
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Removed useApi dependency from analytics features
|
||||
- ✅ Automatic caching of deal data (2-5 minute stale times)
|
||||
- ✅ Consistent error handling via TanStack Query
|
||||
- ✅ Batch fetching for flyer items (single query for multiple flyers)
|
||||
|
||||
### Phase 7: Cleanup (✅ Complete - 2026-01-10)
|
||||
|
||||
**Goal:** Remove legacy hooks once migration is complete.
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/hooks/queries/useUserAddressQuery.ts](../../src/hooks/queries/useUserAddressQuery.ts) - User address fetching
|
||||
- [src/hooks/queries/useAuthProfileQuery.ts](../../src/hooks/queries/useAuthProfileQuery.ts) - Auth profile fetching
|
||||
- [src/hooks/mutations/useGeocodeMutation.ts](../../src/hooks/mutations/useGeocodeMutation.ts) - Address geocoding
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- [src/hooks/useProfileAddress.ts](../../src/hooks/useProfileAddress.ts) - Refactored to use TanStack Query
|
||||
- [src/providers/AuthProvider.tsx](../../src/providers/AuthProvider.tsx) - Refactored to use TanStack Query
|
||||
|
||||
**Files Removed:**
|
||||
|
||||
- ~~src/hooks/useApi.ts~~ - Legacy hook removed
|
||||
- ~~src/hooks/useApi.test.ts~~ - Test file removed
|
||||
- ~~src/hooks/useApiOnMount.ts~~ - Legacy hook removed
|
||||
- ~~src/hooks/useApiOnMount.test.ts~~ - Test file removed
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Removed all legacy `useApi` and `useApiOnMount` hooks
|
||||
- ✅ Complete TanStack Query coverage for all data fetching
|
||||
- ✅ Consistent error handling across the entire application
|
||||
- ✅ Unified caching strategy for all server state
|
||||
|
||||
### Phase 8: Additional Component Migration (✅ Complete - 2026-01-10)
|
||||
|
||||
**Goal:** Migrate remaining components with manual data fetching to TanStack Query.
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- [src/hooks/queries/useUserProfileDataQuery.ts](../../src/hooks/queries/useUserProfileDataQuery.ts) - Combined user profile + achievements query
|
||||
- [src/hooks/queries/useLeaderboardQuery.ts](../../src/hooks/queries/useLeaderboardQuery.ts) - Public leaderboard data
|
||||
- [src/hooks/queries/usePriceHistoryQuery.ts](../../src/hooks/queries/usePriceHistoryQuery.ts) - Historical price data for watched items
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- [src/hooks/useUserProfileData.ts](../../src/hooks/useUserProfileData.ts) - Refactored to use useUserProfileDataQuery
|
||||
- [src/components/Leaderboard.tsx](../../src/components/Leaderboard.tsx) - Refactored to use useLeaderboardQuery
|
||||
- [src/features/charts/PriceHistoryChart.tsx](../../src/features/charts/PriceHistoryChart.tsx) - Refactored to use usePriceHistoryQuery
|
||||
|
||||
**Benefits Achieved:**
|
||||
|
||||
- ✅ Parallel fetching for profile + achievements data
|
||||
- ✅ Public leaderboard cached with 2-minute stale time
|
||||
- ✅ Price history cached with 10-minute stale time (data changes infrequently)
|
||||
- ✅ Backward-compatible setProfile function via queryClient.setQueryData
|
||||
- ✅ Stable query keys with sorted IDs for price history
|
||||
|
||||
## Migration Status
|
||||
|
||||
Current Coverage: **100% complete**
|
||||
|
||||
| Category | Total | Migrated | Status |
|
||||
| ----------------------------- | ----- | -------- | ------- |
|
||||
| Query Hooks (User) | 7 | 7 | ✅ 100% |
|
||||
| Query Hooks (Admin) | 4 | 4 | ✅ 100% |
|
||||
| Query Hooks (Analytics) | 3 | 3 | ✅ 100% |
|
||||
| Query Hooks (Phase 8) | 3 | 3 | ✅ 100% |
|
||||
| Mutation Hooks | 8 | 8 | ✅ 100% |
|
||||
| User Hooks | 2 | 2 | ✅ 100% |
|
||||
| Analytics Features | 2 | 2 | ✅ 100% |
|
||||
| Component Migration (Phase 8) | 3 | 3 | ✅ 100% |
|
||||
| Legacy Hook Cleanup | 4 | 4 | ✅ 100% |
|
||||
|
||||
**Completed:**
|
||||
|
||||
- ✅ Core query hooks (flyers, flyerItems, masterItems, watchedItems, shoppingLists)
|
||||
- ✅ Admin query hooks (activityLog, applicationStats, suggestedCorrections, categories)
|
||||
- ✅ Analytics query hooks (bestSalePrices, flyerItemsForFlyers, flyerItemCount)
|
||||
- ✅ Auth/Profile query hooks (authProfile, userAddress)
|
||||
- ✅ Phase 8 query hooks (userProfileData, leaderboard, priceHistory)
|
||||
- ✅ All mutation hooks (watched items, shopping lists, geocode)
|
||||
- ✅ Provider refactoring (AppProviders, FlyersProvider, MasterItemsProvider, UserDataProvider, AuthProvider)
|
||||
- ✅ User hooks refactoring (useWatchedItems, useShoppingLists, useProfileAddress, useUserProfileData)
|
||||
- ✅ Admin component migration (ActivityLog, AdminStatsPage, CorrectionsPage)
|
||||
- ✅ Analytics features (MyDealsPage, useActiveDeals)
|
||||
- ✅ Component migration (Leaderboard, PriceHistoryChart)
|
||||
- ✅ Legacy hooks removed (useApi, useApiOnMount)
|
||||
|
||||
See [plans/adr-0005-master-migration-status.md](../../plans/adr-0005-master-migration-status.md) for complete tracking of all components.
|
||||
|
||||
## Implementation Guide
|
||||
|
||||
See [plans/adr-0005-implementation-plan.md](../../plans/adr-0005-implementation-plan.md) for detailed implementation steps.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,3 +16,82 @@ We will implement a dedicated background job processing system using a task queu
|
||||
|
||||
**Positive**: Decouples the API from heavy processing, allows for retries on failure, and enables scaling the processing workers independently. Increases application reliability and resilience.
|
||||
**Negative**: Introduces a new dependency (Redis) into the infrastructure. Requires refactoring of the flyer processing logic to work within a job queue structure.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Queue Infrastructure
|
||||
|
||||
The implementation uses **BullMQ v5.65.1** with **ioredis v5.8.2** for Redis connectivity. Six distinct queues handle different job types:
|
||||
|
||||
| Queue Name | Purpose | Retry Attempts | Backoff Strategy |
|
||||
| ---------------------------- | --------------------------- | -------------- | ---------------------- |
|
||||
| `flyer-processing` | OCR/AI processing of flyers | 3 | Exponential (5s base) |
|
||||
| `email-sending` | Email delivery | 5 | Exponential (10s base) |
|
||||
| `analytics-reporting` | Daily report generation | 2 | Exponential (60s base) |
|
||||
| `weekly-analytics-reporting` | Weekly report generation | 2 | Exponential (1h base) |
|
||||
| `file-cleanup` | Temporary file cleanup | 3 | Exponential (30s base) |
|
||||
| `token-cleanup` | Expired token removal | 2 | Exponential (1h base) |
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/services/queues.server.ts` - Queue definitions and configuration
|
||||
- `src/services/workers.server.ts` - Worker implementations with configurable concurrency
|
||||
- `src/services/redis.server.ts` - Redis connection management
|
||||
- `src/services/queueService.server.ts` - Queue lifecycle and graceful shutdown
|
||||
- `src/services/flyerProcessingService.server.ts` - 5-stage flyer processing pipeline
|
||||
- `src/types/job-data.ts` - TypeScript interfaces for all job data types
|
||||
|
||||
### API Design
|
||||
|
||||
Endpoints for long-running tasks return **202 Accepted** immediately with a job ID:
|
||||
|
||||
```text
|
||||
POST /api/ai/upload-and-process → 202 { jobId: "..." }
|
||||
GET /api/ai/jobs/:jobId/status → { state: "...", progress: ... }
|
||||
```
|
||||
|
||||
### Worker Configuration
|
||||
|
||||
Workers are configured via environment variables:
|
||||
|
||||
- `WORKER_CONCURRENCY` - Flyer processing parallelism (default: 1)
|
||||
- `EMAIL_WORKER_CONCURRENCY` - Email worker parallelism (default: 10)
|
||||
- `ANALYTICS_WORKER_CONCURRENCY` - Analytics worker parallelism (default: 1)
|
||||
- `CLEANUP_WORKER_CONCURRENCY` - Cleanup worker parallelism (default: 10)
|
||||
|
||||
### Monitoring
|
||||
|
||||
- **Bull Board UI** available at `/api/admin/jobs` for admin users
|
||||
- Worker status endpoint: `GET /api/admin/workers/status`
|
||||
- Queue status endpoint: `GET /api/admin/queues/status`
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Both API and worker processes implement graceful shutdown with a 30-second timeout, ensuring in-flight jobs complete before process termination.
|
||||
|
||||
## Compliance Notes
|
||||
|
||||
### Deprecated Synchronous Endpoints
|
||||
|
||||
The following endpoints process flyers synchronously and are **deprecated**:
|
||||
|
||||
- `POST /api/ai/upload-legacy` - For integration testing only
|
||||
- `POST /api/ai/flyers/process` - Legacy workflow, should migrate to queue-based approach
|
||||
|
||||
New integrations MUST use `POST /api/ai/upload-and-process` for queue-based processing.
|
||||
|
||||
### Email Handling
|
||||
|
||||
- **Bulk emails** (deal notifications): Enqueued via `emailQueue`
|
||||
- **Transactional emails** (password reset): Sent synchronously for immediate user feedback
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for consideration:
|
||||
|
||||
1. **Dead Letter Queue (DLQ)**: Move permanently failed jobs to a dedicated queue for analysis
|
||||
2. **Job Priority Levels**: Allow priority-based processing for different job types
|
||||
3. **Real-time Progress**: WebSocket/SSE for live job progress updates to clients
|
||||
4. **Per-Queue Rate Limiting**: Throttle job processing based on external API limits
|
||||
5. **Job Dependencies**: Support for jobs that depend on completion of other jobs
|
||||
6. **Prometheus Metrics**: Export queue metrics for observability dashboards
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,3 +18,216 @@ We will introduce a centralized, schema-validated configuration service. We will
|
||||
|
||||
**Positive**: Improves application reliability and developer experience by catching configuration errors at startup rather than at runtime. Provides a single source of truth for all required configuration.
|
||||
**Negative**: Adds a small amount of boilerplate for defining the configuration schema. Requires a one-time effort to refactor all `process.env` access points to use the new configuration service.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ **Centralized Configuration Schema** - Zod-based validation in `src/config/env.ts`
|
||||
- ✅ **Type-Safe Access** - Full TypeScript types for all configuration
|
||||
- ✅ **Fail-Fast Startup** - Clear error messages for missing/invalid config
|
||||
- ✅ **Environment Helpers** - `isProduction`, `isTest`, `isDevelopment` exports
|
||||
- ✅ **Service Configuration Helpers** - `isSmtpConfigured`, `isAiConfigured`, etc.
|
||||
|
||||
### Migration Status
|
||||
|
||||
- ⏳ Gradual migration of `process.env` access to `config.*` in progress
|
||||
- Legacy `process.env` access still works during transition
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Configuration Schema
|
||||
|
||||
The configuration is organized into logical groups:
|
||||
|
||||
```typescript
|
||||
import { config, isProduction, isTest } from './config/env';
|
||||
|
||||
// Database
|
||||
config.database.host; // DB_HOST
|
||||
config.database.port; // DB_PORT (default: 5432)
|
||||
config.database.user; // DB_USER
|
||||
config.database.password; // DB_PASSWORD
|
||||
config.database.name; // DB_NAME
|
||||
|
||||
// Redis
|
||||
config.redis.url; // REDIS_URL
|
||||
config.redis.password; // REDIS_PASSWORD (optional)
|
||||
|
||||
// Authentication
|
||||
config.auth.jwtSecret; // JWT_SECRET (min 32 chars)
|
||||
config.auth.jwtSecretPrevious; // JWT_SECRET_PREVIOUS (for rotation)
|
||||
|
||||
// SMTP (all optional - email degrades gracefully)
|
||||
config.smtp.host; // SMTP_HOST
|
||||
config.smtp.port; // SMTP_PORT (default: 587)
|
||||
config.smtp.user; // SMTP_USER
|
||||
config.smtp.pass; // SMTP_PASS
|
||||
config.smtp.secure; // SMTP_SECURE (default: false)
|
||||
config.smtp.fromEmail; // SMTP_FROM_EMAIL
|
||||
|
||||
// AI Services
|
||||
config.ai.geminiApiKey; // GEMINI_API_KEY
|
||||
config.ai.geminiRpm; // GEMINI_RPM (default: 5)
|
||||
config.ai.priceQualityThreshold; // AI_PRICE_QUALITY_THRESHOLD (default: 0.5)
|
||||
|
||||
// Google Services
|
||||
config.google.mapsApiKey; // GOOGLE_MAPS_API_KEY (optional)
|
||||
config.google.clientId; // GOOGLE_CLIENT_ID (optional)
|
||||
config.google.clientSecret; // GOOGLE_CLIENT_SECRET (optional)
|
||||
|
||||
// Worker Configuration
|
||||
config.worker.concurrency; // WORKER_CONCURRENCY (default: 1)
|
||||
config.worker.lockDuration; // WORKER_LOCK_DURATION (default: 30000)
|
||||
config.worker.emailConcurrency; // EMAIL_WORKER_CONCURRENCY (default: 10)
|
||||
config.worker.analyticsConcurrency; // ANALYTICS_WORKER_CONCURRENCY (default: 1)
|
||||
config.worker.cleanupConcurrency; // CLEANUP_WORKER_CONCURRENCY (default: 10)
|
||||
config.worker.weeklyAnalyticsConcurrency; // WEEKLY_ANALYTICS_WORKER_CONCURRENCY (default: 1)
|
||||
|
||||
// Server
|
||||
config.server.nodeEnv; // NODE_ENV (development/production/test)
|
||||
config.server.port; // PORT (default: 3001)
|
||||
config.server.frontendUrl; // FRONTEND_URL
|
||||
config.server.baseUrl; // BASE_URL
|
||||
config.server.storagePath; // STORAGE_PATH (default: /var/www/.../flyer-images)
|
||||
```
|
||||
|
||||
### Convenience Helpers
|
||||
|
||||
```typescript
|
||||
import { isProduction, isTest, isDevelopment, isSmtpConfigured } from './config/env';
|
||||
|
||||
// Environment checks
|
||||
if (isProduction) {
|
||||
// Production-only logic
|
||||
}
|
||||
|
||||
// Service availability checks
|
||||
if (isSmtpConfigured) {
|
||||
await sendEmail(...);
|
||||
} else {
|
||||
logger.warn('Email not configured, skipping notification');
|
||||
}
|
||||
```
|
||||
|
||||
### Fail-Fast Error Messages
|
||||
|
||||
When configuration is invalid, the application exits with a clear error:
|
||||
|
||||
```text
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ CONFIGURATION ERROR - APPLICATION STARTUP ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
|
||||
The following environment variables are missing or invalid:
|
||||
|
||||
- database.host: DB_HOST is required
|
||||
- auth.jwtSecret: JWT_SECRET must be at least 32 characters for security
|
||||
|
||||
Please check your .env file or environment configuration.
|
||||
See ADR-007 for the complete list of required environment variables.
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
// Before (direct process.env access)
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
// After (type-safe config access)
|
||||
import { config } from './config/env';
|
||||
|
||||
const pool = new Pool({
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
database: config.database.name,
|
||||
});
|
||||
```
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
### Critical (Application will not start without these)
|
||||
|
||||
| Variable | Description |
|
||||
| ------------- | ----------------------------------------------------- |
|
||||
| `DB_HOST` | PostgreSQL database host |
|
||||
| `DB_USER` | PostgreSQL database user |
|
||||
| `DB_PASSWORD` | PostgreSQL database password |
|
||||
| `DB_NAME` | PostgreSQL database name |
|
||||
| `REDIS_URL` | Redis connection URL (e.g., `redis://localhost:6379`) |
|
||||
| `JWT_SECRET` | JWT signing secret (minimum 32 characters) |
|
||||
|
||||
### Optional with Defaults
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------------------- | ------------------------- | ------------------------------- |
|
||||
| `DB_PORT` | 5432 | PostgreSQL port |
|
||||
| `PORT` | 3001 | Server HTTP port |
|
||||
| `NODE_ENV` | development | Environment mode |
|
||||
| `STORAGE_PATH` | /var/www/.../flyer-images | File upload directory |
|
||||
| `SMTP_PORT` | 587 | SMTP server port |
|
||||
| `SMTP_SECURE` | false | Use TLS for SMTP |
|
||||
| `GEMINI_RPM` | 5 | Gemini API requests per minute |
|
||||
| `AI_PRICE_QUALITY_THRESHOLD` | 0.5 | AI extraction quality threshold |
|
||||
| `WORKER_CONCURRENCY` | 1 | Flyer processing concurrency |
|
||||
| `WORKER_LOCK_DURATION` | 30000 | Worker lock duration (ms) |
|
||||
|
||||
### Optional (Feature-specific)
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key (enables AI features) |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps API key (enables geocoding) |
|
||||
| `SMTP_HOST` | SMTP server (enables email notifications) |
|
||||
| `SMTP_USER` | SMTP authentication username |
|
||||
| `SMTP_PASS` | SMTP authentication password |
|
||||
| `SMTP_FROM_EMAIL` | Sender email address |
|
||||
| `FRONTEND_URL` | Frontend URL for email links |
|
||||
| `JWT_SECRET_PREVIOUS` | Previous JWT secret for rotation (ADR-029) |
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/config/env.ts` - Configuration schema and validation
|
||||
- `.env.example` - Template for required environment variables
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To migrate existing `process.env` usage:
|
||||
|
||||
1. Import the config:
|
||||
|
||||
```typescript
|
||||
import { config, isProduction } from '../config/env';
|
||||
```
|
||||
|
||||
2. Replace direct access:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
process.env.DB_HOST;
|
||||
process.env.NODE_ENV === 'production';
|
||||
parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
// After
|
||||
config.database.host;
|
||||
isProduction;
|
||||
config.server.port;
|
||||
```
|
||||
|
||||
3. Use service helpers for optional features:
|
||||
|
||||
```typescript
|
||||
import { isSmtpConfigured, isAiConfigured } from '../config/env';
|
||||
|
||||
if (isSmtpConfigured) {
|
||||
// Email is available
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
@@ -20,3 +20,107 @@ We will implement a multi-layered caching strategy using an in-memory data store
|
||||
|
||||
**Positive**: Directly addresses application performance and scalability. Reduces database load and improves API response times for common requests.
|
||||
**Negative**: Introduces Redis as a dependency if not already used. Adds complexity to the data-fetching logic and requires careful management of cache invalidation to prevent stale data.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Cache Service
|
||||
|
||||
A centralized cache service (`src/services/cacheService.server.ts`) provides reusable caching functionality:
|
||||
|
||||
- **`getOrSet<T>(key, fetcher, options)`**: Cache-aside pattern implementation
|
||||
- **`get<T>(key)`**: Retrieve cached value
|
||||
- **`set<T>(key, value, ttl)`**: Store value with TTL
|
||||
- **`del(key)`**: Delete specific key
|
||||
- **`invalidatePattern(pattern)`**: Delete keys matching a pattern
|
||||
|
||||
All cache operations are fail-safe - cache failures do not break the application.
|
||||
|
||||
### TTL Configuration
|
||||
|
||||
Different data types use different TTL values based on volatility:
|
||||
|
||||
| Data Type | TTL | Rationale |
|
||||
| ------------------- | --------- | -------------------------------------- |
|
||||
| Brands/Stores | 1 hour | Rarely changes, safe to cache longer |
|
||||
| Flyer lists | 5 minutes | Changes when new flyers are added |
|
||||
| Individual flyers | 10 minutes| Stable once created |
|
||||
| Flyer items | 10 minutes| Stable once created |
|
||||
| Statistics | 5 minutes | Can be slightly stale |
|
||||
| Frequent sales | 15 minutes| Aggregated data, updated periodically |
|
||||
| Categories | 1 hour | Rarely changes |
|
||||
|
||||
### Cache Key Strategy
|
||||
|
||||
Cache keys follow a consistent prefix pattern for pattern-based invalidation:
|
||||
|
||||
- `cache:brands` - All brands list
|
||||
- `cache:flyers:{limit}:{offset}` - Paginated flyer lists
|
||||
- `cache:flyer:{id}` - Individual flyer data
|
||||
- `cache:flyer-items:{flyerId}` - Items for a specific flyer
|
||||
- `cache:stats:*` - Statistics data
|
||||
- `geocode:{address}` - Geocoding results (30-day TTL)
|
||||
|
||||
### Cached Endpoints
|
||||
|
||||
The following repository methods implement server-side caching:
|
||||
|
||||
| Method | Cache Key Pattern | TTL |
|
||||
| ------ | ----------------- | --- |
|
||||
| `FlyerRepository.getAllBrands()` | `cache:brands` | 1 hour |
|
||||
| `FlyerRepository.getFlyers()` | `cache:flyers:{limit}:{offset}` | 5 minutes |
|
||||
| `FlyerRepository.getFlyerItems()` | `cache:flyer-items:{flyerId}` | 10 minutes |
|
||||
|
||||
### Cache Invalidation
|
||||
|
||||
**Event-based invalidation** is triggered on write operations:
|
||||
|
||||
- **Flyer creation** (`FlyerPersistenceService.saveFlyer`): Invalidates all `cache:flyers*` keys
|
||||
- **Flyer deletion** (`FlyerRepository.deleteFlyer`): Invalidates specific flyer and flyer items cache, plus flyer lists
|
||||
|
||||
**Manual invalidation** via admin endpoints:
|
||||
|
||||
- `POST /api/admin/system/clear-cache` - Clears all application cache (flyers, brands, stats)
|
||||
- `POST /api/admin/system/clear-geocode-cache` - Clears geocoding cache
|
||||
|
||||
### Client-Side Caching
|
||||
|
||||
TanStack React Query provides client-side caching with configurable stale times:
|
||||
|
||||
| Query Type | Stale Time |
|
||||
| ----------------- | ----------- |
|
||||
| Categories | 1 hour |
|
||||
| Master Items | 10 minutes |
|
||||
| Flyer Items | 5 minutes |
|
||||
| Flyers | 2 minutes |
|
||||
| Shopping Lists | 1 minute |
|
||||
| Activity Log | 30 seconds |
|
||||
|
||||
### Multi-Layer Cache Architecture
|
||||
|
||||
```text
|
||||
Client Request
|
||||
↓
|
||||
[TanStack React Query] ← Client-side cache (staleTime-based)
|
||||
↓
|
||||
[Express API]
|
||||
↓
|
||||
[CacheService.getOrSet()] ← Server-side Redis cache (TTL-based)
|
||||
↓
|
||||
[PostgreSQL Database]
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/cacheService.server.ts` - Centralized cache service
|
||||
- `src/services/db/flyer.db.ts` - Repository with caching for brands, flyers, flyer items
|
||||
- `src/services/flyerPersistenceService.server.ts` - Cache invalidation on flyer creation
|
||||
- `src/routes/admin.routes.ts` - Admin cache management endpoints
|
||||
- `src/config/queryClient.ts` - Client-side query cache configuration
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Recipe caching**: Add caching to expensive recipe queries (by-sale-percentage, etc.)
|
||||
2. **Cache warming**: Pre-populate cache on startup for frequently accessed static data
|
||||
3. **Cache metrics**: Add hit/miss rate monitoring for observability
|
||||
4. **Conditional caching**: Skip cache for authenticated user-specific data
|
||||
5. **Cache compression**: Compress large cached payloads to reduce Redis memory usage
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
@@ -14,9 +14,359 @@ We will formalize the testing pyramid for the project, defining the role of each
|
||||
|
||||
1. **Unit Tests (Vitest)**: For isolated functions, components, and repository methods with mocked dependencies. High coverage is expected.
|
||||
2. **Integration Tests (Supertest)**: For API routes, testing the interaction between controllers, services, and mocked database layers. Focus on contract and middleware correctness.
|
||||
3. **End-to-End (E2E) Tests (Playwright/Cypress)**: For critical user flows (e.g., login, flyer upload, checkout), running against a real browser and a test database to ensure the entire system works together.
|
||||
3. **End-to-End (E2E) Tests (Vitest + Supertest)**: For critical user flows (e.g., login, flyer upload, checkout), running against a real test server and database to ensure the entire system works together.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Ensures a consistent and comprehensive approach to quality assurance. Gives developers confidence when refactoring or adding new features. Clearly defines "done" for a new feature.
|
||||
**Negative**: May require investment in setting up and maintaining the E2E testing environment. Can slightly increase the time required to develop a feature if all test layers are required.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Testing Framework Stack
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
| ------------------------- | --------------- | --------------------------------------- |
|
||||
| Vitest | 4.0.15 | Test runner for all test types |
|
||||
| @testing-library/react | 16.3.0 | React component testing |
|
||||
| @testing-library/jest-dom | 6.9.1 | DOM assertion matchers |
|
||||
| supertest | 7.1.4 | HTTP assertion library for API testing |
|
||||
| msw | 2.12.3 | Mock Service Worker for network mocking |
|
||||
| testcontainers | 11.8.1 | Database containerization (optional) |
|
||||
| c8 + nyc | 10.1.3 / 17.1.0 | Coverage reporting |
|
||||
|
||||
### Test File Organization
|
||||
|
||||
```text
|
||||
src/
|
||||
├── components/
|
||||
│ └── *.test.tsx # Component unit tests (colocated)
|
||||
├── hooks/
|
||||
│ └── *.test.ts # Hook unit tests (colocated)
|
||||
├── services/
|
||||
│ └── *.test.ts # Service unit tests (colocated)
|
||||
├── routes/
|
||||
│ └── *.test.ts # Route handler unit tests (colocated)
|
||||
├── utils/
|
||||
│ └── *.test.ts # Utility function tests (colocated)
|
||||
└── tests/
|
||||
├── setup/ # Test configuration and setup files
|
||||
├── utils/ # Test utilities, factories, helpers
|
||||
├── assets/ # Test fixtures (images, files)
|
||||
├── integration/ # Integration test files (*.test.ts)
|
||||
└── e2e/ # End-to-end test files (*.e2e.test.ts)
|
||||
```
|
||||
|
||||
**Naming Convention**: `{filename}.test.ts` or `{filename}.test.tsx` for unit/integration, `{filename}.e2e.test.ts` for E2E.
|
||||
|
||||
### Configuration Files
|
||||
|
||||
| Config | Environment | Purpose |
|
||||
| ------------------------------ | ----------- | ------------------------------------ |
|
||||
| `vite.config.ts` | jsdom | Unit tests (React components, hooks) |
|
||||
| `vitest.config.integration.ts` | node | Integration tests (API routes) |
|
||||
| `vitest.config.e2e.ts` | node | E2E tests (full user flows) |
|
||||
| `vitest.workspace.ts` | - | Orchestrates all test projects |
|
||||
|
||||
### Test Pyramid
|
||||
|
||||
```text
|
||||
┌─────────────┐
|
||||
│ E2E │ 5 test files
|
||||
│ Tests │ Critical user flows
|
||||
├─────────────┤
|
||||
│ Integration │ 17 test files
|
||||
│ Tests │ API contracts + middleware
|
||||
┌───┴─────────────┴───┐
|
||||
│ Unit Tests │ 185 test files
|
||||
│ Components, Hooks, │ Isolated functions
|
||||
│ Services, Utils │ Mocked dependencies
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Purpose**: Test isolated functions, components, and modules with mocked dependencies.
|
||||
|
||||
**Environment**: jsdom (browser-like)
|
||||
|
||||
**Key Patterns**:
|
||||
|
||||
```typescript
|
||||
// Component testing with providers
|
||||
import { renderWithProviders, screen } from '@/tests/utils/renderWithProviders';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
renderWithProviders(<MyComponent />);
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Hook testing
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useMyHook } from './useMyHook';
|
||||
|
||||
describe('useMyHook', () => {
|
||||
it('returns expected value', async () => {
|
||||
const { result } = renderHook(() => useMyHook());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Global Mocks** (automatically applied via `tests-setup-unit.ts`):
|
||||
|
||||
- Database connections (`pg.Pool`)
|
||||
- AI services (`@google/genai`)
|
||||
- Authentication (`jsonwebtoken`, `bcrypt`)
|
||||
- Logging (`logger.server`, `logger.client`)
|
||||
- Notifications (`notificationService`)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Purpose**: Test API routes with real service interactions and database.
|
||||
|
||||
**Environment**: node
|
||||
|
||||
**Setup**: Real Express server on port 3001, real PostgreSQL database
|
||||
|
||||
```typescript
|
||||
// API route testing pattern
|
||||
import supertest from 'supertest';
|
||||
import { createAndLoginUser } from '@/tests/utils/testHelpers';
|
||||
|
||||
describe('Auth API', () => {
|
||||
let request: ReturnType<typeof supertest>;
|
||||
let authToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = (await import('../../../server')).default;
|
||||
request = supertest(app);
|
||||
const { token } = await createAndLoginUser(request);
|
||||
authToken = token;
|
||||
});
|
||||
|
||||
it('GET /api/auth/me returns user profile', async () => {
|
||||
const response = await request.get('/api/auth/me').set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.email).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Database Cleanup**:
|
||||
|
||||
```typescript
|
||||
import { cleanupDb } from '@/tests/utils/cleanup';
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupDb({ users: [testUserId] });
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
**Purpose**: Test complete user journeys through the application.
|
||||
|
||||
**Timeout**: 120 seconds (for long-running flows)
|
||||
|
||||
**Current E2E Tests**:
|
||||
|
||||
- `auth.e2e.test.ts` - Registration, login, password reset
|
||||
- `flyer-upload.e2e.test.ts` - Complete flyer upload pipeline
|
||||
- `user-journey.e2e.test.ts` - Full user workflow
|
||||
- `admin-authorization.e2e.test.ts` - Admin-specific flows
|
||||
- `admin-dashboard.e2e.test.ts` - Admin dashboard functionality
|
||||
|
||||
### Mock Factories
|
||||
|
||||
The project uses comprehensive mock factories (`src/tests/utils/mockFactories.ts`, 1553 lines) for creating test data:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createMockUser,
|
||||
createMockFlyer,
|
||||
createMockFlyerItem,
|
||||
createMockRecipe,
|
||||
resetMockIds,
|
||||
} from '@/tests/utils/mockFactories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockIds(); // Ensure deterministic IDs
|
||||
});
|
||||
|
||||
it('creates flyer with items', () => {
|
||||
const flyer = createMockFlyer({ store_name: 'TestMart' });
|
||||
const items = [createMockFlyerItem({ flyer_id: flyer.flyer_id })];
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**Factory Coverage**: 90+ factory functions for all domain entities including users, flyers, recipes, shopping lists, budgets, achievements, etc.
|
||||
|
||||
### Test Utilities
|
||||
|
||||
| Utility | Purpose |
|
||||
| ----------------------- | ------------------------------------------ |
|
||||
| `renderWithProviders()` | Wrap components with AppProviders + Router |
|
||||
| `createAndLoginUser()` | Create user and return auth token |
|
||||
| `cleanupDb()` | Database cleanup respecting FK constraints |
|
||||
| `createTestApp()` | Create Express app for route testing |
|
||||
| `poll()` | Polling utility for async operations |
|
||||
|
||||
### Coverage Configuration
|
||||
|
||||
**Coverage Provider**: v8 (built-in Vitest)
|
||||
|
||||
**Report Directories**:
|
||||
|
||||
- `.coverage/unit/` - Unit test coverage
|
||||
- `.coverage/integration/` - Integration test coverage
|
||||
- `.coverage/e2e/` - E2E test coverage
|
||||
|
||||
**Excluded from Coverage**:
|
||||
|
||||
- `src/index.tsx`, `src/main.tsx` (entry points)
|
||||
- `src/tests/**` (test files themselves)
|
||||
- `src/**/*.d.ts` (type declarations)
|
||||
- `src/components/icons/**` (icon components)
|
||||
- `src/db/seed*.ts` (database seeding scripts)
|
||||
|
||||
### npm Scripts
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Run by level
|
||||
npm run test:unit # Unit tests only (jsdom)
|
||||
npm run test:integration # Integration tests only (node)
|
||||
|
||||
# With coverage
|
||||
npm run test:coverage # Unit + Integration with reports
|
||||
|
||||
# Clean coverage directories
|
||||
npm run clean
|
||||
```
|
||||
|
||||
### Test Timeouts
|
||||
|
||||
| Test Type | Timeout | Rationale |
|
||||
| ----------- | ----------- | -------------------------------------- |
|
||||
| Unit | 5 seconds | Fast, isolated tests |
|
||||
| Integration | 60 seconds | AI service calls, DB operations |
|
||||
| E2E | 120 seconds | Full user flow with multiple API calls |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to Write Each Test Type
|
||||
|
||||
1. **Unit Tests** (required):
|
||||
- Pure functions and utilities
|
||||
- React components (rendering, user interactions)
|
||||
- Custom hooks
|
||||
- Service methods with mocked dependencies
|
||||
- Repository methods
|
||||
|
||||
2. **Integration Tests** (required for API changes):
|
||||
- New API endpoints
|
||||
- Authentication/authorization flows
|
||||
- Middleware behavior
|
||||
- Database query correctness
|
||||
|
||||
3. **E2E Tests** (for critical paths):
|
||||
- User registration and login
|
||||
- Core business flows (flyer upload, shopping lists)
|
||||
- Admin operations
|
||||
|
||||
### Test Isolation Guidelines
|
||||
|
||||
1. **Reset mock IDs**: Call `resetMockIds()` in `beforeEach()`
|
||||
2. **Unique test data**: Use timestamps or UUIDs for emails/usernames
|
||||
3. **Clean up after tests**: Use `cleanupDb()` in `afterAll()`
|
||||
4. **Don't share state**: Each test should be independent
|
||||
|
||||
### Mocking Guidelines
|
||||
|
||||
1. **Unit tests**: Mock external dependencies (DB, APIs, services)
|
||||
2. **Integration tests**: Mock only external APIs (AI services)
|
||||
3. **E2E tests**: Minimal mocking, use real services where possible
|
||||
|
||||
### Testing Code Smells
|
||||
|
||||
**When testing requires any of the following patterns, treat it as a code smell indicating the production code needs refactoring:**
|
||||
|
||||
1. **Capturing callbacks through mocks**: If you need to capture a callback passed to a mock and manually invoke it to test behavior, the code under test likely has poor separation of concerns.
|
||||
|
||||
2. **Complex module resets**: If tests require `vi.resetModules()`, `vi.doMock()`, or careful ordering of mock setup to work correctly, the module likely has problematic initialization or hidden global state.
|
||||
|
||||
3. **Indirect verification**: If you can only verify behavior by checking that internal mocks were called with specific arguments (rather than asserting on direct outputs), the code likely lacks proper return values or has side effects that should be explicit.
|
||||
|
||||
4. **Excessive mock setup**: If setting up mocks requires more lines than the actual test assertions, consider whether the code under test has too many dependencies or responsibilities.
|
||||
|
||||
**The Fix**: Rather than writing complex test scaffolding, refactor the production code to be more testable:
|
||||
|
||||
- Extract pure functions that can be tested with simple input/output assertions
|
||||
- Use dependency injection to make dependencies explicit and easily replaceable
|
||||
- Return values from functions instead of relying on side effects
|
||||
- Split modules with complex initialization into smaller, focused units
|
||||
- Make async flows explicit and controllable rather than callback-based
|
||||
|
||||
**Example anti-pattern**:
|
||||
|
||||
```typescript
|
||||
// BAD: Capturing callback to test behavior
|
||||
const capturedCallback = vi.fn();
|
||||
mockService.onEvent.mockImplementation((cb) => {
|
||||
capturedCallback = cb;
|
||||
});
|
||||
await initializeModule();
|
||||
capturedCallback('test-data'); // Manually triggering to test
|
||||
expect(mockOtherService.process).toHaveBeenCalledWith('test-data');
|
||||
```
|
||||
|
||||
**Example preferred pattern**:
|
||||
|
||||
```typescript
|
||||
// GOOD: Direct input/output testing
|
||||
const result = await processEvent('test-data');
|
||||
expect(result).toEqual({ processed: true, data: 'test-data' });
|
||||
```
|
||||
|
||||
### Known Code Smell Violations (Technical Debt)
|
||||
|
||||
The following files contain acknowledged code smell violations that are deferred for future refactoring:
|
||||
|
||||
| File | Violations | Rationale for Deferral |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `src/services/queueService.workers.test.ts` | Callback capture, `vi.resetModules()`, excessive setup | BullMQ workers instantiate at module load; business logic is tested via service classes |
|
||||
| `src/services/workers.server.test.ts` | `vi.resetModules()` | Same as above - worker wiring tests |
|
||||
| `src/services/queues.server.test.ts` | `vi.resetModules()` | Queue instantiation at module load |
|
||||
| `src/App.test.tsx` | Callback capture, excessive setup | Component integration test; refactoring would require significant UI architecture changes |
|
||||
| `src/features/voice-assistant/VoiceAssistant.test.tsx` | Multiple callback captures | WebSocket/audio APIs are inherently callback-based |
|
||||
| `src/services/aiService.server.test.ts` | Multiple `vi.resetModules()` | AI service initialization complexity |
|
||||
|
||||
**Policy**: New code should follow the code smell guidelines. These existing violations are tracked here and will be addressed when the underlying modules are refactored or replaced.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `vite.config.ts` - Unit test configuration
|
||||
- `vitest.config.integration.ts` - Integration test configuration
|
||||
- `vitest.config.e2e.ts` - E2E test configuration
|
||||
- `vitest.workspace.ts` - Workspace orchestration
|
||||
- `src/tests/setup/tests-setup-unit.ts` - Global mocks (488 lines)
|
||||
- `src/tests/setup/integration-global-setup.ts` - Server + DB setup
|
||||
- `src/tests/utils/mockFactories.ts` - Mock factories (1553 lines)
|
||||
- `src/tests/utils/testHelpers.ts` - Test utilities
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Browser E2E Tests**: Consider adding Playwright for actual browser testing
|
||||
2. **Visual Regression**: Screenshot comparison for UI components
|
||||
3. **Performance Testing**: Add benchmarks for critical paths
|
||||
4. **Mutation Testing**: Verify test quality with mutation testing tools
|
||||
5. **Coverage Thresholds**: Define minimum coverage requirements per module
|
||||
|
||||
@@ -14,5 +14,5 @@ We will formalize a centralized Role-Based Access Control (RBAC) or Attribute-Ba
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
||||
* **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
||||
- **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
||||
- **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Partially Implemented
|
||||
|
||||
## Context
|
||||
|
||||
@@ -14,5 +14,257 @@ We will establish a formal Design System and Component Library. This will involv
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
||||
* **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
||||
- **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
||||
- **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
The codebase has a solid foundation for a design system:
|
||||
|
||||
- ✅ **Tailwind CSS v4.1.17** as the styling solution
|
||||
- ✅ **Dark mode** fully implemented with system preference detection
|
||||
- ✅ **55 custom icon components** for consistent iconography
|
||||
- ✅ **Component organization** with shared vs. feature-specific separation
|
||||
- ✅ **Accessibility patterns** with ARIA attributes and focus management
|
||||
|
||||
### What's Not Yet Implemented
|
||||
|
||||
- ❌ **Storybook** is not yet installed or configured
|
||||
- ❌ **Formal design token documentation** (colors, typography, spacing)
|
||||
- ❌ **Visual regression testing** for component changes
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Component Library Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── components/ # 30+ shared UI components
|
||||
│ ├── icons/ # 55 SVG icon components
|
||||
│ ├── Header.tsx
|
||||
│ ├── Footer.tsx
|
||||
│ ├── LoadingSpinner.tsx
|
||||
│ ├── ErrorDisplay.tsx
|
||||
│ ├── ConfirmationModal.tsx
|
||||
│ ├── DarkModeToggle.tsx
|
||||
│ ├── StatCard.tsx
|
||||
│ ├── PasswordInput.tsx
|
||||
│ └── ...
|
||||
├── features/ # Feature-specific components
|
||||
│ ├── charts/ # PriceChart, PriceHistoryChart
|
||||
│ ├── flyer/ # FlyerDisplay, FlyerList, FlyerUploader
|
||||
│ ├── shopping/ # ShoppingListComponent, WatchedItemsList
|
||||
│ └── voice-assistant/ # VoiceAssistant
|
||||
├── layouts/ # Page layouts
|
||||
│ └── MainLayout.tsx
|
||||
├── pages/ # Page components
|
||||
│ └── admin/components/ # Admin-specific components
|
||||
└── providers/ # Context providers
|
||||
```
|
||||
|
||||
### Styling Approach
|
||||
|
||||
**Tailwind CSS** with utility-first classes:
|
||||
|
||||
```typescript
|
||||
// Component example with consistent styling patterns
|
||||
<button className="px-4 py-2 bg-brand-primary text-white rounded-lg
|
||||
hover:bg-brand-dark transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-brand-primary
|
||||
focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
**Common Utility Patterns**:
|
||||
|
||||
| Pattern | Classes |
|
||||
| ------- | ------- |
|
||||
| Card container | `bg-white dark:bg-gray-800 rounded-lg shadow-md p-6` |
|
||||
| Primary button | `bg-brand-primary hover:bg-brand-dark text-white rounded-lg px-4 py-2` |
|
||||
| Secondary button | `bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200` |
|
||||
| Input field | `border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2` |
|
||||
| Focus ring | `focus:outline-none focus:ring-2 focus:ring-brand-primary` |
|
||||
|
||||
### Color System
|
||||
|
||||
**Brand Colors** (Tailwind theme extensions):
|
||||
|
||||
- `brand-primary` - Primary brand color (blue/teal)
|
||||
- `brand-light` - Lighter variant
|
||||
- `brand-dark` - Darker variant for hover states
|
||||
- `brand-secondary` - Secondary accent color
|
||||
|
||||
**Semantic Colors**:
|
||||
|
||||
- Gray scale: `gray-50` through `gray-950`
|
||||
- Error: `red-500`, `red-600`
|
||||
- Success: `green-500`, `green-600`
|
||||
- Warning: `yellow-500`, `orange-500`
|
||||
- Info: `blue-500`, `blue-600`
|
||||
|
||||
### Dark Mode Implementation
|
||||
|
||||
Dark mode is fully implemented using Tailwind's `dark:` variant:
|
||||
|
||||
```typescript
|
||||
// Initialization in useAppInitialization hook
|
||||
const initializeDarkMode = () => {
|
||||
// Priority: user profile > localStorage > system preference
|
||||
const stored = localStorage.getItem('darkMode');
|
||||
const systemPreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDarkMode = stored ? stored === 'true' : systemPreference;
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
return isDarkMode;
|
||||
};
|
||||
```
|
||||
|
||||
**Usage in components**:
|
||||
|
||||
```typescript
|
||||
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
Content adapts to theme
|
||||
</div>
|
||||
```
|
||||
|
||||
### Icon System
|
||||
|
||||
**55 custom SVG icon components** in `src/components/icons/`:
|
||||
|
||||
```typescript
|
||||
// Icon component pattern
|
||||
interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const CheckCircleIcon: React.FC<IconProps> = ({ title, ...props }) => (
|
||||
<svg {...props} fill="currentColor" viewBox="0 0 24 24">
|
||||
{title && <title>{title}</title>}
|
||||
<path d="..." />
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```typescript
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" title="Success" />
|
||||
```
|
||||
|
||||
**External icons**: Lucide React (`lucide-react` v0.555.0) used for additional icons.
|
||||
|
||||
### Accessibility Patterns
|
||||
|
||||
**ARIA Attributes**:
|
||||
|
||||
```typescript
|
||||
// Modal pattern
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<h2 id="modal-title">Modal Title</h2>
|
||||
</div>
|
||||
|
||||
// Button with label
|
||||
<button aria-label="Close modal">
|
||||
<XMarkIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
// Loading state
|
||||
<div role="status" aria-live="polite">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Focus Management**:
|
||||
|
||||
- Consistent focus rings: `focus:ring-2 focus:ring-brand-primary focus:ring-offset-2`
|
||||
- Dark mode offset: `dark:focus:ring-offset-gray-800`
|
||||
- No outline: `focus:outline-none` (using ring instead)
|
||||
|
||||
### State Management
|
||||
|
||||
**Context Providers** (see ADR-005):
|
||||
|
||||
| Provider | Purpose |
|
||||
| -------- | ------- |
|
||||
| `AuthProvider` | Authentication state |
|
||||
| `ModalProvider` | Modal open/close state |
|
||||
| `FlyersProvider` | Flyer data |
|
||||
| `MasterItemsProvider` | Grocery items |
|
||||
| `UserDataProvider` | User-specific data |
|
||||
|
||||
**Provider Hierarchy** in `AppProviders.tsx`:
|
||||
|
||||
```typescript
|
||||
<QueryClientProvider>
|
||||
<ModalProvider>
|
||||
<AuthProvider>
|
||||
<FlyersProvider>
|
||||
<MasterItemsProvider>
|
||||
<UserDataProvider>
|
||||
{children}
|
||||
</UserDataProvider>
|
||||
</MasterItemsProvider>
|
||||
</FlyersProvider>
|
||||
</AuthProvider>
|
||||
</ModalProvider>
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `tailwind.config.js` - Tailwind CSS configuration
|
||||
- `src/index.css` - Tailwind CSS entry point
|
||||
- `src/components/` - Shared UI components
|
||||
- `src/components/icons/` - Icon component library (55 icons)
|
||||
- `src/providers/AppProviders.tsx` - Context provider composition
|
||||
- `src/hooks/useAppInitialization.ts` - Dark mode initialization
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
### When to Create Shared Components
|
||||
|
||||
Create a shared component in `src/components/` when:
|
||||
|
||||
1. Used in 3+ places across the application
|
||||
2. Represents a reusable UI pattern (buttons, cards, modals)
|
||||
3. Has consistent styling/behavior requirements
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Components**: PascalCase (`LoadingSpinner.tsx`)
|
||||
- **Icons**: PascalCase with `Icon` suffix (`CheckCircleIcon.tsx`)
|
||||
- **Hooks**: camelCase with `use` prefix (`useModal.ts`)
|
||||
- **Contexts**: PascalCase with `Context` suffix (`AuthContext.tsx`)
|
||||
|
||||
### Styling Guidelines
|
||||
|
||||
1. Use Tailwind utility classes exclusively
|
||||
2. Include dark mode variants for all colors: `bg-white dark:bg-gray-800`
|
||||
3. Add focus states for interactive elements
|
||||
4. Use semantic color names from the design system
|
||||
|
||||
## Future Enhancements (Storybook Setup)
|
||||
|
||||
To complete ADR-012 implementation:
|
||||
|
||||
1. **Install Storybook**:
|
||||
|
||||
```bash
|
||||
npx storybook@latest init
|
||||
```
|
||||
|
||||
2. **Create stories for core components**:
|
||||
- Button variants
|
||||
- Form inputs (PasswordInput, etc.)
|
||||
- Modal components
|
||||
- Loading states
|
||||
- Icon showcase
|
||||
|
||||
3. **Add visual regression testing** with Chromatic or Percy
|
||||
|
||||
4. **Document design tokens** formally in Storybook
|
||||
|
||||
5. **Create component composition guidelines**
|
||||
|
||||
@@ -14,5 +14,5 @@ We will adopt a dedicated database migration tool, such as **`node-pg-migrate`**
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
||||
* **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
||||
- **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
||||
- **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
||||
|
||||
@@ -2,17 +2,351 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Implemented
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The project is currently run using `pm2`, and the `README.md` contains manual setup instructions. While functional, this lacks the portability, scalability, and consistency of modern deployment practices.
|
||||
The project is currently run using `pm2`, and the `README.md` contains manual setup instructions. While functional, this lacks the portability, scalability, and consistency of modern deployment practices. Local development environments also suffered from inconsistency issues.
|
||||
|
||||
## Platform Requirement: Linux Only
|
||||
|
||||
**CRITICAL**: This application is designed and intended to run **exclusively on Linux**, either:
|
||||
|
||||
- **In a container** (Docker/Podman) - the recommended and primary development environment
|
||||
- **On bare-metal Linux** - for production deployments
|
||||
|
||||
### Windows Compatibility
|
||||
|
||||
**Windows is NOT a supported platform.** Any apparent Windows compatibility is:
|
||||
|
||||
- Coincidental and not guaranteed
|
||||
- Subject to break at any time without notice
|
||||
- Not a priority to fix or maintain
|
||||
|
||||
Specific issues that arise on Windows include:
|
||||
|
||||
- **Path separators**: The codebase uses POSIX-style paths (`/`) which work natively on Linux but may cause issues with `path.join()` on Windows producing backslash paths
|
||||
- **Shell scripts**: Bash scripts in `scripts/` directory are Linux-only
|
||||
- **External dependencies**: Tools like `pdftocairo` assume Linux installation paths
|
||||
- **File permissions**: Unix-style permissions are assumed throughout
|
||||
|
||||
### Test Execution Requirement
|
||||
|
||||
**ALL tests MUST be executed on Linux.** This includes:
|
||||
|
||||
- Unit tests
|
||||
- Integration tests
|
||||
- End-to-end tests
|
||||
- Any CI/CD pipeline tests
|
||||
|
||||
Tests that pass on Windows but fail on Linux are considered **broken tests**. Tests that fail on Windows but pass on Linux are considered **passing tests**.
|
||||
|
||||
**For Windows developers**: Always use the Dev Container (VS Code "Reopen in Container") to run tests. Never rely on test results from the Windows host machine.
|
||||
|
||||
## Decision
|
||||
|
||||
We will standardize the deployment process by containerizing the application using **Docker**. This will involve defining a `Dockerfile` for building a production-ready image and a `docker-compose.yml` file for orchestrating the application, database, and other services (like Redis) in a development environment.
|
||||
We will standardize the deployment process using a hybrid approach:
|
||||
|
||||
1. **PM2 for Production**: Use PM2 cluster mode for process management, load balancing, and zero-downtime reloads.
|
||||
2. **Docker/Podman for Development**: Provide a complete containerized development environment with automatic initialization.
|
||||
3. **VS Code Dev Containers**: Enable one-click development environment setup.
|
||||
4. **Gitea Actions for CI/CD**: Automated deployment pipelines handle builds and deployments.
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
||||
* **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
||||
- **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers to a single "Reopen in Container" action. Improves portability and scalability of the application.
|
||||
- **Negative**: Requires Docker/Podman installation. Container builds take time on first setup.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Quick Start (Development)
|
||||
|
||||
```bash
|
||||
# Prerequisites:
|
||||
# - Docker Desktop or Podman installed
|
||||
# - VS Code with "Dev Containers" extension
|
||||
|
||||
# Option 1: VS Code Dev Containers (Recommended)
|
||||
# 1. Open project in VS Code
|
||||
# 2. Click "Reopen in Container" when prompted
|
||||
# 3. Wait for initialization to complete
|
||||
# 4. Development server starts automatically
|
||||
|
||||
# Option 2: Manual Docker Compose
|
||||
podman-compose -f compose.dev.yml up -d
|
||||
podman exec -it flyer-crawler-dev bash
|
||||
./scripts/docker-init.sh
|
||||
npm run dev:container
|
||||
```
|
||||
|
||||
### Container Services Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Development Environment │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ app │ │ postgres │ │ redis │ │
|
||||
│ │ (Node.js) │───▶│ (PostGIS) │ │ (Cache) │ │
|
||||
│ │ │───▶│ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ :3000/:3001 :5432 :6379 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### compose.dev.yml Services
|
||||
|
||||
| Service | Image | Purpose | Healthcheck |
|
||||
| ---------- | ----------------------- | ---------------------- | ---------------- |
|
||||
| `app` | Custom (Dockerfile.dev) | Node.js application | HTTP /api/health |
|
||||
| `postgres` | postgis/postgis:15-3.4 | Database with PostGIS | pg_isready |
|
||||
| `redis` | redis:alpine | Caching and job queues | redis-cli ping |
|
||||
|
||||
### Automatic Initialization
|
||||
|
||||
The container initialization script (`scripts/docker-init.sh`) performs:
|
||||
|
||||
1. **npm install** - Installs dependencies into isolated volume
|
||||
2. **Wait for PostgreSQL** - Polls until database is ready
|
||||
3. **Wait for Redis** - Polls until Redis is responding
|
||||
4. **Schema Check** - Detects if database needs initialization
|
||||
5. **Database Setup** - Runs `npm run db:reset:dev` if needed (schema + seed data)
|
||||
|
||||
### Development Dockerfile
|
||||
|
||||
Located in `Dockerfile.dev`:
|
||||
|
||||
```dockerfile
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install Node.js 20.x LTS + database clients
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl git build-essential python3 \
|
||||
postgresql-client redis-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_OPTIONS='--max-old-space-size=8192'
|
||||
|
||||
CMD ["bash"]
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Copy `.env.example` to `.env` for local overrides (optional for containers):
|
||||
|
||||
```bash
|
||||
# Container defaults (set in compose.dev.yml)
|
||||
DB_HOST=postgres # Use Docker service name, not IP
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=flyer_crawler_dev
|
||||
REDIS_URL=redis://redis:6379
|
||||
```
|
||||
|
||||
### VS Code Dev Container Configuration
|
||||
|
||||
Located in `.devcontainer/devcontainer.json`:
|
||||
|
||||
| Lifecycle Hook | Timing | Action |
|
||||
| ------------------- | ----------------- | ------------------------------ |
|
||||
| `initializeCommand` | Before container | Start Podman machine (Windows) |
|
||||
| `postCreateCommand` | Container created | Run `docker-init.sh` |
|
||||
| `postAttachCommand` | VS Code attached | Start dev server |
|
||||
|
||||
### Default Test Accounts
|
||||
|
||||
After initialization, these accounts are available:
|
||||
|
||||
| Role | Email | Password |
|
||||
| ----- | ------------------- | --------- |
|
||||
| Admin | `admin@example.com` | adminpass |
|
||||
| User | `user@example.com` | userpass |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment (PM2)
|
||||
|
||||
### PM2 Ecosystem Configuration
|
||||
|
||||
Located in `ecosystem.config.cjs`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
// API Server - Cluster mode for load balancing
|
||||
name: 'flyer-crawler-api',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'server.ts',
|
||||
max_memory_restart: '500M',
|
||||
instances: 'max', // Use all CPU cores
|
||||
exec_mode: 'cluster', // Enable cluster mode
|
||||
kill_timeout: 5000, // Graceful shutdown timeout
|
||||
|
||||
// Restart configuration
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
cwd: '/var/www/flyer-crawler.projectium.com',
|
||||
},
|
||||
env_test: {
|
||||
NODE_ENV: 'test',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Background Worker - Single instance
|
||||
name: 'flyer-crawler-worker',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000, // Workers need more time for jobs
|
||||
// ... similar config
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Deployment Directory Structure
|
||||
|
||||
```text
|
||||
/var/www/
|
||||
├── flyer-crawler.projectium.com/ # Production
|
||||
│ ├── server.ts
|
||||
│ ├── ecosystem.config.cjs
|
||||
│ ├── package.json
|
||||
│ ├── flyer-images/
|
||||
│ │ ├── icons/
|
||||
│ │ └── archive/
|
||||
│ └── ...
|
||||
└── flyer-crawler-test.projectium.com/ # Test environment
|
||||
└── ... (same structure)
|
||||
```
|
||||
|
||||
### Environment-Specific Configuration
|
||||
|
||||
| Environment | Port | Redis DB | PM2 Process Suffix |
|
||||
| ----------- | ---- | -------- | ------------------ |
|
||||
| Production | 3000 | 0 | (none) |
|
||||
| Test | 3001 | 1 | `-test` |
|
||||
| Development | 3000 | 0 | `-dev` |
|
||||
|
||||
### PM2 Commands Reference
|
||||
|
||||
```bash
|
||||
# Start/reload with environment
|
||||
pm2 startOrReload ecosystem.config.cjs --env production --update-env
|
||||
|
||||
# Save process list for startup
|
||||
pm2 save
|
||||
|
||||
# View logs
|
||||
pm2 logs flyer-crawler-api --lines 50
|
||||
|
||||
# Monitor processes
|
||||
pm2 monit
|
||||
|
||||
# List all processes
|
||||
pm2 list
|
||||
|
||||
# Describe process details
|
||||
pm2 describe flyer-crawler-api
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
| Process | Memory Limit | Restart Delay | Kill Timeout |
|
||||
| ---------------- | ------------ | ------------------------ | ------------ |
|
||||
| API Server | 500MB | Exponential (100ms base) | 5s |
|
||||
| Worker | 1GB | Exponential (100ms base) | 10s |
|
||||
| Analytics Worker | 1GB | Exponential (100ms base) | 10s |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Issues
|
||||
|
||||
```bash
|
||||
# Reset everything and start fresh
|
||||
podman-compose -f compose.dev.yml down -v
|
||||
podman-compose -f compose.dev.yml up -d --build
|
||||
|
||||
# View container logs
|
||||
podman-compose -f compose.dev.yml logs -f app
|
||||
|
||||
# Connect to database manually
|
||||
podman exec -it flyer-crawler-postgres psql -U postgres -d flyer_crawler_dev
|
||||
|
||||
# Rebuild just the app container
|
||||
podman-compose -f compose.dev.yml build app
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------------ | --------------------------------------------------------------- |
|
||||
| "Database not ready" | Wait for postgres healthcheck, or run `docker-init.sh` manually |
|
||||
| "node_modules not found" | Run `npm install` inside container |
|
||||
| "Permission denied" | Ensure scripts have execute permission: `chmod +x scripts/*.sh` |
|
||||
| "Network unreachable" | Use service names (postgres, redis) not IPs |
|
||||
|
||||
## Key Files
|
||||
|
||||
- `compose.dev.yml` - Docker Compose configuration
|
||||
- `Dockerfile.dev` - Development container definition
|
||||
- `.devcontainer/devcontainer.json` - VS Code Dev Container config
|
||||
- `scripts/docker-init.sh` - Container initialization script
|
||||
- `.env.example` - Environment variable template
|
||||
- `ecosystem.config.cjs` - PM2 production configuration
|
||||
- `.gitea/workflows/deploy-to-prod.yml` - Production deployment pipeline
|
||||
- `.gitea/workflows/deploy-to-test.yml` - Test deployment pipeline
|
||||
|
||||
## Container Test Readiness Requirement
|
||||
|
||||
**CRITICAL**: The development container MUST be fully test-ready on startup. This means:
|
||||
|
||||
1. **Zero Manual Steps**: After running `podman-compose -f compose.dev.yml up -d` and entering the container, tests MUST run immediately with `npm test` without any additional setup steps.
|
||||
|
||||
2. **Complete Environment**: All environment variables, database connections, Redis connections, and seed data MUST be automatically initialized during container startup.
|
||||
|
||||
3. **Enforcement Checklist**:
|
||||
- [ ] `npm test` runs successfully immediately after container start
|
||||
- [ ] Database is seeded with test data (admin account, sample data)
|
||||
- [ ] Redis is connected and healthy
|
||||
- [ ] All environment variables are set via `compose.dev.yml` or `.env` files
|
||||
- [ ] No "database not ready" or "connection refused" errors on first test run
|
||||
|
||||
4. **Current Gaps (To Fix)**:
|
||||
- Integration tests require database seeding (`npm run db:reset:test`)
|
||||
- Environment variables from `.env.test` may not be loaded automatically
|
||||
- Some npm scripts use `NODE_ENV=` syntax which fails on Windows (use `cross-env`)
|
||||
|
||||
5. **Resolution Steps**:
|
||||
- The `docker-init.sh` script should seed the test database after seeding dev database
|
||||
- Add automatic `.env.test` loading or move all test env vars to `compose.dev.yml`
|
||||
- Update all npm scripts to use `cross-env` for cross-platform compatibility
|
||||
|
||||
**Rationale**: Developers and CI systems should never need to run manual setup commands to execute tests. If the container is running, tests should work. Any deviation from this principle indicates an incomplete container setup.
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-017](./0017-ci-cd-and-branching-strategy.md) - CI/CD Strategy
|
||||
- [ADR-038](./0038-graceful-shutdown-pattern.md) - Graceful Shutdown Pattern
|
||||
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy and Standards
|
||||
|
||||
@@ -2,17 +2,321 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Updated**: 2026-01-11
|
||||
|
||||
## Context
|
||||
|
||||
While `ADR-004` established structured logging, the application lacks a high-level, aggregated view of its health, performance, and errors. It's difficult to spot trends, identify slow API endpoints, or be proactively notified of new types of errors.
|
||||
While `ADR-004` established structured logging with Pino, the application lacks a high-level, aggregated view of its health, performance, and errors. It's difficult to spot trends, identify slow API endpoints, or be proactively notified of new types of errors.
|
||||
|
||||
Key requirements:
|
||||
|
||||
1. **Self-hosted**: No external SaaS dependencies for error tracking
|
||||
2. **Sentry SDK compatible**: Leverage mature, well-documented SDKs
|
||||
3. **Lightweight**: Minimal resource overhead in the dev container
|
||||
4. **Production-ready**: Same architecture works on bare-metal production servers
|
||||
5. **AI-accessible**: MCP server integration for Claude Code and other AI tools
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate a dedicated Application Performance Monitoring (APM) and error tracking service like **Sentry**, **Datadog**, or **New Relic**. This will define how the service is integrated to automatically capture and report unhandled exceptions, performance data (e.g., transaction traces, database query times), and release health.
|
||||
We will implement a self-hosted error tracking stack using **Bugsink** as the Sentry-compatible backend, with the following components:
|
||||
|
||||
### 1. Error Tracking Backend: Bugsink
|
||||
|
||||
**Bugsink** is a lightweight, self-hosted Sentry alternative that:
|
||||
|
||||
- Runs as a single process (no Kafka, Redis, ClickHouse required)
|
||||
- Is fully compatible with Sentry SDKs
|
||||
- Supports ARM64 and AMD64 architectures
|
||||
- Can use SQLite (dev) or PostgreSQL (production)
|
||||
|
||||
**Deployment**:
|
||||
|
||||
- **Dev container**: Installed as a systemd service inside the container
|
||||
- **Production**: Runs as a systemd service on bare-metal, listening on localhost only
|
||||
- **Database**: Uses PostgreSQL with a dedicated `bugsink` user and `bugsink` database (same PostgreSQL instance as the main application)
|
||||
|
||||
### 2. Backend Integration: @sentry/node
|
||||
|
||||
The Express backend will integrate `@sentry/node` SDK to:
|
||||
|
||||
- Capture unhandled exceptions before PM2/process manager restarts
|
||||
- Report errors with full stack traces and context
|
||||
- Integrate with Pino logger for breadcrumbs
|
||||
- Track transaction performance (optional)
|
||||
|
||||
### 3. Frontend Integration: @sentry/react
|
||||
|
||||
The React frontend will integrate `@sentry/react` SDK to:
|
||||
|
||||
- Wrap the app in a Sentry Error Boundary
|
||||
- Capture unhandled JavaScript errors
|
||||
- Report errors with component stack traces
|
||||
- Track user session context
|
||||
- **Frontend Error Correlation**: The global API client (Axios/Fetch wrapper) MUST intercept 4xx/5xx responses. It MUST extract the `x-request-id` header (if present) and attach it to the Sentry scope as a tag `api_request_id` before re-throwing the error. This allows developers to copy the ID from Sentry and search for it in backend logs.
|
||||
|
||||
### 4. Log Aggregation: Logstash
|
||||
|
||||
**Logstash** parses application and infrastructure logs, forwarding error patterns to Bugsink:
|
||||
|
||||
- **Installation**: Installed inside the dev container (and on bare-metal prod servers)
|
||||
- **Inputs**:
|
||||
- Pino JSON logs from the Node.js application
|
||||
- Redis logs (connection errors, memory warnings, slow commands)
|
||||
- PostgreSQL function logs (future - see Implementation Steps)
|
||||
- **Filter**: Identifies error-level logs (5xx responses, unhandled exceptions, Redis errors)
|
||||
- **Output**: Sends to Bugsink via Sentry-compatible HTTP API
|
||||
|
||||
This provides a secondary error capture path for:
|
||||
|
||||
- Errors that occur before Sentry SDK initialization
|
||||
- Log-based errors that don't throw exceptions
|
||||
- Redis connection/performance issues
|
||||
- Database function errors and slow queries
|
||||
- Historical error analysis from log files
|
||||
|
||||
### 5. MCP Server Integration: sentry-selfhosted-mcp
|
||||
|
||||
For AI tool integration (Claude Code, Cursor, etc.), we use the open-source [sentry-selfhosted-mcp](https://github.com/ddfourtwo/sentry-selfhosted-mcp) server:
|
||||
|
||||
- **No code changes required**: Configurable via environment variables
|
||||
- **Capabilities**: List projects, get issues, view events, update status, add comments
|
||||
- **Configuration**:
|
||||
- `SENTRY_URL`: Points to Bugsink instance
|
||||
- `SENTRY_AUTH_TOKEN`: API token from Bugsink
|
||||
- `SENTRY_ORG_SLUG`: Organization identifier
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Dev Container / Production Server │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Frontend │ │ Backend │ │
|
||||
│ │ (React) │ │ (Express) │ │
|
||||
│ │ @sentry/react │ │ @sentry/node │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Sentry SDK Protocol │ │
|
||||
│ └───────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Bugsink │ │
|
||||
│ │ (localhost:8000) │◄──────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ PostgreSQL backend │ │ │
|
||||
│ └──────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┐ │ │
|
||||
│ │ Logstash │───────────────────┘ │
|
||||
│ │ (Log Aggregator) │ Sentry Output │
|
||||
│ │ │ │
|
||||
│ │ Inputs: │ │
|
||||
│ │ - Pino app logs │ │
|
||||
│ │ - Redis logs │ │
|
||||
│ │ - PostgreSQL (future) │
|
||||
│ └──────────────────────┘ │
|
||||
│ ▲ ▲ ▲ │
|
||||
│ │ │ │ │
|
||||
│ ┌───────────┘ │ └───────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴─────┐ ┌─────┴────┐ ┌──────┴─────┐ │
|
||||
│ │ Pino │ │ Redis │ │ PostgreSQL │ │
|
||||
│ │ Logs │ │ Logs │ │ Logs (TBD) │ │
|
||||
│ └──────────┘ └──────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ PostgreSQL │ │
|
||||
│ │ ┌────────────────┐ │ │
|
||||
│ │ │ flyer_crawler │ │ (main app database) │
|
||||
│ │ ├────────────────┤ │ │
|
||||
│ │ │ bugsink │ │ (error tracking database) │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
External (Developer Machine):
|
||||
┌──────────────────────────────────────┐
|
||||
│ Claude Code / Cursor / VS Code │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ sentry-selfhosted-mcp │ │
|
||||
│ │ (MCP Server) │ │
|
||||
│ │ │ │
|
||||
│ │ SENTRY_URL=http://localhost:8000
|
||||
│ │ SENTRY_AUTH_TOKEN=... │ │
|
||||
│ │ SENTRY_ORG_SLUG=... │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default (Dev) |
|
||||
| ------------------ | ------------------------------ | -------------------------- |
|
||||
| `BUGSINK_DSN` | Sentry-compatible DSN for SDKs | Set after project creation |
|
||||
| `BUGSINK_ENABLED` | Enable/disable error reporting | `true` |
|
||||
| `BUGSINK_BASE_URL` | Bugsink web UI URL (internal) | `http://localhost:8000` |
|
||||
|
||||
### PostgreSQL Setup
|
||||
|
||||
```sql
|
||||
-- Create dedicated Bugsink database and user
|
||||
CREATE USER bugsink WITH PASSWORD 'bugsink_dev_password';
|
||||
CREATE DATABASE bugsink OWNER bugsink;
|
||||
GRANT ALL PRIVILEGES ON DATABASE bugsink TO bugsink;
|
||||
```
|
||||
|
||||
### Bugsink Configuration
|
||||
|
||||
```bash
|
||||
# Environment variables for Bugsink service
|
||||
SECRET_KEY=<random-50-char-string>
|
||||
DATABASE_URL=postgresql://bugsink:bugsink_dev_password@localhost:5432/bugsink
|
||||
BASE_URL=http://localhost:8000
|
||||
PORT=8000
|
||||
```
|
||||
|
||||
### Logstash Pipeline
|
||||
|
||||
```conf
|
||||
# /etc/logstash/conf.d/bugsink.conf
|
||||
|
||||
# === INPUTS ===
|
||||
input {
|
||||
# Pino application logs
|
||||
file {
|
||||
path => "/app/logs/*.log"
|
||||
codec => json
|
||||
type => "pino"
|
||||
tags => ["app"]
|
||||
}
|
||||
|
||||
# Redis logs
|
||||
file {
|
||||
path => "/var/log/redis/*.log"
|
||||
type => "redis"
|
||||
tags => ["redis"]
|
||||
}
|
||||
|
||||
# PostgreSQL logs (for function logging - future)
|
||||
# file {
|
||||
# path => "/var/log/postgresql/*.log"
|
||||
# type => "postgres"
|
||||
# tags => ["postgres"]
|
||||
# }
|
||||
}
|
||||
|
||||
# === FILTERS ===
|
||||
filter {
|
||||
# Pino error detection (level 50 = error, 60 = fatal)
|
||||
if [type] == "pino" and [level] >= 50 {
|
||||
mutate { add_tag => ["error"] }
|
||||
}
|
||||
|
||||
# Redis error detection
|
||||
if [type] == "redis" {
|
||||
grok {
|
||||
match => { "message" => "%{POSINT:pid}:%{WORD:role} %{MONTHDAY} %{MONTH} %{TIME} %{WORD:loglevel} %{GREEDYDATA:redis_message}" }
|
||||
}
|
||||
if [loglevel] in ["WARNING", "ERROR"] {
|
||||
mutate { add_tag => ["error"] }
|
||||
}
|
||||
}
|
||||
|
||||
# PostgreSQL function error detection (future)
|
||||
# if [type] == "postgres" {
|
||||
# # Parse PostgreSQL log format and detect ERROR/FATAL levels
|
||||
# }
|
||||
}
|
||||
|
||||
# === OUTPUT ===
|
||||
output {
|
||||
if "error" in [tags] {
|
||||
http {
|
||||
url => "http://localhost:8000/api/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
# Sentry envelope format
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Update Dockerfile.dev**:
|
||||
- Install Bugsink (pip package or binary)
|
||||
- Install Logstash (Elastic APT repository)
|
||||
- Add systemd service files for both
|
||||
|
||||
2. **PostgreSQL initialization**:
|
||||
- Add Bugsink user/database creation to `sql/00-init-extensions.sql`
|
||||
|
||||
3. **Backend SDK integration**:
|
||||
- Install `@sentry/node`
|
||||
- Initialize in `server.ts` before Express app
|
||||
- Configure error handler middleware integration
|
||||
|
||||
4. **Frontend SDK integration**:
|
||||
- Install `@sentry/react`
|
||||
- Wrap `App` component with `Sentry.ErrorBoundary`
|
||||
- Configure in `src/index.tsx`
|
||||
|
||||
5. **Environment configuration**:
|
||||
- Add Bugsink variables to `src/config/env.ts`
|
||||
- Update `.env.example` and `compose.dev.yml`
|
||||
|
||||
6. **Logstash configuration**:
|
||||
- Create pipeline config for Pino → Bugsink
|
||||
- Configure Pino to write to log file in addition to stdout
|
||||
- Configure Redis log monitoring (connection errors, slow commands)
|
||||
|
||||
7. **MCP server documentation**:
|
||||
- Document `sentry-selfhosted-mcp` setup in CLAUDE.md
|
||||
|
||||
8. **PostgreSQL function logging** (future):
|
||||
- Configure PostgreSQL to log function execution errors
|
||||
- Add Logstash input for PostgreSQL logs
|
||||
- Define filter rules for function-level error detection
|
||||
- _Note: Ask for implementation details when this step is reached_
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Provides critical observability into the application's real-world behavior. Enables proactive identification and resolution of performance bottlenecks and errors. Improves overall application reliability and user experience.
|
||||
**Negative**: Introduces a new third-party dependency and potential subscription costs. Requires initial setup and configuration of the APM/error tracking agent.
|
||||
### Positive
|
||||
|
||||
- **Full observability**: Aggregated view of errors, trends, and performance
|
||||
- **Self-hosted**: No external SaaS dependencies or subscription costs
|
||||
- **SDK compatibility**: Leverages mature Sentry SDKs with excellent documentation
|
||||
- **AI integration**: MCP server enables Claude Code to query and analyze errors
|
||||
- **Unified architecture**: Same setup works in dev container and production
|
||||
- **Lightweight**: Bugsink runs in a single process, unlike full Sentry (16GB+ RAM)
|
||||
|
||||
### Negative
|
||||
|
||||
- **Additional services**: Bugsink and Logstash add complexity to the container
|
||||
- **PostgreSQL overhead**: Additional database for error tracking
|
||||
- **Initial setup**: Requires configuration of multiple components
|
||||
- **Logstash learning curve**: Pipeline configuration requires Logstash knowledge
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Full Sentry self-hosted**: Rejected due to complexity (Kafka, Redis, ClickHouse, 16GB+ RAM minimum)
|
||||
2. **GlitchTip**: Considered, but Bugsink is lighter weight and easier to deploy
|
||||
3. **Sentry SaaS**: Rejected due to self-hosted requirement
|
||||
4. **Custom error aggregation**: Rejected in favor of proven Sentry SDK ecosystem
|
||||
|
||||
## References
|
||||
|
||||
- [Bugsink Documentation](https://www.bugsink.com/docs/)
|
||||
- [Bugsink Docker Install](https://www.bugsink.com/docs/docker-install/)
|
||||
- [@sentry/node Documentation](https://docs.sentry.io/platforms/javascript/guides/node/)
|
||||
- [@sentry/react Documentation](https://docs.sentry.io/platforms/javascript/guides/react/)
|
||||
- [sentry-selfhosted-mcp](https://github.com/ddfourtwo/sentry-selfhosted-mcp)
|
||||
- [Logstash Reference](https://www.elastic.co/guide/en/logstash/current/index.html)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
@@ -18,5 +18,199 @@ We will implement a multi-layered security approach for the API:
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
||||
* **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
||||
- **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
||||
- **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ **Helmet** - Security headers middleware with CSP, HSTS, and more
|
||||
- ✅ **Rate Limiting** - Comprehensive implementation with 17+ specific limiters
|
||||
- ✅ **Input Validation** - Zod-based request validation on all routes
|
||||
- ✅ **File Upload Security** - MIME type validation, size limits, filename sanitization
|
||||
- ✅ **Error Handling** - Production-safe error responses (no sensitive data leakage)
|
||||
- ✅ **Request Timeout** - 5-minute timeout protection
|
||||
- ✅ **Secure Cookies** - httpOnly and secure flags for authentication cookies
|
||||
|
||||
### Not Required
|
||||
|
||||
- ℹ️ **CORS** - Not needed (API and frontend are same-origin)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Helmet Security Headers
|
||||
|
||||
Using **helmet v8.x** configured in `server.ts` as the first middleware after app initialization.
|
||||
|
||||
**Security Headers Applied**:
|
||||
|
||||
| Header | Configuration | Purpose |
|
||||
| ------ | ------------- | ------- |
|
||||
| Content-Security-Policy | Custom directives | Prevents XSS, code injection |
|
||||
| Strict-Transport-Security | 1 year, includeSubDomains, preload | Forces HTTPS connections |
|
||||
| X-Content-Type-Options | nosniff | Prevents MIME type sniffing |
|
||||
| X-Frame-Options | DENY | Prevents clickjacking |
|
||||
| X-XSS-Protection | 0 (disabled) | Deprecated, CSP preferred |
|
||||
| Referrer-Policy | strict-origin-when-cross-origin | Controls referrer information |
|
||||
| Cross-Origin-Resource-Policy | cross-origin | Allows external resource loading |
|
||||
|
||||
**Content Security Policy Directives**:
|
||||
|
||||
```typescript
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // React inline scripts
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // Tailwind inline styles
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:'], // External images
|
||||
fontSrc: ["'self'", 'https:', 'data:'],
|
||||
connectSrc: ["'self'", 'https:', 'wss:'], // API + WebSocket
|
||||
frameSrc: ["'none'"], // No iframes
|
||||
objectSrc: ["'none'"], // No plugins
|
||||
upgradeInsecureRequests: [], // Production only
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**HSTS Configuration**:
|
||||
|
||||
- Max-age: 1 year (31536000 seconds)
|
||||
- Includes subdomains
|
||||
- Preload-ready for browser HSTS lists
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Using **express-rate-limit v8.2.1** with a centralized configuration in `src/config/rateLimiters.ts`.
|
||||
|
||||
**Standard Configuration**:
|
||||
|
||||
```typescript
|
||||
const standardConfig = {
|
||||
standardHeaders: true, // Sends RateLimit-* headers
|
||||
legacyHeaders: false,
|
||||
skip: shouldSkipRateLimit, // Disabled in test environment
|
||||
};
|
||||
```
|
||||
|
||||
**Rate Limiters by Category**:
|
||||
|
||||
| Category | Limiter | Window | Max Requests |
|
||||
| -------- | ------- | ------ | ------------ |
|
||||
| **Authentication** | loginLimiter | 15 min | 5 |
|
||||
| | registerLimiter | 1 hour | 5 |
|
||||
| | forgotPasswordLimiter | 15 min | 5 |
|
||||
| | resetPasswordLimiter | 15 min | 10 |
|
||||
| | refreshTokenLimiter | 15 min | 20 |
|
||||
| | logoutLimiter | 15 min | 10 |
|
||||
| **Public/User Read** | publicReadLimiter | 15 min | 100 |
|
||||
| | userReadLimiter | 15 min | 100 |
|
||||
| | userUpdateLimiter | 15 min | 100 |
|
||||
| **Sensitive Operations** | userSensitiveUpdateLimiter | 1 hour | 5 |
|
||||
| | adminTriggerLimiter | 15 min | 30 |
|
||||
| **AI/Costly** | aiGenerationLimiter | 15 min | 20 |
|
||||
| | geocodeLimiter | 1 hour | 100 |
|
||||
| | priceHistoryLimiter | 15 min | 50 |
|
||||
| **Uploads** | adminUploadLimiter | 15 min | 20 |
|
||||
| | aiUploadLimiter | 15 min | 10 |
|
||||
| | batchLimiter | 15 min | 50 |
|
||||
| **Tracking** | trackingLimiter | 15 min | 200 |
|
||||
| | reactionToggleLimiter | 15 min | 150 |
|
||||
|
||||
**Test Environment Handling**:
|
||||
|
||||
Rate limiting is automatically disabled in test environment via `shouldSkipRateLimit` utility (`src/utils/rateLimit.ts`). Tests can opt-in to rate limiting by setting the `x-test-rate-limit-enable: true` header.
|
||||
|
||||
### Input Validation
|
||||
|
||||
**Zod Schema Validation** (`src/middleware/validation.middleware.ts`):
|
||||
|
||||
- Type-safe parsing and coercion for params, query, and body
|
||||
- Applied to all API routes via `validateRequest()` middleware
|
||||
- Returns structured validation errors with field-level details
|
||||
|
||||
**Filename Sanitization** (`src/utils/stringUtils.ts`):
|
||||
|
||||
```typescript
|
||||
// Removes dangerous characters from uploaded filenames
|
||||
sanitizeFilename(filename: string): string
|
||||
```
|
||||
|
||||
### File Upload Security
|
||||
|
||||
**Multer Configuration** (`src/middleware/multer.middleware.ts`):
|
||||
|
||||
- MIME type validation via `imageFileFilter` (only image/* allowed)
|
||||
- File size limits (2MB for logos, configurable per upload type)
|
||||
- Unique filenames using timestamps + random suffixes
|
||||
- User-scoped storage paths
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Production-Safe Responses** (`src/middleware/errorHandler.ts`):
|
||||
|
||||
- Production mode: Returns generic error message with tracking ID
|
||||
- Development mode: Returns detailed error information
|
||||
- Sensitive error details are logged but never exposed to clients
|
||||
|
||||
### Request Security
|
||||
|
||||
**Timeout Protection** (`server.ts`):
|
||||
|
||||
- 5-minute request timeout via `connect-timeout` middleware
|
||||
- Prevents resource exhaustion from long-running requests
|
||||
|
||||
**Secure Cookies**:
|
||||
|
||||
```typescript
|
||||
// Cookie configuration for auth tokens
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days for refresh token
|
||||
}
|
||||
```
|
||||
|
||||
### Request Logging
|
||||
|
||||
Per-request structured logging (ADR-004):
|
||||
|
||||
- Request ID tracking
|
||||
- User ID and IP address logging
|
||||
- Failed request details (4xx+) logged with headers and body
|
||||
- Unhandled errors assigned unique error IDs
|
||||
|
||||
## Key Files
|
||||
|
||||
- `server.ts` - Helmet middleware configuration (security headers)
|
||||
- `src/config/rateLimiters.ts` - Rate limiter definitions (17+ limiters)
|
||||
- `src/utils/rateLimit.ts` - Rate limit skip logic for testing
|
||||
- `src/middleware/validation.middleware.ts` - Zod-based request validation
|
||||
- `src/middleware/errorHandler.ts` - Production-safe error handling
|
||||
- `src/middleware/multer.middleware.ts` - Secure file upload configuration
|
||||
- `src/utils/stringUtils.ts` - Filename sanitization
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Configure CORS** (if needed for cross-origin access):
|
||||
|
||||
```bash
|
||||
npm install cors @types/cors
|
||||
```
|
||||
|
||||
Add to `server.ts`:
|
||||
|
||||
```typescript
|
||||
import cors from 'cors';
|
||||
app.use(cors({
|
||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
}));
|
||||
```
|
||||
|
||||
2. **Redis-backed rate limiting**: For distributed deployments, use `rate-limit-redis` store
|
||||
|
||||
3. **CSP Nonce**: Generate per-request nonces for stricter script-src policy
|
||||
|
||||
4. **Report-Only CSP**: Add `Content-Security-Policy-Report-Only` header for testing policy changes
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
@@ -10,9 +12,186 @@ The project has Gitea workflows but lacks a documented standard for how code mov
|
||||
|
||||
## Decision
|
||||
|
||||
We will formalize the end-to-end CI/CD process. This ADR will define the project's **branching strategy** (e.g., GitFlow or Trunk-Based Development), establish mandatory checks in the pipeline (e.g., linting, unit tests, vulnerability scanning), and specify the process for building and publishing Docker images (`ADR-014`) to a registry.
|
||||
We will formalize the end-to-end CI/CD process using:
|
||||
|
||||
1. **Trunk-Based Development**: All work is merged to `main` branch.
|
||||
2. **Automated Test Deployment**: Every push to `main` triggers deployment to test environment.
|
||||
3. **Manual Production Deployment**: Production deployments require explicit confirmation.
|
||||
4. **Semantic Versioning**: Automated version bumping on deployments.
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
||||
* **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
||||
- **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
||||
- **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Branching Strategy
|
||||
|
||||
**Trunk-Based Development**:
|
||||
|
||||
```text
|
||||
main ─────●─────●─────●─────●─────●─────▶
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └── Deploy to Prod (manual)
|
||||
│ │ │ └── v0.9.70 (patch bump)
|
||||
│ │ └── Deploy to Test (auto)
|
||||
│ └── v0.9.69 (patch bump)
|
||||
└── Feature complete
|
||||
```
|
||||
|
||||
- All development happens on `main` branch
|
||||
- Feature branches are short-lived (< 1 day)
|
||||
- Every merge to `main` triggers test deployment
|
||||
- Production deploys are manual with confirmation
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
**Deploy to Test** (Automatic on push to `main`):
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
deploy-to-test:
|
||||
steps:
|
||||
- Checkout code
|
||||
- Setup Node.js 20
|
||||
- Install dependencies (npm ci)
|
||||
- Bump patch version (npm version patch)
|
||||
- TypeScript type-check
|
||||
- Prettier check
|
||||
- ESLint check
|
||||
- Run unit tests with coverage
|
||||
- Run integration tests with coverage
|
||||
- Run E2E tests with coverage
|
||||
- Merge coverage reports
|
||||
- Check database schema hash
|
||||
- Build React application
|
||||
- Deploy to test server (rsync)
|
||||
- Install production dependencies
|
||||
- Reload PM2 processes
|
||||
- Update schema hash in database
|
||||
```
|
||||
|
||||
**Deploy to Production** (Manual trigger):
|
||||
|
||||
```yaml
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirmation:
|
||||
description: 'Type "deploy-to-prod" to confirm'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
steps:
|
||||
- Verify confirmation phrase
|
||||
- Checkout main branch
|
||||
- Install dependencies
|
||||
- Bump minor version (npm version minor)
|
||||
- Check production schema hash
|
||||
- Build React application
|
||||
- Deploy to production server
|
||||
- Reload PM2 processes
|
||||
- Update schema hash
|
||||
```
|
||||
|
||||
### Version Bumping Strategy
|
||||
|
||||
| Trigger | Version Change | Example |
|
||||
| -------------------------- | -------------- | --------------- |
|
||||
| Push to main (test deploy) | Patch bump | 0.9.69 → 0.9.70 |
|
||||
| Production deploy | Minor bump | 0.9.70 → 0.10.0 |
|
||||
| Major release | Manual | 0.10.0 → 1.0.0 |
|
||||
|
||||
**Commit Message Format**:
|
||||
|
||||
```text
|
||||
ci: Bump version to 0.9.70 [skip ci]
|
||||
```
|
||||
|
||||
The `[skip ci]` tag prevents version bump commits from triggering another workflow.
|
||||
|
||||
### Database Schema Management
|
||||
|
||||
Schema changes are tracked via SHA-256 hash:
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.schema_info (
|
||||
environment VARCHAR(50) PRIMARY KEY,
|
||||
schema_hash VARCHAR(64) NOT NULL,
|
||||
deployed_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Deployment Checks**:
|
||||
|
||||
1. Calculate hash of `sql/master_schema_rollup.sql`
|
||||
2. Compare with hash in target database
|
||||
3. If mismatch: **FAIL** deployment (manual migration required)
|
||||
4. If match: Continue deployment
|
||||
5. After deploy: Update hash in database
|
||||
|
||||
### Quality Gates
|
||||
|
||||
| Check | Required | Blocking |
|
||||
| --------------------- | -------- | ---------------------- |
|
||||
| TypeScript type-check | ✅ | No (continue-on-error) |
|
||||
| Prettier formatting | ✅ | No |
|
||||
| ESLint | ✅ | No |
|
||||
| Unit tests | ✅ | No |
|
||||
| Integration tests | ✅ | No |
|
||||
| E2E tests | ✅ | No |
|
||||
| Schema hash check | ✅ | **Yes** |
|
||||
| Build | ✅ | **Yes** |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Secrets are injected from Gitea repository settings:
|
||||
|
||||
| Secret | Test | Production |
|
||||
| -------------------------------------------------------------- | ------------------ | ------------- |
|
||||
| `DB_DATABASE_TEST` / `DB_DATABASE_PROD` | flyer-crawler-test | flyer-crawler |
|
||||
| `REDIS_PASSWORD_TEST` / `REDIS_PASSWORD_PROD` | \*\*\* | \*\*\* |
|
||||
| `VITE_GOOGLE_GENAI_API_KEY_TEST` / `VITE_GOOGLE_GENAI_API_KEY` | \*\*\* | \*\*\* |
|
||||
|
||||
### Coverage Reporting
|
||||
|
||||
Coverage reports are generated and published:
|
||||
|
||||
```text
|
||||
https://flyer-crawler-test.projectium.com/coverage/
|
||||
```
|
||||
|
||||
Coverage merging combines:
|
||||
|
||||
- Unit test coverage (Vitest)
|
||||
- Integration test coverage (Vitest)
|
||||
- E2E test coverage (Vitest)
|
||||
- Server V8 coverage (c8)
|
||||
|
||||
### Gitea Workflows
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
| ----------------------------- | ------------ | ------------------------- |
|
||||
| `deploy-to-test.yml` | Push to main | Automated test deployment |
|
||||
| `deploy-to-prod.yml` | Manual | Production deployment |
|
||||
| `manual-db-backup.yml` | Manual | Create database backup |
|
||||
| `manual-db-restore.yml` | Manual | Restore from backup |
|
||||
| `manual-db-reset-test.yml` | Manual | Reset test database |
|
||||
| `manual-db-reset-prod.yml` | Manual | Reset production database |
|
||||
| `manual-deploy-major.yml` | Manual | Major version release |
|
||||
| `manual-redis-flush-prod.yml` | Manual | Flush Redis cache |
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.gitea/workflows/deploy-to-test.yml` - Test deployment pipeline
|
||||
- `.gitea/workflows/deploy-to-prod.yml` - Production deployment pipeline
|
||||
- `.gitea/workflows/manual-db-backup.yml` - Database backup workflow
|
||||
- `ecosystem.config.cjs` - PM2 configuration
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-014](./0014-containerization-and-deployment-strategy.md) - Containerization Strategy
|
||||
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy
|
||||
- [ADR-019](./0019-data-backup-and-recovery-strategy.md) - Backup Strategy
|
||||
|
||||
@@ -2,17 +2,265 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-11
|
||||
|
||||
## Context
|
||||
|
||||
As the API grows, it becomes increasingly difficult for frontend developers and other consumers to understand its endpoints, request formats, and response structures. There is no single source of truth for API documentation.
|
||||
|
||||
Key requirements:
|
||||
|
||||
1. **Developer Experience**: Developers need interactive documentation to explore and test API endpoints.
|
||||
2. **Code-Documentation Sync**: Documentation should stay in sync with the actual code to prevent drift.
|
||||
3. **Low Maintenance Overhead**: The documentation approach should be "fast and lite" - minimal additional work for developers.
|
||||
4. **Security**: Documentation should not expose sensitive information in production environments.
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt **OpenAPI (Swagger)** for API documentation. We will use tools (e.g., JSDoc annotations with `swagger-jsdoc`) to generate an `openapi.json` specification directly from the route handler source code. This specification will be served via a UI like Swagger UI for interactive exploration.
|
||||
We will adopt **OpenAPI 3.0 (Swagger)** for API documentation using the following approach:
|
||||
|
||||
1. **JSDoc Annotations**: Use `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments in route files.
|
||||
2. **Swagger UI**: Use `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
3. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
4. **Incremental Adoption**: Start with key public routes and progressively add annotations to all endpoints.
|
||||
|
||||
### Tooling Selection
|
||||
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ---------------------------------------------- |
|
||||
| `swagger-jsdoc` | Generates OpenAPI 3.0 spec from JSDoc comments |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
|
||||
**Why JSDoc over separate schema files?**
|
||||
|
||||
- Documentation lives with the code, reducing drift
|
||||
- No separate files to maintain
|
||||
- Developers see documentation when editing routes
|
||||
- Lower learning curve for the team
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### OpenAPI Configuration
|
||||
|
||||
Located in `src/config/swagger.ts`:
|
||||
|
||||
```typescript
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Flyer Crawler API',
|
||||
version: '1.0.0',
|
||||
description: 'API for the Flyer Crawler application',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
```
|
||||
|
||||
### JSDoc Annotation Pattern
|
||||
|
||||
Each route handler should include OpenAPI annotations using the `@openapi` tag:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* description: Returns a pong response to verify server is responsive
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: pong
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
return sendSuccess(res, { message: 'pong' });
|
||||
});
|
||||
```
|
||||
|
||||
### Route Documentation Priority
|
||||
|
||||
Document routes in this order of priority:
|
||||
|
||||
1. **Health Routes** - `/api/health/*` (public, critical for operations)
|
||||
2. **Auth Routes** - `/api/auth/*` (public, essential for integration)
|
||||
3. **Gamification Routes** - `/api/achievements/*` (simple, good example)
|
||||
4. **Flyer Routes** - `/api/flyers/*` (core functionality)
|
||||
5. **User Routes** - `/api/users/*` (common CRUD patterns)
|
||||
6. **Remaining Routes** - Budget, Recipe, Admin, etc.
|
||||
|
||||
### Swagger UI Setup
|
||||
|
||||
In `server.ts`, add the Swagger UI middleware (development/test only):
|
||||
|
||||
```typescript
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './src/config/swagger';
|
||||
|
||||
// Only serve Swagger UI in non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
|
||||
// Optionally expose raw JSON spec for tooling
|
||||
app.get('/docs/api-docs.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema Standardization
|
||||
|
||||
All API responses follow the standardized format from [ADR-028](./0028-api-response-standardization.md):
|
||||
|
||||
```typescript
|
||||
// Success response
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// Error response
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable message"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Define reusable schema components for these patterns:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* components:
|
||||
* schemas:
|
||||
* SuccessResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* ErrorResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: object
|
||||
* properties:
|
||||
* code:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
|
||||
## API Route Tags
|
||||
|
||||
Organize endpoints using consistent tags:
|
||||
|
||||
| Tag | Description | Routes |
|
||||
| ------------ | ---------------------------------- | --------------------- |
|
||||
| Health | Server health and readiness checks | `/api/health/*` |
|
||||
| Auth | Authentication and authorization | `/api/auth/*` |
|
||||
| Users | User profile management | `/api/users/*` |
|
||||
| Flyers | Flyer uploads and retrieval | `/api/flyers/*` |
|
||||
| Achievements | Gamification and leaderboards | `/api/achievements/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Recipes | Recipe management | `/api/recipes/*` |
|
||||
| Admin | Administrative operations | `/api/admin/*` |
|
||||
| System | System status and monitoring | `/api/system/*` |
|
||||
|
||||
## Testing
|
||||
|
||||
Verify API documentation is correct by:
|
||||
|
||||
1. **Manual Review**: Navigate to `/docs/api-docs` and test each endpoint.
|
||||
2. **Spec Validation**: Use OpenAPI validators to check the generated spec.
|
||||
3. **Integration Tests**: Existing integration tests serve as implicit documentation verification.
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
||||
* **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
||||
### Positive
|
||||
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync.
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from the UI.
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation.
|
||||
- **Onboarding**: New developers can quickly understand the API surface.
|
||||
- **Low Overhead**: JSDoc annotations are minimal additions to existing code.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Maintenance Required**: Developers must update annotations when routes change.
|
||||
- **Build Dependency**: Adds `swagger-jsdoc` and `swagger-ui-express` packages.
|
||||
- **Initial Investment**: Existing routes need annotations added incrementally.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Include documentation checks in code review process.
|
||||
- Start with high-priority routes and expand coverage over time.
|
||||
- Use TypeScript types to reduce documentation duplication where possible.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/config/swagger.ts` - OpenAPI configuration
|
||||
- `src/routes/*.ts` - Route files with JSDoc annotations
|
||||
- `server.ts` - Swagger UI middleware setup
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod schemas)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization
|
||||
- [ADR-016](./0016-api-security-hardening.md) - Security Hardening
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
@@ -14,5 +16,212 @@ We will implement a formal data backup and recovery strategy. This will involve
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
||||
* **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
||||
- **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
||||
- **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Backup Workflow
|
||||
|
||||
Located in `.gitea/workflows/manual-db-backup.yml`:
|
||||
|
||||
```yaml
|
||||
name: Manual - Backup Production Database
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirmation:
|
||||
description: 'Type "backup-production-db" to confirm'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
backup-database:
|
||||
runs-on: projectium.com
|
||||
|
||||
env:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_PORT: ${{ secrets.DB_PORT }}
|
||||
DB_USER: ${{ secrets.DB_USER_PROD }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD_PROD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
|
||||
steps:
|
||||
- name: Validate Secrets
|
||||
run: |
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ]; then
|
||||
echo "ERROR: Database secrets not configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Database Backup
|
||||
run: |
|
||||
TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
|
||||
BACKUP_FILENAME="flyer-crawler-prod-backup-${TIMESTAMP}.sql.gz"
|
||||
|
||||
# Create compressed backup
|
||||
PGPASSWORD="$DB_PASSWORD" pg_dump \
|
||||
-h "$DB_HOST" -p "$DB_PORT" \
|
||||
-U "$DB_USER" -d "$DB_NAME" \
|
||||
--clean --if-exists | gzip > "$BACKUP_FILENAME"
|
||||
|
||||
echo "backup_filename=$BACKUP_FILENAME" >> $GITEA_ENV
|
||||
|
||||
- name: Upload Backup as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: database-backup
|
||||
path: ${{ env.backup_filename }}
|
||||
```
|
||||
|
||||
### Restore Workflow
|
||||
|
||||
Located in `.gitea/workflows/manual-db-restore.yml`:
|
||||
|
||||
```yaml
|
||||
name: Manual - Restore Database from Backup
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirmation:
|
||||
description: 'Type "restore-from-backup" to confirm'
|
||||
required: true
|
||||
backup_file:
|
||||
description: 'Path to backup file on server'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
restore-database:
|
||||
steps:
|
||||
- name: Verify Confirmation
|
||||
run: |
|
||||
if [ "${{ inputs.confirmation }}" != "restore-from-backup" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Restore Database
|
||||
run: |
|
||||
# Decompress and restore
|
||||
gunzip -c "${{ inputs.backup_file }}" | \
|
||||
PGPASSWORD="$DB_PASSWORD" psql \
|
||||
-h "$DB_HOST" -p "$DB_PORT" \
|
||||
-U "$DB_USER" -d "$DB_NAME"
|
||||
```
|
||||
|
||||
### Backup Command Reference
|
||||
|
||||
**Manual Backup**:
|
||||
|
||||
```bash
|
||||
# Create compressed backup
|
||||
PGPASSWORD="password" pg_dump \
|
||||
-h localhost -p 5432 \
|
||||
-U dbuser -d flyer-crawler \
|
||||
--clean --if-exists | gzip > backup-$(date +%Y%m%d).sql.gz
|
||||
|
||||
# List backup contents (without restoring)
|
||||
gunzip -c backup-20260109.sql.gz | head -100
|
||||
```
|
||||
|
||||
**Manual Restore**:
|
||||
|
||||
```bash
|
||||
# Restore from compressed backup
|
||||
gunzip -c backup-20260109.sql.gz | \
|
||||
PGPASSWORD="password" psql \
|
||||
-h localhost -p 5432 \
|
||||
-U dbuser -d flyer-crawler
|
||||
```
|
||||
|
||||
### pg_dump Options
|
||||
|
||||
| Option | Purpose |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `--clean` | Drop objects before recreating |
|
||||
| `--if-exists` | Use IF EXISTS when dropping |
|
||||
| `--no-owner` | Skip ownership commands |
|
||||
| `--no-privileges` | Skip access privilege commands |
|
||||
| `-F c` | Custom format (for pg_restore) |
|
||||
| `-F p` | Plain text SQL (default) |
|
||||
|
||||
### Recovery Objectives
|
||||
|
||||
| Metric | Target | Current |
|
||||
| ---------------------------------- | -------- | -------------- |
|
||||
| **RPO** (Recovery Point Objective) | 24 hours | Manual trigger |
|
||||
| **RTO** (Recovery Time Objective) | 1 hour | ~15 minutes |
|
||||
|
||||
### Backup Retention Policy
|
||||
|
||||
| Type | Retention | Storage |
|
||||
| --------------- | --------- | ---------------- |
|
||||
| Daily backups | 7 days | Gitea artifacts |
|
||||
| Weekly backups | 4 weeks | Gitea artifacts |
|
||||
| Monthly backups | 12 months | Off-site storage |
|
||||
|
||||
### Backup Verification
|
||||
|
||||
Periodically test backup integrity:
|
||||
|
||||
```bash
|
||||
# Verify backup can be read
|
||||
gunzip -t backup-20260109.sql.gz
|
||||
|
||||
# Test restore to a temporary database
|
||||
createdb flyer-crawler-restore-test
|
||||
gunzip -c backup-20260109.sql.gz | psql -d flyer-crawler-restore-test
|
||||
# Verify data integrity...
|
||||
dropdb flyer-crawler-restore-test
|
||||
```
|
||||
|
||||
### Disaster Recovery Checklist
|
||||
|
||||
1. **Identify the Issue**
|
||||
- Data corruption?
|
||||
- Accidental deletion?
|
||||
- Full database loss?
|
||||
|
||||
2. **Select Backup**
|
||||
- Find most recent valid backup
|
||||
- Download from Gitea artifacts or off-site storage
|
||||
|
||||
3. **Stop Application**
|
||||
|
||||
```bash
|
||||
pm2 stop all
|
||||
```
|
||||
|
||||
4. **Restore Database**
|
||||
|
||||
```bash
|
||||
gunzip -c backup.sql.gz | psql -d flyer-crawler
|
||||
```
|
||||
|
||||
5. **Verify Data**
|
||||
- Check table row counts
|
||||
- Verify recent data exists
|
||||
- Test critical queries
|
||||
|
||||
6. **Restart Application**
|
||||
|
||||
```bash
|
||||
pm2 start all
|
||||
```
|
||||
|
||||
7. **Post-Mortem**
|
||||
- Document incident
|
||||
- Update procedures if needed
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.gitea/workflows/manual-db-backup.yml` - Backup workflow
|
||||
- `.gitea/workflows/manual-db-restore.yml` - Restore workflow
|
||||
- `.gitea/workflows/manual-db-reset-test.yml` - Reset test database
|
||||
- `.gitea/workflows/manual-db-reset-prod.yml` - Reset production database
|
||||
- `sql/master_schema_rollup.sql` - Current schema definition
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-013](./0013-database-schema-migration-strategy.md) - Schema Migration Strategy
|
||||
- [ADR-017](./0017-ci-cd-and-branching-strategy.md) - CI/CD Strategy
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
@@ -12,11 +14,203 @@ When the application is containerized (`ADR-014`), the container orchestrator (e
|
||||
|
||||
We will implement dedicated health check endpoints in the Express application.
|
||||
|
||||
* A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
||||
- A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
||||
|
||||
* A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
||||
- A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
||||
|
||||
## Consequences
|
||||
|
||||
* **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
||||
* **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
||||
- **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
||||
- **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ **Liveness Probe** (`/api/health/live`) - Simple process health check
|
||||
- ✅ **Readiness Probe** (`/api/health/ready`) - Comprehensive dependency health check
|
||||
- ✅ **Startup Probe** (`/api/health/startup`) - Initial startup verification
|
||||
- ✅ **Individual Service Checks** - Database, Redis, Storage endpoints
|
||||
- ✅ **Detailed Health Response** - Service latency, status, and details
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Probe Endpoints
|
||||
|
||||
| Endpoint | Purpose | Checks | HTTP Status |
|
||||
| --------------------- | --------------- | ------------------ | ----------------------------- |
|
||||
| `/api/health/live` | Liveness probe | Process running | 200 = alive |
|
||||
| `/api/health/ready` | Readiness probe | DB, Redis, Storage | 200 = ready, 503 = not ready |
|
||||
| `/api/health/startup` | Startup probe | Database only | 200 = started, 503 = starting |
|
||||
|
||||
### Liveness Probe
|
||||
|
||||
The liveness probe is intentionally simple with no external dependencies:
|
||||
|
||||
```typescript
|
||||
// GET /api/health/live
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2026-01-09T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**: If this endpoint fails to respond, the container should be restarted.
|
||||
|
||||
### Readiness Probe
|
||||
|
||||
The readiness probe checks all critical dependencies:
|
||||
|
||||
```typescript
|
||||
// GET /api/health/ready
|
||||
{
|
||||
"status": "healthy", // healthy | degraded | unhealthy
|
||||
"timestamp": "2026-01-09T12:00:00.000Z",
|
||||
"uptime": 3600.5,
|
||||
"services": {
|
||||
"database": {
|
||||
"status": "healthy",
|
||||
"latency": 5,
|
||||
"details": {
|
||||
"totalConnections": 10,
|
||||
"idleConnections": 8,
|
||||
"waitingConnections": 0
|
||||
}
|
||||
},
|
||||
"redis": {
|
||||
"status": "healthy",
|
||||
"latency": 2
|
||||
},
|
||||
"storage": {
|
||||
"status": "healthy",
|
||||
"latency": 1,
|
||||
"details": {
|
||||
"path": "/var/www/.../flyer-images"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Logic**:
|
||||
|
||||
- `healthy` - All critical services (database, Redis) are healthy
|
||||
- `degraded` - Some non-critical issues (high connection wait, storage issues)
|
||||
- `unhealthy` - Critical service unavailable (returns 503)
|
||||
|
||||
### Startup Probe
|
||||
|
||||
The startup probe is used during container initialization:
|
||||
|
||||
```typescript
|
||||
// GET /api/health/startup
|
||||
// Success (200):
|
||||
{
|
||||
"status": "started",
|
||||
"timestamp": "2026-01-09T12:00:00.000Z",
|
||||
"database": { "status": "healthy", "latency": 5 }
|
||||
}
|
||||
|
||||
// Still starting (503):
|
||||
{
|
||||
"status": "starting",
|
||||
"message": "Waiting for database connection",
|
||||
"database": { "status": "unhealthy", "message": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### Individual Service Endpoints
|
||||
|
||||
For detailed diagnostics:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| ----------------------- | ------------------------------- |
|
||||
| `/api/health/ping` | Simple server responsiveness |
|
||||
| `/api/health/db-schema` | Verify database tables exist |
|
||||
| `/api/health/db-pool` | Database connection pool status |
|
||||
| `/api/health/redis` | Redis connectivity |
|
||||
| `/api/health/storage` | File storage accessibility |
|
||||
| `/api/health/time` | Server time synchronization |
|
||||
|
||||
## Kubernetes Configuration Example
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
spec:
|
||||
containers:
|
||||
- name: flyer-crawler
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health/live
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health/ready
|
||||
port: 3001
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health/startup
|
||||
port: 3001
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30 # Allow up to 150 seconds for startup
|
||||
```
|
||||
|
||||
## Docker Compose Configuration Example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
image: flyer-crawler:latest
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3001/api/health/ready']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
## PM2 Configuration Example
|
||||
|
||||
For non-containerized deployments using PM2:
|
||||
|
||||
```javascript
|
||||
// ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'flyer-crawler',
|
||||
script: 'dist/server.js',
|
||||
// PM2 will check this endpoint
|
||||
// and restart if it fails
|
||||
health_check: {
|
||||
url: 'http://localhost:3001/api/health/ready',
|
||||
interval: 30000,
|
||||
timeout: 10000,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/routes/health.routes.ts` - Health check endpoint implementations
|
||||
- `server.ts` - Health routes mounted at `/api/health`
|
||||
|
||||
## Service Health Thresholds
|
||||
|
||||
| Service | Healthy | Degraded | Unhealthy |
|
||||
| -------- | ---------------------- | ----------------------- | ------------------- |
|
||||
| Database | Responds to `SELECT 1` | > 3 waiting connections | Connection fails |
|
||||
| Redis | `PING` returns `PONG` | N/A | Connection fails |
|
||||
| Storage | Write access to path | N/A | Path not accessible |
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
@@ -10,10 +12,203 @@ The project contains both frontend (React) and backend (Node.js) code. While lin
|
||||
|
||||
## Decision
|
||||
|
||||
We will mandate the use of **Prettier** for automated code formatting and a unified **ESLint** configuration for code quality rules across both frontend and backend. This will be enforced automatically using a pre-commit hook managed by a tool like **Husky**.
|
||||
We will mandate the use of **Prettier** for automated code formatting and a unified **ESLint** configuration for code quality rules across both frontend and backend. This will be enforced automatically using a pre-commit hook managed by **Husky** and **lint-staged**.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Improves developer experience and team velocity by automating code consistency. Reduces time spent on stylistic code review comments. Enhances code readability and maintainability.
|
||||
|
||||
**Negative**: Requires an initial setup and configuration of Prettier, ESLint, and Husky. May require a one-time reformatting of the entire codebase.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ **Prettier Configuration** - `.prettierrc` with consistent settings
|
||||
- ✅ **Prettier Ignore** - `.prettierignore` to exclude generated files
|
||||
- ✅ **ESLint Configuration** - `eslint.config.js` with TypeScript and React support
|
||||
- ✅ **ESLint + Prettier Integration** - `eslint-config-prettier` to avoid conflicts
|
||||
- ✅ **Husky Pre-commit Hooks** - Automatic enforcement on commit
|
||||
- ✅ **lint-staged** - Run linters only on staged files for performance
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Prettier Configuration
|
||||
|
||||
The project uses a consistent Prettier configuration in `.prettierrc`:
|
||||
|
||||
```json
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
### ESLint Configuration
|
||||
|
||||
ESLint is configured with:
|
||||
|
||||
- TypeScript support via `typescript-eslint`
|
||||
- React hooks rules via `eslint-plugin-react-hooks`
|
||||
- React Refresh support for HMR
|
||||
- Prettier compatibility via `eslint-config-prettier`
|
||||
- **Relaxed rules for test files** (see below)
|
||||
|
||||
```javascript
|
||||
// eslint.config.js (ESLint v9 flat config)
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import pluginReactRefresh from 'eslint-plugin-react-refresh';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
|
||||
export default tseslint.config(
|
||||
// ... configurations
|
||||
eslintConfigPrettier, // Must be last to override formatting rules
|
||||
);
|
||||
```
|
||||
|
||||
### Relaxed Linting Rules for Test Files
|
||||
|
||||
**Decision Date**: 2026-01-09
|
||||
|
||||
**Status**: Active (revisit when product nears final release)
|
||||
|
||||
The following ESLint rules are relaxed for test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`):
|
||||
|
||||
| Rule | Setting | Rationale |
|
||||
| ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `@typescript-eslint/no-explicit-any` | `off` | Mocking complexity often requires `any`; strict typing in tests adds friction without proportional benefit |
|
||||
|
||||
**Rationale**:
|
||||
|
||||
1. **Tests are not production code** - The primary goal of tests is verifying behavior, not type safety of the test code itself
|
||||
2. **Mocking complexity** - Mocking libraries often require type gymnastics; `any` simplifies creating partial mocks and test doubles
|
||||
3. **Testing edge cases** - Sometimes tests intentionally pass invalid types to verify error handling
|
||||
4. **Development velocity** - Strict typing in tests slows down test writing without proportional benefit during active development
|
||||
|
||||
**Future Consideration**: This decision should be revisited when the product is nearing its final stages. At that point, stricter linting in tests may be warranted to ensure long-term maintainability.
|
||||
|
||||
```javascript
|
||||
// eslint.config.js - Test file overrides
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
The pre-commit hook runs lint-staged automatically:
|
||||
|
||||
```bash
|
||||
# .husky/pre-commit
|
||||
npx lint-staged
|
||||
```
|
||||
|
||||
### lint-staged Configuration
|
||||
|
||||
lint-staged runs appropriate tools based on file type:
|
||||
|
||||
```json
|
||||
{
|
||||
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{json,md,css,html,yml,yaml}": ["prettier --write"]
|
||||
}
|
||||
```
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
| Script | Description |
|
||||
| ------------------ | ---------------------------------------------- |
|
||||
| `npm run format` | Format all files with Prettier |
|
||||
| `npm run lint` | Run ESLint on all TypeScript/JavaScript files |
|
||||
| `npm run validate` | Run Prettier check + TypeScript check + ESLint |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------- | -------------------------------- |
|
||||
| `.prettierrc` | Prettier configuration |
|
||||
| `.prettierignore` | Files to exclude from formatting |
|
||||
| `eslint.config.js` | ESLint flat configuration (v9) |
|
||||
| `.husky/pre-commit` | Pre-commit hook script |
|
||||
| `.lintstagedrc.json` | lint-staged configuration |
|
||||
|
||||
## Developer Workflow
|
||||
|
||||
### Automatic Formatting on Commit
|
||||
|
||||
When you commit changes:
|
||||
|
||||
1. Husky intercepts the commit
|
||||
2. lint-staged identifies staged files
|
||||
3. ESLint fixes auto-fixable issues
|
||||
4. Prettier formats the code
|
||||
5. Changes are automatically staged
|
||||
6. Commit proceeds if no errors
|
||||
|
||||
### Manual Formatting
|
||||
|
||||
```bash
|
||||
# Format entire codebase
|
||||
npm run format
|
||||
|
||||
# Check formatting without changes
|
||||
npx prettier --check .
|
||||
|
||||
# Run ESLint
|
||||
npm run lint
|
||||
|
||||
# Run all validation checks
|
||||
npm run validate
|
||||
```
|
||||
|
||||
### IDE Integration
|
||||
|
||||
For the best experience, configure your IDE:
|
||||
|
||||
**VS Code** - Install extensions:
|
||||
|
||||
- Prettier - Code formatter
|
||||
- ESLint
|
||||
|
||||
Add to `.vscode/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "eslint --fix failed"
|
||||
|
||||
ESLint may fail on unfixable errors. Review the output and manually fix the issues.
|
||||
|
||||
### "prettier --write failed"
|
||||
|
||||
Check for syntax errors in the file that prevent parsing.
|
||||
|
||||
### Bypassing Hooks (Emergency)
|
||||
|
||||
In rare cases, you may need to bypass hooks:
|
||||
|
||||
```bash
|
||||
git commit --no-verify -m "emergency fix"
|
||||
```
|
||||
|
||||
Use sparingly - the CI pipeline will still catch formatting issues.
|
||||
|
||||
@@ -2,17 +2,374 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-19
|
||||
|
||||
## Context
|
||||
|
||||
A core feature is providing "Active Deal Alerts" to users. The current HTTP-based architecture is not suitable for pushing real-time updates to clients efficiently. Relying on traditional polling would be inefficient and slow.
|
||||
|
||||
Users need to be notified immediately when:
|
||||
|
||||
1. **New deals are found** on their watched items
|
||||
2. **System announcements** need to be broadcast
|
||||
3. **Background jobs complete** that affect their data
|
||||
|
||||
Traditional approaches:
|
||||
|
||||
- **HTTP Polling**: Inefficient, creates unnecessary load, delays up to polling interval
|
||||
- **Server-Sent Events (SSE)**: One-way only, no client-to-server messaging
|
||||
- **WebSockets**: Bi-directional, real-time, efficient
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a real-time communication system using **WebSockets** (e.g., with the `ws` library or Socket.IO). This will involve an architecture for a notification service that listens for backend events (like a new deal from a background job) and pushes live updates to connected clients.
|
||||
We will implement a real-time communication system using **WebSockets** with the `ws` library. This will involve:
|
||||
|
||||
1. **WebSocket Server**: Manages connections, authentication, and message routing
|
||||
2. **React Hook**: Provides easy integration for React components
|
||||
3. **Event Bus Integration**: Bridges WebSocket messages to in-app events
|
||||
4. **Background Job Integration**: Emits WebSocket notifications when deals are found
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **JWT Authentication**: WebSocket connections authenticated via JWT tokens
|
||||
- **Type-Safe Messages**: Strongly-typed message formats prevent errors
|
||||
- **Auto-Reconnect**: Client automatically reconnects with exponential backoff
|
||||
- **Graceful Degradation**: Email + DB notifications remain for offline users
|
||||
- **Heartbeat Ping/Pong**: Detect and cleanup dead connections
|
||||
- **Singleton Service**: Single WebSocket service instance shared across app
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### WebSocket Message Types
|
||||
|
||||
Located in `src/types/websocket.ts`:
|
||||
|
||||
```typescript
|
||||
export interface WebSocketMessage<T = unknown> {
|
||||
type: WebSocketMessageType;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type WebSocketMessageType =
|
||||
| 'deal-notification'
|
||||
| 'system-message'
|
||||
| 'ping'
|
||||
| 'pong'
|
||||
| 'error'
|
||||
| 'connection-established';
|
||||
|
||||
// Deal notification payload
|
||||
export interface DealNotificationData {
|
||||
notification_id?: string;
|
||||
deals: DealInfo[];
|
||||
user_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Type-safe message creators
|
||||
export const createWebSocketMessage = {
|
||||
dealNotification: (data: DealNotificationData) => ({ ... }),
|
||||
systemMessage: (data: SystemMessageData) => ({ ... }),
|
||||
error: (data: ErrorMessageData) => ({ ... }),
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### WebSocket Server Service
|
||||
|
||||
Located in `src/services/websocketService.server.ts`:
|
||||
|
||||
```typescript
|
||||
export class WebSocketService {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private clients: Map<string, Set<AuthenticatedWebSocket>> = new Map();
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
initialize(server: HTTPServer): void {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws',
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws, request) => {
|
||||
this.handleConnection(ws, request);
|
||||
});
|
||||
|
||||
this.startHeartbeat(); // Ping every 30s
|
||||
}
|
||||
|
||||
// Authentication via JWT from query string or cookie
|
||||
private extractToken(request: IncomingMessage): string | null {
|
||||
// Extract from ?token=xxx or Cookie: accessToken=xxx
|
||||
}
|
||||
|
||||
// Broadcast to specific user
|
||||
broadcastDealNotification(userId: string, data: DealNotificationData): void {
|
||||
const message = createWebSocketMessage.dealNotification(data);
|
||||
this.broadcastToUser(userId, message);
|
||||
}
|
||||
|
||||
// Broadcast to all users
|
||||
broadcastToAll(data: SystemMessageData): void {
|
||||
// Send to all connected clients
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
// Gracefully close all connections
|
||||
}
|
||||
}
|
||||
|
||||
export const websocketService = new WebSocketService(globalLogger);
|
||||
```
|
||||
|
||||
### Server Integration
|
||||
|
||||
Located in `server.ts`:
|
||||
|
||||
```typescript
|
||||
import { websocketService } from './src/services/websocketService.server';
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`Authentication server started on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Initialize WebSocket server (ADR-022)
|
||||
websocketService.initialize(server);
|
||||
logger.info('WebSocket server initialized for real-time notifications');
|
||||
|
||||
// Graceful shutdown
|
||||
const handleShutdown = (signal: string) => {
|
||||
websocketService.shutdown();
|
||||
gracefulShutdown(signal);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||
}
|
||||
```
|
||||
|
||||
### React Client Hook
|
||||
|
||||
Located in `src/hooks/useWebSocket.ts`:
|
||||
|
||||
```typescript
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const [state, setState] = useState<WebSocketState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = getWebSocketUrl(); // wss://host/ws?token=xxx
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||
|
||||
// Emit to event bus for cross-component communication
|
||||
switch (message.type) {
|
||||
case 'deal-notification':
|
||||
eventBus.dispatch('notification:deal', message.data);
|
||||
break;
|
||||
case 'system-message':
|
||||
eventBus.dispatch('notification:system', message.data);
|
||||
break;
|
||||
// ...
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Auto-reconnect with exponential backoff
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
setTimeout(connect, reconnectDelay * Math.pow(2, reconnectAttempts));
|
||||
reconnectAttempts++;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoConnect) connect();
|
||||
return () => disconnect();
|
||||
}, [autoConnect, connect, disconnect]);
|
||||
|
||||
return { ...state, connect, disconnect, send };
|
||||
}
|
||||
```
|
||||
|
||||
### Background Job Integration
|
||||
|
||||
Located in `src/services/backgroundJobService.ts`:
|
||||
|
||||
```typescript
|
||||
private async _processDealsForUser({ userProfile, deals }: UserDealGroup) {
|
||||
// ... existing email notification logic ...
|
||||
|
||||
// Send real-time WebSocket notification (ADR-022)
|
||||
const { websocketService } = await import('./websocketService.server');
|
||||
websocketService.broadcastDealNotification(userProfile.user_id, {
|
||||
user_id: userProfile.user_id,
|
||||
deals: deals.map((deal) => ({
|
||||
item_name: deal.item_name,
|
||||
best_price_in_cents: deal.best_price_in_cents,
|
||||
store_name: deal.store.name,
|
||||
store_id: deal.store.store_id,
|
||||
})),
|
||||
message: `You have ${deals.length} new deal(s) on your watched items!`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in React Components
|
||||
|
||||
```typescript
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
function NotificationComponent() {
|
||||
// Connect to WebSocket
|
||||
const { isConnected, error } = useWebSocket({ autoConnect: true });
|
||||
|
||||
// Listen for deal notifications via event bus
|
||||
const handleDealNotification = useCallback((data: DealNotificationData) => {
|
||||
toast.success(`${data.deals.length} new deals found!`);
|
||||
}, []);
|
||||
|
||||
useEventBus('notification:deal', handleDealNotification);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isConnected ? '🟢 Live' : '🔴 Offline'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WebSocket Architecture │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
Server Side:
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Background Job │─────▶│ WebSocket │─────▶│ Connected │
|
||||
│ (Deal Checker) │ │ Service │ │ Clients │
|
||||
└──────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ ▲
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ │
|
||||
│ Email Queue │ │
|
||||
│ (BullMQ) │ │
|
||||
└──────────────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ DB Notification │ │ Express Server │
|
||||
│ Storage │ │ + WS Upgrade │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
|
||||
Client Side:
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ useWebSocket │◀────▶│ WebSocket │◀────▶│ Event Bus │
|
||||
│ Hook │ │ Connection │ │ Integration │
|
||||
└──────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ UI Components │
|
||||
│ (Notifications) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authentication**: JWT tokens required for WebSocket connections
|
||||
2. **User Isolation**: Messages routed only to authenticated user's connections
|
||||
3. **Rate Limiting**: Heartbeat ping/pong prevents connection flooding
|
||||
4. **Graceful Shutdown**: Notifies clients before server shutdown
|
||||
5. **Error Handling**: Failed WebSocket sends don't crash the server
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Enables a core, user-facing feature in a scalable and efficient manner. Significantly improves user engagement and experience.
|
||||
**Negative**: Introduces a new dependency (e.g., WebSocket library) and adds complexity to the backend and frontend architecture. Requires careful handling of connection management and scaling.
|
||||
### Positive
|
||||
|
||||
- **Real-time Updates**: Users see deals immediately when found
|
||||
- **Better UX**: No page refresh needed, instant notifications
|
||||
- **Efficient**: Single persistent connection vs polling every N seconds
|
||||
- **Scalable**: Connection pooling per user, heartbeat cleanup
|
||||
- **Type-Safe**: TypeScript types prevent message format errors
|
||||
- **Resilient**: Auto-reconnect with exponential backoff
|
||||
- **Observable**: Connection stats available via `getConnectionStats()`
|
||||
- **Testable**: Comprehensive unit tests for message types and service
|
||||
|
||||
### Negative
|
||||
|
||||
- **Complexity**: WebSocket server adds new infrastructure component
|
||||
- **Memory**: Each connection consumes server memory
|
||||
- **Scaling**: Single-server implementation (multi-server requires Redis pub/sub)
|
||||
- **Browser Support**: Requires WebSocket-capable browsers (all modern browsers)
|
||||
- **Network**: Persistent connections require stable network
|
||||
|
||||
### Mitigation
|
||||
|
||||
- **Graceful Degradation**: Email + DB notifications remain for offline users
|
||||
- **Connection Limits**: Can add max connections per user if needed
|
||||
- **Monitoring**: Connection stats exposed for observability
|
||||
- **Future Scaling**: Can add Redis pub/sub for multi-instance deployments
|
||||
- **Heartbeat**: 30s ping/pong detects and cleans up dead connections
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Located in `src/services/websocketService.server.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('WebSocketService', () => {
|
||||
it('should initialize without errors', () => { ... });
|
||||
it('should handle broadcasting with no active connections', () => { ... });
|
||||
it('should shutdown gracefully', () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
Located in `src/types/websocket.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('WebSocket Message Creators', () => {
|
||||
it('should create valid deal notification messages', () => { ... });
|
||||
it('should generate valid ISO timestamps', () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Future work: Add integration tests that:
|
||||
|
||||
- Connect WebSocket clients to test server
|
||||
- Verify authentication and message routing
|
||||
- Test reconnection logic
|
||||
- Validate message delivery
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/types/websocket.ts` - WebSocket message types and creators
|
||||
- `src/services/websocketService.server.ts` - WebSocket server service
|
||||
- `src/hooks/useWebSocket.ts` - React hook for WebSocket connections
|
||||
- `src/services/backgroundJobService.ts` - Integration point for deal notifications
|
||||
- `server.ts` - Express + WebSocket server initialization
|
||||
- `src/services/websocketService.server.test.ts` - Unit tests
|
||||
- `src/types/websocket.test.ts` - Message type tests
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) - Event Bus Pattern (used by client hook)
|
||||
- [ADR-042](./0042-email-and-notification-architecture.md) - Email Notifications (fallback mechanism)
|
||||
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Jobs (triggers WebSocket notifications)
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
# ADR-023: Database Normalization and Referential Integrity
|
||||
|
||||
**Date:** 2026-01-19
|
||||
**Status:** Accepted
|
||||
**Context:** API design violates database normalization principles
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The application's API layer currently accepts string-based references (category names) instead of numerical IDs when creating relationships between entities. This violates database normalization principles and creates a brittle, error-prone API contract.
|
||||
|
||||
**Example of Current Problem:**
|
||||
|
||||
```typescript
|
||||
// API accepts string:
|
||||
POST /api/users/watched-items
|
||||
{ "itemName": "Milk", "category": "Dairy & Eggs" } // ❌ String reference
|
||||
|
||||
// But database uses normalized foreign keys:
|
||||
CREATE TABLE master_grocery_items (
|
||||
category_id BIGINT REFERENCES categories(category_id) -- ✅ Proper FK
|
||||
)
|
||||
```
|
||||
|
||||
This mismatch forces the service layer to perform string lookups on every request:
|
||||
|
||||
```typescript
|
||||
// Service must do string matching:
|
||||
const categoryRes = await client.query(
|
||||
'SELECT category_id FROM categories WHERE name = $1',
|
||||
[categoryName], // ❌ Error-prone string matching
|
||||
);
|
||||
```
|
||||
|
||||
## Database Normal Forms (In Order of Importance)
|
||||
|
||||
### 1. First Normal Form (1NF) ✅ Currently Satisfied
|
||||
|
||||
**Rule:** Each column contains atomic values; no repeating groups.
|
||||
|
||||
**Status:** ✅ **Compliant**
|
||||
|
||||
- All columns contain single values
|
||||
- No arrays or delimited strings in columns
|
||||
- Each row is uniquely identifiable
|
||||
|
||||
**Example:**
|
||||
|
||||
```sql
|
||||
-- ✅ Good: Atomic values
|
||||
CREATE TABLE master_grocery_items (
|
||||
master_grocery_item_id BIGINT PRIMARY KEY,
|
||||
name TEXT,
|
||||
category_id BIGINT
|
||||
);
|
||||
|
||||
-- ❌ Bad: Non-atomic values (violates 1NF)
|
||||
CREATE TABLE items (
|
||||
id BIGINT,
|
||||
categories TEXT -- "Dairy,Frozen,Snacks" (comma-delimited)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Second Normal Form (2NF) ✅ Currently Satisfied
|
||||
|
||||
**Rule:** No partial dependencies; all non-key columns depend on the entire primary key.
|
||||
|
||||
**Status:** ✅ **Compliant**
|
||||
|
||||
- All tables use single-column primary keys (no composite keys)
|
||||
- All non-key columns depend on the entire primary key
|
||||
|
||||
**Example:**
|
||||
|
||||
```sql
|
||||
-- ✅ Good: All columns depend on full primary key
|
||||
CREATE TABLE flyer_items (
|
||||
flyer_item_id BIGINT PRIMARY KEY,
|
||||
flyer_id BIGINT, -- Depends on flyer_item_id
|
||||
master_item_id BIGINT, -- Depends on flyer_item_id
|
||||
price_in_cents INT -- Depends on flyer_item_id
|
||||
);
|
||||
|
||||
-- ❌ Bad: Partial dependency (violates 2NF)
|
||||
CREATE TABLE flyer_items (
|
||||
flyer_id BIGINT,
|
||||
item_id BIGINT,
|
||||
store_name TEXT, -- Depends only on flyer_id, not (flyer_id, item_id)
|
||||
PRIMARY KEY (flyer_id, item_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Third Normal Form (3NF) ⚠️ VIOLATED IN API LAYER
|
||||
|
||||
**Rule:** No transitive dependencies; non-key columns depend only on the primary key, not on other non-key columns.
|
||||
|
||||
**Status:** ⚠️ **Database is compliant, but API layer violates this principle**
|
||||
|
||||
**Database Schema (Correct):**
|
||||
|
||||
```sql
|
||||
-- ✅ Categories are normalized
|
||||
CREATE TABLE categories (
|
||||
category_id BIGINT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE master_grocery_items (
|
||||
master_grocery_item_id BIGINT PRIMARY KEY,
|
||||
name TEXT,
|
||||
category_id BIGINT REFERENCES categories(category_id) -- Direct reference
|
||||
);
|
||||
```
|
||||
|
||||
**API Layer (Violates 3NF Principle):**
|
||||
|
||||
```typescript
|
||||
// ❌ API accepts category name instead of ID
|
||||
POST /api/users/watched-items
|
||||
{
|
||||
"itemName": "Milk",
|
||||
"category": "Dairy & Eggs" // String! Should be category_id
|
||||
}
|
||||
|
||||
// Service layer must denormalize by doing lookup:
|
||||
SELECT category_id FROM categories WHERE name = $1
|
||||
```
|
||||
|
||||
This creates a **transitive dependency** in the application layer:
|
||||
|
||||
- `watched_item` → `category_name` → `category_id`
|
||||
- Instead of direct: `watched_item` → `category_id`
|
||||
|
||||
### 4. Boyce-Codd Normal Form (BCNF) ✅ Currently Satisfied
|
||||
|
||||
**Rule:** Every determinant is a candidate key (stricter version of 3NF).
|
||||
|
||||
**Status:** ✅ **Compliant**
|
||||
|
||||
- All foreign key references use primary keys
|
||||
- No non-trivial functional dependencies where determinant is not a superkey
|
||||
|
||||
### 5. Fourth Normal Form (4NF) ✅ Currently Satisfied
|
||||
|
||||
**Rule:** No multi-valued dependencies; a record should not contain independent multi-valued facts.
|
||||
|
||||
**Status:** ✅ **Compliant**
|
||||
|
||||
- Junction tables properly separate many-to-many relationships
|
||||
- Examples: `user_watched_items`, `shopping_list_items`, `recipe_ingredients`
|
||||
|
||||
### 6. Fifth Normal Form (5NF) ✅ Currently Satisfied
|
||||
|
||||
**Rule:** No join dependencies; tables cannot be decomposed further without loss of information.
|
||||
|
||||
**Status:** ✅ **Compliant** (as far as schema design goes)
|
||||
|
||||
## Impact of API Violation
|
||||
|
||||
### 1. Brittleness
|
||||
|
||||
```typescript
|
||||
// Test fails because of exact string matching:
|
||||
addWatchedItem('Milk', 'Dairy'); // ❌ Fails - not exact match
|
||||
addWatchedItem('Milk', 'Dairy & Eggs'); // ✅ Works - exact match
|
||||
addWatchedItem('Milk', 'dairy & eggs'); // ❌ Fails - case sensitive
|
||||
```
|
||||
|
||||
### 2. No Discovery Mechanism
|
||||
|
||||
- No API endpoint to list available categories
|
||||
- Frontend cannot dynamically populate dropdowns
|
||||
- Clients must hardcode category names
|
||||
|
||||
### 3. Performance Penalty
|
||||
|
||||
```sql
|
||||
-- Current: String lookup on every request
|
||||
SELECT category_id FROM categories WHERE name = $1; -- Full table scan or index scan
|
||||
|
||||
-- Should be: Direct ID reference (no lookup needed)
|
||||
INSERT INTO master_grocery_items (name, category_id) VALUES ($1, $2);
|
||||
```
|
||||
|
||||
### 4. Impossible Localization
|
||||
|
||||
- Cannot translate category names without breaking API
|
||||
- Category names are hardcoded in English
|
||||
|
||||
### 5. Maintenance Burden
|
||||
|
||||
- Renaming a category breaks all API clients
|
||||
- Must coordinate name changes across frontend, tests, and documentation
|
||||
|
||||
## Decision
|
||||
|
||||
**We adopt the following principles for all API design:**
|
||||
|
||||
### 1. Use Numerical IDs for All Foreign Key References
|
||||
|
||||
**Rule:** APIs MUST accept numerical IDs when creating relationships between entities.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Use IDs
|
||||
POST /api/users/watched-items
|
||||
{
|
||||
"itemName": "Milk",
|
||||
"category_id": 3 // Numerical ID
|
||||
}
|
||||
|
||||
// ❌ INCORRECT: Use strings
|
||||
POST /api/users/watched-items
|
||||
{
|
||||
"itemName": "Milk",
|
||||
"category": "Dairy & Eggs" // String name
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Provide Discovery Endpoints
|
||||
|
||||
**Rule:** For any entity referenced by ID, provide a GET endpoint to list available options.
|
||||
|
||||
```typescript
|
||||
// Required: Category discovery endpoint
|
||||
GET / api / categories;
|
||||
Response: [
|
||||
{ category_id: 1, name: 'Fruits & Vegetables' },
|
||||
{ category_id: 2, name: 'Meat & Seafood' },
|
||||
{ category_id: 3, name: 'Dairy & Eggs' },
|
||||
];
|
||||
```
|
||||
|
||||
### 3. Support Lookup by Name (Optional)
|
||||
|
||||
**Rule:** If convenient, provide query parameters for name-based lookup, but use IDs internally.
|
||||
|
||||
```typescript
|
||||
// Optional: Convenience endpoint
|
||||
GET /api/categories?name=Dairy%20%26%20Eggs
|
||||
Response: { "category_id": 3, "name": "Dairy & Eggs" }
|
||||
```
|
||||
|
||||
### 4. Return Full Objects in Responses
|
||||
|
||||
**Rule:** API responses SHOULD include denormalized data for convenience, but inputs MUST use IDs.
|
||||
|
||||
```typescript
|
||||
// ✅ Response includes category details
|
||||
GET / api / users / watched - items;
|
||||
Response: [
|
||||
{
|
||||
master_grocery_item_id: 42,
|
||||
name: 'Milk',
|
||||
category_id: 3,
|
||||
category: {
|
||||
// ✅ Include full object in response
|
||||
category_id: 3,
|
||||
name: 'Dairy & Eggs',
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Affected Areas
|
||||
|
||||
### Immediate Violations (Must Fix)
|
||||
|
||||
1. **User Watched Items** ([src/routes/user.routes.ts:76](../../src/routes/user.routes.ts))
|
||||
- Currently: `category: string`
|
||||
- Should be: `category_id: number`
|
||||
|
||||
2. **Service Layer** ([src/services/db/personalization.db.ts:175](../../src/services/db/personalization.db.ts))
|
||||
- Currently: `categoryName: string`
|
||||
- Should be: `categoryId: number`
|
||||
|
||||
3. **API Client** ([src/services/apiClient.ts:436](../../src/services/apiClient.ts))
|
||||
- Currently: `category: string`
|
||||
- Should be: `category_id: number`
|
||||
|
||||
4. **Frontend Hooks** ([src/hooks/mutations/useAddWatchedItemMutation.ts:9](../../src/hooks/mutations/useAddWatchedItemMutation.ts))
|
||||
- Currently: `category?: string`
|
||||
- Should be: `category_id: number`
|
||||
|
||||
### Potential Violations (Review Required)
|
||||
|
||||
1. **UPC/Barcode System** ([src/types/upc.ts:85](../../src/types/upc.ts))
|
||||
- Uses `category: string | null`
|
||||
- May be appropriate if category is free-form user input
|
||||
|
||||
2. **AI Extraction** ([src/types/ai.ts:21](../../src/types/ai.ts))
|
||||
- Uses `category_name: z.string()`
|
||||
- AI extracts category names, needs mapping to IDs
|
||||
|
||||
3. **Flyer Data Transformer** ([src/services/flyerDataTransformer.ts:40](../../src/services/flyerDataTransformer.ts))
|
||||
- Uses `category_name: string`
|
||||
- May need category matching/creation logic
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
See [research-category-id-migration.md](../research-category-id-migration.md) for detailed migration plan.
|
||||
|
||||
**High-level approach:**
|
||||
|
||||
1. **Phase 1: Add category discovery endpoint** (non-breaking)
|
||||
- `GET /api/categories`
|
||||
- No API changes yet
|
||||
|
||||
2. **Phase 2: Support both formats** (non-breaking)
|
||||
- Accept both `category` (string) and `category_id` (number)
|
||||
- Deprecate string format with warning logs
|
||||
|
||||
3. **Phase 3: Remove string support** (breaking change, major version bump)
|
||||
- Only accept `category_id`
|
||||
- Update all clients and tests
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- ✅ API matches database schema design
|
||||
- ✅ More robust (no typo-based failures)
|
||||
- ✅ Better performance (no string lookups)
|
||||
- ✅ Enables localization
|
||||
- ✅ Discoverable via REST API
|
||||
- ✅ Follows REST best practices
|
||||
|
||||
### Negative
|
||||
|
||||
- ⚠️ Breaking change for existing API consumers
|
||||
- ⚠️ Requires client updates
|
||||
- ⚠️ More complex migration path
|
||||
|
||||
### Neutral
|
||||
|
||||
- Frontend must fetch categories before displaying form
|
||||
- Slightly more initial API calls (one-time category fetch)
|
||||
|
||||
## References
|
||||
|
||||
- [Database Normalization (Wikipedia)](https://en.wikipedia.org/wiki/Database_normalization)
|
||||
- [REST API Design Best Practices](https://stackoverflow.blog/2020/03/02/best-practices-for-rest-api-design/)
|
||||
- [PostgreSQL Foreign Keys](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK)
|
||||
|
||||
## Related Decisions
|
||||
|
||||
- [ADR-001: Database Schema Design](./0001-database-schema-design.md) (if exists)
|
||||
- [ADR-014: Containerization and Deployment Strategy](./0014-containerization-and-deployment-strategy.md)
|
||||
|
||||
## Approval
|
||||
|
||||
- **Proposed by:** Claude Code (via user observation)
|
||||
- **Date:** 2026-01-19
|
||||
- **Status:** Accepted (pending implementation)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date**: 2025-12-14
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Adopted
|
||||
|
||||
## Context
|
||||
|
||||
@@ -24,8 +24,8 @@ We will adopt a standardized, application-wide structured logging policy for all
|
||||
|
||||
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
||||
|
||||
* `logger.info('A simple message');`
|
||||
* `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
||||
- `logger.info('A simple message');`
|
||||
- `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
||||
|
||||
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('MyComponent', () => {
|
||||
// Assert that the logger was called with the expected structure
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
||||
'Failed to fetch component data' // Check for the message
|
||||
'Failed to fetch component data', // Check for the message
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-027: Standardized Naming Convention for AI and Database Types
|
||||
|
||||
**Date**: 2026-01-05
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The application codebase primarily follows the standard TypeScript convention of `camelCase` for variable and property names. However, the PostgreSQL database uses `snake_case` for column names. Additionally, the AI prompts are designed to extract data that maps directly to these database columns.
|
||||
|
||||
Attempting to enforce `camelCase` strictly across the entire stack created friction and ambiguity, particularly in the background processing pipeline where data moves from the AI model directly to the database. Developers were unsure whether to transform keys immediately upon receipt (adding overhead) or keep them as-is.
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a hybrid naming convention strategy to explicitly distinguish between internal application state and external/persisted data formats.
|
||||
|
||||
1. **Database and AI Types (`snake_case`)**:
|
||||
Interfaces, Type definitions, and Zod schemas that represent raw database rows or direct AI responses **MUST** use `snake_case`.
|
||||
- *Examples*: `AiFlyerDataSchema`, `ExtractedFlyerItemSchema`, `FlyerInsert`.
|
||||
- *Reasoning*: This avoids unnecessary mapping layers when inserting data into the database or parsing AI output. It serves as a visual cue that the data is "raw", "external", or destined for persistence.
|
||||
|
||||
2. **Internal Application Logic (`camelCase`)**:
|
||||
Variables, function arguments, and processed data structures used within the application logic (Service layer, UI components, utility functions) **MUST** use `camelCase`.
|
||||
- *Reasoning*: This adheres to standard JavaScript/TypeScript practices and maintains consistency with the rest of the ecosystem (React, etc.).
|
||||
|
||||
3. **Boundary Handling**:
|
||||
- For background jobs that primarily move data from AI to DB, preserving `snake_case` is preferred to minimize transformation logic.
|
||||
- For API responses sent to the frontend, data should generally be transformed to `camelCase` unless it is a direct dump of a database entity for a specific administrative view.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Visual Distinction**: It is immediately obvious whether a variable holds raw data (`price_in_cents`) or processed application state (`priceInCents`).
|
||||
- **Efficiency**: Reduces boilerplate code for mapping keys (e.g., `price_in_cents: data.priceInCents`) when performing bulk inserts or updates.
|
||||
- **Simplicity**: AI prompts can request JSON keys that match the database schema 1:1, reducing the risk of mapping errors.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Context Switching**: Developers must be mindful of the casing context.
|
||||
- **Linter Configuration**: May require specific overrides or `// eslint-disable-next-line` comments if the linter is configured to strictly enforce `camelCase` everywhere.
|
||||
177
docs/adr/0028-api-response-standardization.md
Normal file
177
docs/adr/0028-api-response-standardization.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# ADR-028: API Response Standardization and Envelope Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Implemented
|
||||
|
||||
## Context
|
||||
|
||||
The API currently has inconsistent response formats across different endpoints:
|
||||
|
||||
1. Some endpoints return raw data arrays (`[{...}, {...}]`)
|
||||
2. Some return wrapped objects (`{ data: [...] }`)
|
||||
3. Pagination is handled inconsistently (some use `page`/`limit`, others use `offset`/`count`)
|
||||
4. Error responses vary in structure between middleware and route handlers
|
||||
5. No standard for including metadata (pagination info, request timing, etc.)
|
||||
|
||||
This inconsistency creates friction for:
|
||||
|
||||
- Frontend developers who must handle multiple response formats
|
||||
- API documentation and client SDK generation
|
||||
- Implementing consistent error handling across the application
|
||||
- Future API versioning transitions
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a standardized response envelope pattern for all API responses.
|
||||
|
||||
### Success Response Format
|
||||
|
||||
```typescript
|
||||
interface ApiSuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: {
|
||||
// Pagination (when applicable)
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
};
|
||||
// Timing
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```typescript
|
||||
interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string; // Machine-readable error code (e.g., 'VALIDATION_ERROR')
|
||||
message: string; // Human-readable message
|
||||
details?: unknown; // Additional context (validation errors, etc.)
|
||||
};
|
||||
meta?: {
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
1. **Response Helper Functions**: Create utility functions in `src/utils/apiResponse.ts`:
|
||||
- `sendSuccess(res, data, meta?)`
|
||||
- `sendPaginated(res, data, pagination)`
|
||||
- `sendError(res, code, message, details?, statusCode?)`
|
||||
|
||||
2. **Error Handler Integration**: Update `errorHandler.ts` to use the standard error format
|
||||
|
||||
3. **Gradual Migration**: Apply to new endpoints immediately, migrate existing endpoints incrementally
|
||||
|
||||
4. **TypeScript Types**: Export response types for frontend consumption
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Consistency**: All responses follow a predictable structure
|
||||
- **Type Safety**: Frontend can rely on consistent types
|
||||
- **Debugging**: Request IDs and timestamps aid in issue investigation
|
||||
- **Pagination**: Standardized pagination metadata reduces frontend complexity
|
||||
- **API Evolution**: Envelope pattern makes it easier to add fields without breaking changes
|
||||
|
||||
### Negative
|
||||
|
||||
- **Verbosity**: Responses are slightly larger due to envelope overhead
|
||||
- **Migration Effort**: Existing endpoints need updating
|
||||
- **Learning Curve**: Developers must learn and use the helper functions
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ Created `src/utils/apiResponse.ts` with helper functions (`sendSuccess`, `sendPaginated`, `sendError`, `sendNoContent`, `sendMessage`, `calculatePagination`)
|
||||
- ✅ Created `src/types/api.ts` with response type definitions (`ApiSuccessResponse`, `ApiErrorResponse`, `PaginationMeta`, `ErrorCode`)
|
||||
- ✅ Updated `src/middleware/errorHandler.ts` to use standard error format
|
||||
- ✅ Migrated all route files to use standardized responses:
|
||||
- `health.routes.ts`
|
||||
- `flyer.routes.ts`
|
||||
- `deals.routes.ts`
|
||||
- `budget.routes.ts`
|
||||
- `personalization.routes.ts`
|
||||
- `price.routes.ts`
|
||||
- `reactions.routes.ts`
|
||||
- `stats.routes.ts`
|
||||
- `system.routes.ts`
|
||||
- `gamification.routes.ts`
|
||||
- `recipe.routes.ts`
|
||||
- `auth.routes.ts`
|
||||
- `user.routes.ts`
|
||||
- `admin.routes.ts`
|
||||
- `ai.routes.ts`
|
||||
|
||||
### Error Codes
|
||||
|
||||
The following error codes are defined in `src/types/api.ts`:
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
| ------------------------ | ----------- | ----------------------------------- |
|
||||
| `VALIDATION_ERROR` | 400 | Request validation failed |
|
||||
| `BAD_REQUEST` | 400 | Malformed request |
|
||||
| `UNAUTHORIZED` | 401 | Authentication required |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `CONFLICT` | 409 | Resource conflict (e.g., duplicate) |
|
||||
| `RATE_LIMITED` | 429 | Too many requests |
|
||||
| `PAYLOAD_TOO_LARGE` | 413 | Request body too large |
|
||||
| `INTERNAL_ERROR` | 500 | Server error |
|
||||
| `NOT_IMPLEMENTED` | 501 | Feature not yet implemented |
|
||||
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
|
||||
| `EXTERNAL_SERVICE_ERROR` | 502 | External service failure |
|
||||
|
||||
## Example Usage
|
||||
|
||||
```typescript
|
||||
// In a route handler
|
||||
router.get('/flyers', async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const { flyers, total } = await flyerService.getFlyers({ page, limit });
|
||||
|
||||
return sendPaginated(res, flyers, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Response:
|
||||
// {
|
||||
// "success": true,
|
||||
// "data": [...],
|
||||
// "meta": {
|
||||
// "pagination": {
|
||||
// "page": 1,
|
||||
// "limit": 20,
|
||||
// "total": 150,
|
||||
// "totalPages": 8,
|
||||
// "hasNextPage": true,
|
||||
// "hasPrevPage": false
|
||||
// },
|
||||
// "requestId": "abc-123",
|
||||
// "timestamp": "2026-01-09T12:00:00.000Z"
|
||||
// }
|
||||
// }
|
||||
```
|
||||
147
docs/adr/0029-secret-rotation-and-key-management.md
Normal file
147
docs/adr/0029-secret-rotation-and-key-management.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# ADR-029: Secret Rotation and Key Management Strategy
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
While ADR-007 covers configuration validation at startup, it does not address the lifecycle management of secrets:
|
||||
|
||||
1. **JWT Secrets**: If the JWT_SECRET is rotated, all existing user sessions are immediately invalidated
|
||||
2. **Database Credentials**: No documented procedure for rotating database passwords without downtime
|
||||
3. **API Keys**: External service API keys (AI services, geocoding) have no rotation strategy
|
||||
4. **Emergency Revocation**: No process for immediately invalidating compromised credentials
|
||||
|
||||
Current risks:
|
||||
|
||||
- Long-lived secrets that never change become high-value targets
|
||||
- No ability to rotate secrets without application restart
|
||||
- No audit trail of when secrets were last rotated
|
||||
- Compromised keys could remain active indefinitely
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a comprehensive secret rotation and key management strategy.
|
||||
|
||||
### 1. JWT Secret Rotation with Dual-Key Support
|
||||
|
||||
Support multiple JWT secrets simultaneously to enable zero-downtime rotation:
|
||||
|
||||
```typescript
|
||||
// Environment variables
|
||||
JWT_SECRET = current_secret;
|
||||
JWT_SECRET_PREVIOUS = old_secret; // Optional, for transition period
|
||||
|
||||
// Token verification tries current first, falls back to previous
|
||||
const verifyToken = (token: string) => {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch {
|
||||
if (process.env.JWT_SECRET_PREVIOUS) {
|
||||
return jwt.verify(token, process.env.JWT_SECRET_PREVIOUS);
|
||||
}
|
||||
throw new AuthenticationError('Invalid token');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Database Credential Rotation
|
||||
|
||||
Document and implement a procedure for PostgreSQL credential rotation:
|
||||
|
||||
1. Create new database user with identical permissions
|
||||
2. Update application configuration to use new credentials
|
||||
3. Restart application instances (rolling restart)
|
||||
4. Remove old database user after all instances updated
|
||||
5. Log rotation event for audit purposes
|
||||
|
||||
### 3. API Key Management
|
||||
|
||||
For external service API keys (Google AI, geocoding services):
|
||||
|
||||
1. **Naming Convention**: `{SERVICE}_API_KEY` and `{SERVICE}_API_KEY_PREVIOUS`
|
||||
2. **Fallback Logic**: Try primary key, fall back to previous on 401/403
|
||||
3. **Health Checks**: Validate API keys on startup
|
||||
4. **Usage Logging**: Track which key is being used for each request
|
||||
|
||||
### 4. Emergency Revocation Procedures
|
||||
|
||||
Document emergency procedures for:
|
||||
|
||||
- **JWT Compromise**: Set new JWT_SECRET, clear all refresh tokens from database
|
||||
- **Database Compromise**: Rotate credentials immediately, audit access logs
|
||||
- **API Key Compromise**: Regenerate at provider, update environment, restart
|
||||
|
||||
### 5. Secret Audit Trail
|
||||
|
||||
Track secret lifecycle events:
|
||||
|
||||
- When secrets were last rotated
|
||||
- Who initiated the rotation
|
||||
- Which instances are using which secrets
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1: Dual JWT Secret Support
|
||||
|
||||
- Modify token verification to support fallback secret
|
||||
- Add JWT_SECRET_PREVIOUS to configuration schema
|
||||
- Update documentation
|
||||
|
||||
### Phase 2: Rotation Scripts
|
||||
|
||||
- Create `scripts/rotate-jwt-secret.sh`
|
||||
- Create `scripts/rotate-db-credentials.sh`
|
||||
- Add rotation instructions to operations runbook
|
||||
|
||||
### Phase 3: API Key Fallback
|
||||
|
||||
- Wrap external API clients with fallback logic
|
||||
- Add key validation to health checks
|
||||
- Implement key usage logging
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero-Downtime Rotation**: Secrets can be rotated without invalidating all sessions
|
||||
- **Reduced Risk**: Regular rotation limits exposure window for compromised credentials
|
||||
- **Audit Trail**: Clear record of when secrets were changed
|
||||
- **Emergency Response**: Documented procedures for security incidents
|
||||
|
||||
### Negative
|
||||
|
||||
- **Complexity**: Dual-key logic adds code complexity
|
||||
- **Operations Overhead**: Regular rotation requires operational discipline
|
||||
- **Testing**: Rotation procedures need to be tested periodically
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ❌ Not yet implemented
|
||||
|
||||
### What Needs To Be Done
|
||||
|
||||
1. Implement dual JWT secret verification
|
||||
2. Create rotation scripts
|
||||
3. Document emergency procedures
|
||||
4. Add secret validation to health checks
|
||||
5. Create rotation schedule recommendations
|
||||
|
||||
## Key Files (To Be Created)
|
||||
|
||||
- `src/utils/secretManager.ts` - Secret rotation utilities
|
||||
- `scripts/rotate-jwt-secret.sh` - JWT rotation script
|
||||
- `scripts/rotate-db-credentials.sh` - Database credential rotation
|
||||
- `docs/operations/secret-rotation.md` - Operations runbook
|
||||
|
||||
## Rotation Schedule Recommendations
|
||||
|
||||
| Secret Type | Rotation Frequency | Grace Period |
|
||||
| ------------------ | -------------------------- | ----------------- |
|
||||
| JWT_SECRET | 90 days | 7 days (dual-key) |
|
||||
| Database Passwords | 180 days | Rolling restart |
|
||||
| AI API Keys | On suspicion of compromise | Immediate |
|
||||
| Refresh Tokens | 7-day max age | N/A (per-token) |
|
||||
150
docs/adr/0030-graceful-degradation-and-circuit-breaker.md
Normal file
150
docs/adr/0030-graceful-degradation-and-circuit-breaker.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# ADR-030: Graceful Degradation and Circuit Breaker Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The application depends on several external services:
|
||||
|
||||
1. **AI Services** (Google Gemini) - For flyer item extraction
|
||||
2. **Redis** - For caching, rate limiting, and job queues
|
||||
3. **PostgreSQL** - Primary data store
|
||||
4. **Geocoding APIs** - For location services
|
||||
|
||||
Currently, when these services fail:
|
||||
|
||||
- AI failures may cause the entire upload to fail
|
||||
- Redis unavailability could crash the application or bypass rate limiting
|
||||
- No circuit breakers prevent repeated calls to failing services
|
||||
- No fallback behaviors are defined
|
||||
|
||||
This creates fragility where a single service outage can cascade into application-wide failures.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a graceful degradation strategy with circuit breakers for external service dependencies.
|
||||
|
||||
### 1. Circuit Breaker Pattern
|
||||
|
||||
Implement circuit breakers for external service calls using a library like `opossum`:
|
||||
|
||||
```typescript
|
||||
import CircuitBreaker from 'opossum';
|
||||
|
||||
const aiCircuitBreaker = new CircuitBreaker(callAiService, {
|
||||
timeout: 30000, // 30 second timeout
|
||||
errorThresholdPercentage: 50, // Open circuit at 50% failures
|
||||
resetTimeout: 30000, // Try again after 30 seconds
|
||||
volumeThreshold: 5, // Minimum calls before calculating error %
|
||||
});
|
||||
|
||||
aiCircuitBreaker.on('open', () => {
|
||||
logger.warn('AI service circuit breaker opened');
|
||||
});
|
||||
|
||||
aiCircuitBreaker.on('halfOpen', () => {
|
||||
logger.info('AI service circuit breaker half-open, testing...');
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Fallback Behaviors by Service
|
||||
|
||||
| Service | Fallback Behavior |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| **Redis (Cache)** | Skip cache, query database directly |
|
||||
| **Redis (Rate Limit)** | Log warning, allow request (fail-open) |
|
||||
| **Redis (Queues)** | Queue to memory, process synchronously |
|
||||
| **AI Service** | Return partial results, queue for retry |
|
||||
| **Geocoding** | Return null location, allow manual entry |
|
||||
| **PostgreSQL** | No fallback - critical dependency |
|
||||
|
||||
### 3. Health Status Aggregation
|
||||
|
||||
Extend health checks (ADR-020) to report service-level health:
|
||||
|
||||
```typescript
|
||||
// GET /api/health/ready response
|
||||
{
|
||||
"status": "degraded", // healthy | degraded | unhealthy
|
||||
"services": {
|
||||
"database": { "status": "healthy", "latency": 5 },
|
||||
"redis": { "status": "healthy", "latency": 2 },
|
||||
"ai": { "status": "degraded", "circuitState": "half-open" },
|
||||
"geocoding": { "status": "healthy", "latency": 150 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Retry Strategies
|
||||
|
||||
Define retry policies for transient failures:
|
||||
|
||||
```typescript
|
||||
const retryConfig = {
|
||||
ai: { maxRetries: 3, backoff: 'exponential', initialDelay: 1000 },
|
||||
geocoding: { maxRetries: 2, backoff: 'linear', initialDelay: 500 },
|
||||
database: { maxRetries: 3, backoff: 'exponential', initialDelay: 100 },
|
||||
};
|
||||
```
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1: Redis Fallbacks
|
||||
|
||||
- Wrap cache operations with try-catch (already partially done in cacheService)
|
||||
- Add fail-open for rate limiting when Redis is down
|
||||
- Log degraded state
|
||||
|
||||
### Phase 2: AI Circuit Breaker
|
||||
|
||||
- Wrap AI service calls with circuit breaker
|
||||
- Implement queue-for-retry on circuit open
|
||||
- Add manual fallback UI for failed extractions
|
||||
|
||||
### Phase 3: Health Aggregation
|
||||
|
||||
- Update health endpoints with service status
|
||||
- Add Prometheus-compatible metrics
|
||||
- Create dashboard for service health
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Resilience**: Application continues functioning during partial outages
|
||||
- **User Experience**: Degraded but functional is better than complete failure
|
||||
- **Observability**: Clear visibility into service health
|
||||
- **Protection**: Circuit breakers prevent cascading failures
|
||||
|
||||
### Negative
|
||||
|
||||
- **Complexity**: Additional code for fallback logic
|
||||
- **Testing**: Requires testing failure scenarios
|
||||
- **Consistency**: Some operations may have different results during degradation
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ Cache operations fail gracefully (cacheService.server.ts)
|
||||
- ❌ Circuit breakers for AI services
|
||||
- ❌ Rate limit fail-open behavior
|
||||
- ❌ Health aggregation endpoint
|
||||
- ❌ Retry strategies with backoff
|
||||
|
||||
### What Needs To Be Done
|
||||
|
||||
1. Install and configure `opossum` circuit breaker library
|
||||
2. Wrap AI service calls with circuit breaker
|
||||
3. Add fail-open to rate limiting
|
||||
4. Extend health endpoints with service status
|
||||
5. Document degraded mode behaviors
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/utils/circuitBreaker.ts` - Circuit breaker configurations (to create)
|
||||
- `src/services/cacheService.server.ts` - Already has graceful fallbacks
|
||||
- `src/routes/health.routes.ts` - Health check endpoints (to extend)
|
||||
- `src/services/aiService.server.ts` - AI service wrapper (to wrap)
|
||||
199
docs/adr/0031-data-retention-and-privacy-compliance.md
Normal file
199
docs/adr/0031-data-retention-and-privacy-compliance.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# ADR-031: Data Retention and Privacy Compliance (GDPR/CCPA)
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The application stores various types of user data:
|
||||
|
||||
1. **User Accounts**: Email, password hash, profile information
|
||||
2. **Shopping Lists**: Personal shopping preferences and history
|
||||
3. **Watch Lists**: Tracked items and price alerts
|
||||
4. **Activity Logs**: User actions for analytics and debugging
|
||||
5. **Tracking Data**: Page views, interactions, feature usage
|
||||
|
||||
Current gaps in privacy compliance:
|
||||
|
||||
- **No Data Retention Policies**: Activity logs accumulate indefinitely
|
||||
- **No User Data Export**: Users cannot export their data (GDPR Article 20)
|
||||
- **No User Data Deletion**: No self-service account deletion (GDPR Article 17)
|
||||
- **No Cookie Consent**: Cookie usage not disclosed or consented
|
||||
- **No Privacy Policy Enforcement**: Privacy commitments not enforced in code
|
||||
|
||||
These gaps create legal exposure for users in EU (GDPR) and California (CCPA).
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement comprehensive data retention and privacy compliance features.
|
||||
|
||||
### 1. Data Retention Policies
|
||||
|
||||
| Data Type | Retention Period | Deletion Method |
|
||||
| ------------------------- | ------------------------ | ------------------------ |
|
||||
| **Activity Logs** | 90 days | Automated cleanup job |
|
||||
| **Tracking Events** | 30 days | Automated cleanup job |
|
||||
| **Deleted User Data** | 30 days (soft delete) | Hard delete after period |
|
||||
| **Expired Sessions** | 7 days after expiry | Token cleanup job |
|
||||
| **Failed Login Attempts** | 24 hours | Automated cleanup |
|
||||
| **Flyer Data** | Indefinite (public data) | N/A |
|
||||
| **User Shopping Lists** | Until account deletion | With account |
|
||||
| **User Watch Lists** | Until account deletion | With account |
|
||||
|
||||
### 2. User Data Export (Right to Portability)
|
||||
|
||||
Implement `GET /api/users/me/export` endpoint:
|
||||
|
||||
```typescript
|
||||
interface UserDataExport {
|
||||
exportDate: string;
|
||||
user: {
|
||||
email: string;
|
||||
created_at: string;
|
||||
profile: ProfileData;
|
||||
};
|
||||
shoppingLists: ShoppingList[];
|
||||
watchedItems: WatchedItem[];
|
||||
priceAlerts: PriceAlert[];
|
||||
achievements: Achievement[];
|
||||
// Exclude: password hash, internal IDs, admin flags
|
||||
}
|
||||
```
|
||||
|
||||
Export formats: JSON (primary), CSV (optional)
|
||||
|
||||
### 3. User Data Deletion (Right to Erasure)
|
||||
|
||||
Implement `DELETE /api/users/me` endpoint:
|
||||
|
||||
1. **Soft Delete**: Mark account as deleted, anonymize PII
|
||||
2. **Grace Period**: 30 days to restore account
|
||||
3. **Hard Delete**: Permanently remove all user data after grace period
|
||||
4. **Audit Log**: Record deletion request (anonymized)
|
||||
|
||||
Deletion cascade:
|
||||
|
||||
- User account → Anonymize email/name
|
||||
- Shopping lists → Delete
|
||||
- Watch lists → Delete
|
||||
- Achievements → Delete
|
||||
- Activity logs → Anonymize user_id
|
||||
- Sessions/tokens → Delete immediately
|
||||
|
||||
### 4. Cookie Consent
|
||||
|
||||
Implement cookie consent banner:
|
||||
|
||||
```typescript
|
||||
// Cookie categories
|
||||
enum CookieCategory {
|
||||
ESSENTIAL = 'essential', // Always allowed (auth, CSRF)
|
||||
FUNCTIONAL = 'functional', // Dark mode, preferences
|
||||
ANALYTICS = 'analytics', // Usage tracking
|
||||
}
|
||||
|
||||
// Store consent in localStorage and server-side
|
||||
interface CookieConsent {
|
||||
essential: true; // Cannot be disabled
|
||||
functional: boolean;
|
||||
analytics: boolean;
|
||||
consentDate: string;
|
||||
consentVersion: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Privacy Policy Enforcement
|
||||
|
||||
Enforce privacy commitments in code:
|
||||
|
||||
- Email addresses never logged in plaintext
|
||||
- Passwords never logged (already in pino redact config)
|
||||
- IP addresses anonymized after 7 days
|
||||
- Third-party data sharing requires explicit consent
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1: Data Retention Jobs
|
||||
|
||||
- Create retention cleanup job in background job service
|
||||
- Add activity_log retention (90 days)
|
||||
- Add tracking_events retention (30 days)
|
||||
|
||||
### Phase 2: User Data Export
|
||||
|
||||
- Create export endpoint
|
||||
- Implement data aggregation query
|
||||
- Add rate limiting (1 export per 24h)
|
||||
|
||||
### Phase 3: Account Deletion
|
||||
|
||||
- Implement soft delete with anonymization
|
||||
- Create hard delete cleanup job
|
||||
- Add account recovery endpoint
|
||||
|
||||
### Phase 4: Cookie Consent
|
||||
|
||||
- Create consent banner component
|
||||
- Store consent preferences
|
||||
- Gate analytics based on consent
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Legal Compliance**: Meets GDPR and CCPA requirements
|
||||
- **User Trust**: Demonstrates commitment to privacy
|
||||
- **Data Hygiene**: Automatic cleanup prevents data bloat
|
||||
- **Reduced Liability**: Less data = less risk
|
||||
|
||||
### Negative
|
||||
|
||||
- **Implementation Effort**: Significant feature development
|
||||
- **Operational Complexity**: Deletion jobs need monitoring
|
||||
- **Feature Limitations**: Some features may be limited without consent
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### What's Implemented
|
||||
|
||||
- ✅ Token cleanup job exists (tokenCleanupQueue)
|
||||
- ❌ Activity log retention
|
||||
- ❌ User data export endpoint
|
||||
- ❌ Account deletion endpoint
|
||||
- ❌ Cookie consent banner
|
||||
- ❌ Data anonymization functions
|
||||
|
||||
### What Needs To Be Done
|
||||
|
||||
1. Add activity_log cleanup to background jobs
|
||||
2. Create `/api/users/me/export` endpoint
|
||||
3. Create `/api/users/me` DELETE endpoint with soft delete
|
||||
4. Implement cookie consent UI component
|
||||
5. Document data retention in privacy policy
|
||||
6. Add anonymization utility functions
|
||||
|
||||
## Key Files (To Be Created/Modified)
|
||||
|
||||
- `src/services/backgroundJobService.ts` - Add retention jobs
|
||||
- `src/routes/user.routes.ts` - Add export/delete endpoints
|
||||
- `src/services/privacyService.server.ts` - Data export/deletion logic
|
||||
- `src/components/CookieConsent.tsx` - Consent banner
|
||||
- `src/utils/anonymize.ts` - Data anonymization utilities
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
### GDPR Requirements
|
||||
|
||||
- [ ] Article 15: Right of Access (data export)
|
||||
- [ ] Article 17: Right to Erasure (account deletion)
|
||||
- [ ] Article 20: Right to Data Portability (JSON export)
|
||||
- [ ] Article 7: Conditions for Consent (cookie consent)
|
||||
- [ ] Article 13: Information to be Provided (privacy policy)
|
||||
|
||||
### CCPA Requirements
|
||||
|
||||
- [ ] Right to Know (data export)
|
||||
- [ ] Right to Delete (account deletion)
|
||||
- [ ] Right to Opt-Out (cookie consent for analytics)
|
||||
- [ ] Non-Discrimination (no feature penalty for privacy choices)
|
||||
147
docs/adr/0032-rate-limiting-strategy.md
Normal file
147
docs/adr/0032-rate-limiting-strategy.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# ADR-032: Rate Limiting Strategy
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
Public-facing APIs are vulnerable to abuse through excessive requests, whether from malicious actors attempting denial-of-service attacks, automated scrapers, or accidental loops in client code. Without proper rate limiting, the application could:
|
||||
|
||||
1. **Experience degraded performance**: Excessive requests can overwhelm database connections and server resources
|
||||
2. **Incur unexpected costs**: AI service calls (Gemini API) and external APIs (Google Maps) are billed per request
|
||||
3. **Allow credential stuffing**: Login endpoints without limits enable brute-force attacks
|
||||
4. **Suffer from data scraping**: Public endpoints could be scraped at high volume
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a tiered rate limiting strategy using `express-rate-limit` middleware, with different limits based on endpoint sensitivity and resource cost.
|
||||
|
||||
### Tier System
|
||||
|
||||
| Tier | Window | Max Requests | Use Case |
|
||||
| --------------------------- | ------ | ------------ | -------------------------------- |
|
||||
| **Authentication (Strict)** | 15 min | 5 | Login, registration |
|
||||
| **Sensitive Operations** | 1 hour | 5 | Password changes, email updates |
|
||||
| **AI/Costly Operations** | 15 min | 10-20 | Gemini API calls, geocoding |
|
||||
| **File Uploads** | 15 min | 10-20 | Flyer uploads, avatar uploads |
|
||||
| **Batch Operations** | 15 min | 50 | Bulk updates |
|
||||
| **User Read** | 15 min | 100 | Standard authenticated endpoints |
|
||||
| **Public Read** | 15 min | 100 | Public data endpoints |
|
||||
| **Tracking/High-Volume** | 15 min | 150-200 | Analytics, reactions |
|
||||
|
||||
### Rate Limiter Configuration
|
||||
|
||||
All rate limiters share a standard configuration:
|
||||
|
||||
```typescript
|
||||
const standardConfig = {
|
||||
standardHeaders: true, // Return rate limit info in headers
|
||||
legacyHeaders: false, // Disable deprecated X-RateLimit headers
|
||||
skip: shouldSkipRateLimit, // Allow bypassing in test environment
|
||||
};
|
||||
```
|
||||
|
||||
### Test Environment Bypass
|
||||
|
||||
Rate limiting is bypassed during integration and E2E tests to avoid test flakiness:
|
||||
|
||||
```typescript
|
||||
export const shouldSkipRateLimit = (req: Request): boolean => {
|
||||
return process.env.NODE_ENV === 'test';
|
||||
};
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Available Rate Limiters
|
||||
|
||||
| Limiter | Window | Max | Endpoint Examples |
|
||||
| ---------------------------- | ------ | --- | --------------------------------- |
|
||||
| `loginLimiter` | 15 min | 5 | POST /api/auth/login |
|
||||
| `registerLimiter` | 1 hour | 5 | POST /api/auth/register |
|
||||
| `forgotPasswordLimiter` | 15 min | 5 | POST /api/auth/forgot-password |
|
||||
| `resetPasswordLimiter` | 15 min | 10 | POST /api/auth/reset-password |
|
||||
| `refreshTokenLimiter` | 15 min | 20 | POST /api/auth/refresh |
|
||||
| `logoutLimiter` | 15 min | 10 | POST /api/auth/logout |
|
||||
| `publicReadLimiter` | 15 min | 100 | GET /api/flyers, GET /api/recipes |
|
||||
| `userReadLimiter` | 15 min | 100 | GET /api/users/profile |
|
||||
| `userUpdateLimiter` | 15 min | 100 | PUT /api/users/profile |
|
||||
| `userSensitiveUpdateLimiter` | 1 hour | 5 | PUT /api/auth/change-password |
|
||||
| `adminTriggerLimiter` | 15 min | 30 | POST /api/admin/jobs/\* |
|
||||
| `aiGenerationLimiter` | 15 min | 20 | POST /api/ai/analyze |
|
||||
| `aiUploadLimiter` | 15 min | 10 | POST /api/ai/upload-and-process |
|
||||
| `geocodeLimiter` | 1 hour | 100 | GET /api/users/geocode |
|
||||
| `priceHistoryLimiter` | 15 min | 50 | GET /api/price-history/\* |
|
||||
| `reactionToggleLimiter` | 15 min | 150 | POST /api/reactions/toggle |
|
||||
| `trackingLimiter` | 15 min | 200 | POST /api/personalization/track |
|
||||
| `batchLimiter` | 15 min | 50 | PATCH /api/budgets/batch |
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```typescript
|
||||
import { loginLimiter, userReadLimiter } from '../config/rateLimiters';
|
||||
|
||||
// Apply to individual routes
|
||||
router.post('/login', loginLimiter, validateRequest(loginSchema), async (req, res, next) => {
|
||||
// handler
|
||||
});
|
||||
|
||||
// Or apply to entire router for consistent limits
|
||||
router.use(userReadLimiter);
|
||||
router.get('/me', async (req, res, next) => {
|
||||
/* handler */
|
||||
});
|
||||
```
|
||||
|
||||
### Response Headers
|
||||
|
||||
When rate limiting is active, responses include standard headers:
|
||||
|
||||
```
|
||||
RateLimit-Limit: 100
|
||||
RateLimit-Remaining: 95
|
||||
RateLimit-Reset: 900
|
||||
```
|
||||
|
||||
### Rate Limit Exceeded Response
|
||||
|
||||
When a client exceeds their limit:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Too many login attempts from this IP, please try again after 15 minutes."
|
||||
}
|
||||
```
|
||||
|
||||
HTTP Status: `429 Too Many Requests`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/config/rateLimiters.ts` - Rate limiter definitions
|
||||
- `src/utils/rateLimit.ts` - Helper functions (test bypass)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Security**: Protects against brute-force and credential stuffing attacks
|
||||
- **Cost Control**: Prevents runaway costs from AI/external API abuse
|
||||
- **Fair Usage**: Ensures all users get reasonable service access
|
||||
- **DDoS Mitigation**: Provides basic protection against request flooding
|
||||
|
||||
### Negative
|
||||
|
||||
- **Legitimate User Impact**: Aggressive users may hit limits during normal use
|
||||
- **IP-Based Limitations**: Shared IPs (offices, VPNs) may cause false positives
|
||||
- **No Distributed State**: Rate limits are per-instance, not cluster-wide (would need Redis store for that)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Redis Store**: Implement distributed rate limiting with Redis for multi-instance deployments
|
||||
2. **User-Based Limits**: Track limits per authenticated user rather than just IP
|
||||
3. **Dynamic Limits**: Adjust limits based on user tier (free vs premium)
|
||||
4. **Monitoring Dashboard**: Track rate limit hits in admin dashboard
|
||||
5. **Allowlisting**: Allow specific IPs (monitoring services) to bypass limits
|
||||
196
docs/adr/0033-file-upload-and-storage-strategy.md
Normal file
196
docs/adr/0033-file-upload-and-storage-strategy.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# ADR-033: File Upload and Storage Strategy
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application handles file uploads for flyer images and user avatars. Without a consistent strategy, file uploads can introduce security vulnerabilities (path traversal, malicious file types), performance issues (unbounded file sizes), and maintenance challenges (inconsistent storage locations).
|
||||
|
||||
Key concerns:
|
||||
|
||||
1. **Security**: Preventing malicious file uploads, path traversal attacks, and unsafe filenames
|
||||
2. **Storage Organization**: Consistent directory structure for uploaded files
|
||||
3. **Size Limits**: Preventing resource exhaustion from oversized uploads
|
||||
4. **File Type Validation**: Ensuring only expected file types are accepted
|
||||
5. **Cleanup**: Managing temporary and orphaned files
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a centralized file upload strategy using `multer` middleware with custom storage configurations, file type validation, and size limits.
|
||||
|
||||
### Storage Types
|
||||
|
||||
| Type | Directory | Purpose | Size Limit |
|
||||
| -------- | ------------------------------ | ------------------------------ | ---------- |
|
||||
| `flyer` | `$STORAGE_PATH` (configurable) | Flyer images for AI processing | 100MB |
|
||||
| `avatar` | `public/uploads/avatars/` | User profile pictures | 5MB |
|
||||
|
||||
### Filename Strategy
|
||||
|
||||
All uploaded files are renamed to prevent:
|
||||
|
||||
- Path traversal attacks
|
||||
- Filename collisions
|
||||
- Problematic characters in filenames
|
||||
|
||||
**Pattern**: `{fieldname}-{timestamp}-{random}-{sanitized-original}`
|
||||
|
||||
Example: `flyer-1704825600000-829461742-grocery-flyer.jpg`
|
||||
|
||||
### File Type Validation
|
||||
|
||||
Only image files (`image/*` MIME type) are accepted. Non-image uploads are rejected with a structured `ValidationError`.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Multer Configuration Factory
|
||||
|
||||
```typescript
|
||||
import { createUploadMiddleware } from '../middleware/multer.middleware';
|
||||
|
||||
// For flyer uploads (100MB limit)
|
||||
const flyerUpload = createUploadMiddleware({
|
||||
storageType: 'flyer',
|
||||
fileSize: 100 * 1024 * 1024, // 100MB
|
||||
fileFilter: 'image',
|
||||
});
|
||||
|
||||
// For avatar uploads (5MB limit)
|
||||
const avatarUpload = createUploadMiddleware({
|
||||
storageType: 'avatar',
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
fileFilter: 'image',
|
||||
});
|
||||
```
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
```typescript
|
||||
// Configurable via environment variable
|
||||
export const flyerStoragePath =
|
||||
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
|
||||
// Relative to project root
|
||||
export const avatarStoragePath = path.join(process.cwd(), 'public', 'uploads', 'avatars');
|
||||
```
|
||||
|
||||
### Filename Sanitization
|
||||
|
||||
The `sanitizeFilename` utility removes dangerous characters:
|
||||
|
||||
```typescript
|
||||
// Removes: path separators, null bytes, special characters
|
||||
// Keeps: alphanumeric, dots, hyphens, underscores
|
||||
const sanitized = sanitizeFilename(file.originalname);
|
||||
```
|
||||
|
||||
### Required File Validation Middleware
|
||||
|
||||
Ensures a file was uploaded before processing:
|
||||
|
||||
```typescript
|
||||
import { requireFileUpload } from '../middleware/fileUpload.middleware';
|
||||
|
||||
router.post(
|
||||
'/upload',
|
||||
flyerUpload.single('flyerImage'),
|
||||
requireFileUpload('flyerImage'), // 400 error if missing
|
||||
handleMulterError,
|
||||
async (req, res) => {
|
||||
// req.file is guaranteed to exist
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { handleMulterError } from '../middleware/multer.middleware';
|
||||
|
||||
// Catches multer-specific errors (file too large, etc.)
|
||||
router.use(handleMulterError);
|
||||
```
|
||||
|
||||
### Directory Initialization
|
||||
|
||||
Storage directories are created automatically at application startup:
|
||||
|
||||
```typescript
|
||||
(async () => {
|
||||
await fs.mkdir(flyerStoragePath, { recursive: true });
|
||||
await fs.mkdir(avatarStoragePath, { recursive: true });
|
||||
})();
|
||||
```
|
||||
|
||||
### Test Environment Handling
|
||||
|
||||
In test environments, files use predictable names for easy cleanup:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return cb(null, `test-avatar${path.extname(file.originalname) || '.png'}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
|
||||
import { requireFileUpload } from '../middleware/fileUpload.middleware';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { aiUploadLimiter } from '../config/rateLimiters';
|
||||
|
||||
const flyerUpload = createUploadMiddleware({
|
||||
storageType: 'flyer',
|
||||
fileSize: 100 * 1024 * 1024,
|
||||
fileFilter: 'image',
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/upload-and-process',
|
||||
aiUploadLimiter,
|
||||
validateRequest(uploadSchema),
|
||||
flyerUpload.single('flyerImage'),
|
||||
requireFileUpload('flyerImage'),
|
||||
handleMulterError,
|
||||
async (req, res, next) => {
|
||||
const filePath = req.file!.path;
|
||||
// Process the uploaded file...
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/middleware/multer.middleware.ts` - Multer configuration and storage handlers
|
||||
- `src/middleware/fileUpload.middleware.ts` - File requirement validation
|
||||
- `src/utils/stringUtils.ts` - Filename sanitization utilities
|
||||
- `src/utils/fileUtils.ts` - File system utilities (deletion, etc.)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Security**: Prevents path traversal and malicious uploads through sanitization and validation
|
||||
- **Consistency**: All uploads follow the same patterns and storage organization
|
||||
- **Predictability**: Test environments use predictable filenames for cleanup
|
||||
- **Extensibility**: Factory pattern allows easy addition of new upload types
|
||||
|
||||
### Negative
|
||||
|
||||
- **Disk Storage**: Files stored on disk require backup and cleanup strategies
|
||||
- **Single Server**: Current implementation doesn't support cloud storage (S3, etc.)
|
||||
- **No Virus Scanning**: Files aren't scanned for malware before processing
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Cloud Storage**: Support for S3/GCS as storage backend
|
||||
2. **Virus Scanning**: Integrate ClamAV or cloud-based scanning
|
||||
3. **Image Optimization**: Automatic resizing/compression before storage
|
||||
4. **CDN Integration**: Serve uploaded files through CDN
|
||||
5. **Cleanup Job**: Scheduled job to remove orphaned/temporary files
|
||||
6. **Presigned URLs**: Direct upload to cloud storage to reduce server load
|
||||
345
docs/adr/0034-repository-pattern-standards.md
Normal file
345
docs/adr/0034-repository-pattern-standards.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# ADR-034: Repository Pattern Standards
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application uses a repository pattern to abstract database access from business logic. However, without clear standards, repository implementations can diverge in:
|
||||
|
||||
1. **Method naming**: Inconsistent verbs (get vs find vs fetch)
|
||||
2. **Return types**: Some methods return `undefined`, others throw errors
|
||||
3. **Error handling**: Varied approaches to database error handling
|
||||
4. **Transaction participation**: Unclear how methods participate in transactions
|
||||
5. **Logging patterns**: Inconsistent logging context and messages
|
||||
|
||||
This ADR establishes standards for all repository implementations, complementing ADR-001 (Error Handling) and ADR-002 (Transaction Management).
|
||||
|
||||
## Decision
|
||||
|
||||
All repository implementations MUST follow these standards:
|
||||
|
||||
### Method Naming Conventions
|
||||
|
||||
| Prefix | Returns | Behavior on Not Found |
|
||||
| --------- | ---------------------- | ------------------------------------ |
|
||||
| `get*` | Single entity | Throws `NotFoundError` |
|
||||
| `find*` | Entity or `null` | Returns `null` |
|
||||
| `list*` | Array (possibly empty) | Returns `[]` |
|
||||
| `create*` | Created entity | Throws on constraint violation |
|
||||
| `update*` | Updated entity | Throws `NotFoundError` if not exists |
|
||||
| `delete*` | `void` or `boolean` | Throws `NotFoundError` if not exists |
|
||||
| `exists*` | `boolean` | Returns true/false |
|
||||
| `count*` | `number` | Returns count |
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
All repository methods MUST use the centralized `handleDbError` function:
|
||||
|
||||
```typescript
|
||||
import { handleDbError, NotFoundError } from './errors.db';
|
||||
|
||||
async getById(id: number): Promise<Entity> {
|
||||
try {
|
||||
const result = await this.pool.query('SELECT * FROM entities WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new NotFoundError(`Entity with ID ${id} not found.`);
|
||||
}
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, this.logger, 'Database error in getById', { id }, {
|
||||
entityName: 'Entity',
|
||||
defaultMessage: 'Failed to fetch entity.',
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Participation
|
||||
|
||||
Repository methods that need to participate in transactions MUST accept an optional `PoolClient`:
|
||||
|
||||
```typescript
|
||||
class UserRepository {
|
||||
private pool: Pool;
|
||||
private client?: PoolClient;
|
||||
|
||||
constructor(poolOrClient?: Pool | PoolClient) {
|
||||
if (poolOrClient && 'query' in poolOrClient && !('connect' in poolOrClient)) {
|
||||
// It's a PoolClient (for transactions)
|
||||
this.client = poolOrClient as PoolClient;
|
||||
} else {
|
||||
this.pool = (poolOrClient as Pool) || getPool();
|
||||
}
|
||||
}
|
||||
|
||||
private get queryable() {
|
||||
return this.client || this.pool;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or using the function-based pattern:
|
||||
|
||||
```typescript
|
||||
async function createUser(userData: CreateUserInput, client?: PoolClient): Promise<User> {
|
||||
const queryable = client || getPool();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Repository File Structure
|
||||
|
||||
```
|
||||
src/services/db/
|
||||
├── connection.db.ts # Pool management, withTransaction
|
||||
├── errors.db.ts # Custom error types, handleDbError
|
||||
├── index.db.ts # Barrel exports
|
||||
├── user.db.ts # User repository
|
||||
├── user.db.test.ts # User repository tests
|
||||
├── flyer.db.ts # Flyer repository
|
||||
├── flyer.db.test.ts # Flyer repository tests
|
||||
└── ... # Other domain repositories
|
||||
```
|
||||
|
||||
### Standard Repository Template
|
||||
|
||||
```typescript
|
||||
// src/services/db/example.db.ts
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { handleDbError, NotFoundError } from './errors.db';
|
||||
import { logger } from '../logger.server';
|
||||
import type { Example, CreateExampleInput, UpdateExampleInput } from '../../types';
|
||||
|
||||
const log = logger.child({ module: 'example.db' });
|
||||
|
||||
/**
|
||||
* Gets an example by ID.
|
||||
* @throws {NotFoundError} If the example doesn't exist.
|
||||
*/
|
||||
export async function getExampleById(id: number, client?: PoolClient): Promise<Example> {
|
||||
const queryable = client || getPool();
|
||||
try {
|
||||
const result = await queryable.query<Example>('SELECT * FROM examples WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new NotFoundError(`Example with ID ${id} not found.`);
|
||||
}
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
log,
|
||||
'Database error in getExampleById',
|
||||
{ id },
|
||||
{
|
||||
entityName: 'Example',
|
||||
defaultMessage: 'Failed to fetch example.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an example by slug, returns null if not found.
|
||||
*/
|
||||
export async function findExampleBySlug(
|
||||
slug: string,
|
||||
client?: PoolClient,
|
||||
): Promise<Example | null> {
|
||||
const queryable = client || getPool();
|
||||
try {
|
||||
const result = await queryable.query<Example>('SELECT * FROM examples WHERE slug = $1', [slug]);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
log,
|
||||
'Database error in findExampleBySlug',
|
||||
{ slug },
|
||||
{
|
||||
entityName: 'Example',
|
||||
defaultMessage: 'Failed to find example.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all examples with optional pagination.
|
||||
*/
|
||||
export async function listExamples(
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
client?: PoolClient,
|
||||
): Promise<Example[]> {
|
||||
const queryable = client || getPool();
|
||||
const { limit = 100, offset = 0 } = options;
|
||||
try {
|
||||
const result = await queryable.query<Example>(
|
||||
'SELECT * FROM examples ORDER BY created_at DESC LIMIT $1 OFFSET $2',
|
||||
[limit, offset],
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
log,
|
||||
'Database error in listExamples',
|
||||
{ limit, offset },
|
||||
{
|
||||
entityName: 'Example',
|
||||
defaultMessage: 'Failed to list examples.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new example.
|
||||
* @throws {UniqueConstraintError} If slug already exists.
|
||||
*/
|
||||
export async function createExample(
|
||||
input: CreateExampleInput,
|
||||
client?: PoolClient,
|
||||
): Promise<Example> {
|
||||
const queryable = client || getPool();
|
||||
try {
|
||||
const result = await queryable.query<Example>(
|
||||
`INSERT INTO examples (name, slug, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[input.name, input.slug, input.description],
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
log,
|
||||
'Database error in createExample',
|
||||
{ input },
|
||||
{
|
||||
entityName: 'Example',
|
||||
uniqueMessage: 'An example with this slug already exists.',
|
||||
defaultMessage: 'Failed to create example.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing example.
|
||||
* @throws {NotFoundError} If the example doesn't exist.
|
||||
*/
|
||||
export async function updateExample(
|
||||
id: number,
|
||||
input: UpdateExampleInput,
|
||||
client?: PoolClient,
|
||||
): Promise<Example> {
|
||||
const queryable = client || getPool();
|
||||
try {
|
||||
const result = await queryable.query<Example>(
|
||||
`UPDATE examples
|
||||
SET name = COALESCE($2, name), description = COALESCE($3, description)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, input.name, input.description],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new NotFoundError(`Example with ID ${id} not found.`);
|
||||
}
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
log,
|
||||
'Database error in updateExample',
|
||||
{ id, input },
|
||||
{
|
||||
entityName: 'Example',
|
||||
defaultMessage: 'Failed to update example.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an example.
|
||||
* @throws {NotFoundError} If the example doesn't exist.
|
||||
*/
|
||||
export async function deleteExample(id: number, client?: PoolClient): Promise<void> {
|
||||
const queryable = client || getPool();
|
||||
try {
|
||||
const result = await queryable.query('DELETE FROM examples WHERE id = $1', [id]);
|
||||
if (result.rowCount === 0) {
|
||||
throw new NotFoundError(`Example with ID ${id} not found.`);
|
||||
}
|
||||
} catch (error) {
|
||||
handleDbError(
|
||||
error,
|
||||
log,
|
||||
'Database error in deleteExample',
|
||||
{ id },
|
||||
{
|
||||
entityName: 'Example',
|
||||
defaultMessage: 'Failed to delete example.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using with Transactions
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from './connection.db';
|
||||
import { createExample, updateExample } from './example.db';
|
||||
import { createRelated } from './related.db';
|
||||
|
||||
async function createExampleWithRelated(data: ComplexInput): Promise<Example> {
|
||||
return withTransaction(async (client) => {
|
||||
const example = await createExample(data.example, client);
|
||||
await createRelated({ exampleId: example.id, ...data.related }, client);
|
||||
return example;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/db/connection.db.ts` - `getPool()`, `withTransaction()`
|
||||
- `src/services/db/errors.db.ts` - `handleDbError()`, custom error classes
|
||||
- `src/services/db/index.db.ts` - Barrel exports for all repositories
|
||||
- `src/services/db/*.db.ts` - Individual domain repositories
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Consistency**: All repositories follow the same patterns
|
||||
- **Predictability**: Method names clearly indicate behavior
|
||||
- **Testability**: Consistent interfaces make mocking straightforward
|
||||
- **Error Handling**: Centralized error handling prevents inconsistent responses
|
||||
- **Transaction Safety**: Clear pattern for transaction participation
|
||||
|
||||
### Negative
|
||||
|
||||
- **Learning Curve**: Developers must learn and follow conventions
|
||||
- **Boilerplate**: Each method requires similar error handling structure
|
||||
- **Refactoring**: Existing repositories may need updates to conform
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
For new repository methods:
|
||||
|
||||
- [ ] Method name follows prefix convention (get/find/list/create/update/delete)
|
||||
- [ ] Throws `NotFoundError` for `get*` methods when entity not found
|
||||
- [ ] Returns `null` for `find*` methods when entity not found
|
||||
- [ ] Uses `handleDbError` for database error handling
|
||||
- [ ] Accepts optional `PoolClient` parameter for transaction support
|
||||
- [ ] Includes JSDoc with `@throws` documentation
|
||||
- [ ] Has corresponding unit tests
|
||||
328
docs/adr/0035-service-layer-architecture.md
Normal file
328
docs/adr/0035-service-layer-architecture.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# ADR-035: Service Layer Architecture
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application has evolved to include multiple service types:
|
||||
|
||||
1. **Repository services** (`*.db.ts`): Direct database access
|
||||
2. **Business services** (`*Service.ts`): Business logic orchestration
|
||||
3. **External services** (`*Service.server.ts`): Integration with external APIs
|
||||
4. **Infrastructure services** (`logger`, `redis`, `queues`): Cross-cutting concerns
|
||||
|
||||
Without clear boundaries, business logic can leak into routes, repositories can contain business rules, and services can become tightly coupled.
|
||||
|
||||
## Decision
|
||||
|
||||
We will establish a clear layered architecture with defined responsibilities for each layer:
|
||||
|
||||
### Layer Responsibilities
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Routes Layer │
|
||||
│ - Request/response handling │
|
||||
│ - Input validation (via middleware) │
|
||||
│ - Authentication/authorization │
|
||||
│ - Rate limiting │
|
||||
│ - Response formatting │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Services Layer │
|
||||
│ - Business logic orchestration │
|
||||
│ - Transaction coordination │
|
||||
│ - External API integration │
|
||||
│ - Cross-repository operations │
|
||||
│ - Event publishing │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Repository Layer │
|
||||
│ - Direct database access │
|
||||
│ - Query construction │
|
||||
│ - Entity mapping │
|
||||
│ - Error translation │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Service Types and Naming
|
||||
|
||||
| Type | Pattern | Suffix | Example |
|
||||
| ------------------- | ------------------------------- | ------------- | --------------------- |
|
||||
| Business Service | Orchestrates business logic | `*Service.ts` | `authService.ts` |
|
||||
| Server-Only Service | External APIs, server-side only | `*.server.ts` | `aiService.server.ts` |
|
||||
| Database Repository | Direct DB access | `*.db.ts` | `user.db.ts` |
|
||||
| Infrastructure | Cross-cutting concerns | Descriptive | `logger.server.ts` |
|
||||
|
||||
### Service Dependencies
|
||||
|
||||
```
|
||||
Routes → Business Services → Repositories
|
||||
↓
|
||||
External Services
|
||||
↓
|
||||
Infrastructure (logger, redis, queues)
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Routes MUST NOT directly access repositories (except simple CRUD)
|
||||
- Repositories MUST NOT call other repositories (use services)
|
||||
- Services MAY call other services
|
||||
- Infrastructure services MAY be called from any layer
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Business Service Pattern
|
||||
|
||||
```typescript
|
||||
// src/services/authService.ts
|
||||
import { withTransaction } from './db/connection.db';
|
||||
import * as userRepo from './db/user.db';
|
||||
import * as profileRepo from './db/personalization.db';
|
||||
import { emailService } from './emailService.server';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
const log = logger.child({ service: 'auth' });
|
||||
|
||||
interface LoginResult {
|
||||
user: UserProfile;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Registers a new user and sends welcome email.
|
||||
* Orchestrates multiple repositories in a transaction.
|
||||
*/
|
||||
async registerAndLoginUser(
|
||||
email: string,
|
||||
password: string,
|
||||
fullName?: string,
|
||||
avatarUrl?: string,
|
||||
reqLog?: Logger,
|
||||
): Promise<LoginResult> {
|
||||
const log = reqLog || logger;
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
// 1. Create user (repository)
|
||||
const user = await userRepo.createUser({ email, password }, client);
|
||||
|
||||
// 2. Create profile (repository)
|
||||
await profileRepo.createProfile(
|
||||
{
|
||||
userId: user.user_id,
|
||||
fullName,
|
||||
avatarUrl,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
// 3. Generate tokens (business logic)
|
||||
const { accessToken, refreshToken } = this.generateTokens(user);
|
||||
|
||||
// 4. Send welcome email (external service, non-blocking)
|
||||
emailService.sendWelcomeEmail(email, fullName).catch((err) => {
|
||||
log.warn({ err, email }, 'Failed to send welcome email');
|
||||
});
|
||||
|
||||
log.info({ userId: user.user_id }, 'User registered successfully');
|
||||
|
||||
return {
|
||||
user: await this.buildUserProfile(user.user_id, client),
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// ... other methods
|
||||
};
|
||||
```
|
||||
|
||||
### Server-Only Service Pattern
|
||||
|
||||
```typescript
|
||||
// src/services/aiService.server.ts
|
||||
// This file MUST only be imported by server-side code
|
||||
|
||||
import { GenAI } from '@google/genai';
|
||||
import { config } from '../config/env';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
const log = logger.child({ service: 'ai' });
|
||||
|
||||
class AiService {
|
||||
private client: GenAI;
|
||||
|
||||
constructor() {
|
||||
this.client = new GenAI({ apiKey: config.ai.geminiApiKey });
|
||||
}
|
||||
|
||||
async analyzeImage(imagePath: string): Promise<AnalysisResult> {
|
||||
log.info({ imagePath }, 'Starting image analysis');
|
||||
// ... implementation
|
||||
}
|
||||
}
|
||||
|
||||
export const aiService = new AiService();
|
||||
```
|
||||
|
||||
### Route Handler Pattern
|
||||
|
||||
```typescript
|
||||
// src/routes/auth.routes.ts
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { loginLimiter } from '../config/rateLimiters';
|
||||
import { authService } from '../services/authService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Route is thin - delegates to service
|
||||
router.post(
|
||||
'/register',
|
||||
registerLimiter,
|
||||
validateRequest(registerSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, full_name } = req.body;
|
||||
|
||||
// Delegate to service
|
||||
const result = await authService.registerAndLoginUser(
|
||||
email,
|
||||
password,
|
||||
full_name,
|
||||
undefined,
|
||||
req.log, // Pass request-scoped logger
|
||||
);
|
||||
|
||||
// Format response
|
||||
res.status(201).json({
|
||||
message: 'Registration successful',
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error); // Let error handler deal with it
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Service File Organization
|
||||
|
||||
```
|
||||
src/services/
|
||||
├── db/ # Repository layer
|
||||
│ ├── connection.db.ts # Pool, transactions
|
||||
│ ├── errors.db.ts # DB error types
|
||||
│ ├── user.db.ts # User repository
|
||||
│ ├── flyer.db.ts # Flyer repository
|
||||
│ └── index.db.ts # Barrel exports
|
||||
├── authService.ts # Authentication business logic
|
||||
├── userService.ts # User management business logic
|
||||
├── gamificationService.ts # Gamification business logic
|
||||
├── aiService.server.ts # AI API integration (server-only)
|
||||
├── emailService.server.ts # Email sending (server-only)
|
||||
├── geocodingService.server.ts # Geocoding API (server-only)
|
||||
├── cacheService.server.ts # Redis caching (server-only)
|
||||
├── queueService.server.ts # BullMQ queues (server-only)
|
||||
├── logger.server.ts # Pino logger (server-only)
|
||||
└── logger.client.ts # Client-side logger
|
||||
```
|
||||
|
||||
### Dependency Injection for Testing
|
||||
|
||||
Services should support dependency injection for easier testing:
|
||||
|
||||
```typescript
|
||||
// Production: use singleton
|
||||
export const authService = createAuthService();
|
||||
|
||||
// Testing: inject mocks
|
||||
export function createAuthService(deps?: Partial<AuthServiceDeps>) {
|
||||
const userRepo = deps?.userRepo || defaultUserRepo;
|
||||
const emailService = deps?.emailService || defaultEmailService;
|
||||
|
||||
return {
|
||||
async registerAndLoginUser(...) { /* ... */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
### Infrastructure Services
|
||||
|
||||
- `src/services/logger.server.ts` - Server-side structured logging
|
||||
- `src/services/logger.client.ts` - Client-side logging
|
||||
- `src/services/redis.server.ts` - Redis connection management
|
||||
- `src/services/queueService.server.ts` - BullMQ queue management
|
||||
- `src/services/cacheService.server.ts` - Caching abstraction
|
||||
|
||||
### Business Services
|
||||
|
||||
- `src/services/authService.ts` - Authentication flows
|
||||
- `src/services/userService.ts` - User management
|
||||
- `src/services/gamificationService.ts` - Achievements, leaderboards
|
||||
- `src/services/flyerProcessingService.server.ts` - Flyer pipeline
|
||||
|
||||
### External Integration Services
|
||||
|
||||
- `src/services/aiService.server.ts` - Gemini AI integration
|
||||
- `src/services/emailService.server.ts` - Email sending
|
||||
- `src/services/geocodingService.server.ts` - Address geocoding
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Separation of Concerns**: Clear boundaries between layers
|
||||
- **Testability**: Services can be tested in isolation with mocked dependencies
|
||||
- **Reusability**: Business logic in services can be used by multiple routes
|
||||
- **Maintainability**: Changes to one layer don't ripple through others
|
||||
- **Transaction Safety**: Services coordinate transactions across repositories
|
||||
|
||||
### Negative
|
||||
|
||||
- **Indirection**: More layers mean more code to navigate
|
||||
- **Potential Over-Engineering**: Simple CRUD operations don't need full service layer
|
||||
- **Coordination Overhead**: Team must agree on layer boundaries
|
||||
|
||||
## Guidelines
|
||||
|
||||
### When to Create a Service
|
||||
|
||||
Create a business service when:
|
||||
|
||||
- Logic spans multiple repositories
|
||||
- External APIs need to be called
|
||||
- Complex business rules exist
|
||||
- The same logic is needed by multiple routes
|
||||
- Transaction coordination is required
|
||||
|
||||
### When Direct Repository Access is OK
|
||||
|
||||
Routes can directly use repositories for:
|
||||
|
||||
- Simple single-entity CRUD operations
|
||||
- Read-only queries with no business logic
|
||||
- Operations that don't need transaction coordination
|
||||
|
||||
### Service Method Guidelines
|
||||
|
||||
- Accept a request-scoped logger as an optional parameter
|
||||
- Return domain objects, not HTTP-specific responses
|
||||
- Throw domain errors, let routes handle HTTP status codes
|
||||
- Use `withTransaction` for multi-repository operations
|
||||
- Log business events (user registered, order placed, etc.)
|
||||
212
docs/adr/0036-event-bus-and-pub-sub-pattern.md
Normal file
212
docs/adr/0036-event-bus-and-pub-sub-pattern.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# ADR-036: Event Bus and Pub/Sub Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
Modern web applications often need to handle cross-component communication without creating tight coupling between modules. In our application, several scenarios require broadcasting events across the system:
|
||||
|
||||
1. **Session Expiry**: When a user's session expires, multiple components need to respond (auth state, UI notifications, API client).
|
||||
2. **Real-time Updates**: When data changes on the server, multiple UI components may need to update.
|
||||
3. **Cross-Component Communication**: Independent components need to communicate without direct references to each other.
|
||||
|
||||
Traditional approaches like prop drilling or global state management can lead to tightly coupled code that is difficult to maintain and test.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a lightweight, in-memory event bus pattern using a publish/subscribe (pub/sub) architecture. This provides:
|
||||
|
||||
1. **Decoupled Communication**: Publishers and subscribers don't need to know about each other.
|
||||
2. **Event-Driven Architecture**: Components react to events rather than polling for changes.
|
||||
3. **Testability**: Events can be easily mocked and verified in tests.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Singleton Pattern**: A single event bus instance is shared across the application.
|
||||
- **Type-Safe Events**: Event names are string constants to prevent typos.
|
||||
- **Memory Management**: Subscribers must unsubscribe when components unmount to prevent memory leaks.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### EventBus Class
|
||||
|
||||
Located in `src/services/eventBus.ts`:
|
||||
|
||||
```typescript
|
||||
type EventCallback = (data?: any) => void;
|
||||
|
||||
export class EventBus {
|
||||
private listeners: { [key: string]: EventCallback[] } = {};
|
||||
|
||||
on(event: string, callback: EventCallback): void {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: EventCallback): void {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event] = this.listeners[event].filter((l) => l !== callback);
|
||||
}
|
||||
|
||||
dispatch(event: string, data?: any): void {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event].forEach((callback) => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const eventBus = new EventBus();
|
||||
```
|
||||
|
||||
### Event Constants
|
||||
|
||||
Define event names as constants to prevent typos:
|
||||
|
||||
```typescript
|
||||
// src/constants/events.ts
|
||||
export const EVENTS = {
|
||||
SESSION_EXPIRED: 'session:expired',
|
||||
SESSION_REFRESHED: 'session:refreshed',
|
||||
USER_LOGGED_OUT: 'user:loggedOut',
|
||||
DATA_UPDATED: 'data:updated',
|
||||
NOTIFICATION_RECEIVED: 'notification:received',
|
||||
} as const;
|
||||
```
|
||||
|
||||
### React Hook for Event Subscription
|
||||
|
||||
```typescript
|
||||
// src/hooks/useEventBus.ts
|
||||
import { useEffect } from 'react';
|
||||
import { eventBus } from '../services/eventBus';
|
||||
|
||||
export function useEventBus(event: string, callback: (data?: any) => void) {
|
||||
useEffect(() => {
|
||||
eventBus.on(event, callback);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
eventBus.off(event, callback);
|
||||
};
|
||||
}, [event, callback]);
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Publishing Events**:
|
||||
|
||||
```typescript
|
||||
import { eventBus } from '../services/eventBus';
|
||||
import { EVENTS } from '../constants/events';
|
||||
|
||||
// In API client when session expires
|
||||
function handleSessionExpiry() {
|
||||
eventBus.dispatch(EVENTS.SESSION_EXPIRED, { reason: 'token_expired' });
|
||||
}
|
||||
```
|
||||
|
||||
**Subscribing in Components**:
|
||||
|
||||
```typescript
|
||||
import { useCallback } from 'react';
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
import { EVENTS } from '../constants/events';
|
||||
|
||||
function AuthenticatedComponent() {
|
||||
const handleSessionExpired = useCallback((data) => {
|
||||
console.log('Session expired:', data.reason);
|
||||
// Redirect to login, show notification, etc.
|
||||
}, []);
|
||||
|
||||
useEventBus(EVENTS.SESSION_EXPIRED, handleSessionExpired);
|
||||
|
||||
return <div>Protected Content</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Subscribing in Non-React Code**:
|
||||
|
||||
```typescript
|
||||
import { eventBus } from '../services/eventBus';
|
||||
import { EVENTS } from '../constants/events';
|
||||
|
||||
// In API client
|
||||
const handleLogout = () => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
eventBus.on(EVENTS.USER_LOGGED_OUT, handleLogout);
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The EventBus is fully tested in `src/services/eventBus.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { EventBus } from './eventBus';
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
it('should call registered listeners when event is dispatched', () => {
|
||||
const callback = vi.fn();
|
||||
bus.on('test', callback);
|
||||
bus.dispatch('test', { value: 42 });
|
||||
expect(callback).toHaveBeenCalledWith({ value: 42 });
|
||||
});
|
||||
|
||||
it('should unsubscribe listeners correctly', () => {
|
||||
const callback = vi.fn();
|
||||
bus.on('test', callback);
|
||||
bus.off('test', callback);
|
||||
bus.dispatch('test');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple listeners for the same event', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
bus.on('test', callback1);
|
||||
bus.on('test', callback2);
|
||||
bus.dispatch('test');
|
||||
expect(callback1).toHaveBeenCalled();
|
||||
expect(callback2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Loose Coupling**: Components don't need direct references to communicate.
|
||||
- **Flexibility**: New subscribers can be added without modifying publishers.
|
||||
- **Testability**: Easy to mock events and verify interactions.
|
||||
- **Simplicity**: Minimal code footprint compared to full state management solutions.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Debugging Complexity**: Event-driven flows can be harder to trace than direct function calls.
|
||||
- **Memory Leaks**: Forgetting to unsubscribe can cause memory leaks (mitigated by the React hook).
|
||||
- **No Type Safety for Payloads**: Event data is typed as `any` (could be improved with generics).
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/eventBus.ts` - EventBus implementation
|
||||
- `src/services/eventBus.test.ts` - EventBus tests
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md) - State Management Strategy
|
||||
- [ADR-022](./0022-real-time-notification-system.md) - Real-time Notification System
|
||||
265
docs/adr/0037-scheduled-jobs-and-cron-pattern.md
Normal file
265
docs/adr/0037-scheduled-jobs-and-cron-pattern.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# ADR-037: Scheduled Jobs and Cron Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
Many business operations need to run on a recurring schedule without user intervention:
|
||||
|
||||
1. **Daily Deal Checks**: Scan watched items for price drops and notify users.
|
||||
2. **Analytics Generation**: Compile daily and weekly statistics reports.
|
||||
3. **Token Cleanup**: Remove expired password reset tokens from the database.
|
||||
4. **Data Maintenance**: Archive old data, clean up temporary files.
|
||||
|
||||
These scheduled operations require:
|
||||
|
||||
- Reliable execution at specific times
|
||||
- Protection against overlapping runs
|
||||
- Graceful error handling that doesn't crash the server
|
||||
- Integration with the existing job queue system (BullMQ)
|
||||
|
||||
## Decision
|
||||
|
||||
We will use `node-cron` for scheduling jobs and integrate with BullMQ for job execution. This provides:
|
||||
|
||||
1. **Cron Expressions**: Standard, well-understood scheduling syntax.
|
||||
2. **Job Queue Integration**: Scheduled jobs enqueue work to BullMQ for reliable processing.
|
||||
3. **Idempotency**: Jobs use predictable IDs to prevent duplicate runs.
|
||||
4. **Overlap Protection**: In-memory locks prevent concurrent execution of the same job.
|
||||
|
||||
### Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ node-cron │────▶│ BullMQ Queue │────▶│ Worker │
|
||||
│ (Scheduler) │ │ (Job Store) │ │ (Processor) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Redis │
|
||||
│ (Persistence) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### BackgroundJobService
|
||||
|
||||
Located in `src/services/backgroundJobService.ts`:
|
||||
|
||||
```typescript
|
||||
import cron from 'node-cron';
|
||||
import type { Logger } from 'pino';
|
||||
import type { Queue } from 'bullmq';
|
||||
|
||||
export class BackgroundJobService {
|
||||
constructor(
|
||||
private personalizationRepo: PersonalizationRepository,
|
||||
private notificationRepo: NotificationRepository,
|
||||
private emailQueue: Queue<EmailJobData>,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
async runDailyDealCheck(): Promise<void> {
|
||||
this.logger.info('[BackgroundJob] Starting daily deal check...');
|
||||
|
||||
// 1. Fetch all deals for all users in one efficient query
|
||||
const allDeals = await this.personalizationRepo.getBestSalePricesForAllUsers(this.logger);
|
||||
|
||||
// 2. Group deals by user
|
||||
const dealsByUser = this.groupDealsByUser(allDeals);
|
||||
|
||||
// 3. Process each user's deals in parallel
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(dealsByUser.values()).map((userGroup) => this._processDealsForUser(userGroup)),
|
||||
);
|
||||
|
||||
// 4. Bulk insert notifications
|
||||
await this.bulkCreateNotifications(results);
|
||||
|
||||
this.logger.info('[BackgroundJob] Daily deal check completed.');
|
||||
}
|
||||
|
||||
async triggerAnalyticsReport(): Promise<string> {
|
||||
const reportDate = getCurrentDateISOString();
|
||||
const jobId = `manual-report-${reportDate}-${Date.now()}`;
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
||||
return job.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cron Job Initialization
|
||||
|
||||
```typescript
|
||||
// In-memory lock to prevent job overlap
|
||||
let isDailyDealCheckRunning = false;
|
||||
|
||||
export function startBackgroundJobs(
|
||||
backgroundJobService: BackgroundJobService,
|
||||
analyticsQueue: Queue,
|
||||
weeklyAnalyticsQueue: Queue,
|
||||
tokenCleanupQueue: Queue,
|
||||
logger: Logger,
|
||||
): void {
|
||||
// Daily deal check at 2:00 AM
|
||||
cron.schedule('0 2 * * *', () => {
|
||||
(async () => {
|
||||
if (isDailyDealCheckRunning) {
|
||||
logger.warn('[BackgroundJob] Daily deal check already running. Skipping.');
|
||||
return;
|
||||
}
|
||||
isDailyDealCheckRunning = true;
|
||||
try {
|
||||
await backgroundJobService.runDailyDealCheck();
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[BackgroundJob] Daily deal check failed.');
|
||||
} finally {
|
||||
isDailyDealCheckRunning = false;
|
||||
}
|
||||
})().catch((error) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Unhandled rejection in cron wrapper.');
|
||||
isDailyDealCheckRunning = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Daily analytics at 3:00 AM
|
||||
cron.schedule('0 3 * * *', () => {
|
||||
(async () => {
|
||||
const reportDate = getCurrentDateISOString();
|
||||
await analyticsQueue.add(
|
||||
'generate-daily-report',
|
||||
{ reportDate },
|
||||
{ jobId: `daily-report-${reportDate}` }, // Prevents duplicates
|
||||
);
|
||||
})().catch((error) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Analytics job enqueue failed.');
|
||||
});
|
||||
});
|
||||
|
||||
// Weekly analytics at 4:00 AM on Sundays
|
||||
cron.schedule('0 4 * * 0', () => {
|
||||
(async () => {
|
||||
const { year, week } = getSimpleWeekAndYear();
|
||||
await weeklyAnalyticsQueue.add(
|
||||
'generate-weekly-report',
|
||||
{ reportYear: year, reportWeek: week },
|
||||
{ jobId: `weekly-report-${year}-${week}` },
|
||||
);
|
||||
})().catch((error) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Weekly analytics enqueue failed.');
|
||||
});
|
||||
});
|
||||
|
||||
// Token cleanup at 5:00 AM
|
||||
cron.schedule('0 5 * * *', () => {
|
||||
(async () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
await tokenCleanupQueue.add(
|
||||
'cleanup-tokens',
|
||||
{ timestamp },
|
||||
{ jobId: `token-cleanup-${timestamp.split('T')[0]}` },
|
||||
);
|
||||
})().catch((error) => {
|
||||
logger.error({ err: error }, '[BackgroundJob] Token cleanup enqueue failed.');
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('[BackgroundJob] All cron jobs scheduled successfully.');
|
||||
}
|
||||
```
|
||||
|
||||
### Job Schedule Reference
|
||||
|
||||
| Job | Schedule | Queue | Purpose |
|
||||
| ---------------- | ---------------------------- | ---------------------- | --------------------------------- |
|
||||
| Daily Deal Check | `0 2 * * *` (2:00 AM) | Direct execution | Find price drops on watched items |
|
||||
| Daily Analytics | `0 3 * * *` (3:00 AM) | `analyticsQueue` | Generate daily statistics |
|
||||
| Weekly Analytics | `0 4 * * 0` (4:00 AM Sunday) | `weeklyAnalyticsQueue` | Generate weekly reports |
|
||||
| Token Cleanup | `0 5 * * *` (5:00 AM) | `tokenCleanupQueue` | Remove expired tokens |
|
||||
|
||||
### Cron Expression Reference
|
||||
|
||||
```text
|
||||
┌───────────── minute (0 - 59)
|
||||
│ ┌───────────── hour (0 - 23)
|
||||
│ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ ┌───────────── day of week (0 - 7, Sun = 0 or 7)
|
||||
│ │ │ │ │
|
||||
* * * * *
|
||||
|
||||
Examples:
|
||||
0 2 * * * = 2:00 AM every day
|
||||
0 4 * * 0 = 4:00 AM every Sunday
|
||||
*/15 * * * * = Every 15 minutes
|
||||
0 0 1 * * = Midnight on the 1st of each month
|
||||
```
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
The async IIFE wrapper with `.catch()` ensures that:
|
||||
|
||||
1. Errors in the job don't crash the cron scheduler
|
||||
2. Unhandled promise rejections are logged
|
||||
3. The lock is always released in the `finally` block
|
||||
|
||||
```typescript
|
||||
cron.schedule('0 2 * * *', () => {
|
||||
(async () => {
|
||||
// Job logic here
|
||||
})().catch((error) => {
|
||||
// Handle unhandled rejections from the async wrapper
|
||||
logger.error({ err: error }, 'Unhandled rejection');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Manual Trigger API
|
||||
|
||||
Admin endpoints allow manual triggering of scheduled jobs:
|
||||
|
||||
```typescript
|
||||
// src/routes/admin.routes.ts
|
||||
router.post('/jobs/daily-deals', isAdmin, async (req, res, next) => {
|
||||
await backgroundJobService.runDailyDealCheck();
|
||||
res.json({ message: 'Daily deal check triggered' });
|
||||
});
|
||||
|
||||
router.post('/jobs/analytics', isAdmin, async (req, res, next) => {
|
||||
const jobId = await backgroundJobService.triggerAnalyticsReport();
|
||||
res.json({ message: 'Analytics report queued', jobId });
|
||||
});
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Reliability**: Jobs run at predictable times without manual intervention.
|
||||
- **Idempotency**: Duplicate job prevention via job IDs.
|
||||
- **Observability**: All job activity is logged with structured logging.
|
||||
- **Flexibility**: Jobs can be triggered manually for testing or urgent runs.
|
||||
- **Separation**: Scheduling is decoupled from job execution (cron vs BullMQ).
|
||||
|
||||
### Negative
|
||||
|
||||
- **Single Server**: Cron runs on a single server instance. For multi-server deployments, consider distributed scheduling.
|
||||
- **Time Zone Dependency**: Cron times are server-local; consider UTC for distributed systems.
|
||||
- **In-Memory Locks**: Overlap protection is per-process, not cluster-wide.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/backgroundJobService.ts` - BackgroundJobService class and `startBackgroundJobs`
|
||||
- `src/services/queueService.server.ts` - BullMQ queue definitions
|
||||
- `src/services/workers.server.ts` - BullMQ worker processors
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Job Processing
|
||||
- [ADR-004](./0004-standardized-application-wide-structured-logging.md) - Structured Logging
|
||||
290
docs/adr/0038-graceful-shutdown-pattern.md
Normal file
290
docs/adr/0038-graceful-shutdown-pattern.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# ADR-038: Graceful Shutdown Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
When deploying or restarting the application, abrupt termination can cause:
|
||||
|
||||
1. **Lost Jobs**: BullMQ jobs in progress may be marked as failed or stalled.
|
||||
2. **Connection Leaks**: Database and Redis connections may not be properly closed.
|
||||
3. **Incomplete Requests**: HTTP requests in flight may receive no response.
|
||||
4. **Data Corruption**: Transactions may be left in an inconsistent state.
|
||||
|
||||
Kubernetes and PM2 send termination signals (SIGTERM, SIGINT) to processes before forcefully killing them. The application must handle these signals to shut down gracefully.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a coordinated graceful shutdown pattern that:
|
||||
|
||||
1. **Stops Accepting New Work**: Closes HTTP server, pauses job queues.
|
||||
2. **Completes In-Flight Work**: Waits for active requests and jobs to finish.
|
||||
3. **Releases Resources**: Closes database pools, Redis connections, and queues.
|
||||
4. **Logs Shutdown Progress**: Provides visibility into the shutdown process.
|
||||
|
||||
### Signal Handling
|
||||
|
||||
| Signal | Source | Behavior |
|
||||
| ------- | ------------------ | --------------------------------------- |
|
||||
| SIGTERM | Kubernetes, PM2 | Graceful shutdown with resource cleanup |
|
||||
| SIGINT | Ctrl+C in terminal | Same as SIGTERM |
|
||||
| SIGKILL | Force kill | Cannot be caught; immediate termination |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Queue and Worker Shutdown
|
||||
|
||||
Located in `src/services/queueService.server.ts`:
|
||||
|
||||
```typescript
|
||||
import { logger } from './logger.server';
|
||||
|
||||
export const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
logger.info(`[Shutdown] Received ${signal}. Closing all queues and workers...`);
|
||||
|
||||
const resources = [
|
||||
{ name: 'flyerQueue', close: () => flyerQueue.close() },
|
||||
{ name: 'emailQueue', close: () => emailQueue.close() },
|
||||
{ name: 'analyticsQueue', close: () => analyticsQueue.close() },
|
||||
{ name: 'weeklyAnalyticsQueue', close: () => weeklyAnalyticsQueue.close() },
|
||||
{ name: 'cleanupQueue', close: () => cleanupQueue.close() },
|
||||
{ name: 'tokenCleanupQueue', close: () => tokenCleanupQueue.close() },
|
||||
{ name: 'redisConnection', close: () => connection.quit() },
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
resources.map(async (resource) => {
|
||||
try {
|
||||
await resource.close();
|
||||
logger.info(`[Shutdown] ${resource.name} closed successfully.`);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, `[Shutdown] Error closing ${resource.name}`);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = results.filter((r) => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
logger.error(`[Shutdown] ${failures.length} resources failed to close.`);
|
||||
}
|
||||
|
||||
logger.info('[Shutdown] All resources closed. Process can now exit.');
|
||||
};
|
||||
|
||||
// Register signal handlers
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
```
|
||||
|
||||
### HTTP Server Shutdown
|
||||
|
||||
Located in `server.ts`:
|
||||
|
||||
```typescript
|
||||
import { gracefulShutdown as shutdownQueues } from './src/services/queueService.server';
|
||||
import { closePool } from './src/services/db/connection.db';
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`Server listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
logger.info(`[Shutdown] Received ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
// 1. Stop accepting new connections
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
logger.error({ err }, '[Shutdown] Error closing HTTP server');
|
||||
} else {
|
||||
logger.info('[Shutdown] HTTP server closed.');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Wait for in-flight requests (with timeout)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
// 3. Close queues and workers
|
||||
await shutdownQueues(signal);
|
||||
|
||||
// 4. Close database pool
|
||||
await closePool();
|
||||
logger.info('[Shutdown] Database pool closed.');
|
||||
|
||||
// 5. Exit process
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
```
|
||||
|
||||
### Database Pool Shutdown
|
||||
|
||||
Located in `src/services/db/connection.db.ts`:
|
||||
|
||||
```typescript
|
||||
let pool: Pool | null = null;
|
||||
|
||||
export function getPool(): Pool {
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
logger.info('[Database] Connection pool closed.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getPoolStatus(): { totalCount: number; idleCount: number; waitingCount: number } {
|
||||
const p = getPool();
|
||||
return {
|
||||
totalCount: p.totalCount,
|
||||
idleCount: p.idleCount,
|
||||
waitingCount: p.waitingCount,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### PM2 Ecosystem Configuration
|
||||
|
||||
Located in `ecosystem.config.cjs`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'flyer-crawler-api',
|
||||
script: 'server.ts',
|
||||
interpreter: 'tsx',
|
||||
|
||||
// Graceful shutdown settings
|
||||
kill_timeout: 10000, // 10 seconds to cleanup before SIGKILL
|
||||
wait_ready: true, // Wait for 'ready' signal before considering app started
|
||||
listen_timeout: 10000, // Timeout for ready signal
|
||||
|
||||
// Cluster mode for zero-downtime reloads
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
|
||||
// Environment variables
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000,
|
||||
},
|
||||
env_test: {
|
||||
NODE_ENV: 'test',
|
||||
PORT: 3001,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Worker Graceful Shutdown
|
||||
|
||||
BullMQ workers can be configured to wait for active jobs:
|
||||
|
||||
```typescript
|
||||
import { Worker } from 'bullmq';
|
||||
|
||||
const worker = new Worker('flyerQueue', processor, {
|
||||
connection,
|
||||
// Graceful shutdown: wait for active jobs before closing
|
||||
settings: {
|
||||
lockDuration: 30000, // Time before job is considered stalled
|
||||
stalledInterval: 5000, // Check for stalled jobs every 5s
|
||||
},
|
||||
});
|
||||
|
||||
// Workers auto-close when connection closes
|
||||
worker.on('closing', () => {
|
||||
logger.info('[Worker] flyerQueue worker is closing...');
|
||||
});
|
||||
|
||||
worker.on('closed', () => {
|
||||
logger.info('[Worker] flyerQueue worker closed.');
|
||||
});
|
||||
```
|
||||
|
||||
### Shutdown Sequence Diagram
|
||||
|
||||
```text
|
||||
SIGTERM Received
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Stop HTTP Server │ ← No new connections accepted
|
||||
│ (server.close()) │
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Wait for In-Flight │ ← 5-second grace period
|
||||
│ Requests │
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Close BullMQ Queues │ ← Stop processing new jobs
|
||||
│ and Workers │
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Close Redis │ ← Disconnect from Redis
|
||||
│ Connection │
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Close Database Pool │ ← Release all DB connections
|
||||
│ (pool.end()) │
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ process.exit(0) │ ← Clean exit
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero Lost Work**: In-flight requests and jobs complete before shutdown.
|
||||
- **Clean Resource Cleanup**: All connections are properly closed.
|
||||
- **Zero-Downtime Deploys**: PM2 can reload without dropping requests.
|
||||
- **Observability**: Shutdown progress is logged for debugging.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Shutdown Delay**: Takes 5-15 seconds to fully shutdown.
|
||||
- **Complexity**: Multiple shutdown handlers must be coordinated.
|
||||
- **Edge Cases**: Very long-running jobs may be killed if they exceed the grace period.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `server.ts` - HTTP server shutdown and signal handling
|
||||
- `src/services/queueService.server.ts` - Queue shutdown (`gracefulShutdown`)
|
||||
- `src/services/db/connection.db.ts` - Database pool shutdown (`closePool`)
|
||||
- `ecosystem.config.cjs` - PM2 configuration with `kill_timeout`
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Job Processing
|
||||
- [ADR-020](./0020-health-checks-and-liveness-readiness-probes.md) - Health Checks
|
||||
- [ADR-014](./0014-containerization-and-deployment-strategy.md) - Containerization
|
||||
278
docs/adr/0039-dependency-injection-pattern.md
Normal file
278
docs/adr/0039-dependency-injection-pattern.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# ADR-039: Dependency Injection Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
As the application grows, tightly coupled components become difficult to test and maintain. Common issues include:
|
||||
|
||||
1. **Hard-to-Test Code**: Components that instantiate their own dependencies cannot be easily unit tested with mocks.
|
||||
2. **Rigid Architecture**: Changing one implementation requires modifying all consumers.
|
||||
3. **Hidden Dependencies**: It's unclear what a component needs to function.
|
||||
4. **Circular Dependencies**: Tight coupling can lead to circular import issues.
|
||||
|
||||
Dependency Injection (DI) addresses these issues by inverting the control of dependency creation.
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a constructor-based dependency injection pattern for all services and repositories. This approach:
|
||||
|
||||
1. **Explicit Dependencies**: All dependencies are declared in the constructor.
|
||||
2. **Default Values**: Production dependencies have sensible defaults.
|
||||
3. **Testability**: Test code can inject mocks without modifying source code.
|
||||
4. **Loose Coupling**: Components depend on interfaces, not implementations.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Constructor Injection**: Dependencies are passed through constructors, not looked up globally.
|
||||
- **Default Production Dependencies**: Use default parameter values for production instances.
|
||||
- **Interface Segregation**: Depend on the minimal interface needed (e.g., `Pick<Pool, 'query'>`).
|
||||
- **Composition Root**: Wire dependencies at the application entry point.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Repository Pattern with DI
|
||||
|
||||
Located in `src/services/db/flyer.db.ts`:
|
||||
|
||||
```typescript
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
|
||||
export class FlyerRepository {
|
||||
// Accept any object with a 'query' method - Pool or PoolClient
|
||||
private db: Pick<Pool | PoolClient, 'query'>;
|
||||
|
||||
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async getFlyerById(flyerId: number, logger: Logger): Promise<Flyer> {
|
||||
const result = await this.db.query<Flyer>('SELECT * FROM flyers WHERE flyer_id = $1', [
|
||||
flyerId,
|
||||
]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async insertFlyer(flyer: FlyerDbInsert, logger: Logger): Promise<Flyer> {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in Production**:
|
||||
|
||||
```typescript
|
||||
// Uses default pool
|
||||
const flyerRepo = new FlyerRepository();
|
||||
```
|
||||
|
||||
**Usage in Tests**:
|
||||
|
||||
```typescript
|
||||
const mockDb = {
|
||||
query: vi.fn().mockResolvedValue({ rows: [mockFlyer] }),
|
||||
};
|
||||
const flyerRepo = new FlyerRepository(mockDb);
|
||||
```
|
||||
|
||||
**Usage in Transactions**:
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from './connection.db';
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
// Pass transactional client to repository
|
||||
const flyerRepo = new FlyerRepository(client);
|
||||
const flyer = await flyerRepo.insertFlyer(flyerData, logger);
|
||||
// ... more operations in the same transaction
|
||||
});
|
||||
```
|
||||
|
||||
### Service Layer with DI
|
||||
|
||||
Located in `src/services/backgroundJobService.ts`:
|
||||
|
||||
```typescript
|
||||
export class BackgroundJobService {
|
||||
constructor(
|
||||
private personalizationRepo: PersonalizationRepository,
|
||||
private notificationRepo: NotificationRepository,
|
||||
private emailQueue: Queue<EmailJobData>,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
async runDailyDealCheck(): Promise<void> {
|
||||
this.logger.info('[BackgroundJob] Starting daily deal check...');
|
||||
|
||||
const deals = await this.personalizationRepo.getBestSalePricesForAllUsers(this.logger);
|
||||
// ... process deals
|
||||
}
|
||||
}
|
||||
|
||||
// Composition root - wire production dependencies
|
||||
import { personalizationRepo, notificationRepo } from './db/index.db';
|
||||
import { logger } from './logger.server';
|
||||
import { emailQueue } from './queueService.server';
|
||||
|
||||
export const backgroundJobService = new BackgroundJobService(
|
||||
personalizationRepo,
|
||||
notificationRepo,
|
||||
emailQueue,
|
||||
logger,
|
||||
);
|
||||
```
|
||||
|
||||
**Testing with Mocks**:
|
||||
|
||||
```typescript
|
||||
describe('BackgroundJobService', () => {
|
||||
it('should process deals for all users', async () => {
|
||||
const mockPersonalizationRepo = {
|
||||
getBestSalePricesForAllUsers: vi.fn().mockResolvedValue([mockDeal]),
|
||||
};
|
||||
const mockNotificationRepo = {
|
||||
createBulkNotifications: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockEmailQueue = {
|
||||
add: vi.fn().mockResolvedValue({ id: 'job-1' }),
|
||||
};
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const service = new BackgroundJobService(
|
||||
mockPersonalizationRepo as any,
|
||||
mockNotificationRepo as any,
|
||||
mockEmailQueue as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
|
||||
await service.runDailyDealCheck();
|
||||
|
||||
expect(mockPersonalizationRepo.getBestSalePricesForAllUsers).toHaveBeenCalled();
|
||||
expect(mockEmailQueue.add).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Processing Service with DI
|
||||
|
||||
Located in `src/services/flyer/flyerProcessingService.ts`:
|
||||
|
||||
```typescript
|
||||
export class FlyerProcessingService {
|
||||
constructor(
|
||||
private fileHandler: FlyerFileHandler,
|
||||
private aiProcessor: FlyerAiProcessor,
|
||||
private fsAdapter: FileSystemAdapter,
|
||||
private cleanupQueue: Queue<CleanupJobData>,
|
||||
private dataTransformer: FlyerDataTransformer,
|
||||
private persistenceService: FlyerPersistenceService,
|
||||
) {}
|
||||
|
||||
async processFlyer(filePath: string, logger: Logger): Promise<ProcessedFlyer> {
|
||||
// Use injected dependencies
|
||||
const fileInfo = await this.fileHandler.extractMetadata(filePath);
|
||||
const aiResult = await this.aiProcessor.analyze(filePath, logger);
|
||||
const transformed = this.dataTransformer.transform(aiResult);
|
||||
const saved = await this.persistenceService.save(transformed, logger);
|
||||
|
||||
// Queue cleanup
|
||||
await this.cleanupQueue.add('cleanup', { filePath });
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
// Composition root
|
||||
const flyerProcessingService = new FlyerProcessingService(
|
||||
new FlyerFileHandler(fsAdapter, execAsync),
|
||||
new FlyerAiProcessor(aiService, db.personalizationRepo),
|
||||
fsAdapter,
|
||||
cleanupQueue,
|
||||
new FlyerDataTransformer(),
|
||||
new FlyerPersistenceService(),
|
||||
);
|
||||
```
|
||||
|
||||
### Interface Segregation
|
||||
|
||||
Use the minimum interface required:
|
||||
|
||||
```typescript
|
||||
// Bad - depends on full Pool
|
||||
constructor(pool: Pool) {}
|
||||
|
||||
// Good - depends only on what's needed
|
||||
constructor(db: Pick<Pool | PoolClient, 'query'>) {}
|
||||
```
|
||||
|
||||
This allows injecting either a `Pool`, `PoolClient` (for transactions), or a mock object with just a `query` method.
|
||||
|
||||
### Composition Root Pattern
|
||||
|
||||
Wire all dependencies at application startup:
|
||||
|
||||
```typescript
|
||||
// src/services/db/index.db.ts - Composition root for repositories
|
||||
import { getPool } from './connection.db';
|
||||
|
||||
export const userRepo = new UserRepository(getPool());
|
||||
export const flyerRepo = new FlyerRepository(getPool());
|
||||
export const adminRepo = new AdminRepository(getPool());
|
||||
export const personalizationRepo = new PersonalizationRepository(getPool());
|
||||
export const notificationRepo = new NotificationRepository(getPool());
|
||||
|
||||
export const db = {
|
||||
userRepo,
|
||||
flyerRepo,
|
||||
adminRepo,
|
||||
personalizationRepo,
|
||||
notificationRepo,
|
||||
};
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Testability**: Unit tests can inject mocks without modifying production code.
|
||||
- **Flexibility**: Swap implementations (e.g., different database adapters) easily.
|
||||
- **Explicit Dependencies**: Clear contract of what a component needs.
|
||||
- **Transaction Support**: Repositories can participate in transactions by accepting a client.
|
||||
|
||||
### Negative
|
||||
|
||||
- **More Boilerplate**: Constructors become longer with many dependencies.
|
||||
- **Composition Complexity**: Must wire dependencies somewhere (composition root).
|
||||
- **No Runtime Type Checking**: TypeScript types are erased at runtime.
|
||||
|
||||
### Mitigation
|
||||
|
||||
For complex services with many dependencies, consider:
|
||||
|
||||
1. **Factory Functions**: Encapsulate construction logic.
|
||||
2. **Dependency Groups**: Pass related dependencies as a single object.
|
||||
3. **DI Containers**: For very large applications, consider a DI library like `tsyringe` or `inversify`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/db/*.db.ts` - Repository classes with constructor DI
|
||||
- `src/services/db/index.db.ts` - Composition root for repositories
|
||||
- `src/services/backgroundJobService.ts` - Service class with constructor DI
|
||||
- `src/services/flyer/flyerProcessingService.ts` - Complex service with multiple dependencies
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-002](./0002-standardized-transaction-management.md) - Transaction Management
|
||||
- [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern Standards
|
||||
- [ADR-035](./0035-service-layer-architecture.md) - Service Layer Architecture
|
||||
214
docs/adr/0040-testing-economics-and-priorities.md
Normal file
214
docs/adr/0040-testing-economics-and-priorities.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# ADR-040: Testing Economics and Priorities
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ADR-010 established the testing strategy and standards. However, it does not address the economic trade-offs of testing: when the cost of writing and maintaining tests exceeds their value. This document provides practical guidance on where to invest testing effort for maximum return.
|
||||
|
||||
## Decision
|
||||
|
||||
We adopt a **value-based testing approach** that prioritizes tests based on:
|
||||
|
||||
1. Risk of the code path (what breaks if this fails?)
|
||||
2. Stability of the code (how often does this change?)
|
||||
3. Complexity of the logic (can a human easily verify correctness?)
|
||||
4. Cost of the test (setup complexity, execution time, maintenance burden)
|
||||
|
||||
## Testing Investment Matrix
|
||||
|
||||
| Test Type | Investment Level | When to Write | When to Skip |
|
||||
| --------------- | ------------------- | ------------------------------- | --------------------------------- |
|
||||
| **E2E** | Minimal (5 tests) | Critical user flows only | Everything else |
|
||||
| **Integration** | Moderate (17 tests) | API contracts, auth, DB queries | Internal service wiring |
|
||||
| **Unit** | High (185+ tests) | Business logic, utilities | Defensive fallbacks, trivial code |
|
||||
|
||||
## High-Value Tests (Always Write)
|
||||
|
||||
### E2E Tests (Budget: 5-10 tests total)
|
||||
|
||||
Write E2E tests for flows where failure means:
|
||||
|
||||
- Users cannot sign up or log in
|
||||
- Users cannot complete the core value proposition (upload flyer → see deals)
|
||||
- Money or data is at risk
|
||||
|
||||
**Current E2E coverage is appropriate:**
|
||||
|
||||
- `auth.e2e.test.ts` - Registration, login, password reset
|
||||
- `flyer-upload.e2e.test.ts` - Complete upload pipeline
|
||||
- `user-journey.e2e.test.ts` - Full user workflow
|
||||
- `admin-authorization.e2e.test.ts` - Admin access control
|
||||
- `admin-dashboard.e2e.test.ts` - Admin operations
|
||||
|
||||
**Do NOT add E2E tests for:**
|
||||
|
||||
- UI variations or styling
|
||||
- Edge cases (handle in unit tests)
|
||||
- Features that can be tested faster at a lower level
|
||||
|
||||
### Integration Tests (Budget: 15-25 tests)
|
||||
|
||||
Write integration tests for:
|
||||
|
||||
- Every public API endpoint (contract testing)
|
||||
- Authentication and authorization flows
|
||||
- Database queries that involve joins or complex logic
|
||||
- Middleware behavior (rate limiting, validation)
|
||||
|
||||
**Current integration coverage is appropriate:**
|
||||
|
||||
- Auth, admin, user routes
|
||||
- Flyer processing pipeline
|
||||
- Shopping lists, budgets, recipes
|
||||
- Gamification and notifications
|
||||
|
||||
**Do NOT add integration tests for:**
|
||||
|
||||
- Internal service-to-service calls (mock at boundaries)
|
||||
- Simple CRUD operations (test the repository pattern once)
|
||||
- UI components (use unit tests)
|
||||
|
||||
### Unit Tests (Budget: Proportional to complexity)
|
||||
|
||||
Write unit tests for:
|
||||
|
||||
- **Pure functions and utilities** - High value, easy to test
|
||||
- **Business logic in services** - Medium-high value
|
||||
- **React components** - Rendering, user interactions, state changes
|
||||
- **Custom hooks** - Data transformation, side effects
|
||||
- **Validators and parsers** - Edge cases matter here
|
||||
|
||||
## Low-Value Tests (Skip or Defer)
|
||||
|
||||
### Tests That Cost More Than They're Worth
|
||||
|
||||
1. **Defensive fallback code protected by types**
|
||||
|
||||
```typescript
|
||||
// This fallback can never execute if types are correct
|
||||
const name = store.name || 'Unknown'; // store.name is required
|
||||
```
|
||||
|
||||
- If you need `as any` to test it, the type system already prevents it
|
||||
- Either remove the fallback or accept the coverage gap
|
||||
|
||||
2. **Switch/case default branches for exhaustive enums**
|
||||
|
||||
```typescript
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'yellow';
|
||||
case 'complete':
|
||||
return 'green';
|
||||
default:
|
||||
return ''; // TypeScript prevents this
|
||||
}
|
||||
```
|
||||
|
||||
- The default exists for safety, not for execution
|
||||
- Don't test impossible states
|
||||
|
||||
3. **Trivial component variations**
|
||||
- Testing every tab in a tab panel when they share logic
|
||||
- Testing loading states that just show a spinner
|
||||
- Testing disabled button states (test the logic that disables, not the disabled state)
|
||||
|
||||
4. **Tests requiring excessive mock setup**
|
||||
- If test setup is longer than test assertions, reconsider
|
||||
- Per ADR-010: "Excessive mock setup" is a code smell
|
||||
|
||||
5. **Framework behavior verification**
|
||||
- React rendering, React Query caching, Router navigation
|
||||
- Trust the framework; test your code
|
||||
|
||||
### Coverage Gaps to Accept
|
||||
|
||||
The following coverage gaps are acceptable and should NOT be closed with tests:
|
||||
|
||||
| Pattern | Reason | Alternative |
|
||||
| ------------------------------------------ | ------------------------- | ----------------------------- |
|
||||
| `value \|\| 'default'` for required fields | Type system prevents | Remove fallback or accept gap |
|
||||
| `catch (error) { ... }` for typed APIs | Error types are known | Test the expected error types |
|
||||
| `default:` in exhaustive switches | TypeScript exhaustiveness | Accept gap |
|
||||
| Logging statements | Observability, not logic | No test needed |
|
||||
| Feature flags / environment checks | Tested by deployment | Config tests if complex |
|
||||
|
||||
## Time Budget Guidelines
|
||||
|
||||
For a typical feature (new API endpoint + UI):
|
||||
|
||||
| Activity | Time Budget | Notes |
|
||||
| --------------------------------------- | ----------- | ------------------------------------- |
|
||||
| Unit tests (component + hook + utility) | 30-45 min | Write alongside code |
|
||||
| Integration test (API contract) | 15-20 min | One test per endpoint |
|
||||
| E2E test | 0 min | Only for critical paths |
|
||||
| Total testing overhead | ~1 hour | Should not exceed implementation time |
|
||||
|
||||
**Rule of thumb**: If testing takes longer than implementation, you're either:
|
||||
|
||||
1. Testing too much
|
||||
2. Writing tests that are too complex
|
||||
3. Testing code that should be refactored
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
We explicitly reject arbitrary coverage percentage targets. Instead:
|
||||
|
||||
| Metric | Target | Rationale |
|
||||
| ---------------------- | --------------- | -------------------------------------- |
|
||||
| Statement coverage | No target | High coverage ≠ quality tests |
|
||||
| Branch coverage | No target | Many branches are defensive/impossible |
|
||||
| E2E test count | 5-10 | Critical paths only |
|
||||
| Integration test count | 15-25 | API contracts |
|
||||
| Unit test files | 1:1 with source | Colocated, proportional |
|
||||
|
||||
## When to Add Tests to Existing Code
|
||||
|
||||
Add tests when:
|
||||
|
||||
1. **Fixing a bug** - Add a test that would have caught it
|
||||
2. **Refactoring** - Add tests before changing behavior
|
||||
3. **Code review feedback** - Reviewer identifies risk
|
||||
4. **Production incident** - Prevent recurrence
|
||||
|
||||
Do NOT add tests:
|
||||
|
||||
1. To increase coverage percentages
|
||||
2. For code that hasn't changed in 6+ months
|
||||
3. For code scheduled for deletion/replacement
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- Testing effort focuses on high-risk, high-value code
|
||||
- Developers spend less time on low-value tests
|
||||
- Test suite runs faster (fewer unnecessary tests)
|
||||
- Maintenance burden decreases
|
||||
|
||||
**Negative:**
|
||||
|
||||
- Some defensive code paths remain untested
|
||||
- Coverage percentages may not satisfy external audits
|
||||
- Requires judgment calls that may be inconsistent
|
||||
|
||||
## Key Files
|
||||
|
||||
- `docs/adr/0010-testing-strategy-and-standards.md` - Testing mechanics
|
||||
- `vitest.config.ts` - Coverage configuration
|
||||
- `src/tests/` - Test utilities and setup
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before adding a new test, ask:
|
||||
|
||||
1. [ ] What user-visible behavior does this test protect?
|
||||
2. [ ] Can this be tested at a lower level (unit vs integration)?
|
||||
3. [ ] Does this test require `as any` or mock gymnastics?
|
||||
4. [ ] Will this test break when implementation changes (brittle)?
|
||||
5. [ ] Is the test setup simpler than the code being tested?
|
||||
|
||||
If any answer suggests low value, skip the test or simplify.
|
||||
291
docs/adr/0041-ai-gemini-integration-architecture.md
Normal file
291
docs/adr/0041-ai-gemini-integration-architecture.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# ADR-041: AI/Gemini Integration Architecture
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application relies heavily on Google Gemini AI for core functionality:
|
||||
|
||||
1. **Flyer Processing**: Extracting store names, dates, addresses, and individual sale items from uploaded flyer images.
|
||||
2. **Receipt Analysis**: Parsing purchased items and prices from receipt images.
|
||||
3. **Recipe Suggestions**: Generating recipe ideas based on available ingredients.
|
||||
4. **Text Extraction**: OCR-style extraction from cropped image regions.
|
||||
|
||||
These AI operations have unique challenges:
|
||||
|
||||
- **Rate Limits**: Google AI API enforces requests-per-minute (RPM) limits.
|
||||
- **Quota Buckets**: Different model families (stable, preview, experimental) have separate quotas.
|
||||
- **Model Availability**: Models may be unavailable due to regional restrictions, updates, or high load.
|
||||
- **Cost Variability**: Different models have different pricing (Flash-Lite vs Pro).
|
||||
- **Output Limits**: Some models have 8k token limits, others 65k.
|
||||
- **Testability**: Tests must not make real API calls.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a centralized `AIService` class with:
|
||||
|
||||
1. **Dependency Injection**: AI client and filesystem are injectable for testability.
|
||||
2. **Model Fallback Chain**: Automatic failover through prioritized model lists.
|
||||
3. **Rate Limiting**: Per-instance rate limiter using `p-ratelimit`.
|
||||
4. **Tiered Model Selection**: Different model lists for different task types.
|
||||
5. **Environment-Aware Mocking**: Automatic mock client in test environments.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Single Responsibility**: `AIService` handles all AI interactions.
|
||||
- **Fail-Safe Fallbacks**: If a model fails, try the next one in the chain.
|
||||
- **Cost Optimization**: Use cheaper "lite" models for simple text tasks.
|
||||
- **Structured Logging**: Log all AI interactions with timing and model info.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### AIService Class Structure
|
||||
|
||||
Located in `src/services/aiService.server.ts`:
|
||||
|
||||
```typescript
|
||||
interface IAiClient {
|
||||
generateContent(request: {
|
||||
contents: Content[];
|
||||
tools?: Tool[];
|
||||
useLiteModels?: boolean;
|
||||
}): Promise<GenerateContentResponse>;
|
||||
}
|
||||
|
||||
interface IFileSystem {
|
||||
readFile(path: string): Promise<Buffer>;
|
||||
}
|
||||
|
||||
export class AIService {
|
||||
private aiClient: IAiClient;
|
||||
private fs: IFileSystem;
|
||||
private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
|
||||
// If aiClient provided: use it (unit test)
|
||||
// Else if test environment: use internal mock (integration test)
|
||||
// Else: create real GoogleGenAI client (production)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tiered Model Lists
|
||||
|
||||
Models are organized by task complexity and quota bucket:
|
||||
|
||||
```typescript
|
||||
// For image processing (vision + long output)
|
||||
private readonly models = [
|
||||
// Tier A: Fast & Stable
|
||||
'gemini-2.5-flash', // Primary, 65k output
|
||||
'gemini-2.5-flash-lite', // Cost-saver, 65k output
|
||||
|
||||
// Tier B: Heavy Lifters
|
||||
'gemini-2.5-pro', // Complex layouts, 65k output
|
||||
|
||||
// Tier C: Preview Bucket (separate quota)
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3-pro-preview',
|
||||
|
||||
// Tier D: Experimental Bucket
|
||||
'gemini-exp-1206',
|
||||
|
||||
// Tier E: Last Resort
|
||||
'gemma-3-27b-it',
|
||||
'gemini-2.0-flash-exp', // WARNING: 8k limit
|
||||
];
|
||||
|
||||
// For simple text tasks (recipes, categorization)
|
||||
private readonly models_lite = [
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.0-flash-lite-001',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemma-3-12b-it',
|
||||
'gemma-3-4b-it',
|
||||
'gemini-2.0-flash-exp',
|
||||
];
|
||||
```
|
||||
|
||||
### Fallback with Retry Logic
|
||||
|
||||
```typescript
|
||||
private async _generateWithFallback(
|
||||
genAI: GoogleGenAI,
|
||||
request: { contents: Content[]; tools?: Tool[] },
|
||||
models: string[],
|
||||
): Promise<GenerateContentResponse> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const modelName of models) {
|
||||
try {
|
||||
return await genAI.models.generateContent({ model: modelName, ...request });
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = extractErrorMessage(error);
|
||||
const isRetriable = [
|
||||
'quota', '429', '503', 'resource_exhausted',
|
||||
'overloaded', 'unavailable', 'not found'
|
||||
].some(term => errorMsg.toLowerCase().includes(term));
|
||||
|
||||
if (isRetriable) {
|
||||
this.logger.warn(`Model '${modelName}' failed, trying next...`);
|
||||
lastError = new Error(errorMsg);
|
||||
continue;
|
||||
}
|
||||
throw error; // Non-retriable error
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('All AI models failed.');
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
const requestsPerMinute = parseInt(process.env.GEMINI_RPM || '5', 10);
|
||||
this.rateLimiter = pRateLimit({
|
||||
interval: 60 * 1000,
|
||||
rate: requestsPerMinute,
|
||||
concurrency: requestsPerMinute,
|
||||
});
|
||||
|
||||
// Usage:
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({ contents: [...] })
|
||||
);
|
||||
```
|
||||
|
||||
### Test Environment Detection
|
||||
|
||||
```typescript
|
||||
const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.VITEST_POOL_ID;
|
||||
|
||||
if (aiClient) {
|
||||
// Unit test: use provided mock
|
||||
this.aiClient = aiClient;
|
||||
} else if (isTestEnvironment) {
|
||||
// Integration test: use internal mock
|
||||
this.aiClient = {
|
||||
generateContent: async () => ({
|
||||
text: JSON.stringify(this.getMockFlyerData()),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Production: use real client
|
||||
const genAI = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
this.aiClient = { generateContent: /* adapter */ };
|
||||
}
|
||||
```
|
||||
|
||||
### Prompt Engineering
|
||||
|
||||
Prompts are constructed with:
|
||||
|
||||
1. **Clear Task Definition**: What to extract and in what format.
|
||||
2. **Structured Output Requirements**: JSON schema with field descriptions.
|
||||
3. **Examples**: Concrete examples of expected output.
|
||||
4. **Context Hints**: User location for store address resolution.
|
||||
|
||||
```typescript
|
||||
private _buildFlyerExtractionPrompt(
|
||||
masterItems: MasterGroceryItem[],
|
||||
submitterIp?: string,
|
||||
userProfileAddress?: string,
|
||||
): string {
|
||||
// Location hint for address resolution
|
||||
let locationHint = '';
|
||||
if (userProfileAddress) {
|
||||
locationHint = `The user has profile address "${userProfileAddress}"...`;
|
||||
}
|
||||
|
||||
// Simplified master item list (reduce token usage)
|
||||
const simplifiedMasterList = masterItems.map(item => ({
|
||||
id: item.master_grocery_item_id,
|
||||
name: item.name,
|
||||
}));
|
||||
|
||||
return `
|
||||
# TASK
|
||||
Analyze the flyer image(s) and extract...
|
||||
|
||||
# RULES
|
||||
1. Extract store_name, valid_from, valid_to, store_address
|
||||
2. Extract items array with item, price_display, price_in_cents...
|
||||
|
||||
# EXAMPLES
|
||||
- { "item": "Red Grapes", "price_display": "$1.99 /lb", ... }
|
||||
|
||||
# MASTER LIST
|
||||
${JSON.stringify(simplifiedMasterList)}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### Response Parsing
|
||||
|
||||
AI responses may contain markdown, trailing text, or formatting issues:
|
||||
|
||||
````typescript
|
||||
private _parseJsonFromAiResponse<T>(responseText: string | undefined, logger: Logger): T | null {
|
||||
if (!responseText) return null;
|
||||
|
||||
// Try to extract from markdown code block
|
||||
const markdownMatch = responseText.match(/```(json)?\s*([\s\S]*?)\s*```/);
|
||||
let jsonString = markdownMatch?.[2]?.trim() || responseText;
|
||||
|
||||
// Find JSON boundaries
|
||||
const startIndex = Math.min(
|
||||
jsonString.indexOf('{') >= 0 ? jsonString.indexOf('{') : Infinity,
|
||||
jsonString.indexOf('[') >= 0 ? jsonString.indexOf('[') : Infinity
|
||||
);
|
||||
const endIndex = Math.max(jsonString.lastIndexOf('}'), jsonString.lastIndexOf(']'));
|
||||
|
||||
if (startIndex === Infinity || endIndex === -1) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonString.substring(startIndex, endIndex + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Resilience**: Automatic failover when models are unavailable or rate-limited.
|
||||
- **Cost Control**: Uses cheaper models for simple tasks.
|
||||
- **Testability**: Full mock support for unit and integration tests.
|
||||
- **Observability**: Detailed logging of all AI operations with timing.
|
||||
- **Maintainability**: Centralized AI logic in one service.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Model List Maintenance**: Must update model lists when new models release.
|
||||
- **Complexity**: Fallback logic adds complexity.
|
||||
- **Delayed Failures**: May take longer to fail if all models are down.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Monitor model deprecation announcements from Google.
|
||||
- Add health checks that validate AI connectivity on startup.
|
||||
- Consider caching successful model selections per task type.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/aiService.server.ts` - Main AIService class
|
||||
- `src/services/aiService.server.test.ts` - Unit tests with mocked AI client
|
||||
- `src/services/aiApiClient.ts` - Low-level API client wrapper
|
||||
- `src/services/aiAnalysisService.ts` - Higher-level analysis orchestration
|
||||
- `src/types/ai.ts` - Zod schemas for AI response validation
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) - Naming Conventions for AI Types
|
||||
- [ADR-039](./0039-dependency-injection-pattern.md) - Dependency Injection Pattern
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling
|
||||
329
docs/adr/0042-email-and-notification-architecture.md
Normal file
329
docs/adr/0042-email-and-notification-architecture.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# ADR-042: Email and Notification Architecture
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application sends emails for multiple purposes:
|
||||
|
||||
1. **Transactional Emails**: Password reset, welcome emails, account verification.
|
||||
2. **Deal Notifications**: Alerting users when watched items go on sale.
|
||||
3. **Bulk Communications**: System announcements, marketing (future).
|
||||
|
||||
Email delivery has unique challenges:
|
||||
|
||||
- **Reliability**: Emails must be delivered even if the main request fails.
|
||||
- **Rate Limits**: SMTP servers enforce sending limits.
|
||||
- **Retry Logic**: Failed emails should be retried with backoff.
|
||||
- **Templating**: Emails need consistent branding and formatting.
|
||||
- **Testing**: Tests should not send real emails.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a queue-based email system using:
|
||||
|
||||
1. **Nodemailer**: For SMTP transport and email composition.
|
||||
2. **BullMQ**: For job queuing, retry logic, and rate limiting.
|
||||
3. **Dedicated Worker**: Background process for email delivery.
|
||||
4. **Structured Logging**: Job-scoped logging for debugging.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Asynchronous Delivery**: Queue emails immediately, deliver asynchronously.
|
||||
- **Idempotent Jobs**: Jobs can be retried safely.
|
||||
- **Separation of Concerns**: Email composition separate from delivery.
|
||||
- **Environment-Aware**: Disable real sending in test environments.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Email Service Structure
|
||||
|
||||
Located in `src/services/emailService.server.ts`:
|
||||
|
||||
```typescript
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
// SMTP transporter configured from environment
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Email Job Data Structure
|
||||
|
||||
```typescript
|
||||
// src/types/job-data.ts
|
||||
export interface EmailJobData {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Core Send Function
|
||||
|
||||
```typescript
|
||||
export const sendEmail = async (options: EmailJobData, logger: Logger) => {
|
||||
const mailOptions = {
|
||||
from: `"Flyer Crawler" <${process.env.SMTP_FROM_EMAIL}>`,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html,
|
||||
};
|
||||
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
logger.info(
|
||||
{ to: options.to, subject: options.subject, messageId: info.messageId },
|
||||
'Email sent successfully.',
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Job Processor
|
||||
|
||||
```typescript
|
||||
export const processEmailJob = async (job: Job<EmailJobData>) => {
|
||||
// Create child logger with job context
|
||||
const jobLogger = globalLogger.child({
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
recipient: job.data.to,
|
||||
});
|
||||
|
||||
jobLogger.info('Picked up email job.');
|
||||
|
||||
try {
|
||||
await sendEmail(job.data, jobLogger);
|
||||
} catch (error) {
|
||||
const wrappedError = error instanceof Error ? error : new Error(String(error));
|
||||
jobLogger.error({ err: wrappedError, attemptsMade: job.attemptsMade }, 'Email job failed.');
|
||||
throw wrappedError; // BullMQ will retry
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Specialized Email Functions
|
||||
|
||||
#### Password Reset
|
||||
|
||||
```typescript
|
||||
export const sendPasswordResetEmail = async (to: string, token: string, logger: Logger) => {
|
||||
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Click the link below to set a new password. This link expires in 1 hour.</p>
|
||||
<a href="${resetUrl}" style="background-color: #007bff; color: white; padding: 14px 25px; ...">
|
||||
Reset Your Password
|
||||
</a>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await sendEmail({ to, subject: 'Your Password Reset Request', text: '...', html }, logger);
|
||||
};
|
||||
```
|
||||
|
||||
#### Welcome Email
|
||||
|
||||
```typescript
|
||||
export const sendWelcomeEmail = async (to: string, name: string | null, logger: Logger) => {
|
||||
const recipientName = name || 'there';
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h2>Welcome!</h2>
|
||||
<p>Hello ${recipientName},</p>
|
||||
<p>Thank you for joining Flyer Crawler.</p>
|
||||
<p>Start by uploading your first flyer to see how much you can save!</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await sendEmail({ to, subject: 'Welcome to Flyer Crawler!', text: '...', html }, logger);
|
||||
};
|
||||
```
|
||||
|
||||
#### Deal Notifications
|
||||
|
||||
```typescript
|
||||
export const sendDealNotificationEmail = async (
|
||||
to: string,
|
||||
name: string | null,
|
||||
deals: WatchedItemDeal[],
|
||||
logger: Logger,
|
||||
) => {
|
||||
const dealsListHtml = deals
|
||||
.map(
|
||||
(deal) => `
|
||||
<li>
|
||||
<strong>${deal.item_name}</strong> is on sale for
|
||||
<strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong>
|
||||
at ${deal.store_name}!
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const html = `
|
||||
<h1>Hi ${name || 'there'},</h1>
|
||||
<p>We found great deals on items you're watching:</p>
|
||||
<ul>${dealsListHtml}</ul>
|
||||
<p>Check them out on the deals page!</p>
|
||||
`;
|
||||
|
||||
await sendEmail({ to, subject: 'New Deals Found!', text: '...', html }, logger);
|
||||
};
|
||||
```
|
||||
|
||||
### Queue Configuration
|
||||
|
||||
Located in `src/services/queueService.server.ts`:
|
||||
|
||||
```typescript
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { processEmailJob } from './emailService.server';
|
||||
|
||||
export const emailQueue = new Queue<EmailJobData>('email', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
});
|
||||
|
||||
// Worker to process email jobs
|
||||
const emailWorker = new Worker('email', processEmailJob, {
|
||||
connection: redisConnection,
|
||||
concurrency: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### Enqueueing Emails
|
||||
|
||||
```typescript
|
||||
// From backgroundJobService.ts
|
||||
await emailQueue.add('deal-notification', {
|
||||
to: user.email,
|
||||
subject: 'New Deals Found!',
|
||||
text: textContent,
|
||||
html: htmlContent,
|
||||
});
|
||||
```
|
||||
|
||||
### Background Job Integration
|
||||
|
||||
Located in `src/services/backgroundJobService.ts`:
|
||||
|
||||
```typescript
|
||||
export class BackgroundJobService {
|
||||
constructor(
|
||||
private personalizationRepo: PersonalizationRepository,
|
||||
private notificationRepo: NotificationRepository,
|
||||
private emailQueue: Queue<EmailJobData>,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
async runDailyDealCheck(): Promise<void> {
|
||||
this.logger.info('Starting daily deal check...');
|
||||
|
||||
const deals = await this.personalizationRepo.getBestSalePricesForAllUsers(this.logger);
|
||||
|
||||
for (const userDeals of deals) {
|
||||
await this.emailQueue.add('deal-notification', {
|
||||
to: userDeals.email,
|
||||
subject: 'New Deals Found!',
|
||||
text: '...',
|
||||
html: '...',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=user@example.com
|
||||
SMTP_PASS=secret
|
||||
SMTP_FROM_EMAIL=noreply@flyer-crawler.com
|
||||
|
||||
# Frontend URL for email links
|
||||
FRONTEND_URL=https://flyer-crawler.com
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Reliability**: Failed emails are automatically retried with exponential backoff.
|
||||
- **Scalability**: Queue can handle burst traffic without overwhelming SMTP.
|
||||
- **Observability**: Job-scoped logging enables easy debugging.
|
||||
- **Separation**: Email composition is decoupled from delivery timing.
|
||||
- **Testability**: Can mock the queue or use Ethereal for testing.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Complexity**: Adds queue infrastructure dependency (Redis).
|
||||
- **Delayed Delivery**: Emails are not instant (queued first).
|
||||
- **Monitoring Required**: Need to monitor queue depth and failure rates.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Use Bull Board UI for queue monitoring (already implemented).
|
||||
- Set up alerts for queue depth and failure rate thresholds.
|
||||
- Consider Ethereal or MailHog for development/testing.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```typescript
|
||||
// Unit test with mocked queue
|
||||
const mockEmailQueue = {
|
||||
add: vi.fn().mockResolvedValue({ id: 'job-1' }),
|
||||
};
|
||||
|
||||
const service = new BackgroundJobService(
|
||||
mockPersonalizationRepo,
|
||||
mockNotificationRepo,
|
||||
mockEmailQueue as any,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
await service.runDailyDealCheck();
|
||||
expect(mockEmailQueue.add).toHaveBeenCalledWith('deal-notification', expect.any(Object));
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/services/emailService.server.ts` - Email composition and sending
|
||||
- `src/services/queueService.server.ts` - Queue configuration and workers
|
||||
- `src/services/backgroundJobService.ts` - Scheduled deal notifications
|
||||
- `src/types/job-data.ts` - Email job data types
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Job Processing
|
||||
- [ADR-004](./0004-standardized-application-wide-structured-logging.md) - Structured Logging
|
||||
- [ADR-039](./0039-dependency-injection-pattern.md) - Dependency Injection
|
||||
392
docs/adr/0043-express-middleware-pipeline.md
Normal file
392
docs/adr/0043-express-middleware-pipeline.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# ADR-043: Express Middleware Pipeline Architecture
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The Express application uses a layered middleware pipeline to handle cross-cutting concerns:
|
||||
|
||||
1. **Security**: Helmet headers, CORS, rate limiting.
|
||||
2. **Parsing**: JSON body, URL-encoded, cookies.
|
||||
3. **Authentication**: Session management, JWT verification.
|
||||
4. **Validation**: Request body/params validation.
|
||||
5. **File Handling**: Multipart form data, file uploads.
|
||||
6. **Error Handling**: Centralized error responses.
|
||||
|
||||
Middleware ordering is critical - incorrect ordering can cause security vulnerabilities or broken functionality. This ADR documents the canonical middleware order and patterns.
|
||||
|
||||
## Decision
|
||||
|
||||
We will establish a strict middleware ordering convention:
|
||||
|
||||
1. **Security First**: Security headers and protections apply to all requests.
|
||||
2. **Parsing Before Logic**: Body/cookie parsing before route handlers.
|
||||
3. **Auth Before Routes**: Authentication middleware before protected routes.
|
||||
4. **Validation At Route Level**: Per-route validation middleware.
|
||||
5. **Error Handler Last**: Centralized error handling catches all errors.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Defense in Depth**: Multiple security layers.
|
||||
- **Fail-Fast**: Reject bad requests early in the pipeline.
|
||||
- **Explicit Ordering**: Document and enforce middleware order.
|
||||
- **Route-Level Flexibility**: Specific middleware per route as needed.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Global Middleware Order
|
||||
|
||||
Located in `src/server.ts`:
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { requestTimeoutMiddleware } from './middleware/timeout.middleware';
|
||||
import { rateLimiter } from './middleware/rateLimit.middleware';
|
||||
import { errorHandler } from './middleware/errorHandler.middleware';
|
||||
|
||||
const app = express();
|
||||
|
||||
// ============================================
|
||||
// LAYER 1: Security Headers & Protections
|
||||
// ============================================
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// LAYER 2: Request Limits & Timeouts
|
||||
// ============================================
|
||||
app.use(requestTimeoutMiddleware(30000)); // 30s default
|
||||
app.use(rateLimiter); // Rate limiting per IP
|
||||
|
||||
// ============================================
|
||||
// LAYER 3: Body & Cookie Parsing
|
||||
// ============================================
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// ============================================
|
||||
// LAYER 4: Static Assets (before auth)
|
||||
// ============================================
|
||||
app.use('/flyer-images', express.static('flyer-images'));
|
||||
|
||||
// ============================================
|
||||
// LAYER 5: Authentication Setup
|
||||
// ============================================
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// ============================================
|
||||
// LAYER 6: Routes (with per-route middleware)
|
||||
// ============================================
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/flyers', flyerRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
// ... more routes
|
||||
|
||||
// ============================================
|
||||
// LAYER 7: Error Handling (must be last)
|
||||
// ============================================
|
||||
app.use(errorHandler);
|
||||
```
|
||||
|
||||
### Validation Middleware
|
||||
|
||||
Located in `src/middleware/validation.middleware.ts`:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
|
||||
export const validate = <T extends z.ZodType>(schema: T) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const result = schema.safeParse({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((err) => ({
|
||||
path: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
return next(new ValidationError(errors));
|
||||
}
|
||||
|
||||
// Attach validated data to request
|
||||
req.validated = result.data;
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Usage in routes:
|
||||
router.post('/flyers', authenticate, validate(CreateFlyerSchema), flyerController.create);
|
||||
```
|
||||
|
||||
### File Upload Middleware
|
||||
|
||||
Located in `src/middleware/fileUpload.middleware.ts`:
|
||||
|
||||
```typescript
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'flyer-images/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const fileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadFlyer = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
files: 10, // Max 10 files per request
|
||||
},
|
||||
});
|
||||
|
||||
// Usage:
|
||||
router.post('/flyers/upload', uploadFlyer.array('files', 10), flyerController.upload);
|
||||
```
|
||||
|
||||
### Authentication Middleware
|
||||
|
||||
Located in `src/middleware/auth.middleware.ts`:
|
||||
|
||||
```typescript
|
||||
import passport from 'passport';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Require authenticated user
|
||||
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user) => {
|
||||
if (err) return next(err);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
})(req, res, next);
|
||||
};
|
||||
|
||||
// Require admin role
|
||||
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user?.role || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Optional auth (attach user if present, continue if not)
|
||||
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user) => {
|
||||
if (user) req.user = user;
|
||||
next();
|
||||
})(req, res, next);
|
||||
};
|
||||
```
|
||||
|
||||
### Error Handler Middleware
|
||||
|
||||
Located in `src/middleware/errorHandler.middleware.ts`:
|
||||
|
||||
```typescript
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { ValidationError, NotFoundError, UniqueConstraintError } from '../services/db/errors.db';
|
||||
|
||||
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
const errorId = uuidv4();
|
||||
|
||||
// Log error with context
|
||||
logger.error(
|
||||
{
|
||||
errorId,
|
||||
err,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userId: req.user?.user_id,
|
||||
},
|
||||
'Request error',
|
||||
);
|
||||
|
||||
// Map error types to HTTP responses
|
||||
if (err instanceof ValidationError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { code: 'VALIDATION_ERROR', message: err.message, details: err.errors },
|
||||
meta: { errorId },
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof NotFoundError) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: err.message },
|
||||
meta: { errorId },
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: { code: 'CONFLICT', message: err.message },
|
||||
meta: { errorId },
|
||||
});
|
||||
}
|
||||
|
||||
// Default: Internal Server Error
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message,
|
||||
},
|
||||
meta: { errorId },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Request Timeout Middleware
|
||||
|
||||
```typescript
|
||||
export const requestTimeoutMiddleware = (timeout: number) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
res.setTimeout(timeout, () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
error: { code: 'TIMEOUT', message: 'Request timed out' },
|
||||
});
|
||||
}
|
||||
});
|
||||
next();
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Route-Level Middleware Patterns
|
||||
|
||||
### Protected Route with Validation
|
||||
|
||||
```typescript
|
||||
router.put(
|
||||
'/flyers/:flyerId',
|
||||
authenticate, // 1. Auth check
|
||||
validate(UpdateFlyerSchema), // 2. Input validation
|
||||
flyerController.update, // 3. Handler
|
||||
);
|
||||
```
|
||||
|
||||
### Admin-Only Route
|
||||
|
||||
```typescript
|
||||
router.delete(
|
||||
'/admin/users/:userId',
|
||||
authenticate, // 1. Auth check
|
||||
requireAdmin, // 2. Role check
|
||||
validate(DeleteUserSchema), // 3. Input validation
|
||||
adminController.deleteUser, // 4. Handler
|
||||
);
|
||||
```
|
||||
|
||||
### File Upload Route
|
||||
|
||||
```typescript
|
||||
router.post(
|
||||
'/flyers/upload',
|
||||
authenticate, // 1. Auth check
|
||||
uploadFlyer.array('files', 10), // 2. File handling
|
||||
validate(UploadFlyerSchema), // 3. Metadata validation
|
||||
flyerController.upload, // 4. Handler
|
||||
);
|
||||
```
|
||||
|
||||
### Public Route with Optional Auth
|
||||
|
||||
```typescript
|
||||
router.get(
|
||||
'/flyers/:flyerId',
|
||||
optionalAuth, // 1. Attach user if present
|
||||
flyerController.getById, // 2. Handler (can check req.user)
|
||||
);
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Security**: Defense-in-depth with multiple security layers.
|
||||
- **Consistency**: Predictable request processing order.
|
||||
- **Maintainability**: Clear separation of concerns.
|
||||
- **Debuggability**: Errors caught and logged centrally.
|
||||
- **Flexibility**: Per-route middleware composition.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Order Sensitivity**: Middleware order bugs can be subtle.
|
||||
- **Performance**: Many middleware layers add latency.
|
||||
- **Complexity**: New developers must understand the pipeline.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Document middleware order in comments (as shown above).
|
||||
- Use integration tests that verify middleware chain behavior.
|
||||
- Profile middleware performance in production.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/server.ts` - Global middleware registration
|
||||
- `src/middleware/validation.middleware.ts` - Zod validation
|
||||
- `src/middleware/fileUpload.middleware.ts` - Multer configuration
|
||||
- `src/middleware/multer.middleware.ts` - File upload handling
|
||||
- `src/middleware/errorHandler.middleware.ts` - Error handling (implicit)
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation
|
||||
- [ADR-016](./0016-api-security-hardening.md) - API Security
|
||||
- [ADR-032](./0032-rate-limiting-strategy.md) - Rate Limiting
|
||||
- [ADR-033](./0033-file-upload-and-storage-strategy.md) - File Uploads
|
||||
275
docs/adr/0044-frontend-feature-organization.md
Normal file
275
docs/adr/0044-frontend-feature-organization.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# ADR-044: Frontend Feature Organization Pattern
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The React frontend has grown to include multiple distinct features:
|
||||
|
||||
- Flyer viewing and management
|
||||
- Shopping list creation
|
||||
- Budget tracking and charts
|
||||
- Voice assistant
|
||||
- User personalization
|
||||
- Admin dashboard
|
||||
|
||||
Without clear organization, code becomes scattered across generic folders (`/components`, `/hooks`, `/utils`), making it hard to:
|
||||
|
||||
1. Understand feature boundaries
|
||||
2. Find related code
|
||||
3. Refactor or remove features
|
||||
4. Onboard new developers
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a **feature-based folder structure** where each major feature is self-contained in its own directory under `/features`. Shared code lives in dedicated top-level folders.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Colocation**: Keep related code together (components, hooks, types, utils).
|
||||
- **Feature Independence**: Features should minimize cross-dependencies.
|
||||
- **Shared Extraction**: Only extract to shared folders when truly reused.
|
||||
- **Flat Within Features**: Avoid deep nesting within feature folders.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── features/ # Feature modules
|
||||
│ ├── flyer/ # Flyer viewing/management
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── types.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── shopping/ # Shopping lists
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ └── index.ts
|
||||
│ ├── charts/ # Budget/analytics charts
|
||||
│ │ ├── components/
|
||||
│ │ └── index.ts
|
||||
│ ├── voice-assistant/ # Voice commands
|
||||
│ │ ├── components/
|
||||
│ │ └── index.ts
|
||||
│ └── admin/ # Admin dashboard
|
||||
│ ├── components/
|
||||
│ └── index.ts
|
||||
├── components/ # Shared UI components
|
||||
│ ├── ui/ # Primitive components (Button, Input, etc.)
|
||||
│ ├── layout/ # Layout components (Header, Footer, etc.)
|
||||
│ └── common/ # Shared composite components
|
||||
├── hooks/ # Shared hooks
|
||||
│ ├── queries/ # TanStack Query hooks
|
||||
│ ├── mutations/ # TanStack Mutation hooks
|
||||
│ └── utils/ # Utility hooks (useDebounce, etc.)
|
||||
├── providers/ # React context providers
|
||||
│ ├── AppProviders.tsx
|
||||
│ ├── UserDataProvider.tsx
|
||||
│ └── FlyersProvider.tsx
|
||||
├── pages/ # Route page components
|
||||
├── services/ # API clients, external services
|
||||
├── types/ # Shared TypeScript types
|
||||
├── utils/ # Shared utility functions
|
||||
└── lib/ # Third-party library wrappers
|
||||
```
|
||||
|
||||
### Feature Module Structure
|
||||
|
||||
Each feature follows a consistent internal structure:
|
||||
|
||||
```
|
||||
features/flyer/
|
||||
├── components/
|
||||
│ ├── FlyerCard.tsx
|
||||
│ ├── FlyerGrid.tsx
|
||||
│ ├── FlyerUploader.tsx
|
||||
│ ├── FlyerItemList.tsx
|
||||
│ └── index.ts # Re-exports all components
|
||||
├── hooks/
|
||||
│ ├── useFlyerDetails.ts
|
||||
│ ├── useFlyerUpload.ts
|
||||
│ └── index.ts # Re-exports all hooks
|
||||
├── types.ts # Feature-specific types
|
||||
├── utils.ts # Feature-specific utilities
|
||||
└── index.ts # Public API of the feature
|
||||
```
|
||||
|
||||
### Feature Index File
|
||||
|
||||
Each feature has an `index.ts` that defines its public API:
|
||||
|
||||
```typescript
|
||||
// features/flyer/index.ts
|
||||
export { FlyerCard, FlyerGrid, FlyerUploader } from './components';
|
||||
export { useFlyerDetails, useFlyerUpload } from './hooks';
|
||||
export type { FlyerViewProps, FlyerUploadState } from './types';
|
||||
```
|
||||
|
||||
### Import Patterns
|
||||
|
||||
```typescript
|
||||
// Importing from a feature (preferred)
|
||||
import { FlyerCard, useFlyerDetails } from '@/features/flyer';
|
||||
|
||||
// Importing shared components
|
||||
import { Button, Card } from '@/components/ui';
|
||||
import { useDebounce } from '@/hooks/utils';
|
||||
|
||||
// Avoid: reaching into feature internals
|
||||
// import { FlyerCard } from '@/features/flyer/components/FlyerCard';
|
||||
```
|
||||
|
||||
### Provider Organization
|
||||
|
||||
Located in `src/providers/`:
|
||||
|
||||
```typescript
|
||||
// AppProviders.tsx - Composes all providers
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<UserDataProvider>
|
||||
<FlyersProvider>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</FlyersProvider>
|
||||
</UserDataProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Query/Mutation Hook Organization
|
||||
|
||||
Located in `src/hooks/`:
|
||||
|
||||
```typescript
|
||||
// hooks/queries/useFlyersQuery.ts
|
||||
export function useFlyersQuery(options?: { storeId?: number }) {
|
||||
return useQuery({
|
||||
queryKey: ['flyers', options],
|
||||
queryFn: () => flyerService.getFlyers(options),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// hooks/mutations/useFlyerUploadMutation.ts
|
||||
export function useFlyerUploadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: flyerService.uploadFlyer,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['flyers'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Page Components
|
||||
|
||||
Pages are thin wrappers that compose feature components:
|
||||
|
||||
```typescript
|
||||
// pages/Flyers.tsx
|
||||
import { FlyerGrid, FlyerUploader } from '@/features/flyer';
|
||||
import { PageLayout } from '@/components/layout';
|
||||
|
||||
export function FliversPage() {
|
||||
return (
|
||||
<PageLayout title="My Flyers">
|
||||
<FlyerUploader />
|
||||
<FlyerGrid />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Feature Communication
|
||||
|
||||
When features need to communicate, use:
|
||||
|
||||
1. **Shared State Providers**: For global state (user, theme).
|
||||
2. **Query Invalidation**: For data synchronization.
|
||||
3. **Event Bus**: For loose coupling (see ADR-036).
|
||||
|
||||
```typescript
|
||||
// Feature A triggers update
|
||||
const uploadMutation = useFlyerUploadMutation();
|
||||
await uploadMutation.mutateAsync(file);
|
||||
// Query invalidation automatically updates Feature B's flyer list
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Item | Convention | Example |
|
||||
| -------------- | -------------------- | -------------------- |
|
||||
| Feature folder | kebab-case | `voice-assistant/` |
|
||||
| Component file | PascalCase | `FlyerCard.tsx` |
|
||||
| Hook file | camelCase with `use` | `useFlyerDetails.ts` |
|
||||
| Type file | lowercase | `types.ts` |
|
||||
| Utility file | lowercase | `utils.ts` |
|
||||
| Index file | lowercase | `index.ts` |
|
||||
|
||||
## When to Create a New Feature
|
||||
|
||||
Create a new feature folder when:
|
||||
|
||||
1. The functionality is distinct and self-contained.
|
||||
2. It has its own set of components, hooks, and potentially types.
|
||||
3. It could theoretically be extracted into a separate package.
|
||||
4. It has minimal dependencies on other features.
|
||||
|
||||
Do NOT create a feature folder for:
|
||||
|
||||
- A single reusable component (use `components/`).
|
||||
- A single utility function (use `utils/`).
|
||||
- A single hook (use `hooks/`).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Discoverability**: Easy to find all code related to a feature.
|
||||
- **Encapsulation**: Features have clear boundaries and public APIs.
|
||||
- **Refactoring**: Can modify or remove features with confidence.
|
||||
- **Scalability**: Supports team growth with feature ownership.
|
||||
- **Testing**: Can test features in isolation.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Duplication Risk**: Similar utilities might be duplicated across features.
|
||||
- **Decision Overhead**: Must decide when to extract to shared folders.
|
||||
- **Import Verbosity**: Feature imports can be longer.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Regular refactoring sessions to extract shared code.
|
||||
- Lint rules to prevent importing from feature internals.
|
||||
- Code review focus on proper feature boundaries.
|
||||
|
||||
## Key Directories
|
||||
|
||||
- `src/features/flyer/` - Flyer viewing and management
|
||||
- `src/features/shopping/` - Shopping list functionality
|
||||
- `src/features/charts/` - Budget and analytics charts
|
||||
- `src/features/voice-assistant/` - Voice command interface
|
||||
- `src/features/admin/` - Admin dashboard
|
||||
- `src/components/ui/` - Shared primitive components
|
||||
- `src/hooks/queries/` - TanStack Query hooks
|
||||
- `src/providers/` - React context providers
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md) - State Management
|
||||
- [ADR-012](./0012-frontend-component-library-and-design-system.md) - Component Library
|
||||
- [ADR-026](./0026-standardized-client-side-structured-logging.md) - Client Logging
|
||||
350
docs/adr/0045-test-data-factories-and-fixtures.md
Normal file
350
docs/adr/0045-test-data-factories-and-fixtures.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# ADR-045: Test Data Factories and Fixtures
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application has a complex domain model with many entity types:
|
||||
|
||||
- Users, Profiles, Addresses
|
||||
- Flyers, FlyerItems, Stores
|
||||
- ShoppingLists, ShoppingListItems
|
||||
- Recipes, RecipeIngredients
|
||||
- Gamification (points, badges, leaderboards)
|
||||
- And more...
|
||||
|
||||
Testing requires realistic mock data that:
|
||||
|
||||
1. Satisfies TypeScript types.
|
||||
2. Has valid relationships between entities.
|
||||
3. Is customizable for specific test scenarios.
|
||||
4. Is consistent across test suites.
|
||||
5. Avoids boilerplate in test files.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a **factory function pattern** for test data generation:
|
||||
|
||||
1. **Centralized Mock Factories**: All factories in a single, organized file.
|
||||
2. **Sensible Defaults**: Each factory produces valid data with minimal input.
|
||||
3. **Override Support**: Factories accept partial overrides for customization.
|
||||
4. **Relationship Helpers**: Factories can generate related entities.
|
||||
5. **Type Safety**: Factories return properly typed objects.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Convention over Configuration**: Factories work with zero arguments.
|
||||
- **Composability**: Factories can call other factories.
|
||||
- **Immutability**: Each call returns a new object (no shared references).
|
||||
- **Predictability**: Deterministic output when seeded.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Factory File Structure
|
||||
|
||||
Located in `src/test/mockFactories.ts`:
|
||||
|
||||
```typescript
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type {
|
||||
User,
|
||||
UserProfile,
|
||||
Flyer,
|
||||
FlyerItem,
|
||||
ShoppingList,
|
||||
// ... other types
|
||||
} from '../types';
|
||||
|
||||
// ============================================
|
||||
// PRIMITIVE HELPERS
|
||||
// ============================================
|
||||
let idCounter = 1;
|
||||
export const nextId = () => idCounter++;
|
||||
export const resetIdCounter = () => {
|
||||
idCounter = 1;
|
||||
};
|
||||
|
||||
export const randomEmail = () => `user-${uuidv4().slice(0, 8)}@test.com`;
|
||||
export const randomDate = (daysAgo = 0) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// USER FACTORIES
|
||||
// ============================================
|
||||
export const createMockUser = (overrides: Partial<User> = {}): User => ({
|
||||
user_id: nextId(),
|
||||
email: randomEmail(),
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
created_at: randomDate(30),
|
||||
updated_at: randomDate(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockUserProfile = (overrides: Partial<UserProfile> = {}): UserProfile => {
|
||||
const user = createMockUser(overrides.user);
|
||||
return {
|
||||
user,
|
||||
profile: createMockProfile({ user_id: user.user_id, ...overrides.profile }),
|
||||
address: overrides.address ?? null,
|
||||
preferences: overrides.preferences ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// FLYER FACTORIES
|
||||
// ============================================
|
||||
export const createMockFlyer = (overrides: Partial<Flyer> = {}): Flyer => ({
|
||||
flyer_id: nextId(),
|
||||
file_name: 'test-flyer.jpg',
|
||||
image_url: 'https://example.com/flyer.jpg',
|
||||
icon_url: 'https://example.com/flyer-icon.jpg',
|
||||
checksum: uuidv4(),
|
||||
store_name: 'Test Store',
|
||||
store_address: '123 Test St',
|
||||
valid_from: randomDate(7),
|
||||
valid_to: randomDate(-7), // 7 days in future
|
||||
item_count: 10,
|
||||
status: 'approved',
|
||||
uploaded_by: null,
|
||||
created_at: randomDate(7),
|
||||
updated_at: randomDate(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockFlyerItem = (overrides: Partial<FlyerItem> = {}): FlyerItem => ({
|
||||
flyer_item_id: nextId(),
|
||||
flyer_id: overrides.flyer_id ?? nextId(),
|
||||
item: 'Test Product',
|
||||
price_display: '$2.99',
|
||||
price_in_cents: 299,
|
||||
quantity: 'each',
|
||||
category_name: 'Groceries',
|
||||
master_item_id: null,
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
created_at: randomDate(7),
|
||||
updated_at: randomDate(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// FLYER WITH ITEMS (COMPOSITE)
|
||||
// ============================================
|
||||
export const createMockFlyerWithItems = (
|
||||
flyerOverrides: Partial<Flyer> = {},
|
||||
itemCount = 5,
|
||||
): { flyer: Flyer; items: FlyerItem[] } => {
|
||||
const flyer = createMockFlyer(flyerOverrides);
|
||||
const items = Array.from({ length: itemCount }, (_, i) =>
|
||||
createMockFlyerItem({
|
||||
flyer_id: flyer.flyer_id,
|
||||
item: `Product ${i + 1}`,
|
||||
price_in_cents: 100 + i * 50,
|
||||
}),
|
||||
);
|
||||
flyer.item_count = items.length;
|
||||
return { flyer, items };
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// SHOPPING LIST FACTORIES
|
||||
// ============================================
|
||||
export const createMockShoppingList = (overrides: Partial<ShoppingList> = {}): ShoppingList => ({
|
||||
shopping_list_id: nextId(),
|
||||
user_id: overrides.user_id ?? nextId(),
|
||||
name: 'Weekly Groceries',
|
||||
is_active: true,
|
||||
created_at: randomDate(14),
|
||||
updated_at: randomDate(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockShoppingListItem = (
|
||||
overrides: Partial<ShoppingListItem> = {},
|
||||
): ShoppingListItem => ({
|
||||
shopping_list_item_id: nextId(),
|
||||
shopping_list_id: overrides.shopping_list_id ?? nextId(),
|
||||
item_name: 'Milk',
|
||||
quantity: 1,
|
||||
is_purchased: false,
|
||||
created_at: randomDate(7),
|
||||
updated_at: randomDate(),
|
||||
...overrides,
|
||||
});
|
||||
```
|
||||
|
||||
### Usage in Tests
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createMockUser,
|
||||
createMockFlyer,
|
||||
createMockFlyerWithItems,
|
||||
resetIdCounter,
|
||||
} from '../test/mockFactories';
|
||||
|
||||
describe('FlyerService', () => {
|
||||
beforeEach(() => {
|
||||
resetIdCounter(); // Consistent IDs across tests
|
||||
});
|
||||
|
||||
it('should get flyer by ID', async () => {
|
||||
const mockFlyer = createMockFlyer({ store_name: 'Walmart' });
|
||||
|
||||
mockDb.query.mockResolvedValue({ rows: [mockFlyer] });
|
||||
|
||||
const result = await flyerService.getFlyerById(mockFlyer.flyer_id);
|
||||
|
||||
expect(result.store_name).toBe('Walmart');
|
||||
});
|
||||
|
||||
it('should return flyer with items', async () => {
|
||||
const { flyer, items } = createMockFlyerWithItems(
|
||||
{ store_name: 'Costco' },
|
||||
10, // 10 items
|
||||
);
|
||||
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [flyer] }).mockResolvedValueOnce({ rows: items });
|
||||
|
||||
const result = await flyerService.getFlyerWithItems(flyer.flyer_id);
|
||||
|
||||
expect(result.flyer.store_name).toBe('Costco');
|
||||
expect(result.items).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Bulk Data Generation
|
||||
|
||||
For integration tests or seeding:
|
||||
|
||||
```typescript
|
||||
export const createMockDataset = () => {
|
||||
const users = Array.from({ length: 10 }, () => createMockUser());
|
||||
const flyers = Array.from({ length: 5 }, () => createMockFlyer());
|
||||
const flyersWithItems = flyers.map((flyer) => ({
|
||||
flyer,
|
||||
items: Array.from({ length: Math.floor(Math.random() * 20) + 5 }, () =>
|
||||
createMockFlyerItem({ flyer_id: flyer.flyer_id }),
|
||||
),
|
||||
}));
|
||||
|
||||
return { users, flyers, flyersWithItems };
|
||||
};
|
||||
```
|
||||
|
||||
### API Response Factories
|
||||
|
||||
For testing API handlers:
|
||||
|
||||
```typescript
|
||||
export const createMockApiResponse = <T>(
|
||||
data: T,
|
||||
overrides: Partial<ApiResponse<T>> = {},
|
||||
): ApiResponse<T> => ({
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: uuidv4(),
|
||||
...overrides.meta,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockPaginatedResponse = <T>(
|
||||
items: T[],
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): PaginatedApiResponse<T> => ({
|
||||
success: true,
|
||||
data: items,
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: uuidv4(),
|
||||
},
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: items.length,
|
||||
totalPages: Math.ceil(items.length / pageSize),
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Database Query Mock Helpers
|
||||
|
||||
```typescript
|
||||
export const mockQueryResult = <T>(rows: T[]) => ({
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
});
|
||||
|
||||
export const mockEmptyResult = () => ({
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
});
|
||||
|
||||
export const mockInsertResult = <T>(inserted: T) => ({
|
||||
rows: [inserted],
|
||||
rowCount: 1,
|
||||
});
|
||||
```
|
||||
|
||||
## Test Cleanup Utilities
|
||||
|
||||
```typescript
|
||||
// For integration tests with real database
|
||||
export const cleanupTestData = async (pool: Pool) => {
|
||||
await pool.query('DELETE FROM flyer_items WHERE flyer_id > 1000000');
|
||||
await pool.query('DELETE FROM flyers WHERE flyer_id > 1000000');
|
||||
await pool.query('DELETE FROM users WHERE user_id > 1000000');
|
||||
};
|
||||
|
||||
// Mark test data with high IDs
|
||||
export const createTestFlyer = (overrides: Partial<Flyer> = {}) =>
|
||||
createMockFlyer({ flyer_id: 1000000 + nextId(), ...overrides });
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Consistency**: All tests use the same factory patterns.
|
||||
- **Type Safety**: Factories return correctly typed objects.
|
||||
- **Reduced Boilerplate**: Tests focus on behavior, not data setup.
|
||||
- **Maintainability**: Update factory once, all tests benefit.
|
||||
- **Flexibility**: Easy to create edge case data.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Single Large File**: Factory file can become large.
|
||||
- **Learning Curve**: New developers must learn factory patterns.
|
||||
- **Maintenance**: Factories must be updated when types change.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Split factories into multiple files if needed (by domain).
|
||||
- Add JSDoc comments explaining each factory.
|
||||
- Use TypeScript to catch type mismatches automatically.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/test/mockFactories.ts` - All mock factory functions
|
||||
- `src/test/testUtils.ts` - Test helper utilities
|
||||
- `src/test/setup.ts` - Global test setup with factory reset
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-010](./0010-testing-strategy-and-standards.md) - Testing Strategy
|
||||
- [ADR-040](./0040-testing-economics-and-priorities.md) - Testing Economics
|
||||
- [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) - Type Naming
|
||||
363
docs/adr/0046-image-processing-pipeline.md
Normal file
363
docs/adr/0046-image-processing-pipeline.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# ADR-046: Image Processing Pipeline
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-09
|
||||
|
||||
## Context
|
||||
|
||||
The application handles significant image processing for flyer uploads:
|
||||
|
||||
1. **Privacy Protection**: Strip EXIF metadata (location, device info).
|
||||
2. **Optimization**: Resize, compress, and convert images for web delivery.
|
||||
3. **Icon Generation**: Create thumbnails for listing views.
|
||||
4. **Format Support**: Handle JPEG, PNG, WebP, and PDF inputs.
|
||||
5. **Storage Management**: Organize processed images on disk.
|
||||
|
||||
These operations must be:
|
||||
|
||||
- **Performant**: Large images should not block the request.
|
||||
- **Secure**: Prevent malicious file uploads.
|
||||
- **Consistent**: Produce predictable output quality.
|
||||
- **Testable**: Support unit testing without real files.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a modular image processing pipeline using:
|
||||
|
||||
1. **Sharp**: For image resizing, compression, and format conversion.
|
||||
2. **EXIF Parsing**: For metadata extraction and stripping.
|
||||
3. **UUID Naming**: For unique, non-guessable file names.
|
||||
4. **Directory Structure**: Organized storage for originals and derivatives.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Pipeline Pattern**: Chain processing steps in a predictable order.
|
||||
- **Fail-Fast Validation**: Reject invalid files before processing.
|
||||
- **Idempotent Operations**: Same input produces same output.
|
||||
- **Resource Cleanup**: Delete temp files on error.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Image Processor Module
|
||||
|
||||
Located in `src/utils/imageProcessor.ts`:
|
||||
|
||||
```typescript
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from 'fs/promises';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
// ============================================
|
||||
// CONFIGURATION
|
||||
// ============================================
|
||||
const IMAGE_CONFIG = {
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
quality: 85,
|
||||
iconSize: 200,
|
||||
allowedFormats: ['jpeg', 'png', 'webp', 'avif'],
|
||||
outputFormat: 'webp' as const,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MAIN PROCESSING FUNCTION
|
||||
// ============================================
|
||||
export async function processAndSaveImage(
|
||||
inputPath: string,
|
||||
outputDir: string,
|
||||
originalFileName: string,
|
||||
logger: Logger,
|
||||
): Promise<string> {
|
||||
const outputFileName = `${uuidv4()}.${IMAGE_CONFIG.outputFormat}`;
|
||||
const outputPath = path.join(outputDir, outputFileName);
|
||||
|
||||
logger.info({ inputPath, outputPath }, 'Processing image');
|
||||
|
||||
try {
|
||||
// Create sharp instance and strip metadata
|
||||
await sharp(inputPath)
|
||||
.rotate() // Auto-rotate based on EXIF orientation
|
||||
.resize(IMAGE_CONFIG.maxWidth, IMAGE_CONFIG.maxHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: IMAGE_CONFIG.quality })
|
||||
.toFile(outputPath);
|
||||
|
||||
logger.info({ outputPath }, 'Image processed successfully');
|
||||
return outputFileName;
|
||||
} catch (error) {
|
||||
logger.error({ error, inputPath }, 'Image processing failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Generation
|
||||
|
||||
```typescript
|
||||
export async function generateFlyerIcon(
|
||||
inputPath: string,
|
||||
iconsDir: string,
|
||||
logger: Logger,
|
||||
): Promise<string> {
|
||||
// Ensure icons directory exists
|
||||
await fs.mkdir(iconsDir, { recursive: true });
|
||||
|
||||
const iconFileName = `${uuidv4()}-icon.webp`;
|
||||
const iconPath = path.join(iconsDir, iconFileName);
|
||||
|
||||
logger.info({ inputPath, iconPath }, 'Generating icon');
|
||||
|
||||
await sharp(inputPath)
|
||||
.resize(IMAGE_CONFIG.iconSize, IMAGE_CONFIG.iconSize, {
|
||||
fit: 'cover',
|
||||
position: 'top', // Flyers usually have store name at top
|
||||
})
|
||||
.webp({ quality: 80 })
|
||||
.toFile(iconPath);
|
||||
|
||||
logger.info({ iconPath }, 'Icon generated successfully');
|
||||
return iconFileName;
|
||||
}
|
||||
```
|
||||
|
||||
### EXIF Metadata Extraction
|
||||
|
||||
For audit/logging purposes before stripping:
|
||||
|
||||
```typescript
|
||||
import ExifParser from 'exif-parser';
|
||||
|
||||
export async function extractExifMetadata(
|
||||
filePath: string,
|
||||
logger: Logger,
|
||||
): Promise<ExifMetadata | null> {
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
const parser = ExifParser.create(buffer);
|
||||
const result = parser.parse();
|
||||
|
||||
const metadata: ExifMetadata = {
|
||||
make: result.tags?.Make,
|
||||
model: result.tags?.Model,
|
||||
dateTime: result.tags?.DateTimeOriginal,
|
||||
gpsLatitude: result.tags?.GPSLatitude,
|
||||
gpsLongitude: result.tags?.GPSLongitude,
|
||||
orientation: result.tags?.Orientation,
|
||||
};
|
||||
|
||||
// Log if GPS data was present (privacy concern)
|
||||
if (metadata.gpsLatitude || metadata.gpsLongitude) {
|
||||
logger.info({ filePath }, 'GPS data found in image, will be stripped during processing');
|
||||
}
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
logger.debug({ error, filePath }, 'No EXIF data found or parsing failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PDF to Image Conversion
|
||||
|
||||
```typescript
|
||||
import * as pdfjs from 'pdfjs-dist';
|
||||
|
||||
export async function convertPdfToImages(
|
||||
pdfPath: string,
|
||||
outputDir: string,
|
||||
logger: Logger,
|
||||
): Promise<string[]> {
|
||||
const pdfData = await fs.readFile(pdfPath);
|
||||
const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||
|
||||
const outputPaths: string[] = [];
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 }); // 2x for quality
|
||||
|
||||
// Create canvas and render
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
}).promise;
|
||||
|
||||
// Save as image
|
||||
const outputFileName = `${uuidv4()}-page-${i}.png`;
|
||||
const outputPath = path.join(outputDir, outputFileName);
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
await fs.writeFile(outputPath, buffer);
|
||||
|
||||
outputPaths.push(outputPath);
|
||||
logger.info({ page: i, outputPath }, 'PDF page converted to image');
|
||||
}
|
||||
|
||||
return outputPaths;
|
||||
}
|
||||
```
|
||||
|
||||
### File Validation
|
||||
|
||||
```typescript
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
|
||||
export async function validateImageFile(
|
||||
filePath: string,
|
||||
logger: Logger,
|
||||
): Promise<{ valid: boolean; mimeType: string | null; error?: string }> {
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath, { length: 4100 }); // Read header only
|
||||
const type = await fileTypeFromBuffer(buffer);
|
||||
|
||||
if (!type) {
|
||||
return { valid: false, mimeType: null, error: 'Unknown file type' };
|
||||
}
|
||||
|
||||
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'application/pdf'];
|
||||
|
||||
if (!allowedMimes.includes(type.mime)) {
|
||||
return {
|
||||
valid: false,
|
||||
mimeType: type.mime,
|
||||
error: `File type ${type.mime} not allowed`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, mimeType: type.mime };
|
||||
} catch (error) {
|
||||
logger.error({ error, filePath }, 'File validation failed');
|
||||
return { valid: false, mimeType: null, error: 'Validation error' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Organization
|
||||
|
||||
```
|
||||
flyer-images/
|
||||
├── originals/ # Uploaded files (if kept)
|
||||
│ └── {uuid}.{ext}
|
||||
├── processed/ # Optimized images (or root level)
|
||||
│ └── {uuid}.webp
|
||||
├── icons/ # Thumbnails
|
||||
│ └── {uuid}-icon.webp
|
||||
└── temp/ # Temporary processing files
|
||||
└── {uuid}.tmp
|
||||
```
|
||||
|
||||
### Cleanup Utilities
|
||||
|
||||
```typescript
|
||||
export async function cleanupTempFiles(
|
||||
tempDir: string,
|
||||
maxAgeMs: number,
|
||||
logger: Logger,
|
||||
): Promise<number> {
|
||||
const files = await fs.readdir(tempDir);
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
const age = now - stats.mtimeMs;
|
||||
|
||||
if (age > maxAgeMs) {
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ deletedCount, tempDir }, 'Cleaned up temp files');
|
||||
return deletedCount;
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with Flyer Processing
|
||||
|
||||
```typescript
|
||||
// In flyerProcessingService.ts
|
||||
export async function processUploadedFlyer(
|
||||
file: Express.Multer.File,
|
||||
logger: Logger,
|
||||
): Promise<{ imageUrl: string; iconUrl: string }> {
|
||||
const flyerImageDir = 'flyer-images';
|
||||
const iconsDir = path.join(flyerImageDir, 'icons');
|
||||
|
||||
// 1. Validate file
|
||||
const validation = await validateImageFile(file.path, logger);
|
||||
if (!validation.valid) {
|
||||
throw new ValidationError([{ path: 'file', message: validation.error! }]);
|
||||
}
|
||||
|
||||
// 2. Extract and log EXIF before stripping
|
||||
await extractExifMetadata(file.path, logger);
|
||||
|
||||
// 3. Process and optimize image
|
||||
const processedFileName = await processAndSaveImage(
|
||||
file.path,
|
||||
flyerImageDir,
|
||||
file.originalname,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 4. Generate icon
|
||||
const processedImagePath = path.join(flyerImageDir, processedFileName);
|
||||
const iconFileName = await generateFlyerIcon(processedImagePath, iconsDir, logger);
|
||||
|
||||
// 5. Construct URLs
|
||||
const baseUrl = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
const imageUrl = `${baseUrl}/flyer-images/${processedFileName}`;
|
||||
const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`;
|
||||
|
||||
// 6. Delete original upload (privacy)
|
||||
await fs.unlink(file.path);
|
||||
|
||||
return { imageUrl, iconUrl };
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Privacy**: EXIF metadata (including GPS) is stripped automatically.
|
||||
- **Performance**: WebP output reduces file sizes by 25-35%.
|
||||
- **Consistency**: All images processed to standard format and dimensions.
|
||||
- **Security**: File type validation prevents malicious uploads.
|
||||
- **Organization**: Clear directory structure for storage management.
|
||||
|
||||
### Negative
|
||||
|
||||
- **CPU Intensive**: Image processing can be slow for large files.
|
||||
- **Storage**: Keeping originals doubles storage requirements.
|
||||
- **Dependency**: Sharp requires native binaries.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Process images in background jobs (BullMQ queue).
|
||||
- Configure whether to keep originals based on requirements.
|
||||
- Use pre-built Sharp binaries via npm.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/utils/imageProcessor.ts` - Core image processing functions
|
||||
- `src/services/flyer/flyerProcessingService.ts` - Integration with flyer workflow
|
||||
- `src/middleware/fileUpload.middleware.ts` - Multer configuration
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-033](./0033-file-upload-and-storage-strategy.md) - File Upload Strategy
|
||||
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Jobs
|
||||
- [ADR-041](./0041-ai-gemini-integration-architecture.md) - AI Integration (uses processed images)
|
||||
545
docs/adr/0047-project-file-and-folder-organization.md
Normal file
545
docs/adr/0047-project-file-and-folder-organization.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# ADR-047: Project File and Folder Organization
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
**Effort**: XL (Major reorganization across entire codebase)
|
||||
|
||||
## Context
|
||||
|
||||
The project has grown organically with a mix of organizational patterns:
|
||||
|
||||
- **By Type**: Components, hooks, middleware, utilities, types all in flat directories
|
||||
- **By Feature**: Routes, database modules, and partial feature directories
|
||||
- **Mixed Concerns**: Frontend and backend code intermingled in `src/`
|
||||
|
||||
Current pain points:
|
||||
|
||||
1. **Flat services directory**: 75+ files with no subdirectory grouping
|
||||
2. **Monolithic types.ts**: 750+ lines, unclear when to add new types
|
||||
3. **Flat components directory**: 43+ components at root level
|
||||
4. **Incomplete feature modules**: Features contain only UI, not domain logic
|
||||
5. **No clear frontend/backend separation**: Both share `src/` root
|
||||
|
||||
As the project scales, these issues compound, making navigation, refactoring, and onboarding increasingly difficult.
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a **domain-driven organization** with clear separation between:
|
||||
|
||||
1. **Client code** (React, browser-only)
|
||||
2. **Server code** (Express, Node-only)
|
||||
3. **Shared code** (Types, utilities used by both)
|
||||
|
||||
Within each layer, organize by **feature/domain** rather than by file type.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Colocation**: Related code lives together (components, hooks, types, tests)
|
||||
- **Explicit Boundaries**: Clear separation between client, server, and shared
|
||||
- **Feature Ownership**: Each domain owns its entire vertical slice
|
||||
- **Discoverability**: New developers can find code by thinking about features, not file types
|
||||
- **Incremental Migration**: Structure supports gradual transition from current layout
|
||||
|
||||
## Target Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # React frontend (browser-only code)
|
||||
│ ├── app/ # App shell and routing
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── routes.tsx
|
||||
│ │ └── providers/ # React context providers
|
||||
│ │ ├── AppProviders.tsx
|
||||
│ │ ├── AuthProvider.tsx
|
||||
│ │ ├── FlyersProvider.tsx
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── features/ # Feature modules (UI + hooks + types)
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── LoginForm.tsx
|
||||
│ │ │ │ ├── RegisterForm.tsx
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ │ ├── useAuth.ts
|
||||
│ │ │ │ ├── useLogin.ts
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── flyer/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── FlyerCard.tsx
|
||||
│ │ │ │ ├── FlyerGrid.tsx
|
||||
│ │ │ │ ├── FlyerUploader.tsx
|
||||
│ │ │ │ ├── BulkImporter.tsx
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ │ ├── useFlyersQuery.ts
|
||||
│ │ │ │ ├── useFlyerUploadMutation.ts
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── shopping/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── recipes/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── charts/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── voice-assistant/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── user/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── gamification/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── admin/
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── pages/ # Admin-specific pages
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── pages/ # Route page components
|
||||
│ │ ├── HomePage.tsx
|
||||
│ │ ├── MyDealsPage.tsx
|
||||
│ │ ├── UserProfilePage.tsx
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── components/ # Shared UI components
|
||||
│ │ ├── ui/ # Primitive components (design system)
|
||||
│ │ │ ├── Button.tsx
|
||||
│ │ │ ├── Card.tsx
|
||||
│ │ │ ├── Input.tsx
|
||||
│ │ │ ├── Modal.tsx
|
||||
│ │ │ ├── Badge.tsx
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── layout/ # Layout components
|
||||
│ │ │ ├── Header.tsx
|
||||
│ │ │ ├── Footer.tsx
|
||||
│ │ │ ├── Sidebar.tsx
|
||||
│ │ │ ├── PageLayout.tsx
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── feedback/ # User feedback components
|
||||
│ │ │ ├── LoadingSpinner.tsx
|
||||
│ │ │ ├── ErrorMessage.tsx
|
||||
│ │ │ ├── Toast.tsx
|
||||
│ │ │ ├── ConfirmDialog.tsx
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── forms/ # Form components
|
||||
│ │ │ ├── FormField.tsx
|
||||
│ │ │ ├── SearchInput.tsx
|
||||
│ │ │ ├── DatePicker.tsx
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── icons/ # Icon components
|
||||
│ │ │ ├── ChevronIcon.tsx
|
||||
│ │ │ ├── UserIcon.tsx
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── hooks/ # Shared hooks (not feature-specific)
|
||||
│ │ ├── useDebounce.ts
|
||||
│ │ ├── useLocalStorage.ts
|
||||
│ │ ├── useMediaQuery.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── services/ # Client-side services (API clients)
|
||||
│ │ ├── apiClient.ts
|
||||
│ │ ├── logger.client.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── lib/ # Third-party library wrappers
|
||||
│ │ ├── queryClient.ts
|
||||
│ │ ├── toast.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ └── styles/ # Global styles
|
||||
│ ├── globals.css
|
||||
│ └── tailwind.css
|
||||
│
|
||||
├── server/ # Express backend (Node-only code)
|
||||
│ ├── app.ts # Express app setup
|
||||
│ ├── server.ts # Server entry point
|
||||
│ │
|
||||
│ ├── domains/ # Domain modules (business logic)
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── auth.service.ts
|
||||
│ │ │ ├── auth.routes.ts
|
||||
│ │ │ ├── auth.controller.ts
|
||||
│ │ │ ├── auth.repository.ts
|
||||
│ │ │ ├── auth.types.ts
|
||||
│ │ │ ├── auth.service.test.ts
|
||||
│ │ │ ├── auth.routes.test.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── flyer/
|
||||
│ │ │ ├── flyer.service.ts
|
||||
│ │ │ ├── flyer.routes.ts
|
||||
│ │ │ ├── flyer.controller.ts
|
||||
│ │ │ ├── flyer.repository.ts
|
||||
│ │ │ ├── flyer.types.ts
|
||||
│ │ │ ├── flyer.processing.ts # Flyer-specific processing logic
|
||||
│ │ │ ├── flyer.ai.ts # AI integration for flyers
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── user/
|
||||
│ │ │ ├── user.service.ts
|
||||
│ │ │ ├── user.routes.ts
|
||||
│ │ │ ├── user.controller.ts
|
||||
│ │ │ ├── user.repository.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── shopping/
|
||||
│ │ │ ├── shopping.service.ts
|
||||
│ │ │ ├── shopping.routes.ts
|
||||
│ │ │ ├── shopping.repository.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── recipe/
|
||||
│ │ │ ├── recipe.service.ts
|
||||
│ │ │ ├── recipe.routes.ts
|
||||
│ │ │ ├── recipe.repository.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── gamification/
|
||||
│ │ │ ├── gamification.service.ts
|
||||
│ │ │ ├── gamification.routes.ts
|
||||
│ │ │ ├── gamification.repository.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── notification/
|
||||
│ │ │ ├── notification.service.ts
|
||||
│ │ │ ├── email.service.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── ai/
|
||||
│ │ │ ├── ai.service.ts
|
||||
│ │ │ ├── ai.client.ts
|
||||
│ │ │ ├── ai.prompts.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── admin/
|
||||
│ │ ├── admin.routes.ts
|
||||
│ │ ├── admin.controller.ts
|
||||
│ │ ├── admin.service.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ ├── auth.middleware.ts
|
||||
│ │ ├── validation.middleware.ts
|
||||
│ │ ├── errorHandler.middleware.ts
|
||||
│ │ ├── rateLimit.middleware.ts
|
||||
│ │ ├── fileUpload.middleware.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── infrastructure/ # Cross-cutting infrastructure
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── pool.ts
|
||||
│ │ │ ├── migrations/
|
||||
│ │ │ └── seeds/
|
||||
│ │ │
|
||||
│ │ ├── cache/
|
||||
│ │ │ ├── redis.ts
|
||||
│ │ │ └── cacheService.ts
|
||||
│ │ │
|
||||
│ │ ├── queue/
|
||||
│ │ │ ├── queueService.ts
|
||||
│ │ │ ├── workers/
|
||||
│ │ │ │ ├── email.worker.ts
|
||||
│ │ │ │ ├── flyer.worker.ts
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── jobs/
|
||||
│ │ │ ├── cronJobs.ts
|
||||
│ │ │ ├── dailyAnalytics.job.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── logging/
|
||||
│ │ ├── logger.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── config/ # Server configuration
|
||||
│ │ ├── database.config.ts
|
||||
│ │ ├── redis.config.ts
|
||||
│ │ ├── auth.config.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ └── utils/ # Server-only utilities
|
||||
│ ├── imageProcessor.ts
|
||||
│ ├── geocoding.ts
|
||||
│ └── index.ts
|
||||
│
|
||||
├── shared/ # Code shared between client and server
|
||||
│ ├── types/ # Shared TypeScript types
|
||||
│ │ ├── entities/ # Domain entities
|
||||
│ │ │ ├── flyer.types.ts
|
||||
│ │ │ ├── user.types.ts
|
||||
│ │ │ ├── shopping.types.ts
|
||||
│ │ │ ├── recipe.types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── api/ # API contract types
|
||||
│ │ │ ├── requests.ts
|
||||
│ │ │ ├── responses.ts
|
||||
│ │ │ ├── errors.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── schemas/ # Zod validation schemas
|
||||
│ │ ├── flyer.schema.ts
|
||||
│ │ ├── user.schema.ts
|
||||
│ │ ├── auth.schema.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── constants/ # Shared constants
|
||||
│ │ ├── categories.ts
|
||||
│ │ ├── errorCodes.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ └── utils/ # Isomorphic utilities
|
||||
│ ├── formatting.ts
|
||||
│ ├── validation.ts
|
||||
│ └── index.ts
|
||||
│
|
||||
├── tests/ # Test infrastructure
|
||||
│ ├── setup/
|
||||
│ │ ├── vitest.setup.ts
|
||||
│ │ └── testDb.setup.ts
|
||||
│ │
|
||||
│ ├── fixtures/
|
||||
│ │ ├── mockFactories.ts
|
||||
│ │ ├── sampleFlyers/
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── utils/
|
||||
│ │ ├── testHelpers.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── integration/ # Integration tests
|
||||
│ │ ├── api/
|
||||
│ │ └── database/
|
||||
│ │
|
||||
│ └── e2e/ # End-to-end tests
|
||||
│ └── flows/
|
||||
│
|
||||
├── scripts/ # Build and utility scripts
|
||||
│ ├── seed.ts
|
||||
│ ├── migrate.ts
|
||||
│ └── generateTypes.ts
|
||||
│
|
||||
└── index.tsx # Client entry point
|
||||
```
|
||||
|
||||
## Domain Module Structure
|
||||
|
||||
Each server domain follows a consistent structure:
|
||||
|
||||
```
|
||||
domains/flyer/
|
||||
├── flyer.service.ts # Business logic
|
||||
├── flyer.routes.ts # Express routes
|
||||
├── flyer.controller.ts # Route handlers
|
||||
├── flyer.repository.ts # Database access
|
||||
├── flyer.types.ts # Domain-specific types
|
||||
├── flyer.service.test.ts # Service tests
|
||||
├── flyer.routes.test.ts # Route tests
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### Domain Index Pattern
|
||||
|
||||
Each domain exports a clean public API:
|
||||
|
||||
```typescript
|
||||
// server/domains/flyer/index.ts
|
||||
export { FlyerService } from './flyer.service';
|
||||
export { flyerRoutes } from './flyer.routes';
|
||||
export type { FlyerWithItems, FlyerCreateInput } from './flyer.types';
|
||||
```
|
||||
|
||||
## Client Feature Module Structure
|
||||
|
||||
Each client feature follows a consistent structure:
|
||||
|
||||
```
|
||||
client/features/flyer/
|
||||
├── components/
|
||||
│ ├── FlyerCard.tsx
|
||||
│ ├── FlyerCard.test.tsx
|
||||
│ ├── FlyerGrid.tsx
|
||||
│ └── index.ts
|
||||
├── hooks/
|
||||
│ ├── useFlyersQuery.ts
|
||||
│ ├── useFlyerUploadMutation.ts
|
||||
│ └── index.ts
|
||||
├── types.ts # Feature-specific client types
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
## Import Path Aliases
|
||||
|
||||
Configure TypeScript and bundler for clean imports:
|
||||
|
||||
```typescript
|
||||
// tsconfig.json paths
|
||||
{
|
||||
"paths": {
|
||||
"@/client/*": ["src/client/*"],
|
||||
"@/server/*": ["src/server/*"],
|
||||
"@/shared/*": ["src/shared/*"],
|
||||
"@/tests/*": ["src/tests/*"]
|
||||
}
|
||||
}
|
||||
|
||||
// Usage examples
|
||||
import { Button, Card } from '@/client/components/ui';
|
||||
import { useFlyersQuery } from '@/client/features/flyer';
|
||||
import { FlyerService } from '@/server/domains/flyer';
|
||||
import type { Flyer } from '@/shared/types/entities';
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
Given the scope of this reorganization, migrate incrementally:
|
||||
|
||||
### Phase 1: Create Directory Structure
|
||||
|
||||
1. Create `client/`, `server/`, `shared/` directories
|
||||
2. Set up path aliases in tsconfig.json
|
||||
3. Update build configuration (Vite)
|
||||
|
||||
### Phase 2: Migrate Shared Code
|
||||
|
||||
1. Move types to `shared/types/`
|
||||
2. Move schemas to `shared/schemas/`
|
||||
3. Move shared utils to `shared/utils/`
|
||||
4. Update imports across codebase
|
||||
|
||||
### Phase 3: Migrate Server Code
|
||||
|
||||
1. Create `server/domains/` structure
|
||||
2. Move one domain at a time (start with `auth` or `user`)
|
||||
3. Move each service + routes + repository together
|
||||
4. Update route registration in app.ts
|
||||
5. Run tests after each domain migration
|
||||
|
||||
### Phase 4: Migrate Client Code
|
||||
|
||||
1. Create `client/features/` structure
|
||||
2. Move components into features
|
||||
3. Move hooks into features or shared hooks
|
||||
4. Move pages to `client/pages/`
|
||||
5. Organize shared components into categories
|
||||
|
||||
### Phase 5: Cleanup
|
||||
|
||||
1. Remove empty old directories
|
||||
2. Update all remaining imports
|
||||
3. Update CI/CD paths if needed
|
||||
4. Update documentation
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Item | Convention | Example |
|
||||
| ----------------- | -------------------- | ----------------------- |
|
||||
| Domain directory | lowercase | `flyer/`, `shopping/` |
|
||||
| Feature directory | kebab-case | `voice-assistant/` |
|
||||
| Service file | domain.service.ts | `flyer.service.ts` |
|
||||
| Route file | domain.routes.ts | `flyer.routes.ts` |
|
||||
| Repository file | domain.repository.ts | `flyer.repository.ts` |
|
||||
| Component file | PascalCase.tsx | `FlyerCard.tsx` |
|
||||
| Hook file | camelCase.ts | `useFlyersQuery.ts` |
|
||||
| Type file | domain.types.ts | `flyer.types.ts` |
|
||||
| Test file | \*.test.ts(x) | `flyer.service.test.ts` |
|
||||
| Index file | index.ts | `index.ts` |
|
||||
|
||||
## File Placement Guidelines
|
||||
|
||||
**Where does this file go?**
|
||||
|
||||
| If the file is... | Place it in... |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
| Used only by React | `client/` |
|
||||
| Used only by Express/Node | `server/` |
|
||||
| TypeScript types used by both | `shared/types/` |
|
||||
| Zod schemas | `shared/schemas/` |
|
||||
| React component for one feature | `client/features/{feature}/components/` |
|
||||
| React component used across features | `client/components/` |
|
||||
| React hook for one feature | `client/features/{feature}/hooks/` |
|
||||
| React hook used across features | `client/hooks/` |
|
||||
| Business logic for a domain | `server/domains/{domain}/` |
|
||||
| Database access for a domain | `server/domains/{domain}/{domain}.repository.ts` |
|
||||
| Express middleware | `server/middleware/` |
|
||||
| Background job worker | `server/infrastructure/queue/workers/` |
|
||||
| Cron job definition | `server/infrastructure/jobs/` |
|
||||
| Test factory/fixture | `tests/fixtures/` |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Clear Boundaries**: Frontend, backend, and shared code are explicitly separated
|
||||
- **Feature Discoverability**: Find all code for a feature in one place
|
||||
- **Parallel Development**: Teams can work on domains independently
|
||||
- **Easier Refactoring**: Domain boundaries make changes localized
|
||||
- **Better Onboarding**: New developers navigate by feature, not file type
|
||||
- **Scalability**: Structure supports growth without becoming unwieldy
|
||||
|
||||
### Negative
|
||||
|
||||
- **Large Migration Effort**: Significant one-time cost (XL effort)
|
||||
- **Import Updates**: All imports need updating
|
||||
- **Learning Curve**: Team must learn new structure
|
||||
- **Merge Conflicts**: In-flight PRs will need rebasing
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Use automated tools (e.g., `ts-morph`) to update imports
|
||||
- Migrate one domain/feature at a time
|
||||
- Create a migration checklist and track progress
|
||||
- Coordinate with team to minimize in-flight work during migration phases
|
||||
- Consider using feature flags to ship incrementally
|
||||
|
||||
## Key Differences from Current Structure
|
||||
|
||||
| Aspect | Current | Target |
|
||||
| ---------------- | -------------------------- | ----------------------------------------- |
|
||||
| Frontend/Backend | Mixed in `src/` | Separated in `client/` and `server/` |
|
||||
| Services | Flat directory (75+ files) | Grouped by domain |
|
||||
| Components | Flat directory (43+ files) | Categorized (ui, layout, feedback, forms) |
|
||||
| Types | Monolithic `types.ts` | Split by entity in `shared/types/` |
|
||||
| Features | UI-only | Full vertical slice (UI + hooks + types) |
|
||||
| Routes | Separate from services | Co-located in domain |
|
||||
| Tests | Co-located + `tests/` | Co-located + `tests/` for fixtures |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern (affects domain structure)
|
||||
- [ADR-035](./0035-service-layer-architecture.md) - Service Layer (affects domain structure)
|
||||
- [ADR-044](./0044-frontend-feature-organization.md) - Frontend Features (this ADR supersedes it)
|
||||
- [ADR-045](./0045-test-data-factories-and-fixtures.md) - Test Fixtures (affects tests/ directory)
|
||||
419
docs/adr/0048-authentication-strategy.md
Normal file
419
docs/adr/0048-authentication-strategy.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# ADR-048: Authentication Strategy
|
||||
|
||||
**Date**: 2026-01-09
|
||||
|
||||
**Status**: Partially Implemented
|
||||
|
||||
**Implemented**: 2026-01-09 (Local auth only)
|
||||
|
||||
## Context
|
||||
|
||||
The application requires a secure authentication system that supports both traditional email/password login and social OAuth providers (Google, GitHub). The system must handle user sessions, token refresh, account security (lockout after failed attempts), and integrate seamlessly with the existing Express middleware pipeline.
|
||||
|
||||
Currently, **only local authentication is enabled**. OAuth strategies are fully implemented but commented out, pending configuration of OAuth provider credentials.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a stateless JWT-based authentication system with the following components:
|
||||
|
||||
1. **Local Authentication**: Email/password login with bcrypt hashing.
|
||||
2. **OAuth Authentication**: Google and GitHub OAuth 2.0 (currently disabled).
|
||||
3. **JWT Access Tokens**: Short-lived tokens (15 minutes) for API authentication.
|
||||
4. **Refresh Tokens**: Long-lived tokens (7 days) stored in HTTP-only cookies.
|
||||
5. **Account Security**: Lockout after 5 failed login attempts for 15 minutes.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Stateless Sessions**: No server-side session storage; JWT contains all auth state.
|
||||
- **Defense in Depth**: Multiple security layers (rate limiting, lockout, secure cookies).
|
||||
- **Graceful OAuth Degradation**: OAuth is optional; system works with local auth only.
|
||||
- **OAuth User Flexibility**: OAuth users have `password_hash = NULL` in database.
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
| Component | Status | Notes |
|
||||
| ------------------------ | ------- | ----------------------------------------------------------- |
|
||||
| **Local Authentication** | Enabled | Email/password with bcrypt (salt rounds = 10) |
|
||||
| **JWT Access Tokens** | Enabled | 15-minute expiry, `Authorization: Bearer` header |
|
||||
| **Refresh Tokens** | Enabled | 7-day expiry, HTTP-only cookie |
|
||||
| **Account Lockout** | Enabled | 5 failed attempts, 15-minute lockout |
|
||||
| **Password Reset** | Enabled | Email-based token flow |
|
||||
| **Google OAuth** | Enabled | Requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars |
|
||||
| **GitHub OAuth** | Enabled | Requires GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET env vars |
|
||||
| **OAuth Routes** | Enabled | `/api/auth/google`, `/api/auth/github` + callbacks |
|
||||
| **OAuth Frontend UI** | Enabled | Login buttons in AuthView.tsx |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AUTHENTICATION FLOW │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Login │───>│ Passport │───>│ JWT │───>│ Protected│ │
|
||||
│ │ Request │ │ Local │ │ Token │ │ Routes │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────────┐ │ │ │
|
||||
│ └────────>│ OAuth │─────────────┘ │ │
|
||||
│ (disabled) │ Provider │ │ │
|
||||
│ └──────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ Refresh │───>│ New │<─────────────────────────┘ │
|
||||
│ │ Token │ │ JWT │ (when access token expires) │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Local Strategy (Enabled)
|
||||
|
||||
Located in `src/routes/passport.routes.ts`:
|
||||
|
||||
```typescript
|
||||
passport.use(
|
||||
new LocalStrategy(
|
||||
{ usernameField: 'email', passReqToCallback: true },
|
||||
async (req, email, password, done) => {
|
||||
// 1. Find user with profile by email
|
||||
const userprofile = await db.userRepo.findUserWithProfileByEmail(email, req.log);
|
||||
|
||||
// 2. Check account lockout
|
||||
if (userprofile.failed_login_attempts >= MAX_FAILED_ATTEMPTS) {
|
||||
// Check if lockout period has passed
|
||||
}
|
||||
|
||||
// 3. Verify password with bcrypt
|
||||
const isMatch = await bcrypt.compare(password, userprofile.password_hash);
|
||||
|
||||
// 4. On success, reset failed attempts and return user
|
||||
// 5. On failure, increment failed attempts
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Security Features**:
|
||||
|
||||
- Bcrypt password hashing with salt rounds = 10
|
||||
- Account lockout after 5 failed attempts
|
||||
- 15-minute lockout duration
|
||||
- Failed attempt tracking persists across lockout refreshes
|
||||
- Activity logging for failed login attempts
|
||||
|
||||
### JWT Strategy (Enabled)
|
||||
|
||||
```typescript
|
||||
const jwtOptions = {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: JWT_SECRET,
|
||||
};
|
||||
|
||||
passport.use(
|
||||
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id);
|
||||
if (userProfile) {
|
||||
return done(null, userProfile);
|
||||
}
|
||||
return done(null, false);
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
**Token Configuration**:
|
||||
|
||||
- Access token: 15 minutes expiry
|
||||
- Refresh token: 7 days expiry, 64-byte random hex
|
||||
- Refresh token stored in HTTP-only cookie with `secure` flag in production
|
||||
|
||||
### OAuth Strategies (Disabled)
|
||||
|
||||
#### Google OAuth
|
||||
|
||||
Located in `src/routes/passport.routes.ts` (lines 167-217, commented):
|
||||
|
||||
```typescript
|
||||
// passport.use(new GoogleStrategy({
|
||||
// clientID: process.env.GOOGLE_CLIENT_ID!,
|
||||
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
// callbackURL: '/api/auth/google/callback',
|
||||
// scope: ['profile', 'email']
|
||||
// },
|
||||
// async (accessToken, refreshToken, profile, done) => {
|
||||
// const email = profile.emails?.[0]?.value;
|
||||
// const user = await db.findUserByEmail(email);
|
||||
// if (user) {
|
||||
// return done(null, user);
|
||||
// }
|
||||
// // Create new user with null password_hash
|
||||
// const newUser = await db.createUser(email, null, {
|
||||
// full_name: profile.displayName,
|
||||
// avatar_url: profile.photos?.[0]?.value
|
||||
// });
|
||||
// return done(null, newUser);
|
||||
// }
|
||||
// ));
|
||||
```
|
||||
|
||||
#### GitHub OAuth
|
||||
|
||||
Located in `src/routes/passport.routes.ts` (lines 219-269, commented):
|
||||
|
||||
```typescript
|
||||
// passport.use(new GitHubStrategy({
|
||||
// clientID: process.env.GITHUB_CLIENT_ID!,
|
||||
// clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
// callbackURL: '/api/auth/github/callback',
|
||||
// scope: ['user:email']
|
||||
// },
|
||||
// async (accessToken, refreshToken, profile, done) => {
|
||||
// const email = profile.emails?.[0]?.value;
|
||||
// // Similar flow to Google OAuth
|
||||
// }
|
||||
// ));
|
||||
```
|
||||
|
||||
#### OAuth Routes (Disabled)
|
||||
|
||||
Located in `src/routes/auth.routes.ts` (lines 289-315, commented):
|
||||
|
||||
```typescript
|
||||
// const handleOAuthCallback = (req, res) => {
|
||||
// const user = req.user;
|
||||
// const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
// const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
//
|
||||
// await db.saveRefreshToken(user.user_id, refreshToken);
|
||||
// res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true });
|
||||
// res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
|
||||
// };
|
||||
|
||||
// router.get('/google', passport.authenticate('google', { session: false }));
|
||||
// router.get('/google/callback', passport.authenticate('google', { ... }), handleOAuthCallback);
|
||||
// router.get('/github', passport.authenticate('github', { session: false }));
|
||||
// router.get('/github/callback', passport.authenticate('github', { ... }), handleOAuthCallback);
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Users Table** (`sql/initial_schema.sql`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.users (
|
||||
user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT, -- NULL for OAuth-only users
|
||||
refresh_token TEXT, -- Current refresh token
|
||||
failed_login_attempts INTEGER DEFAULT 0,
|
||||
last_failed_login TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
**Note**: There is no separate OAuth provider mapping table. OAuth users are identified by `password_hash = NULL`. If a user signs up via OAuth and later wants to add a password, this would require schema changes.
|
||||
|
||||
### Authentication Middleware
|
||||
|
||||
Located in `src/routes/passport.routes.ts`:
|
||||
|
||||
```typescript
|
||||
// Require admin role
|
||||
export const isAdmin = (req, res, next) => {
|
||||
if (req.user?.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
next(new ForbiddenError('Administrator access required.'));
|
||||
}
|
||||
};
|
||||
|
||||
// Optional auth - attach user if present, continue if not
|
||||
export const optionalAuth = (req, res, next) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user) => {
|
||||
if (user) req.user = user;
|
||||
next();
|
||||
})(req, res, next);
|
||||
};
|
||||
|
||||
// Mock auth for testing (only in NODE_ENV=test)
|
||||
export const mockAuth = (req, res, next) => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
req.user = createMockUserProfile({ role: 'admin' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
```
|
||||
|
||||
## Enabling OAuth
|
||||
|
||||
### Step 1: Set Environment Variables
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
# GitHub OAuth
|
||||
GITHUB_CLIENT_ID=your-github-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
```
|
||||
|
||||
### Step 2: Configure OAuth Providers
|
||||
|
||||
**Google Cloud Console**:
|
||||
|
||||
1. Create project at <https://console.cloud.google.com/>
|
||||
2. Enable Google+ API
|
||||
3. Create OAuth 2.0 credentials (Web Application)
|
||||
4. Add authorized redirect URI:
|
||||
- Development: `http://localhost:3001/api/auth/google/callback`
|
||||
- Production: `https://your-domain.com/api/auth/google/callback`
|
||||
|
||||
**GitHub Developer Settings**:
|
||||
|
||||
1. Go to <https://github.com/settings/developers>
|
||||
2. Create new OAuth App
|
||||
3. Set Authorization callback URL:
|
||||
- Development: `http://localhost:3001/api/auth/github/callback`
|
||||
- Production: `https://your-domain.com/api/auth/github/callback`
|
||||
|
||||
### Step 3: Uncomment Backend Code
|
||||
|
||||
**In `src/routes/passport.routes.ts`**:
|
||||
|
||||
1. Uncomment import statements (lines 5-6):
|
||||
|
||||
```typescript
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||
```
|
||||
|
||||
2. Uncomment Google strategy (lines 167-217)
|
||||
3. Uncomment GitHub strategy (lines 219-269)
|
||||
|
||||
**In `src/routes/auth.routes.ts`**:
|
||||
|
||||
1. Uncomment `handleOAuthCallback` function (lines 291-309)
|
||||
2. Uncomment OAuth routes (lines 311-315)
|
||||
|
||||
### Step 4: Add Frontend OAuth Buttons
|
||||
|
||||
Create login buttons that redirect to:
|
||||
|
||||
- Google: `GET /api/auth/google`
|
||||
- GitHub: `GET /api/auth/github`
|
||||
|
||||
Handle callback at `/auth/callback?token=<accessToken>`:
|
||||
|
||||
1. Extract token from URL
|
||||
2. Store in client-side token storage
|
||||
3. Redirect to dashboard
|
||||
|
||||
### Step 5: Handle OAuth Callback Page
|
||||
|
||||
Create `src/pages/AuthCallback.tsx`:
|
||||
|
||||
```typescript
|
||||
const AuthCallback = () => {
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
if (token) {
|
||||
setToken(token);
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/login?error=auth_failed');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No OAuth Provider ID Mapping**: Users are identified by email only. If a user has accounts with different emails on Google and GitHub, they create separate accounts.
|
||||
|
||||
2. **No Account Linking**: Users cannot link multiple OAuth providers to one account.
|
||||
|
||||
3. **No Password Addition for OAuth Users**: OAuth-only users cannot add a password to enable local login.
|
||||
|
||||
4. **No PKCE Flow**: OAuth implementation uses standard flow, not PKCE (Proof Key for Code Exchange).
|
||||
|
||||
5. **No OAuth State Parameter Validation**: The commented code doesn't show explicit state parameter handling for CSRF protection (Passport may handle this internally).
|
||||
|
||||
6. **No Refresh Token from OAuth Providers**: Only email/profile data is extracted; OAuth refresh tokens are not stored for API access.
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Installed** (all available):
|
||||
|
||||
- `passport` v0.7.0
|
||||
- `passport-local` v1.0.0
|
||||
- `passport-jwt` v4.0.1
|
||||
- `passport-google-oauth20` v2.0.0
|
||||
- `passport-github2` v0.1.12
|
||||
- `bcrypt` v5.x
|
||||
- `jsonwebtoken` v9.x
|
||||
|
||||
**Type Definitions**:
|
||||
|
||||
- `@types/passport`
|
||||
- `@types/passport-local`
|
||||
- `@types/passport-jwt`
|
||||
- `@types/passport-google-oauth20`
|
||||
- `@types/passport-github2`
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Stateless Architecture**: No session storage required; scales horizontally.
|
||||
- **Secure by Default**: HTTP-only cookies, short token expiry, bcrypt hashing.
|
||||
- **Account Protection**: Lockout prevents brute-force attacks.
|
||||
- **Flexible OAuth**: Can enable/disable OAuth without code changes (just env vars + uncommenting).
|
||||
- **Graceful Degradation**: System works with local auth only.
|
||||
|
||||
### Negative
|
||||
|
||||
- **OAuth Disabled by Default**: Requires manual uncommenting to enable.
|
||||
- **No Account Linking**: Multiple OAuth providers create separate accounts.
|
||||
- **Frontend Work Required**: OAuth login buttons don't exist yet.
|
||||
- **Token in URL**: OAuth callback passes token in URL (visible in browser history).
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Document OAuth enablement steps clearly (see AUTHENTICATION.md).
|
||||
- Consider adding OAuth provider ID columns for future account linking.
|
||||
- Use URL fragment (`#token=`) instead of query parameter for callback.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------ |
|
||||
| `src/routes/passport.routes.ts` | Passport strategies (local, JWT, OAuth) |
|
||||
| `src/routes/auth.routes.ts` | Auth endpoints (login, register, refresh, OAuth) |
|
||||
| `src/services/authService.ts` | Auth business logic |
|
||||
| `src/services/db/user.db.ts` | User database operations |
|
||||
| `src/config/env.ts` | Environment variable validation |
|
||||
| `AUTHENTICATION.md` | OAuth setup guide |
|
||||
| `.env.example` | Environment variable template |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-011](./0011-advanced-authorization-and-access-control-strategy.md) - Authorization and Access Control
|
||||
- [ADR-016](./0016-api-security-hardening.md) - API Security (rate limiting, headers)
|
||||
- [ADR-032](./0032-rate-limiting-strategy.md) - Rate Limiting
|
||||
- [ADR-043](./0043-express-middleware-pipeline.md) - Middleware Pipeline
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Enable OAuth**: Uncomment strategies and configure providers.
|
||||
2. **Add OAuth Provider Mapping Table**: Store `googleId`, `githubId` for account linking.
|
||||
3. **Implement Account Linking**: Allow users to connect multiple OAuth providers.
|
||||
4. **Add Password to OAuth Users**: Allow OAuth users to set a password.
|
||||
5. **Implement PKCE**: Add PKCE flow for enhanced OAuth security.
|
||||
6. **Token in Fragment**: Use URL fragment for OAuth callback token.
|
||||
7. **OAuth Token Storage**: Store OAuth refresh tokens for provider API access.
|
||||
8. **Magic Link Login**: Add passwordless email login option.
|
||||
299
docs/adr/0049-gamification-and-achievement-system.md
Normal file
299
docs/adr/0049-gamification-and-achievement-system.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# ADR-049: Gamification and Achievement System
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Implemented**: 2026-01-11
|
||||
|
||||
## Context
|
||||
|
||||
The application implements a gamification system to encourage user engagement through achievements and points. Users earn achievements for completing specific actions within the platform, and these achievements contribute to a points-based leaderboard.
|
||||
|
||||
Key requirements:
|
||||
|
||||
1. **User Engagement**: Reward users for meaningful actions (uploads, recipes, sharing).
|
||||
2. **Progress Tracking**: Show users their accomplishments and progress.
|
||||
3. **Social Competition**: Leaderboard to compare users by points.
|
||||
4. **Idempotent Awards**: Achievements should only be awarded once per user.
|
||||
5. **Transactional Safety**: Achievement awards must be atomic with the triggering action.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a database-driven gamification system with:
|
||||
|
||||
1. **Database Functions**: Core logic in PostgreSQL for atomicity and idempotency.
|
||||
2. **Database Triggers**: Automatic achievement awards on specific events.
|
||||
3. **Application-Level Awards**: Explicit calls from service layer when triggers aren't suitable.
|
||||
4. **Points Aggregation**: Stored in user profile for efficient leaderboard queries.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Single Award**: Each achievement can only be earned once per user (enforced by unique constraint).
|
||||
- **Atomic Operations**: Achievement awards happen within the same transaction as the triggering action.
|
||||
- **Silent Failure**: If an achievement doesn't exist, the award function returns silently (no error).
|
||||
- **Points Sync**: Points are updated on the profile immediately when an achievement is awarded.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- Achievements master table
|
||||
CREATE TABLE public.achievements (
|
||||
achievement_id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
points_value INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- User achievements (junction table)
|
||||
CREATE TABLE public.user_achievements (
|
||||
user_id UUID REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||
achievement_id BIGINT REFERENCES public.achievements(achievement_id) ON DELETE CASCADE,
|
||||
achieved_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, achievement_id)
|
||||
);
|
||||
|
||||
-- Points stored on profile for efficient leaderboard
|
||||
ALTER TABLE public.profiles ADD COLUMN points INTEGER DEFAULT 0;
|
||||
```
|
||||
|
||||
### Award Achievement Function
|
||||
|
||||
Located in `sql/Initial_triggers_and_functions.sql`:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.award_achievement(p_user_id UUID, p_achievement_name TEXT)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_achievement_id BIGINT;
|
||||
v_points_value INTEGER;
|
||||
BEGIN
|
||||
-- Find the achievement by name to get its ID and point value.
|
||||
SELECT achievement_id, points_value INTO v_achievement_id, v_points_value
|
||||
FROM public.achievements WHERE name = p_achievement_name;
|
||||
|
||||
-- If the achievement doesn't exist, do nothing.
|
||||
IF v_achievement_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Insert the achievement for the user.
|
||||
-- ON CONFLICT DO NOTHING ensures idempotency.
|
||||
INSERT INTO public.user_achievements (user_id, achievement_id)
|
||||
VALUES (p_user_id, v_achievement_id)
|
||||
ON CONFLICT (user_id, achievement_id) DO NOTHING;
|
||||
|
||||
-- If the insert was successful (user didn't have it), update their points.
|
||||
IF FOUND THEN
|
||||
UPDATE public.profiles SET points = points + v_points_value WHERE user_id = p_user_id;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Current Achievements
|
||||
|
||||
| Name | Description | Icon | Points |
|
||||
| -------------------- | ----------------------------------------------------------- | ------------ | ------ |
|
||||
| Welcome Aboard | Join the community by creating your account. | user-check | 5 |
|
||||
| First Recipe | Create your very first recipe. | chef-hat | 10 |
|
||||
| Recipe Sharer | Share a recipe with another user for the first time. | share-2 | 15 |
|
||||
| List Sharer | Share a shopping list with another user for the first time. | list | 20 |
|
||||
| First Favorite | Mark a recipe as one of your favorites. | heart | 5 |
|
||||
| First Fork | Make a personal copy of a public recipe. | git-fork | 10 |
|
||||
| First Budget Created | Create your first budget to track spending. | piggy-bank | 15 |
|
||||
| First-Upload | Upload your first flyer. | upload-cloud | 25 |
|
||||
|
||||
### Achievement Triggers
|
||||
|
||||
#### User Registration (Database Trigger)
|
||||
|
||||
Awards "Welcome Aboard" when a new user is created:
|
||||
|
||||
```sql
|
||||
-- In handle_new_user() function
|
||||
PERFORM public.award_achievement(new.user_id, 'Welcome Aboard');
|
||||
```
|
||||
|
||||
#### Flyer Upload (Database Trigger + Application Code)
|
||||
|
||||
Awards "First-Upload" when a flyer is inserted with an `uploaded_by` value:
|
||||
|
||||
```sql
|
||||
-- In log_new_flyer() trigger function
|
||||
IF NEW.uploaded_by IS NOT NULL THEN
|
||||
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
|
||||
END IF;
|
||||
```
|
||||
|
||||
Additionally, the `FlyerPersistenceService.saveFlyer()` method explicitly awards the achievement within the transaction:
|
||||
|
||||
```typescript
|
||||
// In src/services/flyerPersistenceService.server.ts
|
||||
if (userId) {
|
||||
const gamificationRepo = new GamificationRepository(client);
|
||||
await gamificationRepo.awardAchievement(userId, 'First-Upload', logger);
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Layer
|
||||
|
||||
Located in `src/services/db/gamification.db.ts`:
|
||||
|
||||
```typescript
|
||||
export class GamificationRepository {
|
||||
private db: Pick<Pool | PoolClient, 'query'>;
|
||||
|
||||
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async getUserAchievements(
|
||||
userId: string,
|
||||
logger: Logger,
|
||||
): Promise<(UserAchievement & Achievement)[]> {
|
||||
const query = `
|
||||
SELECT ua.user_id, ua.achievement_id, ua.achieved_at,
|
||||
a.name, a.description, a.icon, a.points_value, a.created_at
|
||||
FROM public.user_achievements ua
|
||||
JOIN public.achievements a ON ua.achievement_id = a.achievement_id
|
||||
WHERE ua.user_id = $1
|
||||
ORDER BY ua.achieved_at DESC;
|
||||
`;
|
||||
const res = await this.db.query(query, [userId]);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async awardAchievement(userId: string, achievementName: string, logger: Logger): Promise<void> {
|
||||
await this.db.query('SELECT public.award_achievement($1, $2)', [userId, achievementName]);
|
||||
}
|
||||
|
||||
async getLeaderboard(limit: number, logger: Logger): Promise<LeaderboardUser[]> {
|
||||
const query = `
|
||||
SELECT user_id, full_name, avatar_url, points,
|
||||
RANK() OVER (ORDER BY points DESC) as rank
|
||||
FROM public.profiles
|
||||
ORDER BY points DESC, full_name ASC
|
||||
LIMIT $1;
|
||||
`;
|
||||
const res = await this.db.query(query, [limit]);
|
||||
return res.rows;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------- | ------------------------------- |
|
||||
| GET | `/api/achievements` | List all available achievements |
|
||||
| GET | `/api/achievements/me` | Get current user's achievements |
|
||||
| GET | `/api/achievements/leaderboard` | Get top users by points |
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Critical Testing Requirements
|
||||
|
||||
When testing gamification features, be aware of the following:
|
||||
|
||||
1. **Database Seed Data**: Achievement definitions must exist in the database before tests run. The `award_achievement()` function silently returns if the achievement name doesn't exist.
|
||||
|
||||
2. **Transactional Context**: When awarding achievements from within a transaction:
|
||||
- The achievement is visible within the transaction immediately
|
||||
- External queries won't see the achievement until the transaction commits
|
||||
- Tests should wait for job completion before asserting achievement state
|
||||
|
||||
3. **Vitest Global Setup Context**: The integration test global setup runs in a separate Node.js context. Achievement verification must use direct database queries, not mocked services.
|
||||
|
||||
4. **Achievement Idempotency**: Calling `award_achievement()` multiple times for the same user/achievement combination is safe and expected. Only the first call actually inserts.
|
||||
|
||||
### Example Integration Test Pattern
|
||||
|
||||
```typescript
|
||||
it('should award the "First Upload" achievement after flyer processing', async () => {
|
||||
// 1. Create user (awards "Welcome Aboard" via database trigger)
|
||||
const { user: testUser, token } = await createAndLoginUser({...});
|
||||
|
||||
// 2. Upload flyer (triggers async job)
|
||||
const uploadResponse = await request
|
||||
.post('/api/flyers/upload')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.attach('flyerFile', testImagePath);
|
||||
expect(uploadResponse.status).toBe(202);
|
||||
|
||||
// 3. Wait for job to complete
|
||||
await poll(async () => {
|
||||
const status = await request.get(`/api/flyers/job/${jobId}/status`);
|
||||
return status.body.data.status === 'completed';
|
||||
}, { timeout: 15000 });
|
||||
|
||||
// 4. Wait for achievements to be visible (transaction committed)
|
||||
await vi.waitUntil(async () => {
|
||||
const achievements = await db.gamificationRepo.getUserAchievements(
|
||||
testUser.user.user_id,
|
||||
logger
|
||||
);
|
||||
return achievements.length >= 2; // Welcome Aboard + First-Upload
|
||||
}, { timeout: 15000, interval: 500 });
|
||||
|
||||
// 5. Assert specific achievements
|
||||
const userAchievements = await db.gamificationRepo.getUserAchievements(
|
||||
testUser.user.user_id,
|
||||
logger
|
||||
);
|
||||
expect(userAchievements.find(a => a.name === 'Welcome Aboard')).toBeDefined();
|
||||
expect(userAchievements.find(a => a.name === 'First-Upload')).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
### Common Test Pitfalls
|
||||
|
||||
1. **Missing Seed Data**: If tests fail with "achievement not found", ensure the test database has the achievements table populated.
|
||||
|
||||
2. **Race Conditions**: Achievement awards in async jobs may not be visible immediately. Always poll or use `vi.waitUntil()`.
|
||||
|
||||
3. **Wrong User ID**: Verify the user ID passed to `awardAchievement()` matches the user created in the test.
|
||||
|
||||
4. **Transaction Isolation**: When querying within a test, use the same database connection if checking mid-transaction state.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Engagement**: Users have clear goals and rewards for platform activity.
|
||||
- **Scalability**: Points stored on profile enable O(1) leaderboard sorting.
|
||||
- **Reliability**: Database-level idempotency prevents duplicate awards.
|
||||
- **Flexibility**: New achievements can be added via SQL without code changes.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Complexity**: Multiple award paths (triggers + application code) require careful coordination.
|
||||
- **Testing**: Async nature of some awards complicates integration testing.
|
||||
- **Coupling**: Achievement names are strings; typos fail silently.
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Use constants for achievement names in application code.
|
||||
- Document all award trigger points clearly.
|
||||
- Test each achievement path independently.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `sql/initial_data.sql` - Achievement definitions (seed data)
|
||||
- `sql/Initial_triggers_and_functions.sql` - `award_achievement()` function and triggers
|
||||
- `src/services/db/gamification.db.ts` - Repository layer
|
||||
- `src/routes/achievements.routes.ts` - API endpoints
|
||||
- `src/services/flyerPersistenceService.server.ts` - First-Upload award (application code)
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-002](./0002-standardized-transaction-management.md) - Transaction Management
|
||||
- [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern
|
||||
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Jobs (flyer processing)
|
||||
341
docs/adr/0050-postgresql-function-observability.md
Normal file
341
docs/adr/0050-postgresql-function-observability.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# ADR-050: PostgreSQL Function Observability
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
**Related**: [ADR-015](0015-application-performance-monitoring-and-error-tracking.md), [ADR-004](0004-standardized-application-wide-structured-logging.md)
|
||||
|
||||
## Context
|
||||
|
||||
The application uses 30+ PostgreSQL functions and 11+ triggers for business logic, including:
|
||||
|
||||
- Recipe recommendations and search
|
||||
- Shopping list generation from menu plans
|
||||
- Price history tracking
|
||||
- Achievement awards
|
||||
- Activity logging
|
||||
- User profile creation
|
||||
|
||||
**Current Problem**: These database functions can fail silently in several ways:
|
||||
|
||||
1. **`ON CONFLICT DO NOTHING`** - Swallows constraint violations without notification
|
||||
2. **`IF NOT FOUND THEN RETURN;`** - Silently exits when data is missing
|
||||
3. **Trigger functions returning `NULL`** - No indication of partial failures
|
||||
4. **No logging inside functions** - No visibility into function execution
|
||||
|
||||
When these silent failures occur:
|
||||
|
||||
- The application layer receives no error (function "succeeds" but does nothing)
|
||||
- No logs are generated for debugging
|
||||
- Issues are only discovered when users report missing data
|
||||
- Root cause analysis is extremely difficult
|
||||
|
||||
**Example of Silent Failure**:
|
||||
|
||||
```sql
|
||||
-- This function silently does nothing if achievement doesn't exist
|
||||
CREATE OR REPLACE FUNCTION public.award_achievement(p_user_id UUID, p_achievement_name TEXT)
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
SELECT achievement_id INTO v_achievement_id FROM achievements WHERE name = p_achievement_name;
|
||||
IF v_achievement_id IS NULL THEN
|
||||
RETURN; -- Silent failure - no log, no error
|
||||
END IF;
|
||||
-- ...
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
ADR-015 established Logstash + Bugsink for error tracking, with PostgreSQL log integration marked as "future". This ADR defines the implementation.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a standardized PostgreSQL function observability strategy with three tiers of logging severity:
|
||||
|
||||
### 1. Function Logging Helper
|
||||
|
||||
Create a reusable logging function that outputs structured JSON to PostgreSQL logs:
|
||||
|
||||
```sql
|
||||
-- Function to emit structured log messages from PL/pgSQL
|
||||
CREATE OR REPLACE FUNCTION public.fn_log(
|
||||
p_level TEXT, -- 'DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'
|
||||
p_function_name TEXT, -- The calling function name
|
||||
p_message TEXT, -- Human-readable message
|
||||
p_context JSONB DEFAULT NULL -- Additional context (user_id, params, etc.)
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
log_line TEXT;
|
||||
BEGIN
|
||||
-- Build structured JSON log line
|
||||
log_line := jsonb_build_object(
|
||||
'timestamp', now(),
|
||||
'level', p_level,
|
||||
'source', 'postgresql',
|
||||
'function', p_function_name,
|
||||
'message', p_message,
|
||||
'context', COALESCE(p_context, '{}'::jsonb)
|
||||
)::text;
|
||||
|
||||
-- Use appropriate RAISE level
|
||||
CASE p_level
|
||||
WHEN 'DEBUG' THEN RAISE DEBUG '%', log_line;
|
||||
WHEN 'INFO' THEN RAISE INFO '%', log_line;
|
||||
WHEN 'NOTICE' THEN RAISE NOTICE '%', log_line;
|
||||
WHEN 'WARNING' THEN RAISE WARNING '%', log_line;
|
||||
WHEN 'ERROR' THEN RAISE LOG '%', log_line; -- Use LOG for errors to ensure capture
|
||||
ELSE RAISE NOTICE '%', log_line;
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### 2. Logging Tiers
|
||||
|
||||
#### Tier 1: Critical Functions (Always Log)
|
||||
|
||||
Functions where silent failure causes data corruption or user-facing issues:
|
||||
|
||||
| Function | Log Events |
|
||||
| ---------------------------------- | --------------------------------------- |
|
||||
| `handle_new_user()` | User creation, profile creation, errors |
|
||||
| `award_achievement()` | Achievement not found, already awarded |
|
||||
| `approve_correction()` | Correction not found, permission denied |
|
||||
| `complete_shopping_list()` | List not found, permission denied |
|
||||
| `add_menu_plan_to_shopping_list()` | Permission denied, items added |
|
||||
| `fork_recipe()` | Original not found, fork created |
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.award_achievement(p_user_id UUID, p_achievement_name TEXT)
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
v_achievement_id BIGINT;
|
||||
v_points_value INTEGER;
|
||||
v_context JSONB;
|
||||
BEGIN
|
||||
v_context := jsonb_build_object('user_id', p_user_id, 'achievement_name', p_achievement_name);
|
||||
|
||||
SELECT achievement_id, points_value INTO v_achievement_id, v_points_value
|
||||
FROM public.achievements WHERE name = p_achievement_name;
|
||||
|
||||
IF v_achievement_id IS NULL THEN
|
||||
-- Log the issue instead of silent return
|
||||
PERFORM fn_log('WARNING', 'award_achievement',
|
||||
'Achievement not found: ' || p_achievement_name, v_context);
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_achievements (user_id, achievement_id)
|
||||
VALUES (p_user_id, v_achievement_id)
|
||||
ON CONFLICT (user_id, achievement_id) DO NOTHING;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.profiles SET points = points + v_points_value WHERE user_id = p_user_id;
|
||||
PERFORM fn_log('INFO', 'award_achievement',
|
||||
'Achievement awarded: ' || p_achievement_name, v_context);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
#### Tier 2: Business Logic Functions (Log on Anomalies)
|
||||
|
||||
Functions where unexpected conditions should be logged but aren't critical:
|
||||
|
||||
| Function | Log Events |
|
||||
| -------------------------------------- | ---------------------------------- |
|
||||
| `suggest_master_item_for_flyer_item()` | No match found (below threshold) |
|
||||
| `recommend_recipes_for_user()` | No recommendations generated |
|
||||
| `find_recipes_from_pantry()` | Empty pantry, no recipes found |
|
||||
| `get_best_sale_prices_for_user()` | No watched items, no current sales |
|
||||
|
||||
**Pattern**: Log when results are unexpectedly empty or inputs are invalid.
|
||||
|
||||
#### Tier 3: Triggers (Log Errors Only)
|
||||
|
||||
Triggers should be fast, so only log when something goes wrong:
|
||||
|
||||
| Trigger Function | Log Events |
|
||||
| --------------------------------------------- | ------------------------- |
|
||||
| `update_price_history_on_flyer_item_insert()` | Failed to update history |
|
||||
| `update_recipe_rating_aggregates()` | Rating calculation failed |
|
||||
| `log_new_recipe()` | Profile lookup failed |
|
||||
| `log_new_flyer()` | Store lookup failed |
|
||||
|
||||
### 3. PostgreSQL Configuration
|
||||
|
||||
Enable logging in `postgresql.conf`:
|
||||
|
||||
```ini
|
||||
# Log all function notices and above
|
||||
log_min_messages = notice
|
||||
|
||||
# Include function name in log prefix
|
||||
log_line_prefix = '%t [%p] %u@%d '
|
||||
|
||||
# Log to file for Logstash pickup
|
||||
logging_collector = on
|
||||
log_directory = '/var/log/postgresql'
|
||||
log_filename = 'postgresql-%Y-%m-%d.log'
|
||||
log_rotation_age = 1d
|
||||
log_rotation_size = 100MB
|
||||
|
||||
# Capture slow queries from functions
|
||||
log_min_duration_statement = 1000 # Log queries over 1 second
|
||||
```
|
||||
|
||||
### 4. Logstash Integration
|
||||
|
||||
Update the Logstash pipeline (extends ADR-015 configuration):
|
||||
|
||||
```conf
|
||||
# PostgreSQL function log input
|
||||
input {
|
||||
file {
|
||||
path => "/var/log/postgresql/*.log"
|
||||
type => "postgres"
|
||||
tags => ["postgres"]
|
||||
start_position => "beginning"
|
||||
sincedb_path => "/var/lib/logstash/sincedb_postgres"
|
||||
}
|
||||
}
|
||||
|
||||
filter {
|
||||
if [type] == "postgres" {
|
||||
# Extract timestamp and process ID from PostgreSQL log prefix
|
||||
grok {
|
||||
match => { "message" => "%{TIMESTAMP_ISO8601:pg_timestamp} \[%{POSINT:pg_pid}\] %{USER:pg_user}@%{WORD:pg_database} %{GREEDYDATA:pg_message}" }
|
||||
}
|
||||
|
||||
# Check if this is a structured JSON log from fn_log()
|
||||
if [pg_message] =~ /^\{.*"source":"postgresql".*\}$/ {
|
||||
json {
|
||||
source => "pg_message"
|
||||
target => "fn_log"
|
||||
}
|
||||
|
||||
# Mark as error if level is WARNING or ERROR
|
||||
if [fn_log][level] in ["WARNING", "ERROR"] {
|
||||
mutate { add_tag => ["error", "db_function"] }
|
||||
}
|
||||
}
|
||||
|
||||
# Also catch native PostgreSQL errors
|
||||
if [pg_message] =~ /^ERROR:/ or [pg_message] =~ /^FATAL:/ {
|
||||
mutate { add_tag => ["error", "postgres_native"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output {
|
||||
if "error" in [tags] and "postgres" in [tags] {
|
||||
http {
|
||||
url => "http://localhost:8000/api/store/"
|
||||
http_method => "post"
|
||||
format => "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Dual-File Update Requirement
|
||||
|
||||
**IMPORTANT**: All SQL function changes must be applied to BOTH files:
|
||||
|
||||
1. `sql/Initial_triggers_and_functions.sql` - Used for incremental updates
|
||||
2. `sql/master_schema_rollup.sql` - Used for fresh database setup
|
||||
|
||||
Both files must remain in sync for triggers and functions.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create `fn_log()` helper function**:
|
||||
- Add to both `Initial_triggers_and_functions.sql` and `master_schema_rollup.sql`
|
||||
- Test with `SELECT fn_log('INFO', 'test', 'Test message', '{"key": "value"}'::jsonb);`
|
||||
|
||||
2. **Update Tier 1 critical functions** (highest priority):
|
||||
- `award_achievement()` - Log missing achievements, duplicate awards
|
||||
- `handle_new_user()` - Log user creation success/failure
|
||||
- `approve_correction()` - Log not found, permission denied
|
||||
- `complete_shopping_list()` - Log permission checks
|
||||
- `add_menu_plan_to_shopping_list()` - Log permission checks, items added
|
||||
- `fork_recipe()` - Log original not found
|
||||
|
||||
3. **Update Tier 2 business logic functions**:
|
||||
- Add anomaly logging to suggestion/recommendation functions
|
||||
- Log empty result sets with context
|
||||
|
||||
4. **Update Tier 3 trigger functions**:
|
||||
- Add error-only logging to critical triggers
|
||||
- Wrap complex trigger logic in exception handlers
|
||||
|
||||
5. **Configure PostgreSQL logging**:
|
||||
- Update `postgresql.conf` in dev container
|
||||
- Update production PostgreSQL configuration
|
||||
- Verify logs appear in expected location
|
||||
|
||||
6. **Update Logstash pipeline**:
|
||||
- Add PostgreSQL input to `bugsink.conf`
|
||||
- Add filter rules for structured JSON extraction
|
||||
- Test end-to-end: function log → Logstash → Bugsink
|
||||
|
||||
7. **Verify in Bugsink**:
|
||||
- Confirm database function errors appear as issues
|
||||
- Verify context (user_id, function name, params) is captured
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Visibility**: Silent failures become visible in error tracking
|
||||
- **Debugging**: Function execution context captured for root cause analysis
|
||||
- **Proactive detection**: Anomalies logged before users report issues
|
||||
- **Unified monitoring**: Database errors appear alongside application errors in Bugsink
|
||||
- **Structured logs**: JSON format enables filtering and aggregation
|
||||
|
||||
### Negative
|
||||
|
||||
- **Performance overhead**: Logging adds latency to function execution
|
||||
- **Log volume**: Tier 1/2 functions may generate significant log volume
|
||||
- **Maintenance**: Two SQL files must be kept in sync
|
||||
- **PostgreSQL configuration**: Requires access to `postgresql.conf`
|
||||
|
||||
### Mitigations
|
||||
|
||||
- **Performance**: Only log meaningful events, not every function call
|
||||
- **Log volume**: Use appropriate log levels; Logstash filters reduce noise
|
||||
- **Sync**: Add CI check to verify SQL files match for function definitions
|
||||
- **Configuration**: Document PostgreSQL settings in deployment runbook
|
||||
|
||||
## Examples
|
||||
|
||||
### Before (Silent Failure)
|
||||
|
||||
```sql
|
||||
-- User thinks achievement was awarded, but it silently failed
|
||||
SELECT award_achievement('user-uuid', 'Nonexistent Badge');
|
||||
-- Returns: void (no error, no log)
|
||||
-- Result: User never gets achievement, nobody knows why
|
||||
```
|
||||
|
||||
### After (Observable Failure)
|
||||
|
||||
```sql
|
||||
SELECT award_achievement('user-uuid', 'Nonexistent Badge');
|
||||
-- Returns: void
|
||||
-- PostgreSQL log: {"timestamp":"2026-01-11T10:30:00Z","level":"WARNING","source":"postgresql","function":"award_achievement","message":"Achievement not found: Nonexistent Badge","context":{"user_id":"user-uuid","achievement_name":"Nonexistent Badge"}}
|
||||
-- Bugsink: New issue created with full context
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-015: Application Performance Monitoring](0015-application-performance-monitoring-and-error-tracking.md)
|
||||
- [ADR-004: Standardized Structured Logging](0004-standardized-application-wide-structured-logging.md)
|
||||
- [PostgreSQL RAISE Documentation](https://www.postgresql.org/docs/current/plpgsql-errors-and-messages.html)
|
||||
- [PostgreSQL Logging Configuration](https://www.postgresql.org/docs/current/runtime-config-logging.html)
|
||||
54
docs/adr/0051-asynchronous-context-propagation.md
Normal file
54
docs/adr/0051-asynchronous-context-propagation.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ADR-051: Asynchronous Context Propagation
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Accepted (Implemented)
|
||||
|
||||
## Context
|
||||
|
||||
Debugging asynchronous workflows is difficult because the `request_id` generated at the API layer is lost when a task is handed off to a background queue (BullMQ). Logs from the worker appear disconnected from the user action that triggered them.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a context propagation pattern for all background jobs:
|
||||
|
||||
1. **Job Data Payload**: All job data interfaces MUST include a `meta` object containing `requestId`, `userId`, and `origin`.
|
||||
2. **Worker Logger Initialization**: All BullMQ workers MUST initialize a child logger immediately upon processing a job, using the metadata passed in the payload.
|
||||
3. **Correlation**: The worker's logger must use the _same_ `request_id` as the initiating API request.
|
||||
|
||||
## Implementation
|
||||
|
||||
```typescript
|
||||
// 1. Enqueueing (API Layer)
|
||||
await queue.add('process-flyer', {
|
||||
...data,
|
||||
meta: {
|
||||
requestId: req.log.bindings().request_id, // Propagate ID
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Processing (Worker Layer)
|
||||
const worker = new Worker('queue', async (job) => {
|
||||
const { requestId, userId } = job.data.meta || {};
|
||||
|
||||
// Create context-aware logger for this specific job execution
|
||||
const jobLogger = logger.child({
|
||||
request_id: requestId || uuidv4(), // Use propagated ID or generate new
|
||||
user_id: userId,
|
||||
job_id: job.id,
|
||||
service: 'worker',
|
||||
});
|
||||
|
||||
try {
|
||||
await processJob(job.data, jobLogger); // Pass logger down
|
||||
} catch (err) {
|
||||
jobLogger.error({ err }, 'Job failed');
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Complete traceability from API request -> Queue -> Worker execution. Drastically reduces time to find "what happened" to a specific user request.
|
||||
42
docs/adr/0052-granular-debug-logging-strategy.md
Normal file
42
docs/adr/0052-granular-debug-logging-strategy.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# ADR-052: Granular Debug Logging Strategy
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Global log levels (INFO vs DEBUG) are too coarse. Developers need to inspect detailed debug information for specific subsystems (e.g., `ai-service`, `db-pool`) without being flooded by logs from the entire application.
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt a namespace-based debug filter pattern, similar to the `debug` npm package, but integrated into our Pino logger.
|
||||
|
||||
1. **Logger Namespaces**: Every service/module logger must be initialized with a `module` property (e.g., `logger.child({ module: 'ai-service' })`).
|
||||
2. **Environment Filter**: We will support a `DEBUG_MODULES` environment variable that overrides the log level for matching modules.
|
||||
|
||||
## Implementation
|
||||
|
||||
In `src/services/logger.server.ts`:
|
||||
|
||||
```typescript
|
||||
const debugModules = (process.env.DEBUG_MODULES || '').split(',').map((s) => s.trim());
|
||||
|
||||
export const createScopedLogger = (moduleName: string) => {
|
||||
// If DEBUG_MODULES contains "ai-service" or "*", force level to 'debug'
|
||||
const isDebugEnabled = debugModules.includes('*') || debugModules.includes(moduleName);
|
||||
|
||||
return logger.child({
|
||||
module: moduleName,
|
||||
level: isDebugEnabled ? 'debug' : logger.level,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
To debug only AI and Database interactions:
|
||||
|
||||
```bash
|
||||
DEBUG_MODULES=ai-service,db-repo npm run dev
|
||||
```
|
||||
62
docs/adr/0053-worker-health-checks.md
Normal file
62
docs/adr/0053-worker-health-checks.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ADR-053: Worker Health Checks and Stalled Job Monitoring
|
||||
|
||||
**Date**: 2026-01-11
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Our application relies heavily on background workers (BullMQ) for flyer processing, analytics, and emails. If a worker process crashes (e.g., Out of Memory) or hangs, jobs may remain in the 'active' state indefinitely ("stalled") until BullMQ's fail-safe triggers.
|
||||
|
||||
Currently, we lack:
|
||||
|
||||
1. Visibility into queue depths and worker status via HTTP endpoints (for uptime monitors).
|
||||
2. A mechanism to detect if the worker process itself is alive, beyond just queue statistics.
|
||||
3. Explicit configuration to ensure stalled jobs are recovered quickly.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a multi-layered health check strategy for background workers:
|
||||
|
||||
1. **Queue Metrics Endpoint**: Expose a protected endpoint `GET /health/queues` that returns the counts (waiting, active, failed) for all critical queues.
|
||||
2. **Stalled Job Configuration**: Explicitly configure BullMQ workers with aggressive stall detection settings to recover quickly from crashes.
|
||||
3. **Worker Heartbeats**: Workers will periodically update a "heartbeat" key in Redis. The health endpoint will check if this timestamp is recent.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. BullMQ Worker Settings
|
||||
|
||||
Workers must be initialized with specific options to handle stalls:
|
||||
|
||||
```typescript
|
||||
const workerOptions = {
|
||||
// Check for stalled jobs every 30 seconds
|
||||
stalledInterval: 30000,
|
||||
// Fail job after 3 stalls (prevents infinite loops causing infinite retries)
|
||||
maxStalledCount: 3,
|
||||
// Duration of the lock for the job in milliseconds.
|
||||
// If the worker doesn't renew this (e.g. crash), the job stalls.
|
||||
lockDuration: 30000,
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Health Endpoint Logic
|
||||
|
||||
The `/health/queues` endpoint will:
|
||||
|
||||
1. Iterate through all defined queues (`flyerQueue`, `emailQueue`, etc.).
|
||||
2. Fetch job counts (`waiting`, `active`, `failed`, `delayed`).
|
||||
3. Return a 200 OK if queues are accessible, or 503 if Redis is unreachable.
|
||||
4. (Future) Return 500 if the `waiting` count exceeds a critical threshold for too long.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**:
|
||||
|
||||
- Early detection of stuck processing pipelines.
|
||||
- Automatic recovery of stalled jobs via BullMQ configuration.
|
||||
- Metrics available for external monitoring tools (e.g., UptimeRobot, Datadog).
|
||||
|
||||
**Negative**:
|
||||
|
||||
- Requires configuring external monitoring to poll the new endpoint.
|
||||
337
docs/adr/0054-bugsink-gitea-issue-sync.md
Normal file
337
docs/adr/0054-bugsink-gitea-issue-sync.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# ADR-054: Bugsink to Gitea Issue Synchronization
|
||||
|
||||
**Date**: 2026-01-17
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The application uses Bugsink (Sentry-compatible self-hosted error tracking) to capture runtime errors across 6 projects:
|
||||
|
||||
| Project | Type | Environment |
|
||||
| --------------------------------- | -------------- | ------------ |
|
||||
| flyer-crawler-backend | Backend | Production |
|
||||
| flyer-crawler-backend-test | Backend | Test/Staging |
|
||||
| flyer-crawler-frontend | Frontend | Production |
|
||||
| flyer-crawler-frontend-test | Frontend | Test/Staging |
|
||||
| flyer-crawler-infrastructure | Infrastructure | Production |
|
||||
| flyer-crawler-test-infrastructure | Infrastructure | Test/Staging |
|
||||
|
||||
Currently, errors remain in Bugsink until manually reviewed. There is no automated workflow to:
|
||||
|
||||
1. Create trackable tickets for errors
|
||||
2. Assign errors to developers
|
||||
3. Track resolution progress
|
||||
4. Prevent errors from being forgotten
|
||||
|
||||
## Decision
|
||||
|
||||
Implement an automated background worker that synchronizes unresolved Bugsink issues to Gitea as trackable tickets. The sync worker will:
|
||||
|
||||
1. **Run only on the test/staging server** (not production, not dev container)
|
||||
2. **Poll all 6 Bugsink projects** for unresolved issues
|
||||
3. **Create Gitea issues** with full error context
|
||||
4. **Mark synced issues as resolved** in Bugsink (to prevent re-polling)
|
||||
5. **Track sync state in Redis** to ensure idempotency
|
||||
|
||||
### Why Test/Staging Only?
|
||||
|
||||
- The sync worker is a background service that needs API tokens for both Bugsink and Gitea
|
||||
- Running on test/staging provides a single sync point without duplicating infrastructure
|
||||
- All 6 Bugsink projects (including production) are synced from this one worker
|
||||
- Production server stays focused on serving users, not running sync jobs
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ TEST/STAGING SERVER │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
||||
│ │ BullMQ Queue │───▶│ Sync Worker │───▶│ Redis DB 15 │ │
|
||||
│ │ bugsink-sync │ │ (15min repeat) │ │ Sync State │ │
|
||||
│ └──────────────────┘ └────────┬─────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
└───────────────────────────────────┼──────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Bugsink │ │ Gitea │
|
||||
│ (6 projects) │ │ (1 repo) │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Queue Configuration
|
||||
|
||||
| Setting | Value | Rationale |
|
||||
| --------------- | ---------------------- | -------------------------------------------- |
|
||||
| Queue Name | `bugsink-sync` | Follows existing naming pattern |
|
||||
| Repeat Interval | 15 minutes | Balances responsiveness with API rate limits |
|
||||
| Retry Attempts | 3 | Standard retry policy |
|
||||
| Backoff | Exponential (30s base) | Handles temporary API failures |
|
||||
| Concurrency | 1 | Serial processing prevents race conditions |
|
||||
|
||||
### Redis Database Allocation
|
||||
|
||||
| Database | Usage | Owner |
|
||||
| -------- | ------------------- | --------------- |
|
||||
| 0 | BullMQ (Production) | Existing queues |
|
||||
| 1 | BullMQ (Test) | Existing queues |
|
||||
| 2-14 | Reserved | Future use |
|
||||
| 15 | Bugsink Sync State | This feature |
|
||||
|
||||
### Redis Key Schema
|
||||
|
||||
```
|
||||
bugsink:synced:{bugsink_issue_id}
|
||||
└─ Value: JSON {
|
||||
gitea_issue_number: number,
|
||||
synced_at: ISO timestamp,
|
||||
project: string,
|
||||
title: string
|
||||
}
|
||||
```
|
||||
|
||||
### Gitea Labels
|
||||
|
||||
The following labels have been created in `torbo/flyer-crawler.projectium.com`:
|
||||
|
||||
| Label | ID | Color | Purpose |
|
||||
| -------------------- | --- | ------------------ | ---------------------------------- |
|
||||
| `bug:frontend` | 8 | #e11d48 (Red) | Frontend JavaScript/React errors |
|
||||
| `bug:backend` | 9 | #ea580c (Orange) | Backend Node.js/API errors |
|
||||
| `bug:infrastructure` | 10 | #7c3aed (Purple) | Infrastructure errors (Redis, PM2) |
|
||||
| `env:production` | 11 | #dc2626 (Dark Red) | Production environment |
|
||||
| `env:test` | 12 | #2563eb (Blue) | Test/staging environment |
|
||||
| `env:development` | 13 | #6b7280 (Gray) | Development environment |
|
||||
| `source:bugsink` | 14 | #10b981 (Green) | Auto-synced from Bugsink |
|
||||
|
||||
### Label Mapping
|
||||
|
||||
| Bugsink Project | Bug Label | Env Label |
|
||||
| --------------------------------- | ------------------ | -------------- |
|
||||
| flyer-crawler-backend | bug:backend | env:production |
|
||||
| flyer-crawler-backend-test | bug:backend | env:test |
|
||||
| flyer-crawler-frontend | bug:frontend | env:production |
|
||||
| flyer-crawler-frontend-test | bug:frontend | env:test |
|
||||
| flyer-crawler-infrastructure | bug:infrastructure | env:production |
|
||||
| flyer-crawler-test-infrastructure | bug:infrastructure | env:test |
|
||||
|
||||
All synced issues also receive the `source:bugsink` label.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | ------------------------------------------- |
|
||||
| `src/services/bugsinkSync.server.ts` | Core synchronization logic |
|
||||
| `src/services/bugsinkClient.server.ts` | HTTP client for Bugsink API |
|
||||
| `src/services/giteaClient.server.ts` | HTTP client for Gitea API |
|
||||
| `src/types/bugsink.ts` | TypeScript interfaces for Bugsink responses |
|
||||
| `src/routes/admin/bugsink-sync.ts` | Admin endpoints for manual trigger |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------- | ------------------------------------- |
|
||||
| `src/services/queues.server.ts` | Add `bugsinkSyncQueue` definition |
|
||||
| `src/services/workers.server.ts` | Add sync worker implementation |
|
||||
| `src/config/env.ts` | Add bugsink sync configuration schema |
|
||||
| `.env.example` | Document new environment variables |
|
||||
| `.gitea/workflows/deploy-to-test.yml` | Pass sync-related secrets |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Bugsink Configuration
|
||||
BUGSINK_URL=https://bugsink.projectium.com
|
||||
BUGSINK_API_TOKEN=77deaa5e... # From Bugsink Settings > API Keys
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_URL=https://gitea.projectium.com
|
||||
GITEA_API_TOKEN=... # Personal access token with repo scope
|
||||
GITEA_OWNER=torbo
|
||||
GITEA_REPO=flyer-crawler.projectium.com
|
||||
|
||||
# Sync Control
|
||||
BUGSINK_SYNC_ENABLED=false # Set true only in test environment
|
||||
BUGSINK_SYNC_INTERVAL=15 # Minutes between sync runs
|
||||
```
|
||||
|
||||
### Gitea Issue Template
|
||||
|
||||
```markdown
|
||||
## Error Details
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | --------------- |
|
||||
| **Type** | {error_type} |
|
||||
| **Message** | {error_message} |
|
||||
| **Platform** | {platform} |
|
||||
| **Level** | {level} |
|
||||
|
||||
## Occurrence Statistics
|
||||
|
||||
- **First Seen**: {first_seen}
|
||||
- **Last Seen**: {last_seen}
|
||||
- **Total Occurrences**: {count}
|
||||
|
||||
## Request Context
|
||||
|
||||
- **URL**: {request_url}
|
||||
- **Additional Context**: {context}
|
||||
|
||||
## Stacktrace
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
{stacktrace}
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
**Bugsink Issue**: {bugsink_url}
|
||||
**Project**: {project_slug}
|
||||
**Trace ID**: {trace_id}
|
||||
```
|
||||
|
||||
### Sync Workflow
|
||||
|
||||
```
|
||||
1. Worker triggered (every 15 min or manual)
|
||||
2. For each of 6 Bugsink projects:
|
||||
a. List issues with status='unresolved'
|
||||
b. For each issue:
|
||||
i. Check Redis for existing sync record
|
||||
ii. If already synced → skip
|
||||
iii. Fetch issue details + stacktrace
|
||||
iv. Create Gitea issue with labels
|
||||
v. Store sync record in Redis
|
||||
vi. Mark issue as 'resolved' in Bugsink
|
||||
3. Log summary (synced: N, skipped: N, failed: N)
|
||||
```
|
||||
|
||||
### Idempotency Guarantees
|
||||
|
||||
1. **Redis check before creation**: Prevents duplicate Gitea issues
|
||||
2. **Atomic Redis write after Gitea create**: Ensures state consistency
|
||||
3. **Query only unresolved issues**: Resolved issues won't appear in polls
|
||||
4. **No TTL on Redis keys**: Permanent sync history
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Visibility**: All application errors become trackable tickets
|
||||
2. **Accountability**: Errors can be assigned to developers
|
||||
3. **History**: Complete audit trail of when errors were discovered and resolved
|
||||
4. **Integration**: Errors appear alongside feature work in Gitea
|
||||
5. **Automation**: No manual error triage required
|
||||
|
||||
### Negative
|
||||
|
||||
1. **API Dependencies**: Requires both Bugsink and Gitea APIs to be available
|
||||
2. **Token Management**: Additional secrets to manage in CI/CD
|
||||
3. **Potential Noise**: High-frequency errors could create many tickets (mitigated by Bugsink's issue grouping)
|
||||
4. **Single Point**: Sync only runs on test server (if test server is down, no sync occurs)
|
||||
|
||||
### Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ----------------------- | ------------------------------------------------- |
|
||||
| Bugsink API rate limits | 15-minute polling interval |
|
||||
| Gitea API rate limits | Sequential processing with delays |
|
||||
| Redis connection issues | Reuse existing connection patterns |
|
||||
| Duplicate issues | Redis tracking + idempotent checks |
|
||||
| Missing stacktrace | Graceful degradation (create issue without trace) |
|
||||
|
||||
## Admin Interface
|
||||
|
||||
### Manual Sync Endpoint
|
||||
|
||||
```
|
||||
POST /api/admin/bugsink/sync
|
||||
Authorization: Bearer {admin_jwt}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"synced": 3,
|
||||
"skipped": 12,
|
||||
"failed": 0,
|
||||
"duration_ms": 2340
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sync Status Endpoint
|
||||
|
||||
```
|
||||
GET /api/admin/bugsink/sync/status
|
||||
Authorization: Bearer {admin_jwt}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"last_run": "2026-01-17T10:30:00Z",
|
||||
"next_run": "2026-01-17T10:45:00Z",
|
||||
"total_synced": 47,
|
||||
"projects": [
|
||||
{ "slug": "flyer-crawler-backend", "synced_count": 12 },
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
- Add environment variables to `env.ts` schema
|
||||
- Create `BugsinkClient` service (HTTP client)
|
||||
- Create `GiteaClient` service (HTTP client)
|
||||
- Add Redis db 15 connection for sync tracking
|
||||
|
||||
### Phase 2: Sync Logic
|
||||
|
||||
- Create `BugsinkSyncService` with sync logic
|
||||
- Add `bugsink-sync` queue to `queues.server.ts`
|
||||
- Add sync worker to `workers.server.ts`
|
||||
- Create TypeScript types for API responses
|
||||
|
||||
### Phase 3: Integration
|
||||
|
||||
- Add admin endpoints for manual sync trigger
|
||||
- Update `deploy-to-test.yml` with new secrets
|
||||
- Add secrets to Gitea repository settings
|
||||
- Test end-to-end in staging environment
|
||||
|
||||
### Phase 4: Documentation
|
||||
|
||||
- Update CLAUDE.md with sync information
|
||||
- Create operational runbook for sync issues
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Bi-directional sync**: Update Bugsink when Gitea issue is closed
|
||||
2. **Smart deduplication**: Detect similar errors across projects
|
||||
3. **Priority mapping**: High occurrence count → high priority label
|
||||
4. **Slack/Discord notifications**: Alert on new critical errors
|
||||
5. **Metrics dashboard**: Track error trends over time
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-006: Background Job Processing](./0006-background-job-processing-and-task-queues.md)
|
||||
- [ADR-015: Application Performance Monitoring](./0015-application-performance-monitoring-and-error-tracking.md)
|
||||
- [Bugsink API Documentation](https://bugsink.com/docs/api/)
|
||||
- [Gitea API Documentation](https://docs.gitea.io/en-us/api-usage/)
|
||||
164
docs/adr/adr-implementation-tracker.md
Normal file
164
docs/adr/adr-implementation-tracker.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# ADR Implementation Tracker
|
||||
|
||||
This document tracks the implementation status and estimated effort for all Architectural Decision Records (ADRs).
|
||||
|
||||
## Effort Estimation Guide
|
||||
|
||||
| Rating | Description | Typical Duration |
|
||||
| ------ | ------------------------------------------- | ----------------- |
|
||||
| S | Small - Simple, isolated changes | 1-2 hours |
|
||||
| M | Medium - Multiple files, some testing | Half day to 1 day |
|
||||
| L | Large - Significant refactoring, many files | 1-3 days |
|
||||
| XL | Extra Large - Major architectural change | 1+ weeks |
|
||||
|
||||
## Implementation Status Overview
|
||||
|
||||
| Status | Count |
|
||||
| ---------------------------- | ----- |
|
||||
| Accepted (Fully Implemented) | 30 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 16 |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Status
|
||||
|
||||
### Category 1: Foundational / Core Infrastructure
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ---------------------------------------------------------------- | ----------------------- | -------- | ------ | ------------------------------ |
|
||||
| [ADR-002](./0002-standardized-transaction-management.md) | Transaction Management | Accepted | - | Fully implemented |
|
||||
| [ADR-007](./0007-configuration-and-secrets-management.md) | Configuration & Secrets | Accepted | - | Fully implemented |
|
||||
| [ADR-020](./0020-health-checks-and-liveness-readiness-probes.md) | Health Checks | Accepted | - | Fully implemented |
|
||||
| [ADR-030](./0030-graceful-degradation-and-circuit-breaker.md) | Circuit Breaker | Proposed | L | New resilience patterns needed |
|
||||
|
||||
### Category 2: Data Management
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| --------------------------------------------------------------- | ------------------------ | -------- | ------ | ------------------------------ |
|
||||
| [ADR-009](./0009-caching-strategy-for-read-heavy-operations.md) | Caching Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-013](./0013-database-schema-migration-strategy.md) | Schema Migrations v1 | Proposed | M | Superseded by ADR-023 |
|
||||
| [ADR-019](./0019-data-backup-and-recovery-strategy.md) | Backup & Recovery | Accepted | - | Fully implemented |
|
||||
| [ADR-023](./0023-database-schema-migration-strategy.md) | Schema Migrations v2 | Proposed | L | Requires tooling setup |
|
||||
| [ADR-031](./0031-data-retention-and-privacy-compliance.md) | Data Retention & Privacy | Proposed | XL | Legal/compliance review needed |
|
||||
|
||||
### Category 3: API & Integration
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ------------------------------------------------------------------- | ------------------------ | ----------- | ------ | ------------------------------------- |
|
||||
| [ADR-003](./0003-standardized-input-validation-using-middleware.md) | Input Validation | Accepted | - | Fully implemented |
|
||||
| [ADR-008](./0008-api-versioning-strategy.md) | API Versioning | Proposed | L | Major URL/routing changes |
|
||||
| [ADR-018](./0018-api-documentation-strategy.md) | API Documentation | Accepted | - | OpenAPI/Swagger implemented |
|
||||
| [ADR-022](./0022-real-time-notification-system.md) | Real-time Notifications | Proposed | XL | WebSocket infrastructure |
|
||||
| [ADR-028](./0028-api-response-standardization.md) | Response Standardization | Implemented | L | Completed (routes, middleware, tests) |
|
||||
|
||||
### Category 4: Security & Compliance
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ----------------------------------------------------------------------- | --------------------- | -------- | ------ | -------------------------------- |
|
||||
| [ADR-001](./0001-standardized-error-handling.md) | Error Handling | Accepted | - | Fully implemented |
|
||||
| [ADR-011](./0011-advanced-authorization-and-access-control-strategy.md) | Authorization & RBAC | Proposed | XL | Policy engine, permission system |
|
||||
| [ADR-016](./0016-api-security-hardening.md) | Security Hardening | Accepted | - | Fully implemented |
|
||||
| [ADR-029](./0029-secret-rotation-and-key-management.md) | Secret Rotation | Proposed | L | Infrastructure changes needed |
|
||||
| [ADR-032](./0032-rate-limiting-strategy.md) | Rate Limiting | Accepted | - | Fully implemented |
|
||||
| [ADR-033](./0033-file-upload-and-storage-strategy.md) | File Upload & Storage | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 5: Observability & Monitoring
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------------------- | --------------------------- | -------- | ------ | --------------------------------- |
|
||||
| [ADR-004](./0004-standardized-application-wide-structured-logging.md) | Structured Logging | Accepted | - | Fully implemented |
|
||||
| [ADR-015](./0015-application-performance-monitoring-and-error-tracking.md) | APM & Error Tracking | Proposed | M | Third-party integration |
|
||||
| [ADR-050](./0050-postgresql-function-observability.md) | PostgreSQL Fn Observability | Proposed | M | Depends on ADR-015 implementation |
|
||||
|
||||
### Category 6: Deployment & Operations
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ----------------- | -------- | ------ | -------------------------- |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 7: Frontend / User Interface
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ------------------------------------------------------------------------ | -------------------- | -------- | ------ | ------------------------------------------- |
|
||||
| [ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md) | State Management | Accepted | - | Fully implemented |
|
||||
| [ADR-012](./0012-frontend-component-library-and-design-system.md) | Component Library | Partial | L | Core components done, design tokens pending |
|
||||
| [ADR-025](./0025-internationalization-and-localization-strategy.md) | i18n & l10n | Proposed | XL | All UI strings need extraction |
|
||||
| [ADR-026](./0026-standardized-client-side-structured-logging.md) | Client-Side Logging | Accepted | - | Fully implemented |
|
||||
| [ADR-044](./0044-frontend-feature-organization.md) | Feature Organization | Accepted | - | Fully implemented |
|
||||
|
||||
### Category 8: Development Workflow & Quality
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| ----------------------------------------------------------------------------- | -------------------- | -------- | ------ | -------------------- |
|
||||
| [ADR-010](./0010-testing-strategy-and-standards.md) | Testing Strategy | Accepted | - | Fully implemented |
|
||||
| [ADR-021](./0021-code-formatting-and-linting-unification.md) | Formatting & Linting | Accepted | - | Fully implemented |
|
||||
| [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) | Naming Conventions | Accepted | - | Fully implemented |
|
||||
| [ADR-045](./0045-test-data-factories-and-fixtures.md) | Test Data Factories | Accepted | - | Fully implemented |
|
||||
| [ADR-047](./0047-project-file-and-folder-organization.md) | Project Organization | Proposed | XL | Major reorganization |
|
||||
|
||||
### Category 9: Architecture Patterns
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------- | --------------------- | -------- | ------ | ----------------- |
|
||||
| [ADR-034](./0034-repository-pattern-standards.md) | Repository Pattern | Accepted | - | Fully implemented |
|
||||
| [ADR-035](./0035-service-layer-architecture.md) | Service Layer | Accepted | - | Fully implemented |
|
||||
| [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) | Event Bus | Accepted | - | Fully implemented |
|
||||
| [ADR-039](./0039-dependency-injection-pattern.md) | Dependency Injection | Accepted | - | Fully implemented |
|
||||
| [ADR-041](./0041-ai-gemini-integration-architecture.md) | AI/Gemini Integration | Accepted | - | Fully implemented |
|
||||
| [ADR-042](./0042-email-and-notification-architecture.md) | Email & Notifications | Accepted | - | Fully implemented |
|
||||
| [ADR-043](./0043-express-middleware-pipeline.md) | Middleware Pipeline | Accepted | - | Fully implemented |
|
||||
| [ADR-046](./0046-image-processing-pipeline.md) | Image Processing | Accepted | - | Fully implemented |
|
||||
| [ADR-049](./0049-gamification-and-achievement-system.md) | Gamification System | Accepted | - | Fully implemented |
|
||||
|
||||
---
|
||||
|
||||
## Work Still To Be Completed (Priority Order)
|
||||
|
||||
These ADRs are proposed but not yet implemented, ordered by suggested implementation priority:
|
||||
|
||||
| Priority | ADR | Title | Effort | Rationale |
|
||||
| -------- | ------- | --------------------------- | ------ | ------------------------------------------------- |
|
||||
| 1 | ADR-015 | APM & Error Tracking | M | Production visibility, debugging |
|
||||
| 1b | ADR-050 | PostgreSQL Fn Observability | M | Database function visibility (depends on ADR-015) |
|
||||
| 2 | ADR-024 | Feature Flags | M | Safer deployments, A/B testing |
|
||||
| 3 | ADR-023 | Schema Migrations v2 | L | Database evolution support |
|
||||
| 4 | ADR-029 | Secret Rotation | L | Security improvement |
|
||||
| 5 | ADR-008 | API Versioning | L | Future API evolution |
|
||||
| 6 | ADR-030 | Circuit Breaker | L | Resilience improvement |
|
||||
| 7 | ADR-022 | Real-time Notifications | XL | Major feature enhancement |
|
||||
| 8 | ADR-011 | Authorization & RBAC | XL | Advanced permission system |
|
||||
| 9 | ADR-025 | i18n & l10n | XL | Multi-language support |
|
||||
| 10 | ADR-031 | Data Retention & Privacy | XL | Compliance requirements |
|
||||
|
||||
---
|
||||
|
||||
## Recent Implementation History
|
||||
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | ---------------------------------------------------------------------- |
|
||||
| 2026-01-11 | ADR-050 | Created - PostgreSQL function observability with fn_log() and Logstash |
|
||||
| 2026-01-11 | ADR-018 | Implemented - OpenAPI/Swagger documentation at /docs/api-docs |
|
||||
| 2026-01-11 | ADR-049 | Created - Gamification system, achievements, and testing requirements |
|
||||
| 2026-01-09 | ADR-047 | Created - Project file/folder organization with migration plan |
|
||||
| 2026-01-09 | ADR-041 | Created - AI/Gemini integration with model fallback and rate limiting |
|
||||
| 2026-01-09 | ADR-042 | Created - Email and notification architecture with BullMQ queuing |
|
||||
| 2026-01-09 | ADR-043 | Created - Express middleware pipeline ordering and patterns |
|
||||
| 2026-01-09 | ADR-044 | Created - Frontend feature-based folder organization |
|
||||
| 2026-01-09 | ADR-045 | Created - Test data factory pattern for mock generation |
|
||||
| 2026-01-09 | ADR-046 | Created - Image processing pipeline with Sharp and EXIF stripping |
|
||||
| 2026-01-09 | ADR-026 | Fully implemented - client-side structured logger |
|
||||
| 2026-01-09 | ADR-028 | Fully implemented - all routes, middleware, and tests updated |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Effort estimates** are rough guidelines and may vary based on current codebase state
|
||||
- **Dependencies** between ADRs should be considered when planning implementation order
|
||||
- This document should be updated when ADRs are implemented or status changes
|
||||
@@ -4,49 +4,75 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
|
||||
## 1. Foundational / Core Infrastructure
|
||||
|
||||
**[ADR-002](./0002-standardized-transaction-management.md)**: Standardized Transaction Management and Unit of Work Pattern (Proposed)
|
||||
**[ADR-007](./0007-configuration-and-secrets-management.md)**: Configuration and Secrets Management (Proposed)
|
||||
**[ADR-020](./0020-health-checks-and-liveness-readiness-probes.md)**: Health Checks and Liveness/Readiness Probes (Proposed)
|
||||
**[ADR-002](./0002-standardized-transaction-management.md)**: Standardized Transaction Management and Unit of Work Pattern (Accepted)
|
||||
**[ADR-007](./0007-configuration-and-secrets-management.md)**: Configuration and Secrets Management (Accepted)
|
||||
**[ADR-020](./0020-health-checks-and-liveness-readiness-probes.md)**: Health Checks and Liveness/Readiness Probes (Accepted)
|
||||
**[ADR-030](./0030-graceful-degradation-and-circuit-breaker.md)**: Graceful Degradation and Circuit Breaker Pattern (Proposed)
|
||||
|
||||
## 2. Data Management
|
||||
|
||||
**[ADR-009](./0009-caching-strategy-for-read-heavy-operations.md)**: Caching Strategy for Read-Heavy Operations (Proposed)
|
||||
**[ADR-009](./0009-caching-strategy-for-read-heavy-operations.md)**: Caching Strategy for Read-Heavy Operations (Accepted)
|
||||
**[ADR-013](./0013-database-schema-migration-strategy.md)**: Database Schema Migration Strategy (Proposed)
|
||||
**[ADR-019](./0019-data-backup-and-recovery-strategy.md)**: Data Backup and Recovery Strategy (Proposed)
|
||||
**[ADR-019](./0019-data-backup-and-recovery-strategy.md)**: Data Backup and Recovery Strategy (Accepted)
|
||||
**[ADR-023](./0023-database-schema-migration-strategy.md)**: Database Schema Migration Strategy (Proposed)
|
||||
**[ADR-031](./0031-data-retention-and-privacy-compliance.md)**: Data Retention and Privacy Compliance (Proposed)
|
||||
|
||||
## 3. API & Integration
|
||||
|
||||
**[ADR-003](./0003-standardized-input-validation-using-middleware.md)**: Standardized Input Validation using Middleware (Proposed)
|
||||
**[ADR-003](./0003-standardized-input-validation-using-middleware.md)**: Standardized Input Validation using Middleware (Accepted)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Proposed)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Proposed)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Proposed)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
|
||||
|
||||
## 4. Security & Compliance
|
||||
|
||||
**[ADR-001](./0001-standardized-error-handling.md)**: Standardized Error Handling for Service and Repository Layers (Accepted)
|
||||
**[ADR-011](./0011-advanced-authorization-and-access-control-strategy.md)**: Advanced Authorization and Access Control Strategy (Proposed)
|
||||
**[ADR-016](./0016-api-security-hardening.md)**: API Security Hardening (Proposed)
|
||||
**[ADR-016](./0016-api-security-hardening.md)**: API Security Hardening (Accepted)
|
||||
**[ADR-029](./0029-secret-rotation-and-key-management.md)**: Secret Rotation and Key Management Strategy (Proposed)
|
||||
**[ADR-032](./0032-rate-limiting-strategy.md)**: Rate Limiting Strategy (Accepted)
|
||||
**[ADR-033](./0033-file-upload-and-storage-strategy.md)**: File Upload and Storage Strategy (Accepted)
|
||||
**[ADR-048](./0048-authentication-strategy.md)**: Authentication Strategy (Partially Implemented)
|
||||
|
||||
## 5. Observability & Monitoring
|
||||
|
||||
**[ADR-004](./0004-standardized-application-wide-structured-logging.md)**: Standardized Application-Wide Structured Logging (Proposed)
|
||||
**[ADR-004](./0004-standardized-application-wide-structured-logging.md)**: Standardized Application-Wide Structured Logging (Accepted)
|
||||
**[ADR-015](./0015-application-performance-monitoring-and-error-tracking.md)**: Application Performance Monitoring (APM) and Error Tracking (Proposed)
|
||||
|
||||
## 6. Deployment & Operations
|
||||
|
||||
**[ADR-006](./0006-background-job-processing-and-task-queues.md)**: Background Job Processing and Task Queues (Proposed)
|
||||
**[ADR-014](./0014-containerization-and-deployment-strategy.md)**: Containerization and Deployment Strategy (Proposed)
|
||||
**[ADR-017](./0017-ci-cd-and-branching-strategy.md)**: CI/CD and Branching Strategy (Proposed)
|
||||
**[ADR-006](./0006-background-job-processing-and-task-queues.md)**: Background Job Processing and Task Queues (Accepted)
|
||||
**[ADR-014](./0014-containerization-and-deployment-strategy.md)**: Containerization and Deployment Strategy (Partially Implemented)
|
||||
**[ADR-017](./0017-ci-cd-and-branching-strategy.md)**: CI/CD and Branching Strategy (Accepted)
|
||||
**[ADR-024](./0024-feature-flagging-strategy.md)**: Feature Flagging Strategy (Proposed)
|
||||
**[ADR-037](./0037-scheduled-jobs-and-cron-pattern.md)**: Scheduled Jobs and Cron Pattern (Accepted)
|
||||
**[ADR-038](./0038-graceful-shutdown-pattern.md)**: Graceful Shutdown Pattern (Accepted)
|
||||
|
||||
## 7. Frontend / User Interface
|
||||
|
||||
**[ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md)**: Frontend State Management and Server Cache Strategy (Proposed)
|
||||
**[ADR-012](./0012-frontend-component-library-and-design-system.md)**: Frontend Component Library and Design System (Proposed)
|
||||
**[ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md)**: Frontend State Management and Server Cache Strategy (Accepted)
|
||||
**[ADR-012](./0012-frontend-component-library-and-design-system.md)**: Frontend Component Library and Design System (Partially Implemented)
|
||||
**[ADR-025](./0025-internationalization-and-localization-strategy.md)**: Internationalization (i18n) and Localization (l10n) Strategy (Proposed)
|
||||
**[ADR-026](./0026-standardized-client-side-structured-logging.md)**: Standardized Client-Side Structured Logging (Proposed)
|
||||
**[ADR-044](./0044-frontend-feature-organization.md)**: Frontend Feature Organization Pattern (Accepted)
|
||||
|
||||
## 8. Development Workflow & Quality
|
||||
|
||||
**[ADR-010](./0010-testing-strategy-and-standards.md)**: Testing Strategy and Standards (Proposed)
|
||||
**[ADR-021](./0021-code-formatting-and-linting-unification.md)**: Code Formatting and Linting Unification (Proposed)
|
||||
**[ADR-010](./0010-testing-strategy-and-standards.md)**: Testing Strategy and Standards (Accepted)
|
||||
**[ADR-021](./0021-code-formatting-and-linting-unification.md)**: Code Formatting and Linting Unification (Accepted)
|
||||
**[ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md)**: Standardized Naming Convention for AI and Database Types (Accepted)
|
||||
**[ADR-040](./0040-testing-economics-and-priorities.md)**: Testing Economics and Priorities (Accepted)
|
||||
**[ADR-045](./0045-test-data-factories-and-fixtures.md)**: Test Data Factories and Fixtures (Accepted)
|
||||
**[ADR-047](./0047-project-file-and-folder-organization.md)**: Project File and Folder Organization (Proposed)
|
||||
|
||||
## 9. Architecture Patterns
|
||||
|
||||
**[ADR-034](./0034-repository-pattern-standards.md)**: Repository Pattern Standards (Accepted)
|
||||
**[ADR-035](./0035-service-layer-architecture.md)**: Service Layer Architecture (Accepted)
|
||||
**[ADR-036](./0036-event-bus-and-pub-sub-pattern.md)**: Event Bus and Pub/Sub Pattern (Accepted)
|
||||
**[ADR-039](./0039-dependency-injection-pattern.md)**: Dependency Injection Pattern (Accepted)
|
||||
**[ADR-041](./0041-ai-gemini-integration-architecture.md)**: AI/Gemini Integration Architecture (Accepted)
|
||||
**[ADR-042](./0042-email-and-notification-architecture.md)**: Email and Notification Architecture (Accepted)
|
||||
**[ADR-043](./0043-express-middleware-pipeline.md)**: Express Middleware Pipeline Architecture (Accepted)
|
||||
**[ADR-046](./0046-image-processing-pipeline.md)**: Image Processing Pipeline (Accepted)
|
||||
|
||||
349
docs/plans/2026-01-18-frontend-test-automation-plan.md
Normal file
349
docs/plans/2026-01-18-frontend-test-automation-plan.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Frontend Test Automation Plan
|
||||
|
||||
**Date**: 2026-01-18
|
||||
**Status**: Awaiting Approval
|
||||
**Related**: [2026-01-18-frontend-tests.md](../tests/2026-01-18-frontend-tests.md)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan formalizes the automated testing of 35+ API endpoints manually tested on 2026-01-18. The testing covered 7 major areas including end-to-end user flows, edge cases, queue behavior, authentication, performance, real-time features, and data integrity.
|
||||
|
||||
**Recommendation**: Most tests should be added as **integration tests** (Supertest-based), with select critical flows as **E2E tests**. This aligns with ADR-010 and ADR-040's guidance on testing economics.
|
||||
|
||||
---
|
||||
|
||||
## Analysis of Manual Tests vs Existing Coverage
|
||||
|
||||
### Current Test Coverage
|
||||
|
||||
| Test Type | Existing Files | Existing Tests |
|
||||
| ----------- | -------------- | -------------- |
|
||||
| Integration | 21 files | ~150+ tests |
|
||||
| E2E | 9 files | ~40+ tests |
|
||||
|
||||
### Gap Analysis
|
||||
|
||||
| Manual Test Area | Existing Coverage | Gap | Priority |
|
||||
| -------------------------- | ------------------------- | --------------------------- | -------- |
|
||||
| Budget API | budget.integration.test | Partial - add validation | Medium |
|
||||
| Deals API | None | **New file needed** | Low |
|
||||
| Reactions API | None | **New file needed** | Low |
|
||||
| Gamification API | gamification.integration | Good coverage | None |
|
||||
| Recipe API | recipe.integration.test | Add fork error, comment | Medium |
|
||||
| Receipt API | receipt.integration.test | Good coverage | None |
|
||||
| UPC API | upc.integration.test | Good coverage | None |
|
||||
| Price History API | price.integration.test | Good coverage | None |
|
||||
| Personalization API | public.routes.integration | Good coverage | None |
|
||||
| Admin Routes | admin.integration.test | Add queue/trigger endpoints | Medium |
|
||||
| Edge Cases (Area 2) | Scattered | **Consolidate/add** | High |
|
||||
| Queue/Worker (Area 3) | Partial | Add admin trigger tests | Medium |
|
||||
| Auth Edge Cases (Area 4) | auth.integration.test | Add token malformation | Medium |
|
||||
| Performance (Area 5) | None | **Not recommended** | Skip |
|
||||
| Real-time/Polling (Area 6) | notification.integration | Add job status polling | Low |
|
||||
| Data Integrity (Area 7) | Scattered | **Consolidate** | High |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: New Integration Test Files (Priority: High)
|
||||
|
||||
#### 1.1 Create `deals.integration.test.ts`
|
||||
|
||||
**Rationale**: Routes were unmounted until this testing session; no tests exist.
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Deals API', () => {
|
||||
it('GET /api/deals/best-watched-prices requires auth');
|
||||
it('GET /api/deals/best-watched-prices returns watched items for user');
|
||||
it('Returns empty array when no watched items');
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 30 minutes
|
||||
|
||||
#### 1.2 Create `reactions.integration.test.ts`
|
||||
|
||||
**Rationale**: Routes were unmounted until this testing session; no tests exist.
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Reactions API', () => {
|
||||
it('GET /api/reactions/summary/:targetType/:targetId returns counts');
|
||||
it('POST /api/reactions/toggle requires auth');
|
||||
it('POST /api/reactions/toggle toggles reaction on/off');
|
||||
it('Returns validation error for invalid target_type');
|
||||
it('Returns validation error for non-string entity_id');
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 45 minutes
|
||||
|
||||
#### 1.3 Create `edge-cases.integration.test.ts`
|
||||
|
||||
**Rationale**: Consolidate edge case tests discovered during manual testing.
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Edge Cases', () => {
|
||||
describe('File Upload Validation', () => {
|
||||
it('Accepts small files');
|
||||
it('Processes corrupt file with IMAGE_CONVERSION_FAILED');
|
||||
it('Rejects wrong checksum format');
|
||||
it('Rejects short checksum');
|
||||
});
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
it('Handles XSS payloads in shopping list names (stores as-is)');
|
||||
it('Handles unicode/emoji in text fields');
|
||||
it('Rejects null bytes in JSON');
|
||||
it('Handles very long input strings');
|
||||
});
|
||||
|
||||
describe('Authorization Boundaries', () => {
|
||||
it('Cross-user access returns 404 (not 403)');
|
||||
it('SQL injection in query params is safely handled');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 1.5 hours
|
||||
|
||||
#### 1.4 Create `data-integrity.integration.test.ts`
|
||||
|
||||
**Rationale**: Consolidate FK/cascade/constraint tests.
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Data Integrity', () => {
|
||||
describe('Cascade Deletes', () => {
|
||||
it('User deletion cascades to shopping lists, budgets, notifications');
|
||||
it('Shopping list deletion cascades to items');
|
||||
it('Admin cannot delete own account');
|
||||
});
|
||||
|
||||
describe('FK Constraints', () => {
|
||||
it('Rejects invalid FK references via API');
|
||||
it('Rejects invalid FK references via direct DB');
|
||||
});
|
||||
|
||||
describe('Unique Constraints', () => {
|
||||
it('Duplicate email returns CONFLICT');
|
||||
it('Duplicate flyer checksum is handled');
|
||||
});
|
||||
|
||||
describe('CHECK Constraints', () => {
|
||||
it('Budget period rejects invalid values');
|
||||
it('Budget amount rejects negative values');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 2 hours
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Extend Existing Integration Tests (Priority: Medium)
|
||||
|
||||
#### 2.1 Extend `budget.integration.test.ts`
|
||||
|
||||
Add validation edge cases discovered during manual testing:
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
it('Rejects period="yearly" (only weekly/monthly allowed)');
|
||||
it('Rejects negative amount_cents');
|
||||
it('Rejects invalid date format');
|
||||
it('Returns 404 for update on non-existent budget');
|
||||
it('Returns 404 for delete on non-existent budget');
|
||||
```
|
||||
|
||||
**Estimated effort**: 30 minutes
|
||||
|
||||
#### 2.2 Extend `admin.integration.test.ts`
|
||||
|
||||
Add queue and trigger endpoint tests:
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Queue Management', () => {
|
||||
it('GET /api/admin/queues/status returns all queue counts');
|
||||
it('POST /api/admin/trigger/analytics-report enqueues job');
|
||||
it('POST /api/admin/trigger/weekly-analytics enqueues job');
|
||||
it('POST /api/admin/trigger/daily-deal-check enqueues job');
|
||||
it('POST /api/admin/jobs/:queue/:id/retry retries failed job');
|
||||
it('POST /api/admin/system/clear-cache clears Redis cache');
|
||||
it('Returns validation error for invalid queue name');
|
||||
it('Returns 404 for retry on non-existent job');
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 1 hour
|
||||
|
||||
#### 2.3 Extend `auth.integration.test.ts`
|
||||
|
||||
Add token malformation edge cases:
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Token Edge Cases', () => {
|
||||
it('Empty Bearer token returns Unauthorized');
|
||||
it('Token without dots returns Unauthorized');
|
||||
it('Token with 2 parts returns Unauthorized');
|
||||
it('Token with invalid signature returns Unauthorized');
|
||||
it('Lowercase "bearer" scheme is accepted');
|
||||
it('Basic auth scheme returns Unauthorized');
|
||||
it('Tampered token payload returns Unauthorized');
|
||||
});
|
||||
|
||||
describe('Login Security', () => {
|
||||
it('Wrong password and non-existent user return same error');
|
||||
it('Forgot password returns same response for existing/non-existing');
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 45 minutes
|
||||
|
||||
#### 2.4 Extend `recipe.integration.test.ts`
|
||||
|
||||
Add fork error case and comment tests:
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
it('Fork fails for seed recipes (null user_id)');
|
||||
it('POST /api/recipes/:id/comments adds comment');
|
||||
it('GET /api/recipes/:id/comments returns comments');
|
||||
```
|
||||
|
||||
**Estimated effort**: 30 minutes
|
||||
|
||||
#### 2.5 Extend `notification.integration.test.ts`
|
||||
|
||||
Add job status polling tests:
|
||||
|
||||
```typescript
|
||||
// Tests to add:
|
||||
describe('Job Status Polling', () => {
|
||||
it('GET /api/ai/jobs/:id/status returns completed job');
|
||||
it('GET /api/ai/jobs/:id/status returns failed job with error');
|
||||
it('GET /api/ai/jobs/:id/status returns 404 for non-existent');
|
||||
it('Job status endpoint works without auth (public)');
|
||||
});
|
||||
```
|
||||
|
||||
**Estimated effort**: 30 minutes
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: E2E Tests (Priority: Low-Medium)
|
||||
|
||||
Per ADR-040, E2E tests should be limited to critical user flows. The existing E2E tests cover the main flows well. However, we should consider:
|
||||
|
||||
#### 3.1 Do NOT Add
|
||||
|
||||
- Performance tests (handle via monitoring, not E2E)
|
||||
- Pagination tests (integration level is sufficient)
|
||||
- Cache behavior tests (integration level is sufficient)
|
||||
|
||||
#### 3.2 Consider Adding (Optional)
|
||||
|
||||
**Budget flow E2E** - If budget management becomes a critical feature:
|
||||
|
||||
```typescript
|
||||
// budget-journey.e2e.test.ts
|
||||
describe('Budget Journey', () => {
|
||||
it('User creates budget → tracks spending → sees analysis');
|
||||
});
|
||||
```
|
||||
|
||||
**Recommendation**: Defer unless budget becomes a core value proposition.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Documentation Updates
|
||||
|
||||
#### 4.1 Update ADR-010
|
||||
|
||||
Add the newly discovered API gotchas to the testing documentation:
|
||||
|
||||
- `entity_id` must be STRING in reactions
|
||||
- `customItemName` (camelCase) in shopping list items
|
||||
- `scan_source` must be `manual_entry`, not `manual`
|
||||
|
||||
#### 4.2 Update CLAUDE.md
|
||||
|
||||
Add API reference section for correct endpoint calls (already captured in test doc).
|
||||
|
||||
---
|
||||
|
||||
## Tests NOT Recommended
|
||||
|
||||
Per ADR-040 (Testing Economics), the following tests from the manual session should NOT be automated:
|
||||
|
||||
| Test Area | Reason |
|
||||
| --------------------------- | ------------------------------------------------- |
|
||||
| Performance benchmarks | Use APM/monitoring tools instead (see ADR-015) |
|
||||
| Concurrent request handling | Connection pool behavior is framework-level |
|
||||
| Cache hit/miss timing | Observable via Redis metrics, not test assertions |
|
||||
| Response time consistency | Better suited for production monitoring |
|
||||
| WebSocket/SSE | Not implemented - polling is the architecture |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
| Phase | Description | Effort | Priority |
|
||||
| --------- | ------------------------------ | ------------ | -------- |
|
||||
| 1.1 | deals.integration.test.ts | 30 min | High |
|
||||
| 1.2 | reactions.integration.test.ts | 45 min | High |
|
||||
| 1.3 | edge-cases.integration.test.ts | 1.5 hours | High |
|
||||
| 1.4 | data-integrity.integration.ts | 2 hours | High |
|
||||
| 2.1 | Extend budget tests | 30 min | Medium |
|
||||
| 2.2 | Extend admin tests | 1 hour | Medium |
|
||||
| 2.3 | Extend auth tests | 45 min | Medium |
|
||||
| 2.4 | Extend recipe tests | 30 min | Medium |
|
||||
| 2.5 | Extend notification tests | 30 min | Medium |
|
||||
| 4.x | Documentation updates | 30 min | Low |
|
||||
| **Total** | | **~8 hours** | |
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
For each new test file, verify by running:
|
||||
|
||||
```bash
|
||||
# In dev container
|
||||
npm run test:integration -- --run src/tests/integration/<file>.test.ts
|
||||
```
|
||||
|
||||
All tests should:
|
||||
|
||||
1. Pass consistently (no flaky tests)
|
||||
2. Run in isolation (no shared state)
|
||||
3. Clean up test data (use `cleanupDb()`)
|
||||
4. Follow existing patterns in the codebase
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ------------------------------------ | --------------------------------------------------- |
|
||||
| Test flakiness from async operations | Use proper waitFor/polling utilities |
|
||||
| Database state leakage between tests | Strict cleanup in afterEach/afterAll |
|
||||
| Queue state affecting test isolation | Drain/pause queues in tests that interact with them |
|
||||
| Port conflicts | Use dedicated test port (3099) |
|
||||
|
||||
---
|
||||
|
||||
## Approval Request
|
||||
|
||||
Please review and approve this plan. Upon approval, implementation will proceed in priority order (Phase 1 first).
|
||||
|
||||
**Questions for clarification**:
|
||||
|
||||
1. Should the deals/reactions routes remain mounted, or was that a temporary fix?
|
||||
2. Is the recipe fork failure for seed recipes expected behavior or a bug to fix?
|
||||
3. Any preference on splitting Phase 1 into multiple PRs vs one large PR?
|
||||
1029
docs/research-category-id-migration.md
Normal file
1029
docs/research-category-id-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
232
docs/research-e2e-test-separation.md
Normal file
232
docs/research-e2e-test-separation.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Research: Separating E2E Tests from Integration Tests
|
||||
|
||||
**Date:** 2026-01-19
|
||||
**Status:** In Progress
|
||||
**Context:** E2E tests exist with their own config but are not being run separately
|
||||
|
||||
## Current State
|
||||
|
||||
### Test Structure
|
||||
|
||||
- **Unit tests**: `src/tests/unit/` (but most are co-located with source files)
|
||||
- **Integration tests**: `src/tests/integration/` (28 test files)
|
||||
- **E2E tests**: `src/tests/e2e/` (11 test files) **← NOT CURRENTLY RUNNING**
|
||||
|
||||
### Configurations
|
||||
|
||||
| Config File | Project Name | Environment | Port | Include Pattern |
|
||||
| ------------------------------ | ------------- | ----------- | ---- | ------------------------------------------ |
|
||||
| `vite.config.ts` | `unit` | jsdom | N/A | Component/hook tests |
|
||||
| `vitest.config.integration.ts` | `integration` | node | 3099 | `src/tests/integration/**/*.test.{ts,tsx}` |
|
||||
| `vitest.config.e2e.ts` | `e2e` | node | 3098 | `src/tests/e2e/**/*.e2e.test.ts` |
|
||||
|
||||
### Workspace Configuration
|
||||
|
||||
**`vitest.workspace.ts` currently includes:**
|
||||
|
||||
```typescript
|
||||
export default [
|
||||
'vite.config.ts', // Unit tests
|
||||
'vitest.config.integration.ts', // Integration tests
|
||||
// ❌ vitest.config.e2e.ts is NOT included!
|
||||
];
|
||||
```
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
|
||||
"test:unit": "... --project unit ...",
|
||||
"test:integration": "... --project integration ..."
|
||||
// ❌ NO test:e2e script exists!
|
||||
}
|
||||
```
|
||||
|
||||
### CI/CD Status
|
||||
|
||||
**`.gitea/workflows/deploy-to-test.yml` runs:**
|
||||
|
||||
- ✅ `npm run test:unit -- --coverage`
|
||||
- ✅ `npm run test:integration -- --coverage`
|
||||
- ❌ E2E tests are NOT run in CI
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. E2E Tests Are Orphaned
|
||||
|
||||
- 11 E2E test files exist but are never executed
|
||||
- E2E config file exists (`vitest.config.e2e.ts`) but is not referenced anywhere
|
||||
- No npm script to run E2E tests
|
||||
- Not included in vitest workspace
|
||||
- Not run in CI/CD pipeline
|
||||
|
||||
### 2. When Were E2E Tests Created?
|
||||
|
||||
Git history shows E2E config was added in commit `e66027d` ("fix e2e and deploy to prod"), but:
|
||||
|
||||
- It was never added to the workspace
|
||||
- It was never added to CI
|
||||
- No test:e2e script was created
|
||||
|
||||
This suggests the E2E separation was **started but never completed**.
|
||||
|
||||
### 3. How Are Tests Currently Run?
|
||||
|
||||
**Locally:**
|
||||
|
||||
- `npm test` → runs workspace (unit + integration only)
|
||||
- `npm run test:unit` → runs only unit tests
|
||||
- `npm run test:integration` → runs only integration tests
|
||||
- E2E tests: **Not accessible via any command**
|
||||
|
||||
**In CI:**
|
||||
|
||||
- Only `test:unit` and `test:integration` are run
|
||||
- E2E tests are never executed
|
||||
|
||||
### 4. Port Allocation
|
||||
|
||||
- Integration tests: Port 3099
|
||||
- E2E tests: Port 3098 (configured but never used)
|
||||
- No conflicts if both run sequentially
|
||||
|
||||
## E2E Test Files (11 total)
|
||||
|
||||
1. `admin-authorization.e2e.test.ts`
|
||||
2. `admin-dashboard.e2e.test.ts`
|
||||
3. `auth.e2e.test.ts`
|
||||
4. `budget-journey.e2e.test.ts`
|
||||
5. `deals-journey.e2e.test.ts` ← Just fixed URL constraint issue
|
||||
6. `error-reporting.e2e.test.ts`
|
||||
7. `flyer-upload.e2e.test.ts`
|
||||
8. `inventory-journey.e2e.test.ts`
|
||||
9. `receipt-journey.e2e.test.ts`
|
||||
10. `upc-journey.e2e.test.ts`
|
||||
11. `user-journey.e2e.test.ts`
|
||||
|
||||
## Problems to Solve
|
||||
|
||||
### Immediate Issues
|
||||
|
||||
1. **E2E tests are not running** - Code exists but is never executed
|
||||
2. **No way to run E2E tests** - No npm script or CI job
|
||||
3. **Coverage gaps** - E2E scenarios are untested in practice
|
||||
4. **False sense of security** - Team may think E2E tests are running
|
||||
|
||||
### Implementation Challenges
|
||||
|
||||
#### 1. Adding E2E to Workspace
|
||||
|
||||
**Option A: Add to workspace**
|
||||
|
||||
```typescript
|
||||
// vitest.workspace.ts
|
||||
export default [
|
||||
'vite.config.ts',
|
||||
'vitest.config.integration.ts',
|
||||
'vitest.config.e2e.ts', // ← Add this
|
||||
];
|
||||
```
|
||||
|
||||
**Impact:** E2E tests would run with `npm test`, increasing test time significantly
|
||||
|
||||
**Option B: Keep separate**
|
||||
|
||||
- E2E remains outside workspace
|
||||
- Requires explicit `npm run test:e2e` command
|
||||
- CI would need separate step for E2E tests
|
||||
|
||||
#### 2. Adding NPM Script
|
||||
|
||||
```json
|
||||
{
|
||||
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project e2e -c vitest.config.e2e.ts"
|
||||
}
|
||||
```
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- Uses same global setup pattern as integration tests
|
||||
- Requires server to be stopped first (like integration tests)
|
||||
- Port 3098 must be available
|
||||
|
||||
#### 3. CI/CD Integration
|
||||
|
||||
**Add to `.gitea/workflows/deploy-to-test.yml`:**
|
||||
|
||||
```yaml
|
||||
- name: Run E2E Tests
|
||||
run: |
|
||||
npm run test:e2e -- --coverage \
|
||||
--reporter=verbose \
|
||||
--includeTaskLocation \
|
||||
--testTimeout=120000 \
|
||||
--silent=passed-only
|
||||
```
|
||||
|
||||
**Questions:**
|
||||
|
||||
- Should E2E run before or after integration tests?
|
||||
- Should E2E failures block deployment?
|
||||
- Should E2E have separate coverage reports?
|
||||
|
||||
#### 4. Test Organization Questions
|
||||
|
||||
- Are current "integration" tests actually E2E tests?
|
||||
- Should some E2E tests be moved to integration?
|
||||
- What's the distinction between integration and E2E in this project?
|
||||
|
||||
#### 5. Coverage Implications
|
||||
|
||||
- E2E tests have separate coverage directory: `.coverage/e2e`
|
||||
- Integration tests: `.coverage/integration`
|
||||
- How to merge coverage from all test types?
|
||||
- Do we need combined coverage reports?
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### Phase 1: Quick Fix (Enable E2E Tests)
|
||||
|
||||
1. ✅ Fix any failing E2E tests (like URL constraints)
|
||||
2. Add `test:e2e` npm script
|
||||
3. Document how to run E2E tests manually
|
||||
4. Do NOT add to workspace yet (keep separate)
|
||||
|
||||
### Phase 2: CI Integration
|
||||
|
||||
1. Add E2E test step to `.gitea/workflows/deploy-to-test.yml`
|
||||
2. Run after integration tests pass
|
||||
3. Allow failures initially (monitor results)
|
||||
4. Make blocking once stable
|
||||
|
||||
### Phase 3: Optimize
|
||||
|
||||
1. Review test categorization (integration vs E2E)
|
||||
2. Consider adding to workspace if test time is acceptable
|
||||
3. Merge coverage reports if needed
|
||||
4. Document test strategy in testing docs
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create `test:e2e` script** in package.json
|
||||
2. **Run E2E tests manually** to verify they work
|
||||
3. **Fix any failing E2E tests**
|
||||
4. **Document E2E testing** in TESTING.md
|
||||
5. **Add to CI** once stable
|
||||
6. **Consider workspace integration** after CI is stable
|
||||
|
||||
## Questions for Team
|
||||
|
||||
1. Why were E2E tests never fully integrated?
|
||||
2. Should E2E tests run on every commit or separately?
|
||||
3. What's the acceptable test time for local development?
|
||||
4. Should we run E2E tests in parallel or sequentially with integration?
|
||||
|
||||
## Related Files
|
||||
|
||||
- `vitest.workspace.ts` - Workspace configuration
|
||||
- `vitest.config.e2e.ts` - E2E test configuration
|
||||
- `src/tests/setup/e2e-global-setup.ts` - E2E global setup
|
||||
- `.gitea/workflows/deploy-to-test.yml` - CI pipeline
|
||||
- `package.json` - NPM scripts
|
||||
1641
docs/tests/2026-01-18-frontend-tests.md
Normal file
1641
docs/tests/2026-01-18-frontend-tests.md
Normal file
File diff suppressed because it is too large
Load Diff
158
ecosystem-test.config.cjs
Normal file
158
ecosystem-test.config.cjs
Normal file
@@ -0,0 +1,158 @@
|
||||
// ecosystem-test.config.cjs
|
||||
// PM2 configuration for the TEST environment only.
|
||||
// NOTE: The filename must end with `.config.cjs` for PM2 to recognize it as a config file.
|
||||
// This file defines test-specific apps that run alongside production apps.
|
||||
//
|
||||
// Test apps: flyer-crawler-api-test, flyer-crawler-worker-test, flyer-crawler-analytics-worker-test
|
||||
//
|
||||
// These apps:
|
||||
// - Run from /var/www/flyer-crawler-test.projectium.com
|
||||
// - Use NODE_ENV='staging' (enables file logging in logger.server.ts)
|
||||
// - Use Redis database 1 (isolated from production which uses database 0)
|
||||
// - Have distinct PM2 process names to avoid conflicts with production
|
||||
|
||||
// --- Load Environment Variables from .env file ---
|
||||
// This allows PM2 to start without requiring the CI/CD pipeline to inject variables.
|
||||
// The .env file should be created on the server with the required secrets.
|
||||
// NOTE: We implement a simple .env parser since dotenv may not be installed.
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const envPath = path.join('/var/www/flyer-crawler-test.projectium.com', '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
console.log('[ecosystem-test.config.cjs] Loading environment from:', envPath);
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const lines = envContent.split('\n');
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=value
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex);
|
||||
let value = trimmed.substring(eqIndex + 1);
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
// Only set if not already in environment (don't override CI/CD vars)
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[ecosystem-test.config.cjs] Environment loaded successfully');
|
||||
} else {
|
||||
console.warn('[ecosystem-test.config.cjs] No .env file found at:', envPath);
|
||||
console.warn(
|
||||
'[ecosystem-test.config.cjs] Environment variables must be provided by the shell or CI/CD.'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Environment Variable Validation ---
|
||||
// NOTE: We only WARN about missing secrets, not exit.
|
||||
// Calling process.exit(1) prevents PM2 from reading the apps array.
|
||||
// The actual application will fail to start if secrets are missing,
|
||||
// which PM2 will handle with its restart logic.
|
||||
const requiredSecrets = ['DB_HOST', 'JWT_SECRET', 'GEMINI_API_KEY'];
|
||||
const missingSecrets = requiredSecrets.filter(key => !process.env[key]);
|
||||
|
||||
if (missingSecrets.length > 0) {
|
||||
console.warn('\n[ecosystem.config.test.cjs] WARNING: The following environment variables are MISSING:');
|
||||
missingSecrets.forEach(key => console.warn(` - ${key}`));
|
||||
console.warn('[ecosystem.config.test.cjs] The application may fail to start if these are required.\n');
|
||||
} else {
|
||||
console.log('[ecosystem.config.test.cjs] Critical environment variables are present.');
|
||||
}
|
||||
|
||||
// --- Shared Environment Variables ---
|
||||
const sharedEnv = {
|
||||
DB_HOST: process.env.DB_HOST,
|
||||
DB_USER: process.env.DB_USER,
|
||||
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||
DB_NAME: process.env.DB_NAME,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||
JWT_SECRET: process.env.JWT_SECRET,
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_PASS: process.env.SMTP_PASS,
|
||||
SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
SENTRY_ENABLED: process.env.SENTRY_ENABLED,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
// =========================================================================
|
||||
// TEST APPS
|
||||
// =========================================================================
|
||||
{
|
||||
// --- Test API Server ---
|
||||
name: 'flyer-crawler-api-test',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'server.ts',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
max_memory_restart: '500M',
|
||||
// Test environment: single instance (no cluster) to conserve resources
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
kill_timeout: 5000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
env: {
|
||||
NODE_ENV: 'staging',
|
||||
PORT: 3002,
|
||||
WORKER_LOCK_DURATION: '120000',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --- Test General Worker ---
|
||||
name: 'flyer-crawler-worker-test',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
env: {
|
||||
NODE_ENV: 'staging',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --- Test Analytics Worker ---
|
||||
name: 'flyer-crawler-analytics-worker-test',
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'src/services/worker.ts',
|
||||
cwd: '/var/www/flyer-crawler-test.projectium.com',
|
||||
max_memory_restart: '1G',
|
||||
kill_timeout: 10000,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_restarts: 40,
|
||||
exp_backoff_restart_delay: 100,
|
||||
min_uptime: '10s',
|
||||
env: {
|
||||
NODE_ENV: 'staging',
|
||||
...sharedEnv,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user