Compare commits
648 Commits
feature/do
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92cd230d5 | ||
|
|
bcbfdd3c91 | ||
|
|
3c21093e66 | ||
|
|
e4e0a19873 | ||
|
|
5b0503abf5 | ||
|
|
cc8aab7ee1 | ||
|
|
2c1f7d093f | ||
|
|
11a07692ad | ||
|
|
05754c9d5b | ||
|
|
e26da88f22 | ||
|
|
b703b27676 | ||
|
|
5dffd96187 | ||
|
|
9ff1f2b37b | ||
|
|
23ad9f2824 | ||
|
|
b90cb829c9 | ||
|
|
69d9174314 | ||
|
|
c350ecbb3c | ||
|
|
06098c7013 | ||
|
|
709321383c | ||
|
|
7506466c38 | ||
|
|
c84455a11b | ||
|
|
2e7fff135c | ||
|
|
a1a8e3ee9c | ||
|
|
72ab5d8baa | ||
|
|
fc715c6022 | ||
|
|
32a00493f8 | ||
|
|
ffc9405c34 | ||
|
|
732c46cabb | ||
|
|
1906a28347 | ||
|
|
5de445d1cf | ||
|
|
9855d41869 | ||
|
|
319c5cc4d5 | ||
|
|
44af326ebb | ||
|
|
4f4e96dd84 | ||
|
|
fcc428b9f1 | ||
|
|
fc9580470e | ||
|
|
bc9aaf745d | ||
|
|
9df385e2e1 | ||
|
|
102b2c1803 | ||
|
|
6705a8916a | ||
|
|
3e71c35c9e | ||
|
|
c772431475 | ||
|
|
c6b8d76373 | ||
|
|
6ce095c60a | ||
|
|
45ed3818fa | ||
|
|
461b37ab30 | ||
|
|
88167995b1 | ||
|
|
e8622765a2 | ||
|
|
983752a396 | ||
|
|
b338571fb9 | ||
|
|
7990c2ab55 | ||
|
|
e419c57072 | ||
|
|
05144fe04b | ||
|
|
23aa144f85 | ||
|
|
bdf56d3d95 | ||
|
|
de1574f920 | ||
|
|
ff2f9ba64c | ||
|
|
362cb8091b | ||
|
|
0af81efac0 | ||
|
|
c705ef0cd0 | ||
|
|
ae5d7bf47a | ||
|
|
76ced26127 | ||
|
|
e98ad5d611 | ||
|
|
37a6eb67b2 | ||
|
|
2bf97fe4e1 | ||
|
|
f9130d1c67 | ||
|
|
a542112361 | ||
|
|
b2108651d8 | ||
|
|
b81b3ea8eb | ||
|
|
3ae38ff5ee | ||
|
|
5218a44701 | ||
|
|
6234cea62c | ||
|
|
6e26a65f12 | ||
|
|
86532e27fe | ||
|
|
f2b8d04d03 | ||
|
|
615dbd3ee6 | ||
|
|
fb6cf407d4 | ||
|
|
9fcfc8b484 | ||
|
|
829d4c6b6c | ||
|
|
60e1564c0f | ||
|
|
a42d1bc3c8 | ||
|
|
729234bd7f | ||
|
|
d718741cd3 | ||
|
|
1e7e1b5934 | ||
|
|
3ac4358c0b | ||
|
|
cc997cfa20 | ||
|
|
37dd49f9ec | ||
|
|
e16281e237 | ||
|
|
64479a5c84 | ||
|
|
5b1b085e06 | ||
|
|
e0caa83325 | ||
|
|
90bc7f3907 | ||
|
|
b7fb6c5a66 | ||
|
|
0d38f6dc5e | ||
|
|
8c4b424eb6 | ||
|
|
2cf335d019 | ||
|
|
9f0678a17c | ||
|
|
ad9c41dd28 | ||
|
|
1732bcbee2 | ||
|
|
96276cc118 | ||
|
|
dc69033ca4 | ||
|
|
bcf25eba38 | ||
|
|
9116d9b055 | ||
|
|
b7a3b5c924 | ||
|
|
5b9be3368a | ||
|
|
5c7ea61937 | ||
|
|
29a8bdc85f | ||
|
|
8116de4659 | ||
|
|
578753235d | ||
|
|
8eef5c265e | ||
|
|
1fe1749d6f | ||
|
|
a9c7b3034c | ||
|
|
0d17575f56 | ||
|
|
9366f099ec | ||
|
|
b3edc4bf87 | ||
|
|
00aa796daf | ||
|
|
9153d4e950 | ||
|
|
c7250e26e2 | ||
|
|
49677fefdc | ||
|
|
bebb3874f9 | ||
|
|
a79ffe343f | ||
|
|
283420e898 | ||
|
|
6dd53f17ae | ||
|
|
08dc3b389a | ||
|
|
57e81c002d | ||
|
|
523ea5093e | ||
|
|
a77a5b1b11 | ||
|
|
3842ffd893 | ||
|
|
c0c3c2a754 | ||
|
|
486c16d0fa | ||
|
|
1c2afe416f | ||
|
|
cf30040161 | ||
|
|
df48d581ee | ||
|
|
f489b8e789 | ||
|
|
88768334aa | ||
|
|
55ec2b833d | ||
|
|
b503cc284f | ||
|
|
550da56b4e | ||
|
|
327aec34cc | ||
|
|
14cb5194e8 | ||
|
|
a33de047fd | ||
|
|
04f09f2cd4 | ||
|
|
d87d22ab27 | ||
|
|
d7fa02aeff | ||
|
|
c3f81b10f1 | ||
|
|
2424e35435 | ||
| a48b76a1f4 | |||
| 2417dedce2 | |||
| a6e934e4a4 | |||
|
|
0aa2cf4ee3 | ||
| fdba05140b | |||
| 0b29cac5eb | |||
| cc7cf86ea9 | |||
| 7143222cd0 | |||
|
|
e6c8fd8c3c | ||
|
|
cea7ca5119 | ||
|
|
a849e9cd34 | ||
|
|
fcb0a158ea | ||
|
|
7614ed0fdd | ||
|
|
6c96aaa11b | ||
| 7b5f3db26a | |||
|
|
51047fc315 | ||
|
|
dff1475550 | ||
| 9fdeaaa7b2 | |||
|
|
f1827aba18 | ||
|
|
39aa92d116 | ||
|
|
7e82c3d343 | ||
|
|
7020f51ac7 | ||
| 737eed473e | |||
|
|
4c8412a47b | ||
|
|
093bcb6e58 | ||
|
|
5fc6e008a5 | ||
|
|
0591eabfee | ||
|
|
3451a4b86a | ||
|
|
9c321b86c1 | ||
|
|
1f08ea8f12 | ||
|
|
de3faece35 | ||
|
|
370bb99e8f | ||
|
|
62f71d5c8d | ||
|
|
239a0ff2c0 | ||
|
|
660f982d71 | ||
|
|
3321f8e593 | ||
|
|
3984307e44 | ||
|
|
9c5b8f3cfb | ||
|
|
d2a3a05ea1 | ||
|
|
eac1d4cb0a | ||
|
|
a0c0dafe34 | ||
|
|
91b7e0c0e0 | ||
|
|
c2692a3e86 | ||
|
|
ad2c680cda | ||
|
|
d46d587687 | ||
|
|
f06bc254c8 | ||
|
|
ad517a6332 | ||
|
|
6cb74eab7f | ||
|
|
2ea43f7c8b | ||
|
|
90ae8dcf23 | ||
|
|
9648247fe3 | ||
|
|
fd30bb4f27 | ||
|
|
9e83341c89 | ||
|
|
93e521f440 | ||
|
|
ec9853c571 | ||
|
|
636bdafc9e | ||
|
|
c7d6ee5e21 | ||
|
|
496ca61489 | ||
|
|
a812380b32 | ||
|
|
9bb0f6d373 | ||
|
|
df7c41887c | ||
|
|
00410478c0 | ||
|
|
a943a412ac | ||
|
|
6550ecff12 | ||
|
|
c72c73e88c | ||
|
|
d4ec430790 | ||
|
|
5cce19d849 | ||
|
|
6ae2be604f | ||
|
|
11edda5411 | ||
|
|
44d21fa146 | ||
|
|
798476e991 | ||
|
|
bad6c24597 | ||
|
|
5b7898f478 | ||
|
|
9cc582b869 | ||
|
|
db2386b8a9 | ||
|
|
ac70cc0247 | ||
|
|
eb95528b76 | ||
|
|
879d1c61df | ||
|
|
0af6db4461 | ||
|
|
0f5901e55f | ||
|
|
8fcc3629bd | ||
|
|
0b54c251bc | ||
|
|
8995c60d88 | ||
|
|
c4e178a900 | ||
|
|
6688bbf8a1 | ||
|
|
bb5f2c8aaa | ||
|
|
a9d0f328a8 | ||
|
|
3b769905b7 | ||
|
|
f7727d8c17 | ||
|
|
6d7eb4f151 | ||
|
|
0c260f69b0 | ||
|
|
63b9372372 | ||
|
|
aaff332937 | ||
|
|
964548ba38 | ||
|
|
cf05d8cad1 | ||
|
|
05dca8f847 | ||
|
|
27328c9106 | ||
|
|
b3dd9a8e23 | ||
|
|
1cd6c15cb3 | ||
|
|
3554578554 | ||
|
|
3962807fc6 | ||
|
|
32054ddcce | ||
|
|
5905699ca1 | ||
|
|
eb8e2a89c4 | ||
|
|
8286aebf4e | ||
|
|
4cff4af841 | ||
|
|
8abcd3291e | ||
|
|
a7c3eb4183 | ||
|
|
1ed62fe0de | ||
|
|
160b312ca5 | ||
|
|
6d22a99259 | ||
|
|
febfd75016 | ||
|
|
fbb72f902b | ||
|
|
fd11ae0fe0 | ||
|
|
16c5c455fa | ||
|
|
df587fdda3 | ||
|
|
3fb5747aa2 | ||
|
|
33c9420b00 | ||
|
|
37204edfd7 | ||
|
|
8d9725b501 | ||
|
|
6cf8ad1854 | ||
|
|
58f787feb0 | ||
|
|
970ce05846 | ||
|
|
672b0d5f6b | ||
|
|
4415194b28 | ||
|
|
213b0ef8f2 | ||
|
|
13dbe046e1 | ||
|
|
592df4de44 | ||
|
|
ae581b4d5c | ||
|
|
8a8f83cc0c | ||
|
|
722904d487 | ||
|
|
ddc84f6730 | ||
|
|
2c510844f0 | ||
|
|
105a1e8ce0 | ||
|
|
7e06ff3488 | ||
|
|
aed1e62c65 | ||
|
|
f9f1b8dc46 | ||
|
|
89d3a54988 | ||
|
|
0c60e5c519 | ||
|
|
1ecc4a916b | ||
|
|
d4ec8c16f3 | ||
|
|
f9d7573cb4 | ||
|
|
e48e9c9b82 | ||
|
|
afbb1ba79c | ||
|
|
5f0042e483 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
329c01523a | ||
|
|
5fb26f901d | ||
|
|
6baadf5744 | ||
|
|
a3508c57a2 | ||
|
|
38cba2cd72 | ||
|
|
735e09ab90 | ||
|
|
05ef21cd71 | ||
|
|
65c65bf9cc | ||
|
|
e33f0d0182 | ||
|
|
c8faf2f2d6 | ||
|
|
50bb3fce77 | ||
|
|
c7fdc67060 | ||
|
|
c7e2b0e4ac | ||
|
|
0cf83744db | ||
|
|
defeeffa07 | ||
|
|
0fbf99c005 | ||
|
|
67eb679c7e | ||
|
|
3b7f3acaa6 | ||
|
|
3d1f3b1057 | ||
|
|
7a2748e904 | ||
|
|
4f2061cd00 | ||
|
|
8bb9044f2d | ||
|
|
7da52677d5 | ||
|
|
a049db38a9 | ||
|
|
bb60a772f9 | ||
|
|
95d92f27d3 | ||
|
|
f08910bbf4 | ||
|
|
e043137269 | ||
|
|
de988d9abd | ||
|
|
72df0cfe88 | ||
|
|
65a752f4d8 | ||
|
|
7d0230be5f | ||
|
|
75305a01b0 | ||
|
|
f2ce0dfee3 | ||
|
|
1222610080 | ||
|
|
c1d0cdf477 | ||
|
|
a55ea906ac | ||
|
|
70e274415d | ||
|
|
fca89475cc | ||
|
|
b33ebac9bf | ||
|
|
a88eeb7981 | ||
|
|
eed4df0c4a | ||
|
|
915b0407cf | ||
|
|
f173254700 | ||
|
|
539cd0e4e1 | ||
|
|
050a446ba0 | ||
|
|
8fe4213178 | ||
|
|
d7413784ea | ||
|
|
b6b049e321 | ||
|
|
11509c4af0 | ||
|
|
8651e5a9e6 | ||
|
|
e0d931d72c | ||
|
|
6c7a0d2a35 | ||
|
|
95684ffae0 | ||
|
|
b30f5db061 | ||
|
|
266bb3ff9c | ||
|
|
f227a53ac1 | ||
|
|
6d0adb0b02 | ||
|
|
61b2a2beb6 | ||
|
|
fdfe132545 | ||
|
|
c9e191ee7e | ||
|
|
d42c964c30 | ||
|
|
b8e7ebc3ac | ||
|
|
e156716002 | ||
|
|
b5c1d92397 | ||
|
|
72e96b7e0e | ||
|
|
4489377762 | ||
|
|
eedd4c9cef | ||
|
|
2370f31a18 | ||
|
|
27c8395d5a | ||
|
|
dbee401f61 | ||
|
|
b17bc590bb | ||
|
|
6ce5ca14e2 | ||
|
|
454b85ffb1 | ||
|
|
e13d7cd7ad | ||
|
|
f3436d35ec | ||
|
|
a46b44055e | ||
|
|
a3dda1520e | ||
|
|
4068bfc0b2 | ||
|
|
497523ee0c | ||
|
|
94d68f80e4 | ||
|
|
c091c3c168 | ||
|
|
7c54ece253 | ||
|
|
f7294fcf83 | ||
|
|
6d64d9527a | ||
|
|
08df003b20 | ||
|
|
59cd09eb5b | ||
|
|
3a6ab1c207 | ||
|
|
404a731bd9 | ||
|
|
2b30deed11 | ||
|
|
109d9cd39d | ||
|
|
aadd7a500a | ||
|
|
111ef20684 | ||
|
|
85fdb71f92 | ||
|
|
08e2eb3ac6 | ||
|
|
87e8384aca | ||
|
|
e56ad20568 | ||
|
|
fafb05e29b | ||
|
|
a322d7609b | ||
|
|
2aefba3619 | ||
|
|
b47fc35857 | ||
|
|
e5e1dea055 | ||
|
|
e5e485d636 | ||
|
|
3d383e0490 | ||
|
|
df188e21ce | ||
|
|
55016f7009 | ||
|
|
9cf89c7b1a | ||
|
|
0d810dff27 | ||
|
|
624a36d2c5 | ||
|
|
92e3e171e1 | ||
|
|
58ca83c8c2 | ||
|
|
7f175709a5 | ||
|
|
26a903bdd9 | ||
|
|
e871426817 | ||
|
|
c99511d696 | ||
|
|
963f00cd39 | ||
|
|
0db70220c7 | ||
|
|
4bcd0cca8a | ||
|
|
6c7d7016c9 | ||
|
|
6d92f37ea7 | ||
|
|
318d6b4fe8 | ||
|
|
9ea69447ec | ||
|
|
a24fbaac9a | ||
|
|
412a3beeed | ||
|
|
4e7f344941 | ||
|
|
d0e9369795 | ||
|
|
8f56f32e62 | ||
|
|
b8d307200b | ||
|
|
4e979c3158 | ||
|
|
085ca6c415 | ||
|
|
1d363d7157 | ||
|
|
71effd6f4c | ||
|
|
2198008b4c | ||
|
|
2320511cd3 | ||
|
|
6124e8fa07 | ||
|
|
23195d1887 | ||
|
|
d9e99b3091 | ||
|
|
e774093e94 | ||
|
|
697ba5f0f4 | ||
|
|
ef043bda0c | ||
|
|
0f419075cd | ||
|
|
9b3bb1d93b | ||
|
|
8b4f6a48ad | ||
|
|
f5d537cb67 | ||
|
|
fad91c5d7d | ||
|
|
7e2b3d4ce6 | ||
|
|
918d2a3a95 | ||
|
|
bff2199cb6 | ||
|
|
8b32be2c19 | ||
|
|
9ee02b6115 | ||
|
|
7c1ff57eb1 | ||
|
|
67c663faf4 | ||
|
|
691aeda2c2 | ||
|
|
0e4e7784d3 | ||
|
|
315a206542 | ||
|
|
d1ff2e8221 | ||
|
|
a2184e2de2 | ||
|
|
cf4a77c72a | ||
|
|
85d0ca2369 | ||
|
|
61fd09f6a8 | ||
|
|
ed20135cbe | ||
|
|
e6f33d4fa9 | ||
|
|
66da7b5a7a | ||
|
|
5dfef28a20 | ||
|
|
2e1eda8c5d | ||
|
|
58e35dc78e | ||
|
|
43b49aafd7 | ||
|
|
b265b407b1 | ||
|
|
4b71bbea6a | ||
|
|
398cd41361 | ||
|
|
17b0f65680 | ||
|
|
a4514f4985 | ||
|
|
3ba9ae86b4 | ||
|
|
261f00043e | ||
|
|
656ebd023b | ||
|
|
55ab18ee53 | ||
|
|
391bd6546b | ||
|
|
ef5af08609 | ||
|
|
8f171c0784 | ||
|
|
d8d2bc5fb1 | ||
|
|
11c67f491c | ||
|
|
f3b8281cf7 | ||
|
|
8ec47836d7 | ||
|
|
e4205cbc77 | ||
|
|
8f6701fb9c | ||
|
|
648d9d56ab | ||
|
|
577dd6c369 | ||
|
|
6015195885 | ||
|
|
7522cadce5 | ||
|
|
af899f39ca | ||
|
|
90b752cb8f | ||
|
|
3f049b505b | ||
|
|
daf9ec9134 | ||
|
|
ee757761e3 | ||
|
|
010e1f9259 | ||
|
|
154ecfb507 | ||
|
|
97a41afed1 | ||
|
|
3088d05825 | ||
|
|
93648ed001 | ||
|
|
88b201222f | ||
|
|
de402c03d5 | ||
|
|
b92ba4b86d | ||
|
|
f8f219f00b | ||
|
|
f16dac012d | ||
|
|
f566b83cc6 | ||
|
|
418da7a39e | ||
|
|
3c6fe92811 | ||
|
|
7d3243b67e | ||
|
|
8f6597f428 | ||
|
|
64d38b8b2f | ||
|
|
7aa366eda9 | ||
|
|
d7adaf0cba | ||
|
|
f8b5599b4b | ||
|
|
d6161817ad | ||
|
|
be8b039c23 | ||
|
|
8519c7dd40 | ||
|
|
096bbcc173 | ||
|
|
cdff325a3c | ||
|
|
8eb822ec81 | ||
|
|
ef0eb78d93 | ||
|
|
1dfdb74bc5 | ||
|
|
75352af8b0 | ||
|
|
544d33324a | ||
|
|
235981d908 | ||
|
|
fc1dcb5d77 | ||
|
|
91548b00de | ||
|
|
bcd6426e2e | ||
|
|
22e5be9a63 | ||
|
|
515957f7e7 | ||
|
|
18f98afc95 | ||
|
|
e3781747c1 | ||
|
|
8ef10be240 | ||
|
|
e239e6ece6 | ||
|
|
5e307d25e6 | ||
|
|
5c4ec7fd46 | ||
|
|
c1b487624c | ||
|
|
5d47a8dedf | ||
|
|
b6e89cfac1 | ||
|
|
4f272af661 | ||
|
|
f1820eb47d | ||
|
|
5b78f8db0f | ||
|
|
90f414ddf7 | ||
|
|
61b0977fc7 | ||
|
|
7954804998 | ||
|
|
88fc44cee6 | ||
|
|
e02cb0415a | ||
|
|
0307a7a310 | ||
|
|
991ca95c70 | ||
|
|
3905f86d6a | ||
|
|
ece7dc602d | ||
|
|
b093e088c8 | ||
|
|
22a62ba005 | ||
|
|
57c236dd27 | ||
|
|
97f38985a9 | ||
|
|
c698c621d3 | ||
|
|
e072b01d6a | ||
|
|
076b990573 | ||
|
|
e1f34935e4 | ||
|
|
ba286d830e | ||
|
|
95db76ed09 | ||
|
|
725891b975 | ||
|
|
04b0c7a991 | ||
|
|
9dcaf5bdd7 | ||
|
|
36fac08dd1 | ||
|
|
427d5c905f | ||
|
|
641b6dc74c | ||
|
|
1d0a3d3221 | ||
|
|
fba6cd69ad | ||
|
|
ce5c670bf2 | ||
|
|
578720130b | ||
|
|
12f7ba9949 | ||
|
|
3f8625fc0d | ||
|
|
02270569ed | ||
|
|
df84066b0e | ||
|
|
d035c3ba46 | ||
|
|
6577cd0c0a | ||
|
|
8dfd9076dc | ||
|
|
ff8a2c93b4 | ||
|
|
e98aaa034b | ||
|
|
a892514c3a | ||
|
|
559a1ee2fc | ||
|
|
664c081680 | ||
|
|
6517f2fa44 | ||
|
|
c9b99efbe0 | ||
|
|
8469ff5204 | ||
|
|
9be95461cb | ||
|
|
b24266cdc1 | ||
|
|
b96beddef5 | ||
|
|
1eabb951e5 | ||
|
|
486e6864b6 | ||
|
|
cd25979e13 | ||
|
|
382c1cc29e | ||
|
|
edad5712fc | ||
|
|
91b2002dd6 | ||
|
|
54e52add84 | ||
|
|
6290274719 | ||
|
|
5bbc740962 | ||
|
|
82cac8ebab | ||
|
|
dc668e24d5 | ||
|
|
910be9c14a | ||
|
|
aab2e65903 | ||
|
|
f85be8a676 | ||
|
|
36473e1c49 | ||
|
|
fe0c6b22af | ||
|
|
06e35cb296 | ||
|
|
4b347112c6 | ||
|
|
632ddce08a | ||
|
|
35c603944f | ||
|
|
ea3ed4de0a | ||
|
|
179c9a7818 | ||
|
|
6835a19b39 | ||
|
|
3b9ddd8865 | ||
|
|
d9d8190835 | ||
|
|
8af01a6772 | ||
|
|
e11a934766 | ||
|
|
86c2e0cf1c | ||
|
|
f899e5f8cb | ||
|
|
f2b1ceebe9 | ||
|
|
b0e343f2b5 | ||
|
|
609d55d5c9 | ||
|
|
d649c8239f | ||
|
|
86b7d8db4e | ||
|
|
701534dd6b | ||
|
|
f341fc6673 | ||
|
|
103b7a6077 | ||
|
|
5a57fd1e27 | ||
|
|
6f56d21936 | ||
|
|
44cf1423e4 | ||
|
|
ceea43823b | ||
|
|
618d5aeea9 | ||
|
|
9c3e3b1c7b | ||
|
|
b3a5eebd56 | ||
|
|
dc804e8e25 | ||
|
|
20709d201f | ||
|
|
b33e71fecc | ||
|
|
b3ae727c5a | ||
|
|
c004ee3b1e | ||
|
|
41f8bee6a6 | ||
|
|
f53124cd2e | ||
|
|
1d1ac2d520 | ||
|
|
bca2cd5c77 | ||
|
|
ff25196d51 | ||
|
|
58006d7b19 | ||
|
|
4237cf45ab | ||
|
|
5f591bee19 | ||
|
|
c9fa8d7578 | ||
|
|
4adc611e83 | ||
|
|
3c88bbfb4d | ||
|
|
3496421264 | ||
|
|
91f1ae217a | ||
|
|
5b7a2dd7bf | ||
|
|
c991d3f141 |
@@ -8,8 +8,8 @@ node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Composer
|
||||
/vendor
|
||||
# Composer (NOT excluded - Dockerfile.fast needs pre-built vendor)
|
||||
# /vendor
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -58,7 +58,7 @@ docker-compose.*.yml
|
||||
# Build artifacts
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/build
|
||||
# /public/build - NOT excluded, Dockerfile.fast needs pre-built assets
|
||||
|
||||
# Misc
|
||||
.env.backup
|
||||
|
||||
66
.env.example
66
.env.example
@@ -24,12 +24,13 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# PostgreSQL: 10.100.6.50:5432
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=pgsql
|
||||
DB_HOST=10.100.6.50
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=cannabrands_app
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
DB_DATABASE=cannabrands_dev
|
||||
DB_USERNAME=cannabrands
|
||||
DB_PASSWORD=SpDyCannaBrands2024
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
@@ -66,9 +67,10 @@ CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
# Redis: 10.100.9.50:6379
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_HOST=10.100.9.50
|
||||
REDIS_PASSWORD=SpDyR3d1s2024!
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
@@ -88,41 +90,29 @@ MAIL_FROM_NAME="${APP_NAME}"
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL DEVELOPMENT (Docker MinIO) │
|
||||
# │ MinIO (S3-Compatible Storage) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# Use local MinIO container for development (versioning enabled)
|
||||
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
||||
# Server: 10.100.9.80:9000 | Console: 10.100.9.80:9001
|
||||
FILESYSTEM_DISK=minio
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_ACCESS_KEY_ID=cannabrands-app
|
||||
AWS_SECRET_ACCESS_KEY=cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=media
|
||||
AWS_ENDPOINT=http://minio:9000
|
||||
AWS_URL=http://localhost:9000/media
|
||||
AWS_BUCKET=cannabrands
|
||||
AWS_ENDPOINT=http://10.100.9.80:9000
|
||||
AWS_URL=http://10.100.9.80:9000/cannabrands
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ STAGING/DEVELOP (media-dev bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=<staging-access-key>
|
||||
# AWS_SECRET_ACCESS_KEY=<staging-secret-key>
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media-dev
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media-dev
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ PRODUCTION (media bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# AI Orchestrator Configuration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
AI_ENABLED=true
|
||||
AI_LLM_PROVIDER=mock
|
||||
# AI_LLM_PROVIDER=openai
|
||||
# AI_LLM_PROVIDER=anthropic
|
||||
AI_OPENAI_API_KEY=
|
||||
AI_OPENAI_MODEL=gpt-4o
|
||||
AI_ANTHROPIC_API_KEY=
|
||||
AI_ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
AI_LOG_CHANNEL=ai
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -37,6 +37,7 @@ yarn-error.log
|
||||
*.gz
|
||||
*.sql.gz
|
||||
*.sql
|
||||
!database/dumps/*.sql
|
||||
|
||||
# Version files (generated at build time or locally)
|
||||
version.txt
|
||||
@@ -46,6 +47,9 @@ version.env
|
||||
.cannabrands-secrets/
|
||||
reverb-keys*
|
||||
|
||||
# Local Claude context (DO NOT COMMIT)
|
||||
CLAUDE.local.md
|
||||
|
||||
# Core dumps and debug files
|
||||
core
|
||||
core.*
|
||||
@@ -61,7 +65,9 @@ core.*
|
||||
!resources/**/*.png
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
.claude/settings.local.json
|
||||
# Claude Code settings (personal AI preferences)
|
||||
.claude/
|
||||
|
||||
storage/tmp/*
|
||||
!storage/tmp/.gitignore
|
||||
SESSION_ACTIVE
|
||||
@@ -71,3 +77,9 @@ SESSION_ACTIVE
|
||||
*.dev.md
|
||||
NOTES.md
|
||||
TODO.personal.md
|
||||
SESSION_*
|
||||
|
||||
# AI workflow personal context files
|
||||
CLAUDE.local.md
|
||||
claude.*.md
|
||||
cannabrands_dev_backup.dump
|
||||
|
||||
@@ -1,343 +1,301 @@
|
||||
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
||||
# Documentation: https://woodpecker-ci.org/docs/intro
|
||||
# Optimized for fast deploys (~8-10 min)
|
||||
#
|
||||
# 3-Environment Workflow:
|
||||
# - develop branch → dev.cannabrands.app (unstable, daily integration)
|
||||
# - master branch → staging.cannabrands.app (stable, pre-production)
|
||||
# - tags (2025.X) → cannabrands.app (production releases)
|
||||
# Optimizations:
|
||||
# - Parallel composer + frontend builds
|
||||
# - Split tests (unit + feature run in parallel)
|
||||
# - Dependency caching (npm + composer)
|
||||
# - Single-stage Dockerfile.fast
|
||||
# - Kaniko layer caching
|
||||
#
|
||||
# External Services:
|
||||
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
|
||||
# - Redis: 10.100.9.50:6379
|
||||
# - MinIO: 10.100.9.80:9000
|
||||
# - Docker Registry: git.spdy.io (for k8s pulls)
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
steps:
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
restore: true
|
||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
||||
archive_format: "gzip"
|
||||
mount:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
depth: 50
|
||||
lfs: false
|
||||
partial: false
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# PARALLEL: Composer + Frontend (with caching)
|
||||
# ============================================
|
||||
|
||||
# Install dependencies
|
||||
composer-install:
|
||||
image: php:8.3-cli
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
commands:
|
||||
- echo "Installing system dependencies..."
|
||||
- apt-get update -qq
|
||||
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
|
||||
- echo "Installing PHP extensions..."
|
||||
- docker-php-ext-configure gd --with-freetype --with-jpeg
|
||||
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
|
||||
- echo "Installing Composer..."
|
||||
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
- |
|
||||
cat > .env << 'EOF'
|
||||
APP_NAME="Cannabrands Hub"
|
||||
APP_ENV=testing
|
||||
APP_ENV=development
|
||||
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
APP_DEBUG=true
|
||||
CACHE_STORE=array
|
||||
SESSION_DRIVER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=testing
|
||||
DB_USERNAME=testing
|
||||
DB_PASSWORD=testing
|
||||
EOF
|
||||
- echo "Checking for cached dependencies..."
|
||||
- |
|
||||
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
|
||||
echo "✅ Restored vendor from cache"
|
||||
echo "Verifying cached dependencies are up to date..."
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
else
|
||||
echo "📦 Installing fresh dependencies (cache miss)"
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
fi
|
||||
- echo "Composer dependencies ready!"
|
||||
# Restore composer cache if available
|
||||
- mkdir -p /root/.composer/cache
|
||||
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
|
||||
# Clean vendor and bootstrap cache to force fresh install
|
||||
- rm -rf vendor bootstrap/cache/*.php
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
# Verify test command is available
|
||||
- php artisan list test | head -5
|
||||
# Save cache for next build
|
||||
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
|
||||
- echo "✅ Composer done"
|
||||
|
||||
# Rebuild Composer cache
|
||||
rebuild-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
rebuild: true
|
||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
||||
archive_format: "gzip"
|
||||
mount:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
build-frontend:
|
||||
image: 10.100.9.70:5000/library/node:22-alpine
|
||||
environment:
|
||||
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
|
||||
VITE_REVERB_HOST: dev.cannabrands.app
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: https
|
||||
npm_config_cache: .npm-cache
|
||||
commands:
|
||||
# Use cached node_modules if available
|
||||
- npm ci --prefer-offline
|
||||
- npm run build
|
||||
- echo "✅ Frontend built"
|
||||
|
||||
# ============================================
|
||||
# PR CHECKS (Parallel: lint, style, tests)
|
||||
# ============================================
|
||||
|
||||
# PHP Syntax Check (runs after composer install so traits/classes are available)
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \;
|
||||
- find routes -name "*.php" -exec php -l {} \;
|
||||
- find database -name "*.php" -exec php -l {} \;
|
||||
- echo "PHP syntax check complete!"
|
||||
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run Laravel Pint (Code Style)
|
||||
code-style:
|
||||
image: php:8.3-cli
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking code style with Laravel Pint..."
|
||||
- ./vendor/bin/pint --test
|
||||
- echo "Code style check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run PHPUnit Tests
|
||||
# Note: Uses array cache/session for speed and isolation (Laravel convention)
|
||||
# Redis + Reverb services used for real-time broadcasting tests
|
||||
tests:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
# Split tests: Unit tests (with DB - some unit tests use factories)
|
||||
tests-unit:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
BROADCAST_CONNECTION: reverb
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_HOST: 10.100.6.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
REDIS_HOST: redis
|
||||
REVERB_APP_ID: test-app-id
|
||||
REVERB_APP_KEY: test-key
|
||||
REVERB_APP_SECRET: test-secret
|
||||
REVERB_HOST: localhost
|
||||
REVERB_PORT: 8080
|
||||
REVERB_SCHEME: http
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
commands:
|
||||
- echo "Setting up Laravel environment..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Starting Reverb server in background..."
|
||||
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
|
||||
- sleep 2
|
||||
- echo "Running tests..."
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
- php artisan test --testsuite=Unit
|
||||
- echo "✅ Unit tests passed"
|
||||
|
||||
# Split tests: Feature tests (with DB)
|
||||
tests-feature:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 10.100.7.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
REDIS_HOST: 10.100.9.50
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: SpDyR3d1s2024!
|
||||
commands:
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- php artisan test --testsuite=Feature
|
||||
- echo "✅ Feature tests passed"
|
||||
|
||||
# ============================================
|
||||
# BUILD & DEPLOY
|
||||
# ============================================
|
||||
|
||||
# Create Docker config for registry auth (runs before Kaniko)
|
||||
setup-registry-auth:
|
||||
image: alpine
|
||||
depends_on:
|
||||
- composer-install
|
||||
- build-frontend
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: registry_user
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: registry_password
|
||||
commands:
|
||||
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
|
||||
- |
|
||||
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
|
||||
{"auths":{"registry.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||
EOF
|
||||
- echo "Auth config created"
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- dev # Latest dev build → dev.cannabrands.app
|
||||
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (develop)
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "dev"
|
||||
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
|
||||
VITE_REVERB_HOST: "dev.cannabrands.app"
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: "https"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-dev
|
||||
platforms: linux/amd64
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth
|
||||
commands:
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=registry.spdy.io/cannabrands/hub:dev \
|
||||
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=dev \
|
||||
--registry-mirror=10.100.9.70:5000 \
|
||||
--insecure-registry=10.100.9.70:5000 \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
||||
deploy-dev:
|
||||
image: bitnami/kubectl:latest
|
||||
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-dev
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_dev
|
||||
commands:
|
||||
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
|
||||
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
|
||||
- echo ""
|
||||
# Setup kubeconfig
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
# Update deployment to use new SHA-tagged image (both app and init containers)
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-dev
|
||||
# Wait for rollout to complete (timeout 5 minutes)
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
|
||||
# Verify deployment health
|
||||
- |
|
||||
echo ""
|
||||
echo "✅ Deployment successful!"
|
||||
echo "Pod status:"
|
||||
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
|
||||
echo ""
|
||||
echo "Image deployed:"
|
||||
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||
echo ""
|
||||
- echo "✅ Deployed to dev.cannabrands.app"
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for STAGING environment (master branch)
|
||||
build-image-staging:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- staging # Latest staging build → staging.cannabrands.app
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (master)
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "staging"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-staging
|
||||
platforms: linux/amd64
|
||||
build-image-production:
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth
|
||||
commands:
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=production \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||
--insecure \
|
||||
--insecure-pull \
|
||||
--skip-tls-verify
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for PRODUCTION (tagged releases)
|
||||
build-image-release:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
|
||||
- latest # Latest stable release
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "${CI_COMMIT_TAG}"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
deploy-production:
|
||||
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-production
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_prod
|
||||
commands:
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-prod
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
||||
- echo "✅ Deployed to cannabrands.app"
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
|
||||
# For tags, setup auth first
|
||||
setup-registry-auth-release:
|
||||
image: alpine
|
||||
depends_on:
|
||||
- composer-install
|
||||
- build-frontend
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: registry_user
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: registry_password
|
||||
commands:
|
||||
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
|
||||
- |
|
||||
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
|
||||
{"auths":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||
EOF
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
# Success notification
|
||||
success:
|
||||
image: alpine:latest
|
||||
when:
|
||||
- evaluate: 'CI_PIPELINE_STATUS == "success"'
|
||||
build-image-release:
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth-release
|
||||
commands:
|
||||
- echo "✅ Pipeline completed successfully!"
|
||||
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Version: ${CI_COMMIT_TAG}"
|
||||
echo "Registry: code.cannabrands.app/cannabrands/hub"
|
||||
echo ""
|
||||
echo "Available as:"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:latest"
|
||||
echo ""
|
||||
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
|
||||
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
||||
echo " docker-compose -f docker-compose.production.yml up -d"
|
||||
echo ""
|
||||
echo "⚠️ This is a CUSTOMER-FACING release!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🧪 STAGING BUILD COMPLETE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Branch: master"
|
||||
echo "Registry: code.cannabrands.app/cannabrands/hub"
|
||||
echo "Tags:"
|
||||
echo " - staging"
|
||||
echo " - sha-${CI_COMMIT_SHA:0:7}"
|
||||
echo " - ${CI_COMMIT_BRANCH}"
|
||||
echo ""
|
||||
echo "📦 Deploy to STAGING (staging.cannabrands.app):"
|
||||
echo " docker pull code.cannabrands.app/cannabrands/hub:staging"
|
||||
echo " docker-compose -f docker-compose.staging.yml up -d"
|
||||
echo ""
|
||||
echo "👥 Next steps:"
|
||||
echo " 1. Super-admin tests on staging.cannabrands.app"
|
||||
echo " 2. Validate all features work"
|
||||
echo " 3. When ready, create production tag:"
|
||||
echo " git tag -a 2025.10.1 -m 'Release 2025.10.1'"
|
||||
echo " git push origin 2025.10.1"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Branch: develop"
|
||||
echo "Commit: ${CI_COMMIT_SHA:0:7}"
|
||||
echo ""
|
||||
echo "✅ Built & Tagged:"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:dev"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
|
||||
echo ""
|
||||
echo "✅ Auto-Deployed to Kubernetes:"
|
||||
echo " - Environment: dev.cannabrands.app"
|
||||
echo " - Namespace: cannabrands-dev"
|
||||
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
|
||||
echo ""
|
||||
echo "🧪 Test your changes:"
|
||||
echo " - Visit: https://dev.cannabrands.app"
|
||||
echo " - Login: admin@example.com / password"
|
||||
echo " - Check: https://dev.cannabrands.app/telescope"
|
||||
echo ""
|
||||
echo "👥 Next steps:"
|
||||
echo " 1. Verify feature works on dev.cannabrands.app"
|
||||
echo " 2. When stable, merge to master for staging:"
|
||||
echo " git checkout master && git merge develop && git push"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
# Services for tests
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: testing
|
||||
POSTGRES_PASSWORD: testing
|
||||
POSTGRES_DB: testing
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
commands:
|
||||
- redis-server --bind 0.0.0.0
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
|
||||
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||
--insecure \
|
||||
--insecure-pull \
|
||||
--skip-tls-verify
|
||||
when:
|
||||
event: tag
|
||||
|
||||
@@ -69,14 +69,14 @@ git push origin develop
|
||||
|
||||
**Before (Mutable Tags - Problematic):**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
|
||||
git.spdy.io/cannabrands/hub:dev # Overwritten each build
|
||||
```
|
||||
|
||||
**After (Immutable Tags - Best Practice):**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
|
||||
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
|
||||
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||
```
|
||||
|
||||
### Auto-Deploy Flow
|
||||
@@ -109,14 +109,14 @@ If a deployment breaks dev, roll back to the previous version:
|
||||
kubectl get deployment cannabrands-hub -n cannabrands-dev \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||
|
||||
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
||||
# Output: git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||
|
||||
# 2. Check git log for previous commit
|
||||
git log --oneline develop | head -5
|
||||
|
||||
# 3. Rollback to previous SHA
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
|
||||
app=git.spdy.io/cannabrands/hub:dev-PREVIOUS_SHA \
|
||||
-n cannabrands-dev
|
||||
|
||||
# 4. Verify rollback
|
||||
@@ -156,7 +156,7 @@ deploy-staging:
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
|
||||
app=git.spdy.io/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-staging
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
|
||||
when:
|
||||
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
|
||||
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
|
||||
|
||||
Image deployed:
|
||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
||||
git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -47,8 +47,8 @@ steps:
|
||||
build-image:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags: [latest, ${CI_COMMIT_SHA:0:8}]
|
||||
when:
|
||||
branch: master
|
||||
@@ -68,7 +68,7 @@ steps:
|
||||
```bash
|
||||
# On production server
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
||||
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||
docker-compose up -d
|
||||
# Or use deployment tool like Ansible, Deployer, etc.
|
||||
```
|
||||
@@ -108,7 +108,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
- docker exec cannabrands php artisan migrate --force
|
||||
- docker exec cannabrands php artisan config:cache
|
||||
@@ -160,7 +160,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
when:
|
||||
branch: develop
|
||||
@@ -176,7 +176,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
when:
|
||||
branch: master
|
||||
@@ -367,7 +367,7 @@ Production:
|
||||
```bash
|
||||
# Quick rollback (under 2 minutes)
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
|
||||
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_COMMIT_SHA
|
||||
docker-compose up -d
|
||||
|
||||
# Database rollback (if migrations ran)
|
||||
@@ -536,8 +536,8 @@ steps:
|
||||
build-image:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- ${CI_COMMIT_BRANCH}
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -559,7 +559,7 @@ steps:
|
||||
from_secret: staging_ssh_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
- docker exec cannabrands php artisan migrate --force
|
||||
- docker exec cannabrands php artisan config:cache
|
||||
@@ -582,7 +582,7 @@ steps:
|
||||
- echo "To deploy to production:"
|
||||
- echo " ssh cannabrands-prod"
|
||||
- echo " cd /var/www/cannabrands"
|
||||
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo " docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo " docker-compose up -d"
|
||||
- echo ""
|
||||
- echo "⚠️ Remember: Check deployment checklist first!"
|
||||
|
||||
@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
|
||||
→ Build Docker image
|
||||
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
|
||||
→ Tag: cannabrands-hub:latest
|
||||
→ Push to code.cannabrands.app/cannabrands/hub
|
||||
→ Push to git.spdy.io/cannabrands/hub
|
||||
→ Image ready, no deployment yet
|
||||
```
|
||||
|
||||
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
|
||||
### Staging Deployment:
|
||||
```bash
|
||||
# Pull the same image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
|
||||
# Run with staging environment
|
||||
docker run \
|
||||
@@ -186,13 +186,13 @@ docker run \
|
||||
-e DB_DATABASE=cannabrands_staging \
|
||||
-e APP_DEBUG=true \
|
||||
-e MAIL_MAILER=log \
|
||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
### Production Deployment:
|
||||
```bash
|
||||
# Pull THE EXACT SAME IMAGE
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
|
||||
# Run with production environment
|
||||
docker run \
|
||||
@@ -201,7 +201,7 @@ docker run \
|
||||
-e DB_DATABASE=cannabrands_production \
|
||||
-e APP_DEBUG=false \
|
||||
-e MAIL_MAILER=smtp \
|
||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
|
||||
@@ -218,7 +218,7 @@ docker run \
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:latest
|
||||
image: git.spdy.io/cannabrands/hub:latest
|
||||
env_file:
|
||||
- .env.staging # Staging-specific vars
|
||||
ports:
|
||||
@@ -253,7 +253,7 @@ secrets:
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
|
||||
image: git.spdy.io/cannabrands/hub:c165bf9 # Specific SHA
|
||||
env_file:
|
||||
- .env.production # Production-specific vars
|
||||
ports:
|
||||
@@ -301,7 +301,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
image: git.spdy.io/cannabrands/hub:c165bf9
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: app-config-staging # Different per namespace
|
||||
@@ -350,8 +350,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest # Always overwrite
|
||||
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
|
||||
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
|
||||
Image: cannabrands-hub:c165bf9
|
||||
Deployed by: jon@cannabrands.com
|
||||
Approved by: compliance@cannabrands.com
|
||||
Git commit: https://code.cannabrands.app/.../c165bf9
|
||||
Git commit: https://git.spdy.io/.../c165bf9
|
||||
Changes: Invoice picking workflow update
|
||||
Tests passed: ✅ 28/28
|
||||
Staging tested: ✅ 2 hours
|
||||
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
|
||||
```bash
|
||||
# On production server
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
@@ -487,14 +487,14 @@ steps:
|
||||
security-scan:
|
||||
image: aquasec/trivy
|
||||
commands:
|
||||
- trivy image code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- trivy image git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
```
|
||||
|
||||
### 4. Sign Images (Advanced)
|
||||
|
||||
Use Cosign to cryptographically sign images:
|
||||
```bash
|
||||
cosign sign code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
cosign sign git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
Compliance benefit: Prove image hasn't been tampered with.
|
||||
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
|
||||
|
||||
```bash
|
||||
# List recent deployments
|
||||
docker images code.cannabrands.app/cannabrands/hub
|
||||
docker images git.spdy.io/cannabrands/hub
|
||||
|
||||
# Rollback to previous version
|
||||
docker pull code.cannabrands.app/cannabrands/hub:a1b2c3d
|
||||
docker pull git.spdy.io/cannabrands/hub:a1b2c3d
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
@@ -531,7 +531,7 @@ deploy:
|
||||
# Before risky deployment
|
||||
git tag -a v1.5.2-stable -m "Last known good version"
|
||||
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
|
||||
docker push code.cannabrands.app/cannabrands/hub:v1.5.2-stable
|
||||
docker push git.spdy.io/cannabrands/hub:v1.5.2-stable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
|
||||
|
||||
**Build and push to Gitea:**
|
||||
```bash
|
||||
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
|
||||
docker push code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
docker build -f docker/ci-php.Dockerfile -t git.spdy.io/cannabrands/ci-php:8.3 .
|
||||
docker push git.spdy.io/cannabrands/ci-php:8.3
|
||||
```
|
||||
|
||||
**Update `.woodpecker/.ci.yml`:**
|
||||
```yaml
|
||||
steps:
|
||||
php-lint:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- find app routes database -name "*.php" -exec php -l {} \;
|
||||
|
||||
composer-install:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
code-style:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- ./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
@@ -107,7 +107,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:latest
|
||||
image: git.spdy.io/cannabrands/hub:latest
|
||||
container_name: cannabrands_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -204,8 +204,8 @@ steps:
|
||||
build-image:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
@@ -564,7 +564,7 @@ docker images | grep cannabrands
|
||||
|
||||
```bash
|
||||
# Pull previous commit's image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
|
||||
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_SHA
|
||||
|
||||
# Update docker-compose.yml to use specific tag
|
||||
docker compose up -d app
|
||||
|
||||
@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
|
||||
|
||||
Your images will be available at:
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub
|
||||
git.spdy.io/cannabrands/hub
|
||||
```
|
||||
|
||||
**View packages**: https://code.cannabrands.app/Cannabrands/hub/-/packages
|
||||
**View packages**: https://git.spdy.io/Cannabrands/hub/-/packages
|
||||
|
||||
## Step 1: Enable Gitea Package Registry
|
||||
|
||||
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
|
||||
|
||||
1. **Check as admin**: Admin → Site Administration → Configuration
|
||||
2. **Look for**: `[packages]` section with `ENABLED = true`
|
||||
3. **Test**: Visit https://code.cannabrands.app/-/packages
|
||||
3. **Test**: Visit https://git.spdy.io/-/packages
|
||||
|
||||
If not enabled, ask your Gitea admin to enable it in `app.ini`:
|
||||
```ini
|
||||
@@ -61,8 +61,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
|
||||
|
||||
```bash
|
||||
# Login to Gitea registry
|
||||
docker login code.cannabrands.app
|
||||
docker login git.spdy.io
|
||||
# Username: your-gitea-username
|
||||
# Password: your-personal-access-token
|
||||
|
||||
# Pull latest image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:latest
|
||||
docker pull git.spdy.io/cannabrands/hub:latest
|
||||
|
||||
# Or pull specific commit
|
||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
||||
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||
```
|
||||
|
||||
## Image Tagging Strategy
|
||||
@@ -218,8 +218,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -236,7 +236,7 @@ steps:
|
||||
notify-deploy:
|
||||
image: alpine:latest
|
||||
commands:
|
||||
- echo "✅ New image published: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo "✅ New image published: git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo "Ready for deployment to production!"
|
||||
when:
|
||||
- branch: master
|
||||
@@ -271,8 +271,8 @@ services:
|
||||
- Subsequent builds will work fine
|
||||
|
||||
**Images not appearing in Gitea packages**
|
||||
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
|
||||
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
|
||||
- Check Gitea packages are enabled: https://git.spdy.io/-/packages
|
||||
- Verify registry URL is `git.spdy.io` (not `ci.cannabrands.app`)
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ git push origin 2025.11.3
|
||||
|
||||
### Step 3: Wait for CI Build (2-4 minutes)
|
||||
|
||||
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
|
||||
Watch at: `git.spdy.io/cannabrands/hub/pipelines`
|
||||
|
||||
CI will automatically:
|
||||
- Run tests
|
||||
@@ -113,7 +113,7 @@ git push origin master
|
||||
```bash
|
||||
# Deploy specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.3
|
||||
|
||||
# Watch deployment
|
||||
kubectl rollout status deployment/cannabrands
|
||||
@@ -131,7 +131,7 @@ kubectl get pods
|
||||
```bash
|
||||
# Option 1: Rollback to previous version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.2
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.2
|
||||
|
||||
# Option 2: Kubernetes automatic rollback
|
||||
kubectl rollout undo deployment/cannabrands
|
||||
@@ -154,7 +154,7 @@ git push origin 2025.11.4
|
||||
|
||||
# 4. Deploy when confident
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.4
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.4
|
||||
```
|
||||
|
||||
---
|
||||
@@ -170,7 +170,7 @@ master → Branch tracking
|
||||
|
||||
**Use in K3s dev/staging:**
|
||||
```yaml
|
||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
||||
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
@@ -182,7 +182,7 @@ stable → Latest production release
|
||||
|
||||
**Use in K3s production:**
|
||||
```yaml
|
||||
image: code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
image: git.spdy.io/cannabrands/hub:2025.11.3
|
||||
imagePullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
|
||||
### View CI Status
|
||||
```bash
|
||||
# Visit Woodpecker
|
||||
open https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
open https://git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Or check latest build
|
||||
# (Visit Gitea → Repository → Pipelines)
|
||||
@@ -227,7 +227,7 @@ open https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
### CI Build Failing
|
||||
```bash
|
||||
# Check Woodpecker logs
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Run tests locally first
|
||||
./vendor/bin/sail artisan test
|
||||
@@ -291,6 +291,42 @@ npm run changelog
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline Stages
|
||||
|
||||
The Woodpecker CI pipeline runs the following stages for every push to `develop` or `master`:
|
||||
|
||||
1. **PHP Lint** - Syntax validation
|
||||
2. **Code Style (Pint)** - Formatting check
|
||||
3. **Tests** - PHPUnit/Pest tests with `APP_ENV=testing`
|
||||
4. **Seeder Validation** - Validates seeders with `APP_ENV=development`
|
||||
5. **Docker Build** - Creates container image
|
||||
6. **Auto-Deploy** - Deploys to dev.cannabrands.app (develop branch only)
|
||||
|
||||
### Why Seeder Validation?
|
||||
|
||||
The dev environment (`dev.cannabrands.app`) runs `migrate:fresh --seed` on every K8s deployment via init container. If seeders have bugs (e.g., undefined functions, missing relationships), the deployment fails and pods crash.
|
||||
|
||||
**The Problem:**
|
||||
- Tests run with `APP_ENV=testing` which **skips DevSeeder**
|
||||
- K8s runs with `APP_ENV=development` which **runs DevSeeder**
|
||||
- Seeder bugs passed CI but crashed in K8s
|
||||
|
||||
**The Solution:**
|
||||
- Add dedicated seeder validation step with `APP_ENV=development`
|
||||
- Runs the exact same command as K8s init container
|
||||
- Catches seeder errors before deployment
|
||||
|
||||
**Time Cost:** ~20-30 seconds added to CI pipeline
|
||||
|
||||
**What It Catches:**
|
||||
- Runtime errors (e.g., `fake()` outside factory context)
|
||||
- Database constraint violations
|
||||
- Missing relationships (foreign key errors)
|
||||
- Invalid enum values
|
||||
- Seeder syntax errors
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Checklist
|
||||
|
||||
Before committing:
|
||||
@@ -300,6 +336,7 @@ Before committing:
|
||||
|
||||
Before releasing:
|
||||
- [ ] All tests green in CI
|
||||
- [ ] **Seeder validation passed in CI**
|
||||
- [ ] Tested in dev/staging environment
|
||||
- [ ] Release notes written
|
||||
- [ ] CHANGELOG updated (auto-generated)
|
||||
@@ -325,8 +362,8 @@ Before deploying:
|
||||
- Pair with senior dev for first release
|
||||
|
||||
### CI/CD
|
||||
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
|
||||
- Gitea: `code.cannabrands.app/cannabrands/hub`
|
||||
- Woodpecker: `git.spdy.io/cannabrands/hub`
|
||||
- Gitea: `git.spdy.io/cannabrands/hub`
|
||||
- K3s Dashboard: (ask devops for link)
|
||||
|
||||
---
|
||||
@@ -334,13 +371,13 @@ Before deploying:
|
||||
## Important URLs
|
||||
|
||||
**Code Repository:**
|
||||
https://code.cannabrands.app/cannabrands/hub
|
||||
https://git.spdy.io/cannabrands/hub
|
||||
|
||||
**CI/CD Pipeline:**
|
||||
https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
https://git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
**Container Registry:**
|
||||
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
|
||||
https://git.spdy.io/-/packages/container/cannabrands%2Fhub
|
||||
|
||||
**Documentation:**
|
||||
`.woodpecker/` directory in repository
|
||||
@@ -393,7 +430,7 @@ Closes #42"
|
||||
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
|
||||
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
|
||||
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
|
||||
| View builds | Visit `code.cannabrands.app/cannabrands/hub/pipelines` |
|
||||
| View builds | Visit `git.spdy.io/cannabrands/hub/pipelines` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ git push origin master
|
||||
2. Tests run (PHP lint, Pint, PHPUnit)
|
||||
3. Docker image builds (if tests pass)
|
||||
4. Tagged as: latest-dev, dev-c658193, master
|
||||
5. Pushed to code.cannabrands.app/cannabrands/hub
|
||||
5. Pushed to git.spdy.io/cannabrands/hub
|
||||
6. Available in K3s dev namespace (manual or auto-pull)
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ git push origin master
|
||||
**Use in K3s:**
|
||||
```yaml
|
||||
# dev/staging namespace
|
||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
||||
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||
imagePullPolicy: Always # Always pull newest
|
||||
```
|
||||
|
||||
@@ -81,7 +81,7 @@ git push origin 2025.11.1
|
||||
**Use in K3s:**
|
||||
```yaml
|
||||
# production namespace
|
||||
image: code.cannabrands.app/cannabrands/hub:2025.11.1
|
||||
image: git.spdy.io/cannabrands/hub:2025.11.1
|
||||
imagePullPolicy: IfNotPresent # Pin to specific version
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ git push origin master
|
||||
./vendor/bin/sail artisan test
|
||||
|
||||
# Check CI is green
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Test in staging/dev environment
|
||||
# Verify key workflows work
|
||||
@@ -264,12 +264,12 @@ git push origin 2025.11.3
|
||||
|
||||
```bash
|
||||
# Watch Woodpecker build
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Wait for success (2-4 minutes)
|
||||
# CI will build and push:
|
||||
# - code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
# - code.cannabrands.app/cannabrands/hub:stable
|
||||
# - git.spdy.io/cannabrands/hub:2025.11.3
|
||||
# - git.spdy.io/cannabrands/hub:stable
|
||||
```
|
||||
|
||||
#### 5. Deploy to Production (When Ready)
|
||||
@@ -277,7 +277,7 @@ git push origin 2025.11.3
|
||||
```bash
|
||||
# Deploy new version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.3
|
||||
|
||||
# Watch rollout
|
||||
kubectl rollout status deployment/cannabrands
|
||||
@@ -328,11 +328,11 @@ git push origin master
|
||||
```bash
|
||||
# Option 1: Rollback to specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.2
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.2
|
||||
|
||||
# Option 2: Use previous stable
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:stable
|
||||
app=git.spdy.io/cannabrands/hub:stable
|
||||
|
||||
# Note: 'stable' is updated on every release
|
||||
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
|
||||
@@ -367,7 +367,7 @@ git push origin 2025.11.4
|
||||
|
||||
# Deploy
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.4
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
**Current tagging strategy:**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:latest # Always changes
|
||||
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
|
||||
git.spdy.io/cannabrands/hub:latest # Always changes
|
||||
git.spdy.io/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||
git.spdy.io/cannabrands/hub:master # Branch name (changes)
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- dev # Latest dev build
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
|
||||
@@ -170,13 +170,13 @@ build-image-release:
|
||||
**Result:**
|
||||
```
|
||||
# Development push to master
|
||||
code.cannabrands.app/cannabrands/hub:dev
|
||||
code.cannabrands.app/cannabrands/hub:sha-c658193
|
||||
code.cannabrands.app/cannabrands/hub:master
|
||||
git.spdy.io/cannabrands/hub:dev
|
||||
git.spdy.io/cannabrands/hub:sha-c658193
|
||||
git.spdy.io/cannabrands/hub:master
|
||||
|
||||
# Release (git tag 2025.10.1)
|
||||
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
|
||||
code.cannabrands.app/cannabrands/hub:latest # Latest stable
|
||||
git.spdy.io/cannabrands/hub:2025.10.1 # Specific version
|
||||
git.spdy.io/cannabrands/hub:latest # Latest stable
|
||||
```
|
||||
|
||||
---
|
||||
@@ -243,11 +243,11 @@ git checkout c658193
|
||||
```bash
|
||||
# Option 1: Rollback to specific version (recommended)
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.2
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.2
|
||||
|
||||
# Option 2: Rollback to last stable
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:stable
|
||||
app=git.spdy.io/cannabrands/hub:stable
|
||||
|
||||
# Option 3: Kubernetes rollback (uses previous deployment)
|
||||
kubectl rollout undo deployment/cannabrands
|
||||
@@ -281,7 +281,7 @@ cat CHANGELOG.md
|
||||
|
||||
# 5. Deploy specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.1
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.1
|
||||
```
|
||||
|
||||
---
|
||||
@@ -357,7 +357,7 @@ audit-deployment:
|
||||
```
|
||||
Developer → Commit to master → CI tests → Build dev image
|
||||
↓
|
||||
code.cannabrands.app/cannabrands/hub:dev-COMMIT
|
||||
git.spdy.io/cannabrands/hub:dev-COMMIT
|
||||
↓
|
||||
Deploy to dev/staging (optional)
|
||||
```
|
||||
@@ -486,7 +486,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: code.cannabrands.app/cannabrands/hub:v1.2.3
|
||||
image: git.spdy.io/cannabrands/hub:v1.2.3
|
||||
imagePullPolicy: IfNotPresent # Don't pull if tag exists
|
||||
ports:
|
||||
- containerPort: 80
|
||||
@@ -535,7 +535,7 @@ git push origin master
|
||||
|
||||
# 5. Deploy to production (manual)
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.3.0
|
||||
app=git.spdy.io/cannabrands/hub:v1.3.0
|
||||
```
|
||||
|
||||
### Emergency Rollback
|
||||
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
|
||||
|
||||
# Or specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.3
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.3
|
||||
|
||||
# Verify
|
||||
kubectl rollout status deployment/cannabrands
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,337 +0,0 @@
|
||||
# Analytics System Implementation - Complete
|
||||
|
||||
## Overview
|
||||
Comprehensive analytics system for Cannabrands B2B marketplace with multi-tenancy security, real-time notifications, and advanced buyer intelligence.
|
||||
|
||||
## IMPORTANT: Automatic Tracking for All Buyer Pages
|
||||
**Analytics tracking is AUTOMATIC and requires NO CODE in individual views.**
|
||||
|
||||
- Tracking is included in the buyer layout file: `layouts/buyer-app-with-sidebar.blade.php`
|
||||
- Any page that extends this layout automatically gets tracking (page views, scroll depth, time on page, sessions)
|
||||
- When creating new buyer pages, simply extend the layout - tracking is automatic
|
||||
- NO need to add analytics code to individual views
|
||||
|
||||
```blade
|
||||
{{-- Example: Any new buyer page --}}
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
{{-- Your content here - tracking happens automatically --}}
|
||||
@endsection
|
||||
```
|
||||
|
||||
**Optional:** Add `data-track-click` attributes to specific elements for granular click tracking, but basic analytics work without any additional code.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Multi-Tenancy Security
|
||||
- **BusinessScope**: All analytics models use global scope for automatic data isolation
|
||||
- **Auto-scoping**: business_id automatically set on model creation
|
||||
- **Permission System**: Granular analytics permissions via business_user.permissions JSON
|
||||
- **Cross-Business Protection**: Users cannot access other businesses' analytics data
|
||||
|
||||
### 2. Analytics Models (10 Total)
|
||||
All models in `app/Models/Analytics/`:
|
||||
|
||||
1. **AnalyticsEvent** - Raw event stream (all interactions)
|
||||
2. **ProductView** - Product engagement tracking
|
||||
3. **EmailCampaign** - Email campaign management
|
||||
4. **EmailInteraction** - Individual recipient tracking
|
||||
5. **EmailClick** - Email link click tracking
|
||||
6. **ClickTracking** - General click event tracking
|
||||
7. **UserSession** - Session tracking and conversion funnel
|
||||
8. **IntentSignal** - High-intent buyer detection
|
||||
9. **BuyerEngagementScore** - Calculated engagement metrics
|
||||
10. **EmailCampaign** - (already mentioned above)
|
||||
|
||||
### 3. Database Migrations (7 Tables)
|
||||
All in `database/migrations/2025_11_08_*`:
|
||||
|
||||
- `analytics_events` - Event stream
|
||||
- `product_views` - Product engagement
|
||||
- `email_campaigns`, `email_interactions`, `email_clicks` - Email analytics
|
||||
- `click_tracking` - Click events
|
||||
- `user_sessions`, `intent_signals`, `buyer_engagement_scores` - Buyer intelligence
|
||||
|
||||
**Important**: All composite indexes start with `business_id` for query performance.
|
||||
|
||||
### 4. Services & Jobs
|
||||
|
||||
**AnalyticsTracker Service** (`app/Services/AnalyticsTracker.php`):
|
||||
- `trackProductView()` - Track product page views with signals
|
||||
- `trackClick()` - Track general click events
|
||||
- `trackEmailInteraction()` - Track email actions
|
||||
- `startSession()` - Initialize/update user sessions
|
||||
- `detectIntentSignals()` - Automatic high-intent detection
|
||||
|
||||
**Queue Jobs**:
|
||||
- `CalculateEngagementScore` - Compute buyer engagement scores
|
||||
- `ProcessAnalyticsEvent` - Async event processing
|
||||
|
||||
### 5. Real-Time Features
|
||||
|
||||
**Reverb Event** (`app/Events/HighIntentBuyerDetected.php`):
|
||||
- Broadcasts when high-intent signals detected
|
||||
- Channel: `business.{id}.analytics`
|
||||
- Event: `high-intent-buyer-detected`
|
||||
|
||||
### 6. Controllers (5 Total)
|
||||
All in `app/Http/Controllers/Analytics/`:
|
||||
|
||||
1. **AnalyticsDashboardController** - Overview dashboard
|
||||
2. **ProductAnalyticsController** - Product performance
|
||||
3. **MarketingAnalyticsController** - Email campaigns
|
||||
4. **SalesAnalyticsController** - Sales funnel
|
||||
5. **BuyerIntelligenceController** - Buyer engagement
|
||||
|
||||
### 7. Views (4 Main Pages)
|
||||
All in `resources/views/seller/analytics/`:
|
||||
|
||||
- `dashboard.blade.php` - Analytics overview
|
||||
- `products.blade.php` - Product analytics
|
||||
- `marketing.blade.php` - Marketing analytics
|
||||
- `sales.blade.php` - Sales analytics
|
||||
- `buyers.blade.php` - Buyer intelligence
|
||||
|
||||
**Design**:
|
||||
- DaisyUI/Nexus components
|
||||
- ApexCharts for visualizations
|
||||
- Anime.js for counter animations
|
||||
- Responsive grid layouts
|
||||
|
||||
### 8. Navigation
|
||||
Updated `resources/views/components/seller-sidebar.blade.php`:
|
||||
- Dashboard - Single top-level item
|
||||
- Analytics - Parent with subsections (Products, Marketing, Sales, Buyers)
|
||||
- Reports - Separate future section
|
||||
- Permission-based visibility
|
||||
|
||||
### 9. Permissions System
|
||||
|
||||
**Available Permissions**:
|
||||
- `analytics.overview` - Main dashboard
|
||||
- `analytics.products` - Product analytics
|
||||
- `analytics.marketing` - Marketing analytics
|
||||
- `analytics.sales` - Sales analytics
|
||||
- `analytics.buyers` - Buyer intelligence
|
||||
- `analytics.export` - Data export
|
||||
|
||||
**Permission UI** (`resources/views/business/users/index.blade.php`):
|
||||
- Modal for managing user permissions
|
||||
- Available to business owners only
|
||||
- Real-time updates via AJAX
|
||||
|
||||
### 10. Client-Side Tracking
|
||||
|
||||
**analytics-tracker.js**:
|
||||
- Automatic page view tracking
|
||||
- Scroll depth tracking
|
||||
- Time on page tracking
|
||||
- Click tracking via `data-track-click` attributes
|
||||
- ProductPageTracker for enhanced product tracking
|
||||
|
||||
**reverb-analytics-listener.js**:
|
||||
- Real-time high-intent buyer notifications
|
||||
- Toast notifications
|
||||
- Auto-navigation to buyer details
|
||||
- Notification badge updates
|
||||
|
||||
### 11. Security Tests
|
||||
Comprehensive test suite in `tests/Feature/Analytics/AnalyticsSecurityTest.php`:
|
||||
- ✓ Data scoped to business
|
||||
- ✓ Permission enforcement
|
||||
- ✓ Cross-business access prevention
|
||||
- ✓ Auto business_id assignment
|
||||
- ✓ forBusiness scope functionality
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Run Composer Autoload
|
||||
```bash
|
||||
docker compose exec laravel.test composer dump-autoload
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
```bash
|
||||
docker compose exec laravel.test php artisan migrate
|
||||
```
|
||||
|
||||
### 3. Include JavaScript Files
|
||||
Add to your layout file:
|
||||
```html
|
||||
<script src="{{ asset('js/analytics-tracker.js') }}"></script>
|
||||
<script src="{{ asset('js/reverb-analytics-listener.js') }}"></script>
|
||||
|
||||
<!-- Add business ID meta tag -->
|
||||
<meta name="business-id" content="{{ currentBusinessId() }}">
|
||||
```
|
||||
|
||||
### 4. Queue Configuration
|
||||
Ensure Redis queues are configured in `.env`:
|
||||
```env
|
||||
QUEUE_CONNECTION=redis
|
||||
```
|
||||
|
||||
Start queue worker:
|
||||
```bash
|
||||
docker compose exec laravel.test php artisan queue:work --queue=analytics
|
||||
```
|
||||
|
||||
### 5. Reverb Configuration
|
||||
Reverb should already be configured. Verify broadcasting is enabled:
|
||||
```env
|
||||
BROADCAST_DRIVER=reverb
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Assigning Analytics Permissions
|
||||
1. Navigate to Business > Users
|
||||
2. Click "Permissions" button on user card (owner only)
|
||||
3. Select analytics permissions
|
||||
4. Save
|
||||
|
||||
### Tracking Product Views (Server-Side)
|
||||
```php
|
||||
use App\Services\AnalyticsTracker;
|
||||
|
||||
$tracker = new AnalyticsTracker();
|
||||
$tracker->trackProductView($product, [
|
||||
'zoomed_image' => true,
|
||||
'time_on_page' => 120,
|
||||
'added_to_cart' => true
|
||||
]);
|
||||
```
|
||||
|
||||
### Tracking Product Views (Client-Side)
|
||||
```html
|
||||
<!-- Product page -->
|
||||
<script>
|
||||
const productTracker = new ProductPageTracker({{ $product->id }});
|
||||
</script>
|
||||
|
||||
<!-- Mark trackable elements -->
|
||||
<button data-product-add-cart>Add to Cart</button>
|
||||
<a data-product-spec-download href="/spec.pdf">Download Spec</a>
|
||||
<div data-product-image-zoom>
|
||||
<img src="/product.jpg">
|
||||
</div>
|
||||
```
|
||||
|
||||
### Tracking Clicks
|
||||
```html
|
||||
<button data-track-click="cta_button"
|
||||
data-track-id="123"
|
||||
data-track-label="Buy Now">
|
||||
Buy Now
|
||||
</button>
|
||||
```
|
||||
|
||||
### Listening to Real-Time Events
|
||||
```javascript
|
||||
window.addEventListener('analytics:high-intent-buyer', (event) => {
|
||||
console.log('High intent buyer:', event.detail);
|
||||
// Update UI, show notification, etc.
|
||||
});
|
||||
```
|
||||
|
||||
### Calculating Engagement Scores
|
||||
```php
|
||||
use App\Jobs\CalculateEngagementScore;
|
||||
|
||||
CalculateEngagementScore::dispatch($sellerBusinessId, $buyerBusinessId)
|
||||
->onQueue('analytics');
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Analytics Routes
|
||||
All routes prefixed with `/s/{business}/analytics`:
|
||||
|
||||
- `GET /` - Analytics dashboard
|
||||
- `GET /products` - Product analytics
|
||||
- `GET /products/{product}` - Product detail
|
||||
- `GET /marketing` - Marketing analytics
|
||||
- `GET /marketing/campaigns/{campaign}` - Campaign detail
|
||||
- `GET /sales` - Sales analytics
|
||||
- `GET /buyers` - Buyer intelligence
|
||||
- `GET /buyers/{buyer}` - Buyer detail
|
||||
|
||||
### Permission Management
|
||||
- `POST /s/{business}/users/{user}/permissions` - Update user permissions
|
||||
|
||||
## Database Indexes
|
||||
|
||||
All analytics tables have composite indexes starting with `business_id`:
|
||||
- `idx_business_created` - (business_id, created_at)
|
||||
- `idx_business_type_created` - (business_id, event_type, created_at)
|
||||
- `idx_business_product_time` - (business_id, product_id, viewed_at)
|
||||
|
||||
This ensures optimal query performance for multi-tenant queries.
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Global helpers available everywhere:
|
||||
|
||||
```php
|
||||
// Get current business
|
||||
$business = currentBusiness();
|
||||
|
||||
// Get current business ID
|
||||
$businessId = currentBusinessId();
|
||||
|
||||
// Check permission
|
||||
if (hasBusinessPermission('analytics.overview')) {
|
||||
// User has permission
|
||||
}
|
||||
|
||||
// Get business from product
|
||||
$sellerBusiness = \App\Helpers\BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
## Git Commits
|
||||
|
||||
Total of 6 commits:
|
||||
|
||||
1. **Foundation** - Helpers, migrations, base models
|
||||
2. **Backend Logic** - Remaining models, services, jobs, events, controllers, routes
|
||||
3. **Navigation** - Updated seller sidebar
|
||||
4. **Views** - 4 analytics dashboards
|
||||
5. **Permissions** - User permission management UI
|
||||
6. **JavaScript** - Client-side tracking and Reverb listeners
|
||||
7. **Tests** - Security test suite
|
||||
|
||||
## Testing
|
||||
|
||||
Run analytics security tests:
|
||||
```bash
|
||||
docker compose exec laravel.test php artisan test tests/Feature/Analytics/AnalyticsSecurityTest.php
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All analytics data is scoped to business_id automatically via BusinessScope
|
||||
- Permission checks use `hasBusinessPermission()` helper
|
||||
- High-intent signals trigger real-time Reverb events
|
||||
- Engagement scores calculated asynchronously via queue
|
||||
- Client-side tracking uses sendBeacon for reliability
|
||||
- All views use DaisyUI components (no inline styles)
|
||||
|
||||
## Next Steps (Optional Enhancements)
|
||||
|
||||
1. Add more granular permissions (view vs export)
|
||||
2. Implement scheduled engagement score recalculation
|
||||
3. Add email templates for high-intent buyer alerts
|
||||
4. Create analytics export functionality (CSV/PDF)
|
||||
5. Add custom date range selectors
|
||||
6. Implement analytics API for third-party integrations
|
||||
7. Add more chart types and visualizations
|
||||
8. Create analytics widgets for main dashboard
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check migration files for table structure
|
||||
- Review controller methods for query patterns
|
||||
- Examine test file for usage examples
|
||||
- Check JavaScript console for client-side errors
|
||||
@@ -1,196 +0,0 @@
|
||||
# Analytics System - Quick Start Guide
|
||||
|
||||
## ✅ What's Already Done
|
||||
|
||||
### 1. Global Tracking (Automatic)
|
||||
- Analytics tracker loaded on every page via `app-with-sidebar.blade.php`
|
||||
- Automatically tracks:
|
||||
- Page views
|
||||
- Time spent on page
|
||||
- Scroll depth
|
||||
- User sessions
|
||||
- All clicks with `data-track-click` attributes
|
||||
|
||||
### 2. Product Page Tracking (Implemented!)
|
||||
- **File**: `buyer/marketplace/product.blade.php`
|
||||
- **Tracks**:
|
||||
- Product views (automatic on page load)
|
||||
- Image zoom clicks (gallery images)
|
||||
- Lab report downloads
|
||||
- Add to cart button clicks
|
||||
- Related product clicks
|
||||
|
||||
### 3. Real-Time Notifications (For Sellers)
|
||||
- Reverb integration for high-intent buyer alerts
|
||||
- Automatically enabled for sellers
|
||||
- Toast notifications appear when buyers show buying signals
|
||||
|
||||
## 🚀 How to See It Working
|
||||
|
||||
### Step 1: Visit a Product Page
|
||||
```
|
||||
http://yoursite.com/b/{business}/brands/{brand}/products/{product}
|
||||
```
|
||||
|
||||
### Step 2: Open Browser DevTools
|
||||
- Press `F12`
|
||||
- Go to **Network** tab
|
||||
- Filter by: `track`
|
||||
|
||||
### Step 3: Interact with the Page
|
||||
- Scroll down
|
||||
- Click gallery images
|
||||
- Click "Add to Cart"
|
||||
- Click "View Report" on lab results
|
||||
- Click related products
|
||||
|
||||
### Step 4: Check Network Requests
|
||||
Look for POST requests to `/api/analytics/track` with payloads like:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "product_view",
|
||||
"product_id": 123,
|
||||
"session_id": "abc-123-def",
|
||||
"timestamp": 1699123456789
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: View Analytics Dashboard
|
||||
```
|
||||
http://yoursite.com/s/{business-slug}/analytics
|
||||
```
|
||||
|
||||
## 📊 What Data You'll See
|
||||
|
||||
### Product Analytics
|
||||
Navigate to: **Analytics > Products**
|
||||
|
||||
View:
|
||||
- Most viewed products
|
||||
- Product engagement rates
|
||||
- Image zoom rates
|
||||
- Lab report download counts
|
||||
- Add to cart conversion rates
|
||||
|
||||
### Buyer Intelligence
|
||||
Navigate to: **Analytics > Buyers**
|
||||
|
||||
View:
|
||||
- Active buyers this week
|
||||
- High-intent buyers (with real-time alerts)
|
||||
- Engagement scores
|
||||
- Buyer activity timelines
|
||||
|
||||
### Click Heatmap Data
|
||||
- All clicks tracked with element type, ID, and label
|
||||
- Position tracking for understanding UX
|
||||
- Referrer and UTM campaign tracking
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### 1. Add Tracking to More Pages
|
||||
|
||||
**Marketplace/Catalog Pages:**
|
||||
```blade
|
||||
<div class="product-card">
|
||||
<a href="{{ route('products.show', $product) }}"
|
||||
data-track-click="product-card"
|
||||
data-track-id="{{ $product->id }}"
|
||||
data-track-label="{{ $product->name }}">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Navigation Links:**
|
||||
```blade
|
||||
<a href="{{ route('brands.index') }}"
|
||||
data-track-click="navigation"
|
||||
data-track-label="Browse Brands">
|
||||
Browse All Brands
|
||||
</a>
|
||||
```
|
||||
|
||||
**Filter/Sort Controls:**
|
||||
```blade
|
||||
<select data-track-click="filter"
|
||||
data-track-id="category-filter"
|
||||
data-track-label="Product Category">
|
||||
<option>All Categories</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### 2. Grant Analytics Permissions
|
||||
|
||||
1. Go to: `Settings > Users`
|
||||
2. Click "Permissions" on a user
|
||||
3. Check analytics permissions:
|
||||
- ✅ `analytics.overview` - Dashboard
|
||||
- ✅ `analytics.products` - Product stats
|
||||
- ✅ `analytics.buyers` - Buyer intelligence
|
||||
- ✅ `analytics.marketing` - Email campaigns
|
||||
- ✅ `analytics.sales` - Sales pipeline
|
||||
- ✅ `analytics.export` - Export data
|
||||
|
||||
### 3. Test Real-Time Notifications (Sellers)
|
||||
|
||||
**Trigger Conditions:**
|
||||
- View same product 3+ times
|
||||
- View 5+ products from same brand
|
||||
- Download lab reports
|
||||
- Add items to cart
|
||||
- Watch product videos (when implemented)
|
||||
|
||||
**Where Alerts Appear:**
|
||||
- Toast notification (top-right)
|
||||
- Bell icon badge (topbar)
|
||||
- Buyer Intelligence page
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Not Seeing Tracking Requests?
|
||||
1. Check browser console for errors
|
||||
2. Verify `/js/analytics-tracker.js` loads correctly
|
||||
3. Check CSRF token is present in page meta tags
|
||||
|
||||
### Analytics Dashboard Empty?
|
||||
1. Ensure you've visited product pages as a buyer
|
||||
2. Check database: `select * from analytics_events limit 10;`
|
||||
3. Verify background jobs are running: `php artisan queue:work`
|
||||
|
||||
### No Real-Time Notifications?
|
||||
1. Ensure Reverb server is running: `php artisan reverb:start`
|
||||
2. Check business ID meta tag in page source
|
||||
3. Verify Laravel Echo is initialized
|
||||
|
||||
## 📁 Key Files
|
||||
|
||||
### JavaScript
|
||||
- `/public/js/analytics-tracker.js` - Main tracker
|
||||
- `/public/js/reverb-analytics-listener.js` - Real-time listener
|
||||
|
||||
### Blade Templates
|
||||
- `layouts/app-with-sidebar.blade.php` - Global setup
|
||||
- `buyer/marketplace/product.blade.php` - Example implementation
|
||||
|
||||
### Controllers
|
||||
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
|
||||
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
|
||||
|
||||
### Models
|
||||
- `app/Models/Analytics/AnalyticsEvent.php`
|
||||
- `app/Models/Analytics/ProductView.php`
|
||||
- `app/Models/Analytics/ClickTracking.php`
|
||||
- `app/Models/Analytics/BuyerEngagementScore.php`
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Full Implementation Guide**: `ANALYTICS_IMPLEMENTATION.md`
|
||||
- **Tracking Examples**: `ANALYTICS_TRACKING_EXAMPLES.md`
|
||||
- **This Quick Start**: `ANALYTICS_QUICK_START.md`
|
||||
|
||||
## 🎉 You're Ready!
|
||||
|
||||
The analytics system is now live and collecting data. Start browsing product pages and watch the data flow into your analytics dashboard!
|
||||
@@ -1,216 +0,0 @@
|
||||
# Analytics Tracking Implementation Examples
|
||||
|
||||
This guide shows you how to implement analytics tracking on your pages to start collecting data.
|
||||
|
||||
## 1. Auto-Tracking (Already Working!)
|
||||
|
||||
The analytics tracker is now automatically loaded on all pages via `app-with-sidebar.blade.php`. It already tracks:
|
||||
|
||||
- Page views
|
||||
- Time on page
|
||||
- Scroll depth
|
||||
- Session management
|
||||
- Any element with `data-track-click` attribute
|
||||
|
||||
## 2. Product Detail Page Tracking
|
||||
|
||||
Add this script to the **bottom** of your product blade file (`buyer/marketplace/product.blade.php`):
|
||||
|
||||
```blade
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Initialize product page tracker
|
||||
const productTracker = new ProductPageTracker({{ $product->id }});
|
||||
|
||||
// The tracker automatically tracks:
|
||||
// - Product views (done on initialization)
|
||||
// - Image zoom clicks
|
||||
// - Video plays
|
||||
// - Spec downloads
|
||||
// - Add to cart
|
||||
// - Add to wishlist
|
||||
</script>
|
||||
@endpush
|
||||
```
|
||||
|
||||
### Add Tracking Attributes to Product Page Elements
|
||||
|
||||
Update your product page HTML to include tracking attributes:
|
||||
|
||||
```blade
|
||||
<!-- Image Gallery (for zoom tracking) -->
|
||||
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-75"
|
||||
data-product-image-zoom>
|
||||
<img src="{{ asset('storage/' . $image->path) }}" alt="{{ $product->name }}">
|
||||
</div>
|
||||
|
||||
<!-- Product Videos (for video tracking) -->
|
||||
<video controls data-product-video>
|
||||
<source src="{{ asset('storage/' . $video->path) }}" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Download Spec Sheet Button -->
|
||||
<a href="{{ route('buyer.products.spec-download', $product) }}"
|
||||
data-product-spec-download
|
||||
class="btn btn-outline">
|
||||
<span class="icon-[lucide--download] size-4"></span>
|
||||
Download Spec Sheet
|
||||
</a>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<button type="submit"
|
||||
data-product-add-cart
|
||||
class="btn btn-primary btn-block">
|
||||
<span class="icon-[lucide--shopping-cart] size-5"></span>
|
||||
Add to Cart
|
||||
</button>
|
||||
|
||||
<!-- Add to Wishlist Button -->
|
||||
<button type="button"
|
||||
data-product-add-wishlist
|
||||
class="btn btn-ghost">
|
||||
<span class="icon-[lucide--heart] size-5"></span>
|
||||
Save for Later
|
||||
</button>
|
||||
```
|
||||
|
||||
## 3. Generic Click Tracking
|
||||
|
||||
Track any button or link by adding the `data-track-click` attribute:
|
||||
|
||||
```blade
|
||||
<!-- Track navigation clicks -->
|
||||
<a href="{{ route('buyer.brands.index') }}"
|
||||
data-track-click="navigation"
|
||||
data-track-id="brands-link"
|
||||
data-track-label="View All Brands">
|
||||
Browse Brands
|
||||
</a>
|
||||
|
||||
<!-- Track CTA buttons -->
|
||||
<button data-track-click="cta"
|
||||
data-track-id="contact-seller"
|
||||
data-track-label="Contact Seller Button">
|
||||
Contact Seller
|
||||
</button>
|
||||
|
||||
<!-- Track filters -->
|
||||
<select data-track-click="filter"
|
||||
data-track-id="category-filter"
|
||||
data-track-label="Category Filter">
|
||||
<option>All Categories</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### Click Tracking Attributes
|
||||
|
||||
- `data-track-click="type"` - Element type (required): `navigation`, `cta`, `filter`, `button`, etc.
|
||||
- `data-track-id="unique-id"` - Unique identifier for this element (optional)
|
||||
- `data-track-label="Label"` - Human-readable label (optional, defaults to element text)
|
||||
- `data-track-url="url"` - Destination URL (optional, auto-detected for links)
|
||||
|
||||
## 4. Dashboard/Catalog Page Tracking
|
||||
|
||||
For product listings and catalogs, add click tracking to product cards:
|
||||
|
||||
```blade
|
||||
@foreach($products as $product)
|
||||
<div class="card">
|
||||
<!-- Track product card clicks -->
|
||||
<a href="{{ route('buyer.products.show', $product) }}"
|
||||
data-track-click="product-card"
|
||||
data-track-id="{{ $product->id }}"
|
||||
data-track-label="{{ $product->name }}"
|
||||
class="card-body">
|
||||
|
||||
<img src="{{ $product->image_url }}" alt="{{ $product->name }}">
|
||||
<h3>{{ $product->name }}</h3>
|
||||
<p>${{ $product->wholesale_price }}</p>
|
||||
</a>
|
||||
|
||||
<!-- Track quick actions -->
|
||||
<button data-track-click="quick-add"
|
||||
data-track-id="{{ $product->id }}"
|
||||
data-track-label="Quick Add - {{ $product->name }}"
|
||||
class="btn btn-sm">
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
## 5. Viewing Analytics Data
|
||||
|
||||
Once tracking is implemented, data will flow to:
|
||||
|
||||
1. **Analytics Dashboard**: `https://yoursite.com/s/{business-slug}/analytics`
|
||||
2. **Product Analytics**: `https://yoursite.com/s/{business-slug}/analytics/products`
|
||||
3. **Buyer Intelligence**: `https://yoursite.com/s/{business-slug}/analytics/buyers`
|
||||
|
||||
## 6. Backend API Endpoint
|
||||
|
||||
The tracker sends events to: `/api/analytics/track`
|
||||
|
||||
This endpoint is already set up and accepts:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "product_view|click|product_signal",
|
||||
"product_id": 123,
|
||||
"element_type": "button",
|
||||
"element_id": "add-to-cart",
|
||||
"element_label": "Add to Cart",
|
||||
"session_id": "uuid",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Real-Time Notifications (Sellers Only)
|
||||
|
||||
For sellers with Reverb enabled, high-intent buyer alerts will automatically appear when:
|
||||
|
||||
- A buyer views multiple products from your brand
|
||||
- A buyer repeatedly views the same product
|
||||
- A buyer downloads spec sheets
|
||||
- A buyer adds items to cart
|
||||
- A buyer watches product videos
|
||||
|
||||
Notifications appear as toast messages and in the notification dropdown.
|
||||
|
||||
## 8. Permission Setup
|
||||
|
||||
To grant users access to analytics:
|
||||
|
||||
1. Go to **Users** page in your business dashboard
|
||||
2. Click "Permissions" on a user card
|
||||
3. Check the analytics permissions you want to grant:
|
||||
- `analytics.overview` - Dashboard access
|
||||
- `analytics.products` - Product performance
|
||||
- `analytics.marketing` - Email campaigns
|
||||
- `analytics.sales` - Sales intelligence
|
||||
- `analytics.buyers` - Buyer insights
|
||||
- `analytics.export` - Export data
|
||||
|
||||
## Quick Start Checklist
|
||||
|
||||
- [x] Analytics scripts loaded in layout ✅ (Done automatically)
|
||||
- [ ] Add product page tracker to product detail pages
|
||||
- [ ] Add tracking attributes to product images/videos/buttons
|
||||
- [ ] Add click tracking to navigation and CTAs
|
||||
- [ ] Add tracking to product cards in listings
|
||||
- [ ] Grant analytics permissions to team members
|
||||
- [ ] Visit analytics dashboard to see data
|
||||
|
||||
## Testing
|
||||
|
||||
To test if tracking is working:
|
||||
|
||||
1. Open browser DevTools (F12)
|
||||
2. Go to Network tab
|
||||
3. Navigate to a product page
|
||||
4. Look for POST requests to `/api/analytics/track`
|
||||
5. Check the payload to see what data is being sent
|
||||
|
||||
## Need Help?
|
||||
|
||||
Check `ANALYTICS_IMPLEMENTATION.md` for full technical documentation.
|
||||
@@ -157,7 +157,7 @@
|
||||
* implement Brand model with 14 actual brands ([49d4f11](https://code.cannabrands.app/Cannabrands/hub/commit/49d4f11102f83ce5d2fdf1add9c95f17d28d2005))
|
||||
* implement business management features (contacts, locations, users, profiles) ([38ac09e](https://code.cannabrands.app/Cannabrands/hub/commit/38ac09e9e7def2d4d4533b880eee0dfbdea1df4b))
|
||||
* implement buyer order and invoice management portal (Day 13) ([aaad277](https://code.cannabrands.app/Cannabrands/hub/commit/aaad277a49d80640e7b397d3df22adeda2a8ff7d))
|
||||
* implement buyer-specific Nexus dashboard with LeafLink-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
|
||||
* implement buyer-specific Nexus dashboard with Marketplace Platform-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
|
||||
* implement buyer/seller routing structure with dual registration options ([5393969](https://code.cannabrands.app/Cannabrands/hub/commit/53939692c01669724372028f8056d9bfdf0bb92a))
|
||||
* implement buyer/seller user type separation in database layer ([4e15b3d](https://code.cannabrands.app/Cannabrands/hub/commit/4e15b3d15cc8fd046dd40d0a6de512ec8b878b84))
|
||||
* implement CalVer versioning system with sidebar display ([197d102](https://code.cannabrands.app/Cannabrands/hub/commit/197d10269004f758692cacb444a0e9698ec7e7f1))
|
||||
|
||||
120
CLAUDE.local.md
120
CLAUDE.local.md
@@ -1,120 +0,0 @@
|
||||
# Local Workflow Notes (NOT COMMITTED TO GIT)
|
||||
|
||||
## 🚨 MANDATORY WORKTREES WORKFLOW
|
||||
|
||||
**CRITICAL:** Claude MUST use worktrees for ALL feature work. NO exceptions.
|
||||
|
||||
### Worktrees Location
|
||||
- **Main repo:** `C:\Users\Boss Man\Documents\GitHub\hub`
|
||||
- **Worktrees folder:** `C:\Users\Boss Man\Documents\GitHub\Work Trees\`
|
||||
|
||||
---
|
||||
|
||||
## Claude's Proactive Workflow Guide
|
||||
|
||||
### When User Requests a Feature
|
||||
|
||||
**Claude MUST immediately say:**
|
||||
> "Let me create a worktree for this feature"
|
||||
|
||||
**Then Claude MUST:**
|
||||
|
||||
1. **Create worktree with descriptive name:**
|
||||
```bash
|
||||
cd "C:\Users\Boss Man\Documents\GitHub\hub"
|
||||
git worktree add "../Work Trees/feature-descriptive-name" -b feature/descriptive-name
|
||||
```
|
||||
|
||||
2. **Switch to worktree:**
|
||||
```bash
|
||||
cd "C:\Users\Boss Man\Documents\GitHub\Work Trees/feature-descriptive-name"
|
||||
```
|
||||
|
||||
3. **Work on that ONE feature only:**
|
||||
- Keep focused on single feature
|
||||
- Commit regularly with clear messages
|
||||
- Run tests frequently
|
||||
|
||||
### When Feature is Complete
|
||||
|
||||
**Claude MUST prompt user:**
|
||||
> "Feature complete! Ready to create a PR?"
|
||||
|
||||
**Then Claude MUST:**
|
||||
|
||||
1. **Run tests first:**
|
||||
```bash
|
||||
./vendor/bin/pint
|
||||
php artisan test --parallel
|
||||
```
|
||||
|
||||
2. **Push branch:**
|
||||
```bash
|
||||
git push -u origin feature/descriptive-name
|
||||
```
|
||||
|
||||
3. **Create PR with good description:**
|
||||
```bash
|
||||
gh pr create --title "Feature: description" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Bullet point summary
|
||||
|
||||
## Changes
|
||||
- What was added/modified
|
||||
|
||||
## Test Plan
|
||||
- How to test
|
||||
|
||||
🤖 Generated with Claude Code
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### After PR is Merged
|
||||
|
||||
**Claude MUST help cleanup:**
|
||||
|
||||
1. **Return to main repo:**
|
||||
```bash
|
||||
cd "C:\Users\Boss Man\Documents\GitHub\hub"
|
||||
```
|
||||
|
||||
2. **Remove worktree:**
|
||||
```bash
|
||||
git worktree remove "../Work Trees/feature-descriptive-name"
|
||||
```
|
||||
|
||||
3. **Delete local branch:**
|
||||
```bash
|
||||
git branch -d feature/descriptive-name
|
||||
```
|
||||
|
||||
4. **Pull latest develop:**
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why Worktrees are Mandatory
|
||||
|
||||
- ✅ **Isolation:** Each feature has its own directory
|
||||
- ✅ **No conflicts:** Work on multiple features safely
|
||||
- ✅ **Clean commits:** No mixing of changes
|
||||
- ✅ **Safety:** Main repo stays clean on develop
|
||||
- ✅ **Easy PR workflow:** One worktree = one PR
|
||||
|
||||
---
|
||||
|
||||
## Emergency: Uncommitted Work in Main Repo
|
||||
|
||||
If there are uncommitted changes in main repo:
|
||||
|
||||
1. **Best:** Commit to feature branch first
|
||||
2. **Alternative:** Stash them: `git stash`
|
||||
3. **Last resort:** Ask user what to do
|
||||
|
||||
---
|
||||
|
||||
**Note:** This file is in `.gitignore` and will never be committed or pushed to remote.
|
||||
340
CLAUDE.md
340
CLAUDE.md
@@ -4,6 +4,19 @@
|
||||
|
||||
**ALWAYS read `claude.kelly.md` first** - Contains personal preferences and session tracking workflow
|
||||
|
||||
## 📘 Platform Conventions
|
||||
|
||||
**For ALL naming, routing, and architectural conventions, see:**
|
||||
`/docs/platform_naming_and_style_guide.md`
|
||||
|
||||
This guide is the **source of truth** for:
|
||||
- Terminology (no vendor references)
|
||||
- Routing patterns
|
||||
- Model naming
|
||||
- UI copy standards
|
||||
- Commit message rules
|
||||
- Database conventions
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Mistakes You Make
|
||||
@@ -41,7 +54,95 @@ ALL routes need auth + user type middleware except public pages
|
||||
❌ No IF/ELSE logic in migrations (not supported)
|
||||
✅ Use Laravel Schema builder or conditional PHP code
|
||||
|
||||
### 7. Styling - DaisyUI/Tailwind Only
|
||||
### 7. Git Workflow - ALWAYS Use PRs
|
||||
❌ **NEVER** push directly to `develop` or `master`
|
||||
❌ **NEVER** bypass pull requests
|
||||
❌ **NEVER** use GitHub CLI (`gh`) - we use Gitea
|
||||
✅ **ALWAYS** create a feature branch and PR for review
|
||||
✅ **ALWAYS** use Gitea API for PR creation (see below)
|
||||
**Why:** PRs are required for code review, CI checks, and audit trail
|
||||
|
||||
**Creating PRs via Gitea API:**
|
||||
```bash
|
||||
# Requires GITEA_TOKEN environment variable
|
||||
curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
|
||||
```
|
||||
|
||||
**Infrastructure Services:**
|
||||
|
||||
| Service | Host | Notes |
|
||||
|---------|------|-------|
|
||||
| **Gitea** | `https://git.spdy.io` | Git repository |
|
||||
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
|
||||
| **Docker Registry** | `registry.spdy.io` | HTTPS registry with Let's Encrypt |
|
||||
|
||||
**PostgreSQL (Dev) - EXTERNAL DATABASE**
|
||||
⚠️ **DO NOT create PostgreSQL databases on spdy.io infrastructure for cannabrands.**
|
||||
Cannabrands uses an external managed PostgreSQL database.
|
||||
```
|
||||
Host: 10.100.6.50 (read replica)
|
||||
Port: 5432
|
||||
Database: cannabrands_dev
|
||||
Username: cannabrands
|
||||
Password: SpDyCannaBrands2024
|
||||
URL: postgresql://cannabrands:SpDyCannaBrands2024@10.100.6.50:5432/cannabrands_dev
|
||||
```
|
||||
|
||||
**PostgreSQL (CI)** - Ephemeral container for isolated tests
|
||||
```
|
||||
Host: postgres (service name)
|
||||
Port: 5432
|
||||
Database: testing
|
||||
Username: testing
|
||||
Password: testing
|
||||
```
|
||||
|
||||
**Redis**
|
||||
```
|
||||
Host: 10.100.9.50
|
||||
Port: 6379
|
||||
Password: SpDyR3d1s2024!
|
||||
URL: redis://:SpDyR3d1s2024!@10.100.9.50:6379
|
||||
```
|
||||
|
||||
**MinIO (S3-Compatible Storage)**
|
||||
```
|
||||
Endpoint: 10.100.9.80:9000
|
||||
Console: 10.100.9.80:9001
|
||||
Region: us-east-1
|
||||
Path Style: true
|
||||
Bucket: cannabrands
|
||||
Access Key: cannabrands-app
|
||||
Secret Key: cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||
```
|
||||
|
||||
**Gitea Container Registry** (for CI image pushes)
|
||||
```
|
||||
Registry: git.spdy.io
|
||||
User: kelly@spdy.io
|
||||
Token: c89fa0eeb417343b171f11de6b8e4292b2f50e2b
|
||||
Scope: write:package
|
||||
```
|
||||
Woodpecker secrets: `registry_user`, `registry_password`
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
|
||||
- Images pushed to `registry.spdy.io/cannabrands/hub`
|
||||
- Base images pulled from `registry.spdy.io` (HTTPS with Let's Encrypt)
|
||||
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
|
||||
|
||||
### 8. User-Business Relationship (Pivot Table)
|
||||
Users connect to businesses via `business_user` pivot table (many-to-many).
|
||||
❌ **Wrong:** `User::where('business_id', $id)` — users table has NO business_id column
|
||||
✅ **Right:** `User::whereHas('businesses', fn($q) => $q->where('businesses.id', $id))`
|
||||
|
||||
**Pivot table columns:** `business_id`, `user_id`, `role`, `role_template`, `is_primary`, `permissions`
|
||||
**Why:** Allows users to belong to multiple businesses with different roles per business
|
||||
|
||||
### 9. Styling - DaisyUI/Tailwind Only
|
||||
❌ **NEVER use inline `style=""` attributes** in Blade templates
|
||||
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
|
||||
**Why:** Consistency, maintainability, theme switching, and better performance
|
||||
@@ -54,18 +155,78 @@ ALL routes need auth + user type middleware except public pages
|
||||
|
||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||
|
||||
### 8. Media Storage - MinIO Architecture (CRITICAL!)
|
||||
### 10. Suites Architecture - NOT Modules (CRITICAL!)
|
||||
❌ **NEVER use** `has_crm`, `has_marketing`, or other legacy module flags
|
||||
❌ **NEVER create** routes like `seller.crm.*` (without `.business.`)
|
||||
❌ **NEVER extend** `seller.crm.layouts.crm` layout (outdated CRM module layout)
|
||||
✅ **ALWAYS use** Suites system (Sales Suite, Processing Suite, etc.)
|
||||
✅ **ALWAYS use** route pattern `seller.business.crm.*` (includes `{business}` segment)
|
||||
✅ **ALWAYS extend** `layouts.seller` for seller views
|
||||
**Why:** We migrated from individual modules to a Suites architecture. CRM features are now part of the **Sales Suite**.
|
||||
|
||||
**See:** `docs/SUITES_AND_PRICING_MODEL.md` for full architecture
|
||||
|
||||
**The 7 Suites:**
|
||||
1. **Sales Suite** - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
|
||||
2. **Processing Suite** - Extraction, Wash Reports, Yields (internal)
|
||||
3. **Manufacturing Suite** - Work Orders, BOM, Packaging (internal)
|
||||
4. **Delivery Suite** - Pick/Pack, Drivers, Manifests (internal)
|
||||
5. **Management Suite** - Finance, AP/AR, Budgets (Canopy only)
|
||||
6. **Brand Manager Suite** - Read-only brand portal (external partners)
|
||||
7. **Dispensary Suite** - Buyer marketplace (dispensaries)
|
||||
|
||||
**Legacy module flags still exist** in database but are deprecated. Suite permissions control access now.
|
||||
|
||||
### 11. Media Storage - MinIO Architecture (CRITICAL!)
|
||||
❌ **NEVER use** `Storage::disk('public')` for brand/product media
|
||||
✅ **ALWAYS use** `Storage` (respects .env FILESYSTEM_DISK=minio)
|
||||
**Why:** All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.
|
||||
|
||||
**⚠️ BLADE TEMPLATE RULES (CRITICAL):**
|
||||
❌ **NEVER use** `/storage/` prefix in image src attributes
|
||||
❌ **NEVER use** `asset('storage/...')` for media
|
||||
✅ **ALWAYS use** dynamic image routes with model methods
|
||||
|
||||
**Correct Image Display Patterns:**
|
||||
```blade
|
||||
{{-- Product images - use getImageUrl() method --}}
|
||||
<img src="{{ $product->getImageUrl('medium') }}" alt="{{ $product->name }}">
|
||||
<img src="{{ $product->getImageUrl('thumb') }}" alt="{{ $product->name }}">
|
||||
|
||||
{{-- Brand logos - use getLogoUrl() method --}}
|
||||
<img src="{{ $brand->getLogoUrl('medium') }}" alt="{{ $brand->name }}">
|
||||
|
||||
{{-- In Alpine.js - use route() helper --}}
|
||||
<img :src="`{{ url('/images/product/') }}/${product.hashid}/400`">
|
||||
```
|
||||
|
||||
**URL Patterns (for accessing images):**
|
||||
- **Product image:** `/images/product/{product_hashid}/{width?}`
|
||||
- Example: `/images/product/78xd4/400` (400px width)
|
||||
- **Brand logo:** `/images/brand-logo/{brand_hashid}/{width?}`
|
||||
- Example: `/images/brand-logo/75pg7` (original)
|
||||
- Example: `/images/brand-logo/75pg7/600` (600px thumbnail)
|
||||
- **Brand banner:** `/images/brand-banner/{brand_hashid}/{width?}`
|
||||
- Example: `/images/brand-banner/75pg7/1344` (1344px banner)
|
||||
|
||||
**Product Image Storage (TWO METHODS):**
|
||||
Products can store images in TWO ways - **always check both**:
|
||||
1. **Direct `image_path` column** - Single image stored directly on product
|
||||
- Access via `$product->getImageUrl()` method
|
||||
- Path stored like: `businesses/cannabrands/brands/thunder-bud/products/TB-AM-AZ1G/images/alien-marker.png`
|
||||
2. **`images()` relation** - Multiple images in `product_images` table
|
||||
- Access via `$product->images` collection
|
||||
- Used for galleries with multiple images
|
||||
|
||||
**When loading product images for display:**
|
||||
```php
|
||||
// Check BOTH methods - direct image_path first, then relation
|
||||
if ($product->image_path) {
|
||||
$imageUrl = $product->getImageUrl('medium');
|
||||
} elseif ($product->images->count() > 0) {
|
||||
$imageUrl = $product->images->first()->url;
|
||||
}
|
||||
```
|
||||
|
||||
**Storage Path Requirements (on MinIO):**
|
||||
- **Brand logos/banners:** `businesses/{business_slug}/brands/{brand_slug}/branding/{filename}`
|
||||
- Example: `businesses/cannabrands/brands/thunder-bud/branding/logo.png`
|
||||
@@ -73,10 +234,13 @@ ALL routes need auth + user type middleware except public pages
|
||||
- Example: `businesses/cannabrands/brands/thunder-bud/products/TB-BM-AZ1G/images/black-maple.png`
|
||||
|
||||
**DO NOT:**
|
||||
- Use `/storage/` prefix in Blade templates for ANY media
|
||||
- Use `asset('storage/...')` for ANY media
|
||||
- Use numeric IDs in paths (e.g., `products/14/`)
|
||||
- Use hashids in storage paths
|
||||
- Skip business or brand directories
|
||||
- Use `Storage::disk('public')` anywhere in media code
|
||||
- Assume images are ONLY in `images()` relation - check `image_path` too!
|
||||
|
||||
**See Comments In:**
|
||||
- `app/Models/Brand.php` (line 47) - Brand asset paths
|
||||
@@ -86,6 +250,101 @@ ALL routes need auth + user type middleware except public pages
|
||||
|
||||
**This has caused multiple production outages - review docs before ANY storage changes!**
|
||||
|
||||
### 12. Dashboard & Metrics Performance (CRITICAL!)
|
||||
|
||||
**Production outages have occurred from violating these rules.**
|
||||
|
||||
#### The Golden Rule
|
||||
**NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.**
|
||||
|
||||
#### What Goes Where
|
||||
|
||||
| Location | Allowed | Not Allowed |
|
||||
|----------|---------|-------------|
|
||||
| Controller | `Redis::get()`, simple lookups by ID | `->sum()`, `->count()`, `->avg()`, loops with queries |
|
||||
| Background Job | All aggregations, joins, complex queries | N/A |
|
||||
|
||||
#### ❌ BANNED Patterns in Controllers:
|
||||
|
||||
```php
|
||||
// BANNED: Aggregation in controller
|
||||
$revenue = Order::sum('total');
|
||||
|
||||
// BANNED: N+1 in loop
|
||||
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
|
||||
|
||||
// BANNED: Query per day/iteration
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$data[] = Order::whereDate('created_at', $date)->sum('total');
|
||||
}
|
||||
|
||||
// BANNED: Selecting columns that don't exist
|
||||
->select('id', 'stage_1_metadata') // Column doesn't exist!
|
||||
```
|
||||
|
||||
#### ✅ REQUIRED Pattern:
|
||||
|
||||
```php
|
||||
// Controller: Just read Redis
|
||||
public function analytics(Business $business)
|
||||
{
|
||||
$data = Redis::get("dashboard:{$business->id}:analytics");
|
||||
|
||||
if (!$data) {
|
||||
CalculateDashboardMetrics::dispatch($business->id);
|
||||
return view('dashboard.analytics', ['data' => $this->emptyState()]);
|
||||
}
|
||||
|
||||
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
|
||||
}
|
||||
|
||||
// Background Job: Do all the heavy lifting
|
||||
public function handle()
|
||||
{
|
||||
// Batch query - ONE query for all products
|
||||
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, SUM(quantity) as total')
|
||||
->pluck('total', 'product_id');
|
||||
|
||||
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
|
||||
}
|
||||
```
|
||||
|
||||
#### Before Merging Dashboard PRs:
|
||||
|
||||
1. Search for `->sum(`, `->count(`, `->avg(` in the controller
|
||||
2. Search for `->map(function` with queries inside
|
||||
3. If found → Move to background job
|
||||
4. Query count must be < 20 for any dashboard page
|
||||
|
||||
#### The Architecture
|
||||
|
||||
```
|
||||
BACKGROUND (every 10 min) HTTP REQUEST
|
||||
======================== =============
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ CalculateMetricsJob │ │ DashboardController │
|
||||
│ │ │ │
|
||||
│ - Heavy queries │ │ - Redis::get() only │
|
||||
│ - Joins │──► Redis ──►│ - No aggregations │
|
||||
│ - Aggregations │ │ - No loops+queries │
|
||||
│ - Loops are OK here │ │ │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
Takes 5-30 sec Takes 10ms
|
||||
Runs in background User waits for this
|
||||
```
|
||||
|
||||
#### Prevention Checklist for Future Dashboard Work
|
||||
|
||||
- [ ] All `->sum()`, `->count()`, `->avg()` are in background jobs, not controllers
|
||||
- [ ] No `->map(function` with queries inside in controllers
|
||||
- [ ] Redis keys exist after job runs (`redis-cli KEYS "dashboard:*"`)
|
||||
- [ ] Job completes without errors (check `storage/logs/worker.log`)
|
||||
- [ ] Controller only does `Redis::get()` for metrics
|
||||
- [ ] Column names in `->select()` match actual database schema
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack by Area
|
||||
@@ -108,6 +367,35 @@ Users have `user_type` matching their business type.
|
||||
|
||||
---
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
**First-time setup or fresh database:**
|
||||
```bash
|
||||
./vendor/bin/sail artisan dev:setup --fresh
|
||||
```
|
||||
|
||||
This command:
|
||||
- Runs migrations (use `--fresh` to drop all tables first)
|
||||
- Prompts to seed dev fixtures (users, businesses, brands)
|
||||
- Seeds brand profiles and orchestrator profiles
|
||||
- Displays test credentials when complete
|
||||
|
||||
**Options:**
|
||||
- `--fresh` — Drop all tables and re-run migrations
|
||||
- `--skip-seed` — Skip the seeding prompt
|
||||
|
||||
**Test Credentials (seeded by dev:setup):**
|
||||
| Role | Email | Password |
|
||||
|------|-------|----------|
|
||||
| Super Admin | admin@cannabrands.com | password |
|
||||
| Admin | admin@example.com | password |
|
||||
| Seller | seller@example.com | password |
|
||||
| Buyer | buyer@example.com | password |
|
||||
| Cannabrands Owner | cannabrands-owner@example.com | password |
|
||||
| Brand Manager | brand-manager@example.com | password |
|
||||
|
||||
---
|
||||
|
||||
## Testing & Git
|
||||
|
||||
**Before commit:**
|
||||
@@ -121,7 +409,7 @@ php artisan test --parallel # REQUIRED
|
||||
- ❌ **DO NOT** add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
|
||||
- ✅ Write clean, professional commit messages without AI attribution
|
||||
|
||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
||||
**Credentials:** See "Local Development Setup" section above
|
||||
|
||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||
|
||||
@@ -173,6 +461,48 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
---
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
**Database Queries:**
|
||||
- NEVER write N+1 queries - always use eager loading (`with()`) for relationships
|
||||
- NEVER run queries inside loops - batch them before the loop
|
||||
- Avoid multiple queries when one JOIN or subquery works
|
||||
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
|
||||
- Use `DB::enableQueryLog()` mentally - if a page would log 20+ queries, refactor
|
||||
- Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
|
||||
- Test with `DB::listen()` or Laravel Debugbar before committing controller code
|
||||
|
||||
**Before submitting controller code, verify:**
|
||||
1. No queries inside foreach/map loops
|
||||
2. All relationships eager loaded
|
||||
3. Aggregations done in SQL, not PHP collections
|
||||
4. Would this cause a 503 under load? If unsure, simplify.
|
||||
|
||||
**Examples:**
|
||||
```php
|
||||
// ❌ N+1 query - DON'T DO THIS
|
||||
$orders = Order::all();
|
||||
foreach ($orders as $order) {
|
||||
echo $order->customer->name; // Query per iteration!
|
||||
}
|
||||
|
||||
// ✅ Eager loaded - DO THIS
|
||||
$orders = Order::with('customer')->get();
|
||||
|
||||
// ❌ Query in loop - DON'T DO THIS
|
||||
foreach ($products as $product) {
|
||||
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
|
||||
}
|
||||
|
||||
// ✅ Batch query - DO THIS
|
||||
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, SUM(quantity) as total')
|
||||
->pluck('total', 'product_id');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What You Often Forget
|
||||
|
||||
✅ Scope by business_id BEFORE finding by ID
|
||||
@@ -181,3 +511,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
✅ Eager load relationships to prevent N+1 queries
|
||||
✅ No queries inside loops - batch before the loop
|
||||
|
||||
@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://code.cannabrands.app/Cannabrands/hub.git
|
||||
git clone https://git.spdy.io/Cannabrands/hub.git
|
||||
cd hub
|
||||
```
|
||||
|
||||
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
|
||||
git push origin feature/my-feature-name
|
||||
|
||||
# 4. Create Pull Request on Gitea
|
||||
# - Navigate to https://code.cannabrands.app
|
||||
# - Navigate to https://git.spdy.io
|
||||
# - Create PR to merge your branch into develop
|
||||
# - CI will run automatically
|
||||
# - Request review from team
|
||||
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
|
||||
|
||||
### Services
|
||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Gitea:** `https://git.spdy.io`
|
||||
- **Production:** `https://app.cannabrands.com` (future)
|
||||
|
||||
---
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -3,7 +3,7 @@
|
||||
# ============================================
|
||||
|
||||
# ==================== Stage 1: Node Builder ====================
|
||||
FROM node:22-alpine AS node-builder
|
||||
FROM 10.100.9.70:5000/library/node:22-alpine AS node-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -34,14 +34,18 @@ COPY public ./public
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Stage 2: Composer Builder ====================
|
||||
FROM composer:2 AS composer-builder
|
||||
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
|
||||
FROM 10.100.9.70:5000/library/php:8.4-cli-alpine AS composer-builder
|
||||
|
||||
# Install Composer
|
||||
COPY --from=10.100.9.70:5000/library/composer:2.8 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install required PHP extensions for Filament and Horizon
|
||||
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev \
|
||||
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install intl gd pcntl
|
||||
&& docker-php-ext-install intl gd pcntl zip
|
||||
|
||||
# Copy composer files
|
||||
COPY composer.json composer.lock ./
|
||||
@@ -56,7 +60,7 @@ RUN composer install \
|
||||
--optimize-autoloader
|
||||
|
||||
# ==================== Stage 3: Production Runtime ====================
|
||||
FROM php:8.3-fpm-alpine
|
||||
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
|
||||
|
||||
LABEL maintainer="CannaBrands Team"
|
||||
|
||||
|
||||
93
Dockerfile.fast
Normal file
93
Dockerfile.fast
Normal file
@@ -0,0 +1,93 @@
|
||||
# ============================================
|
||||
# Fast Production Dockerfile
|
||||
# Single-stage build using CI pre-built assets
|
||||
# Saves time by skipping multi-stage node/composer builders
|
||||
# ============================================
|
||||
#
|
||||
# This Dockerfile expects:
|
||||
# - vendor/ already populated (from CI composer-install step)
|
||||
# - public/build/ already populated (from CI build-frontend step)
|
||||
#
|
||||
# Build time: ~5-7 min (vs 15-20 min with multi-stage Dockerfile)
|
||||
# ============================================
|
||||
|
||||
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
|
||||
|
||||
LABEL maintainer="CannaBrands Team"
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
nginx \
|
||||
supervisor \
|
||||
postgresql-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
freetype-dev \
|
||||
libzip-dev \
|
||||
icu-dev \
|
||||
icu-data-full \
|
||||
zip \
|
||||
unzip \
|
||||
git \
|
||||
curl \
|
||||
bash
|
||||
|
||||
# Install build dependencies for PHP extensions
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
autoconf \
|
||||
g++ \
|
||||
make
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
pdo_pgsql \
|
||||
pgsql \
|
||||
gd \
|
||||
zip \
|
||||
intl \
|
||||
pcntl \
|
||||
bcmath \
|
||||
opcache
|
||||
|
||||
# Install Redis extension
|
||||
RUN pecl install redis \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& apk del .build-deps
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=www-data:www-data . .
|
||||
|
||||
# Copy pre-built frontend assets (built in CI step)
|
||||
# These are already in public/build from the build-frontend step
|
||||
|
||||
# Copy pre-installed vendor (from CI composer-install step)
|
||||
# Already included in COPY . .
|
||||
|
||||
# Create version metadata file
|
||||
RUN echo "VERSION=${APP_VERSION}" > /var/www/html/version.env && \
|
||||
echo "COMMIT=${GIT_COMMIT_SHA}" >> /var/www/html/version.env && \
|
||||
chown www-data:www-data /var/www/html/version.env
|
||||
|
||||
# Copy production configurations
|
||||
COPY docker/production/nginx/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/production/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY docker/production/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
|
||||
|
||||
# Remove default PHP-FPM pool config and use our custom one
|
||||
RUN rm -f /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.default
|
||||
COPY docker/production/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Create supervisor log directory and fix permissions
|
||||
RUN mkdir -p /var/log/supervisor \
|
||||
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
|
||||
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
@@ -1,587 +0,0 @@
|
||||
# Executive Access Guide: Subdivisions & Department-Based Permissions
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how Cannabrands handles multi-division organizations with department-based access control. It covers:
|
||||
|
||||
- **Parent Companies** with multiple **Subdivisions** (Divisions)
|
||||
- **Department-Based Access Control** - users see only their department's data
|
||||
- **Executive Override** - executives can view all divisions and departments
|
||||
- **Cross-Department Visibility** for shared resources
|
||||
|
||||
## How Companies Work with Subdivisions
|
||||
|
||||
### Organizational Structure
|
||||
|
||||
**Parent Company** → Multiple **Divisions** → Multiple **Departments** → Multiple **Users**
|
||||
|
||||
**Example: Canopy AZ Group**
|
||||
|
||||
```
|
||||
Canopy AZ Group (Parent Company)
|
||||
├── Hash Factory AZ (Division)
|
||||
│ ├── Executive (Department)
|
||||
│ ├── Manufacturing (Department)
|
||||
│ ├── Sales (Department)
|
||||
│ └── Compliance (Department)
|
||||
│
|
||||
├── Leopard AZ (Division)
|
||||
│ ├── Executive (Department)
|
||||
│ ├── Manufacturing (Department)
|
||||
│ ├── Sales (Department)
|
||||
│ └── Fleet Management (Department)
|
||||
│
|
||||
└── Canopy Retail (Division)
|
||||
├── Executive (Department)
|
||||
├── Sales (Department)
|
||||
└── Compliance (Department)
|
||||
```
|
||||
|
||||
### Business Relationships
|
||||
|
||||
**Database Schema:**
|
||||
- Each subdivision has `parent_id` pointing to the parent company
|
||||
- Each subdivision has a `division_name` (e.g., "Hash Factory AZ")
|
||||
- Parent company has `is_parent_company = true`
|
||||
|
||||
**Code Reference:** `app/Models/Business.php`
|
||||
|
||||
```php
|
||||
// Check if this is a parent company
|
||||
$business->isParentCompany()
|
||||
|
||||
// Get all divisions under this parent
|
||||
$business->divisions()
|
||||
|
||||
// Get parent company (from a division)
|
||||
$business->parent
|
||||
```
|
||||
|
||||
## Department-Based Access Control
|
||||
|
||||
### Core Principle
|
||||
|
||||
**Users only see data related to their assigned department(s).**
|
||||
|
||||
This means:
|
||||
- Manufacturing users see only manufacturing batches, wash reports, and conversions
|
||||
- Sales users see only sales orders, customers, and invoices
|
||||
- Compliance users see only compliance tracking and lab results
|
||||
- Fleet users see only drivers, vehicles, and delivery routes
|
||||
|
||||
### User-Department Assignments
|
||||
|
||||
**Database:** `department_user` pivot table
|
||||
|
||||
```
|
||||
department_user
|
||||
├── department_id (Foreign Key → departments)
|
||||
├── user_id (Foreign Key → users)
|
||||
├── is_admin (Boolean) - Department administrator flag
|
||||
└── timestamps
|
||||
```
|
||||
|
||||
**A user can be assigned to multiple departments:**
|
||||
- John works in both Manufacturing AND Compliance
|
||||
- Sarah is Sales admin for Division A and Division B
|
||||
- Mike is Executive (sees all departments)
|
||||
|
||||
### How Data Filtering Works
|
||||
|
||||
**File:** `app/Http/Controllers/DashboardController.php:416-424`
|
||||
|
||||
```php
|
||||
// Step 1: Get user's assigned departments
|
||||
$userDepartments = auth()->user()->departments()
|
||||
->where('business_id', $business->id)
|
||||
->pluck('departments.id');
|
||||
|
||||
// Step 2: Find operators in those departments
|
||||
$allowedOperatorIds = User::whereHas('departments', function($q) use ($userDepartments) {
|
||||
$q->whereIn('departments.id', $userDepartments);
|
||||
})->pluck('id');
|
||||
|
||||
// Step 3: Filter data by those operators
|
||||
$activeWashes = Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'in_progress')
|
||||
->whereIn('operator_user_id', $allowedOperatorIds) // ← Department filtering
|
||||
->get();
|
||||
```
|
||||
|
||||
**Result:** Manufacturing users only see wash reports from other manufacturing users.
|
||||
|
||||
### Department Isolation Examples
|
||||
|
||||
#### Scenario 1: Manufacturing User
|
||||
**User:** Mike (Department: Manufacturing)
|
||||
**Can See:**
|
||||
- ✅ Batches created by manufacturing team
|
||||
- ✅ Wash reports from manufacturing operators
|
||||
- ✅ Work orders assigned to manufacturing
|
||||
- ✅ Purchase orders for manufacturing materials
|
||||
- ❌ Sales orders (Sales department)
|
||||
- ❌ Fleet deliveries (Fleet department)
|
||||
|
||||
#### Scenario 2: Sales User
|
||||
**User:** Sarah (Department: Sales)
|
||||
**Can See:**
|
||||
- ✅ Customer orders and invoices
|
||||
- ✅ Product catalog
|
||||
- ✅ Sales reports and analytics
|
||||
- ❌ Manufacturing batches (Manufacturing department)
|
||||
- ❌ Compliance tracking (Compliance department)
|
||||
|
||||
#### Scenario 3: Multi-Department User
|
||||
**User:** John (Departments: Manufacturing + Compliance)
|
||||
**Can See:**
|
||||
- ✅ Manufacturing batches and wash reports
|
||||
- ✅ Compliance tracking and lab results
|
||||
- ✅ COA (Certificate of Analysis) for batches
|
||||
- ✅ Quarantine holds and releases
|
||||
- ❌ Sales orders (not in Sales department)
|
||||
- ❌ Fleet operations (not in Fleet department)
|
||||
|
||||
## Executive Access Override
|
||||
|
||||
### Who Are Executives?
|
||||
|
||||
**Executives** are users with special permissions to view ALL data across ALL departments and divisions.
|
||||
|
||||
**Common Executive Roles:**
|
||||
- CEO / Owner
|
||||
- CFO / Finance Director
|
||||
- COO / Operations Director
|
||||
- Corporate Administrator
|
||||
|
||||
### Executive Permissions
|
||||
|
||||
**File:** `app/Http/Controllers/DashboardController.php:408-411`
|
||||
|
||||
```php
|
||||
// Executives bypass department filtering
|
||||
if (auth()->user()->hasRole('executive')) {
|
||||
// Get ALL departments in the business
|
||||
$userDepartments = $business->departments->pluck('id');
|
||||
}
|
||||
```
|
||||
|
||||
**What This Means:**
|
||||
- Executives see data from Manufacturing, Sales, Compliance, and ALL other departments
|
||||
- Executives can access Executive Dashboard with consolidated metrics
|
||||
- Executives can view corporate settings for all divisions
|
||||
|
||||
### Executive-Only Features
|
||||
|
||||
**1. Executive Dashboard**
|
||||
- **Route:** `/s/{business}/executive/dashboard`
|
||||
- **Controller:** `app/Http/Controllers/Seller/ExecutiveDashboardController.php`
|
||||
- **View:** `resources/views/seller/executive/dashboard.blade.php`
|
||||
|
||||
**Shows:**
|
||||
- Consolidated metrics across ALL divisions
|
||||
- Division-by-division performance comparison
|
||||
- Corporate-wide production analytics
|
||||
- Cross-division resource utilization
|
||||
|
||||
**2. Corporate Settings**
|
||||
- **Controller:** `app/Http/Controllers/Seller/CorporateSettingsController.php`
|
||||
- **View:** `resources/views/seller/corporate/divisions.blade.php`
|
||||
|
||||
**Manage:**
|
||||
- Division list and configuration
|
||||
- Corporate resource allocation
|
||||
- Cross-division policies
|
||||
|
||||
**3. Consolidated Analytics**
|
||||
- **Controller:** `app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php`
|
||||
|
||||
**Reports:**
|
||||
- Total production across all manufacturing divisions
|
||||
- Combined sales performance
|
||||
- Corporate inventory levels
|
||||
- Cross-division compliance status
|
||||
|
||||
## Permission Levels
|
||||
|
||||
### 1. Regular User (Department-Scoped)
|
||||
**Access:** Only data from assigned department(s)
|
||||
**Example:** Manufacturing operator sees only manufacturing batches
|
||||
|
||||
### 2. Department Administrator
|
||||
**Access:** All data in department + department management
|
||||
**Example:** Manufacturing Manager can assign users to Manufacturing department
|
||||
|
||||
**Flag:** `department_user.is_admin = true`
|
||||
|
||||
```php
|
||||
// Check if user is department admin
|
||||
$user->departments()
|
||||
->where('department_id', $departmentId)
|
||||
->wherePivot('is_admin', true)
|
||||
->exists();
|
||||
```
|
||||
|
||||
### 3. Division Owner
|
||||
**Access:** All departments within their division
|
||||
**Example:** "Hash Factory AZ" owner sees Manufacturing, Sales, Compliance for Hash Factory AZ only
|
||||
|
||||
**Implementation:**
|
||||
```php
|
||||
// Division owners have 'owner' role scoped to their business
|
||||
if ($user->hasRole('owner') && $user->business_id === $business->id) {
|
||||
// See all departments in this division
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Corporate Executive
|
||||
**Access:** All divisions + all departments + corporate features
|
||||
**Example:** Canopy AZ Group CEO sees everything across Hash Factory AZ, Leopard AZ, and Canopy Retail
|
||||
|
||||
**Implementation:**
|
||||
```php
|
||||
// Corporate executives have 'executive' role at parent company level
|
||||
if ($user->hasRole('executive') && $business->isParentCompany()) {
|
||||
// Access to all divisions and departments
|
||||
}
|
||||
```
|
||||
|
||||
## Shared Departments Across Divisions
|
||||
|
||||
### Use Case: Shared Manufacturing Facility
|
||||
|
||||
**Scenario:** Hash Factory AZ and Leopard AZ share the same physical manufacturing facility with the same equipment and operators.
|
||||
|
||||
**Solution:** Users can be assigned to departments in MULTIPLE divisions.
|
||||
|
||||
**Example:**
|
||||
|
||||
**User:** Carlos (Manufacturing Operator)
|
||||
**Department Assignments:**
|
||||
- Hash Factory AZ → Manufacturing Department
|
||||
- Leopard AZ → Manufacturing Department
|
||||
|
||||
**Result:**
|
||||
- Carlos sees batches from both Hash Factory AZ and Leopard AZ
|
||||
- Carlos can create wash reports for either division
|
||||
- Dashboard shows combined data from both divisions
|
||||
|
||||
**Database:**
|
||||
```
|
||||
department_user table
|
||||
├── id: 1, department_id: 5 (Hash Factory Manufacturing), user_id: 10 (Carlos)
|
||||
└── id: 2, department_id: 12 (Leopard Manufacturing), user_id: 10 (Carlos)
|
||||
```
|
||||
|
||||
**Code Implementation:**
|
||||
```php
|
||||
// Get all departments across all divisions for this user
|
||||
$userDepartments = auth()->user()->departments()->pluck('departments.id');
|
||||
|
||||
// This returns: [5, 12] - departments from BOTH divisions
|
||||
```
|
||||
|
||||
### Use Case: Corporate Fleet Shared Across Divisions
|
||||
|
||||
**Scenario:** All divisions share the same delivery fleet.
|
||||
|
||||
**Solution:** Create a Fleet department at parent company level, assign drivers to it.
|
||||
|
||||
**Users in Fleet Department:**
|
||||
- See deliveries for all divisions
|
||||
- Manage vehicles shared across divisions
|
||||
- Track routes spanning multiple division locations
|
||||
|
||||
## Data Visibility Rules
|
||||
|
||||
### Rule 1: Business Isolation (Always Enforced)
|
||||
|
||||
**Users can ONLY see data from businesses they have access to.**
|
||||
|
||||
```php
|
||||
// ALWAYS scope by business_id first
|
||||
$query->where('business_id', $business->id);
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- Hash Factory AZ users cannot see Competitor Company's data
|
||||
- Each business is completely isolated
|
||||
|
||||
### Rule 2: Department Filtering (Enforced for Non-Executives)
|
||||
|
||||
**Regular users see only data from their assigned departments.**
|
||||
|
||||
```php
|
||||
if (!auth()->user()->hasRole('executive')) {
|
||||
$query->whereHas('operator.departments', function($q) use ($userDepts) {
|
||||
$q->whereIn('departments.id', $userDepts);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- Manufacturing user sees batches from manufacturing operators only
|
||||
- Sales user sees orders assigned to sales team only
|
||||
|
||||
### Rule 3: Executive Override (Top Priority)
|
||||
|
||||
**Executives bypass department filtering and see ALL data.**
|
||||
|
||||
```php
|
||||
if (auth()->user()->hasRole('executive')) {
|
||||
// No department filtering - see everything
|
||||
$userDepartments = $business->departments->pluck('id');
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- CEO sees manufacturing, sales, compliance, and all other data
|
||||
- CFO sees financial data across all departments
|
||||
|
||||
### Rule 4: Multi-Division Access
|
||||
|
||||
**Users can be assigned to departments in MULTIPLE divisions.**
|
||||
|
||||
```php
|
||||
// User's departments span multiple businesses
|
||||
$userDepartments = auth()->user()->departments()
|
||||
->pluck('departments.id'); // Includes departments from all divisions
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- Shared resource (operator, driver) sees data from all assigned divisions
|
||||
- Corporate admin manages users across multiple divisions
|
||||
|
||||
## Permission System Integration
|
||||
|
||||
### Spatie Permissions
|
||||
|
||||
Cannabrands uses **Spatie Laravel Permission** for role-based access control.
|
||||
|
||||
**Permissions Structure:**
|
||||
- `view-dashboard` - See basic dashboard
|
||||
- `view-batches` - See batch tracking (Manufacturing)
|
||||
- `create-batches` - Create batches (Manufacturing Admin)
|
||||
- `view-orders` - See sales orders (Sales)
|
||||
- `manage-fleet` - Manage vehicles/drivers (Fleet)
|
||||
- `view-executive-dashboard` - Access executive features (Executive)
|
||||
|
||||
### Combining Permissions + Departments
|
||||
|
||||
**Two-Layer Security:**
|
||||
1. **Permission Check:** Does user have permission to access this feature?
|
||||
2. **Department Check:** Is the data from user's department?
|
||||
|
||||
**Example:**
|
||||
```php
|
||||
// Layer 1: Permission check
|
||||
if (!auth()->user()->can('view-batches')) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Layer 2: Department filtering
|
||||
$batches = Batch::where('business_id', $business->id)
|
||||
->whereHas('operator.departments', function($q) use ($userDepartments) {
|
||||
$q->whereIn('departments.id', $userDepartments);
|
||||
})
|
||||
->get();
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- User must have `view-batches` permission AND be in Manufacturing department
|
||||
- Even with permission, they only see batches from their department
|
||||
|
||||
## Navigation & Menu Filtering
|
||||
|
||||
### Department-Based Menu Items
|
||||
|
||||
**File:** `resources/views/components/seller-sidebar.blade.php`
|
||||
|
||||
```blade
|
||||
{{-- Manufacturing Section - Only for Manufacturing department users --}}
|
||||
@if(auth()->user()->departments()->where('code', 'MFG')->exists())
|
||||
<li class="menu-title">Manufacturing</li>
|
||||
<li><a href="/batches">Batches</a></li>
|
||||
<li><a href="/wash-reports">Wash Reports</a></li>
|
||||
<li><a href="/work-orders">Work Orders</a></li>
|
||||
@endif
|
||||
|
||||
{{-- Sales Section - Only for Sales department users --}}
|
||||
@if(auth()->user()->departments()->where('code', 'SALES')->exists())
|
||||
<li class="menu-title">Sales</li>
|
||||
<li><a href="/orders">Orders</a></li>
|
||||
<li><a href="/customers">Customers</a></li>
|
||||
@endif
|
||||
|
||||
{{-- Executive Section - Only for executives --}}
|
||||
@if(auth()->user()->hasRole('executive'))
|
||||
<li class="menu-title">Executive</li>
|
||||
<li><a href="/executive/dashboard">Executive Dashboard</a></li>
|
||||
<li><a href="/corporate/divisions">Manage Divisions</a></li>
|
||||
@endif
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Users only see menu items relevant to their departments
|
||||
- Executives see all sections + executive-only items
|
||||
|
||||
## Common Access Scenarios
|
||||
|
||||
### Scenario 1: New Manufacturing Operator
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Hash Factory AZ" business
|
||||
3. Assign to "Manufacturing" department
|
||||
4. Give permissions: `view-batches`, `create-wash-reports`
|
||||
|
||||
**Access:**
|
||||
- ✅ Dashboard shows manufacturing metrics
|
||||
- ✅ Can view batches from manufacturing team
|
||||
- ✅ Can create wash reports
|
||||
- ❌ Cannot see sales orders
|
||||
- ❌ Cannot see executive dashboard
|
||||
|
||||
### Scenario 2: Sales Manager for Multiple Divisions
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Hash Factory AZ" → Sales Department (is_admin = true)
|
||||
3. Assign to "Leopard AZ" → Sales Department (is_admin = true)
|
||||
4. Give permissions: `view-orders`, `manage-customers`, `view-sales-reports`
|
||||
|
||||
**Access:**
|
||||
- ✅ See orders from both Hash Factory AZ and Leopard AZ
|
||||
- ✅ Manage customers across both divisions
|
||||
- ✅ View combined sales reports
|
||||
- ✅ Assign users to Sales department (dept admin)
|
||||
- ❌ Cannot see manufacturing data
|
||||
- ❌ Cannot see executive dashboard
|
||||
|
||||
### Scenario 3: Corporate CFO
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Canopy AZ Group" (parent company)
|
||||
3. Assign role: `executive`
|
||||
4. Give permissions: `view-executive-dashboard`, `view-financial-reports`, `manage-billing`
|
||||
|
||||
**Access:**
|
||||
- ✅ Executive dashboard with all divisions
|
||||
- ✅ Financial reports across all divisions
|
||||
- ✅ Manufacturing data from all divisions
|
||||
- ✅ Sales data from all divisions
|
||||
- ✅ Compliance data from all divisions
|
||||
- ✅ Corporate settings and division management
|
||||
|
||||
### Scenario 4: Shared Equipment Operator
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Hash Factory AZ" → Manufacturing Department
|
||||
3. Assign to "Leopard AZ" → Manufacturing Department
|
||||
4. Give permissions: `operate-equipment`, `create-wash-reports`
|
||||
|
||||
**Access:**
|
||||
- ✅ See batches from both divisions
|
||||
- ✅ Create wash reports for either division
|
||||
- ✅ View shared equipment schedule
|
||||
- ❌ Cannot see sales or compliance data
|
||||
- ❌ Cannot manage departments (not admin)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Always Check Business ID First
|
||||
|
||||
```php
|
||||
// CORRECT
|
||||
$batch = Batch::where('business_id', $business->id)
|
||||
->findOrFail($id);
|
||||
|
||||
// WRONG - Allows cross-business access!
|
||||
$batch = Batch::findOrFail($id);
|
||||
if ($batch->business_id !== $business->id) abort(403);
|
||||
```
|
||||
|
||||
### Always Apply Department Filtering
|
||||
|
||||
```php
|
||||
// CORRECT
|
||||
$batches = Batch::where('business_id', $business->id)
|
||||
->whereHas('operator.departments', function($q) use ($depts) {
|
||||
$q->whereIn('departments.id', $depts);
|
||||
})
|
||||
->get();
|
||||
|
||||
// WRONG - Shows all batches in business!
|
||||
$batches = Batch::where('business_id', $business->id)->get();
|
||||
```
|
||||
|
||||
### Check Permissions Before Department Filtering
|
||||
|
||||
```php
|
||||
// CORRECT ORDER
|
||||
if (!$user->can('view-batches')) abort(403);
|
||||
$batches = Batch::forUserDepartments($user)->get();
|
||||
|
||||
// WRONG - Filtered data still requires permission!
|
||||
$batches = Batch::forUserDepartments($user)->get();
|
||||
if (!$user->can('view-batches')) abort(403);
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When adding new features with department access:
|
||||
|
||||
- [ ] Check business_id isolation first
|
||||
- [ ] Check user permissions (Spatie)
|
||||
- [ ] Apply department filtering for non-executives
|
||||
- [ ] Allow executive override
|
||||
- [ ] Test with multi-department users
|
||||
- [ ] Test with shared department across divisions
|
||||
- [ ] Update navigation to show/hide menu items
|
||||
- [ ] Add department filtering to all queries
|
||||
- [ ] Document which departments can access the feature
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/features/PARENT_COMPANY_SUBDIVISIONS.md` - Technical implementation details
|
||||
- `docs/ROUTE_ISOLATION.md` - Module routing and isolation
|
||||
- `CLAUDE.md` - Common security mistakes to avoid
|
||||
- `app/Models/Business.php` - Parent/division relationships
|
||||
- `app/Models/Department.php` - Department structure
|
||||
- `database/migrations/2025_11_13_020000_add_hierarchy_to_businesses_table.php` - Business hierarchy schema
|
||||
- `database/migrations/2025_11_13_010000_create_departments_table.php` - Department schema
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
**Controllers:**
|
||||
- `app/Http/Controllers/DashboardController.php:408-424` - Department filtering logic
|
||||
- `app/Http/Controllers/Seller/ExecutiveDashboardController.php` - Executive dashboard
|
||||
- `app/Http/Controllers/Seller/CorporateSettingsController.php` - Corporate settings
|
||||
|
||||
**Models:**
|
||||
- `app/Models/User.php` - Department relationships
|
||||
- `app/Models/Department.php` - Department model
|
||||
- `app/Models/Business.php` - Parent/division methods
|
||||
|
||||
**Views:**
|
||||
- `resources/views/components/seller-sidebar.blade.php` - Department-based navigation
|
||||
- `resources/views/layouts/app-with-sidebar.blade.php` - Division name display
|
||||
|
||||
## Questions & Troubleshooting
|
||||
|
||||
**Q: User can't see data they should have access to?**
|
||||
A: Check department assignments in `department_user` table. User must be assigned to the correct department.
|
||||
|
||||
**Q: Executive sees only one department's data?**
|
||||
A: Check role assignment. User needs `executive` role, not just department assignment.
|
||||
|
||||
**Q: Shared operator sees data from only one division?**
|
||||
A: Check `department_user` table. User needs assignments to departments in BOTH divisions.
|
||||
|
||||
**Q: How to give user access to all departments without making them executive?**
|
||||
A: Assign user to ALL departments individually. Executive role bypasses this need.
|
||||
|
||||
**Q: Department admin can't manage department users?**
|
||||
A: Check `department_user.is_admin` flag is set to `true` for that user's assignment.
|
||||
@@ -1,195 +0,0 @@
|
||||
# Missing Files Report - Manufacturing Features Worktree
|
||||
|
||||
**Date:** 2025-11-13
|
||||
**Comparison:** Main repo vs `/home/kelly/git/hub-worktrees/manufacturing-features`
|
||||
|
||||
## Summary
|
||||
|
||||
The main repository contains **significantly more files** than the manufacturing-features worktree. These files represent work from commits:
|
||||
- `2831def` (9:00 PM Nov 13) - Manufacturing module with departments and executive features
|
||||
- `812fb20` (9:39 PM Nov 13) - UI improvements and enhanced dashboard
|
||||
|
||||
---
|
||||
|
||||
## Controllers Missing from Worktree (22 files)
|
||||
|
||||
### Admin Controllers (1):
|
||||
- `app/Http/Controllers/Admin/QuickSwitchController.php`
|
||||
|
||||
### Analytics Controllers (6):
|
||||
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
|
||||
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
|
||||
- `app/Http/Controllers/Analytics/MarketingAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/SalesAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/TrackingController.php`
|
||||
|
||||
### Seller Controllers (13):
|
||||
- `app/Http/Controllers/Seller/BatchController.php`
|
||||
- `app/Http/Controllers/Seller/BrandController.php`
|
||||
- `app/Http/Controllers/Seller/BrandPreviewController.php`
|
||||
- `app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php`
|
||||
- `app/Http/Controllers/Seller/CorporateSettingsController.php`
|
||||
- `app/Http/Controllers/Seller/DashboardController.php`
|
||||
- `app/Http/Controllers/Seller/ExecutiveDashboardController.php`
|
||||
- `app/Http/Controllers/Seller/OrderController.php`
|
||||
- `app/Http/Controllers/Seller/PurchaseOrderController.php`
|
||||
- `app/Http/Controllers/Seller/WashReportController.php`
|
||||
- `app/Http/Controllers/Seller/WorkOrderController.php`
|
||||
|
||||
### Fleet Controllers (2):
|
||||
- `app/Http/Controllers/Seller/Fleet/DriverController.php`
|
||||
- `app/Http/Controllers/Seller/Fleet/VehicleController.php`
|
||||
|
||||
### Marketing Controllers (2):
|
||||
- `app/Http/Controllers/Seller/Marketing/BroadcastController.php`
|
||||
- `app/Http/Controllers/Seller/Marketing/TemplateController.php`
|
||||
|
||||
---
|
||||
|
||||
## Models Missing from Worktree (5 files)
|
||||
|
||||
- `app/Models/ComponentCategory.php`
|
||||
- `app/Models/Conversion.php`
|
||||
- `app/Models/Department.php`
|
||||
- `app/Models/PurchaseOrder.php`
|
||||
- `app/Models/WorkOrder.php`
|
||||
|
||||
---
|
||||
|
||||
## Views Missing from Worktree (30+ files)
|
||||
|
||||
### Admin Views (1):
|
||||
- `resources/views/admin/quick-switch.blade.php`
|
||||
|
||||
### Component Views (3):
|
||||
- `resources/views/components/back-to-admin-button.blade.php`
|
||||
- `resources/views/components/dashboard/strain-performance.blade.php`
|
||||
- `resources/views/components/user-session-info.blade.php`
|
||||
|
||||
### Analytics Views (8):
|
||||
- `resources/views/seller/analytics/buyer-detail.blade.php`
|
||||
- `resources/views/seller/analytics/buyers.blade.php`
|
||||
- `resources/views/seller/analytics/campaign-detail.blade.php`
|
||||
- `resources/views/seller/analytics/dashboard.blade.php`
|
||||
- `resources/views/seller/analytics/marketing.blade.php`
|
||||
- `resources/views/seller/analytics/product-detail.blade.php`
|
||||
- `resources/views/seller/analytics/products.blade.php`
|
||||
- `resources/views/seller/analytics/sales.blade.php`
|
||||
|
||||
### Batch Views (3):
|
||||
- `resources/views/seller/batches/create.blade.php`
|
||||
- `resources/views/seller/batches/edit.blade.php`
|
||||
- `resources/views/seller/batches/index.blade.php`
|
||||
|
||||
### Brand Views (5):
|
||||
- `resources/views/seller/brands/create.blade.php`
|
||||
- `resources/views/seller/brands/edit.blade.php`
|
||||
- `resources/views/seller/brands/index.blade.php`
|
||||
- `resources/views/seller/brands/preview.blade.php`
|
||||
- `resources/views/seller/brands/show.blade.php`
|
||||
|
||||
### Corporate/Executive Views (2):
|
||||
- `resources/views/seller/corporate/divisions.blade.php`
|
||||
- `resources/views/seller/executive/dashboard.blade.php`
|
||||
|
||||
### Marketing Views (8):
|
||||
- `resources/views/seller/marketing/broadcasts/analytics.blade.php`
|
||||
- `resources/views/seller/marketing/broadcasts/create.blade.php`
|
||||
- `resources/views/seller/marketing/broadcasts/index.blade.php`
|
||||
- `resources/views/seller/marketing/broadcasts/show.blade.php`
|
||||
- `resources/views/seller/marketing/templates/create.blade.php`
|
||||
- `resources/views/seller/marketing/templates/edit.blade.php`
|
||||
- `resources/views/seller/marketing/templates/index.blade.php`
|
||||
- `resources/views/seller/marketing/templates/show.blade.php`
|
||||
|
||||
### Purchase Order Views (4):
|
||||
- `resources/views/seller/purchase-orders/create.blade.php`
|
||||
- `resources/views/seller/purchase-orders/edit.blade.php`
|
||||
- `resources/views/seller/purchase-orders/index.blade.php`
|
||||
- `resources/views/seller/purchase-orders/show.blade.php`
|
||||
|
||||
### Wash Report Views (4):
|
||||
- `resources/views/seller/wash-reports/active-dashboard.blade.php`
|
||||
- `resources/views/seller/wash-reports/daily-performance.blade.php`
|
||||
- `resources/views/seller/wash-reports/print.blade.php`
|
||||
- `resources/views/seller/wash-reports/search.blade.php`
|
||||
|
||||
### Work Order Views (5):
|
||||
- `resources/views/seller/work-orders/create.blade.php`
|
||||
- `resources/views/seller/work-orders/edit.blade.php`
|
||||
- `resources/views/seller/work-orders/index.blade.php`
|
||||
- `resources/views/seller/work-orders/my-work-orders.blade.php`
|
||||
- `resources/views/seller/work-orders/show.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## Migrations Missing from Worktree (10 files)
|
||||
|
||||
- `database/migrations/2025_10_29_135618_create_component_categories_table.php`
|
||||
- `database/migrations/2025_11_12_033522_add_role_template_to_business_user_table.php`
|
||||
- `database/migrations/2025_11_12_035044_add_module_flags_to_businesses_table.php`
|
||||
- `database/migrations/2025_11_12_201000_create_conversions_table.php`
|
||||
- `database/migrations/2025_11_12_201100_create_conversion_inputs_table.php`
|
||||
- `database/migrations/2025_11_12_202000_create_purchase_orders_table.php`
|
||||
- `database/migrations/2025_11_13_010000_create_departments_table.php`
|
||||
- `database/migrations/2025_11_13_010100_create_department_user_table.php`
|
||||
- `database/migrations/2025_11_13_010200_create_work_orders_table.php`
|
||||
- `database/migrations/2025_11_13_020000_add_hierarchy_to_businesses_table.php`
|
||||
|
||||
---
|
||||
|
||||
## Seeders Missing from Worktree
|
||||
|
||||
- `database/seeders/CanopyAzBusinessRestructureSeeder.php`
|
||||
- `database/seeders/CanopyAzDepartmentsSeeder.php`
|
||||
- `database/seeders/CompleteManufacturingSeeder.php`
|
||||
- `database/seeders/ManufacturingSampleDataSeeder.php`
|
||||
- `database/seeders/ManufacturingStructureSeeder.php`
|
||||
|
||||
---
|
||||
|
||||
## Documentation Missing from Worktree
|
||||
|
||||
- `EXECUTIVE_ACCESS_GUIDE.md` (root)
|
||||
- `docs/features/PARENT_COMPANY_SUBDIVISIONS.md`
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
To sync the manufacturing-features worktree with main repo:
|
||||
|
||||
```bash
|
||||
cd /home/kelly/git/hub-worktrees/manufacturing-features
|
||||
git fetch origin
|
||||
git merge origin/feature/manufacturing-module
|
||||
```
|
||||
|
||||
Or copy specific files:
|
||||
|
||||
```bash
|
||||
# Copy controllers
|
||||
cp -r /home/kelly/git/hub/app/Http/Controllers/Analytics /home/kelly/git/hub-worktrees/manufacturing-features/app/Http/Controllers/
|
||||
cp /home/kelly/git/hub/app/Http/Controllers/Admin/QuickSwitchController.php /home/kelly/git/hub-worktrees/manufacturing-features/app/Http/Controllers/Admin/
|
||||
|
||||
# Copy models
|
||||
cp /home/kelly/git/hub/app/Models/{ComponentCategory,Conversion,Department,PurchaseOrder,WorkOrder}.php /home/kelly/git/hub-worktrees/manufacturing-features/app/Models/
|
||||
|
||||
# Copy views
|
||||
cp -r /home/kelly/git/hub/resources/views/seller/{analytics,batches,brands,marketing,corporate,executive,purchase-orders,wash-reports,work-orders} /home/kelly/git/hub-worktrees/manufacturing-features/resources/views/seller/
|
||||
|
||||
# Copy migrations
|
||||
cp /home/kelly/git/hub/database/migrations/2025_*.php /home/kelly/git/hub-worktrees/manufacturing-features/database/migrations/
|
||||
|
||||
# Copy seeders
|
||||
cp /home/kelly/git/hub/database/seeders/{CanopyAz*,Complete*,Manufacturing*}.php /home/kelly/git/hub-worktrees/manufacturing-features/database/seeders/
|
||||
|
||||
# Copy docs
|
||||
cp /home/kelly/git/hub/EXECUTIVE_ACCESS_GUIDE.md /home/kelly/git/hub-worktrees/manufacturing-features/
|
||||
cp /home/kelly/git/hub/docs/features/PARENT_COMPANY_SUBDIVISIONS.md /home/kelly/git/hub-worktrees/manufacturing-features/docs/features/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Total Missing Files:** ~80+ files across controllers, models, views, migrations, seeders, and documentation
|
||||
@@ -1,336 +0,0 @@
|
||||
# Push Notifications & Laravel Horizon Setup
|
||||
|
||||
## Overview
|
||||
This feature adds browser push notifications for high-intent buyer signals as part of the Premium Buyer Analytics module.
|
||||
|
||||
## Prerequisites
|
||||
- HTTPS (production/staging) or localhost (development)
|
||||
- Browser that supports Web Push API (Chrome, Firefox, Edge, Safari 16+)
|
||||
|
||||
---
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
composer update
|
||||
```
|
||||
|
||||
This will install:
|
||||
- `laravel-notification-channels/webpush: ^10.2` - Web push notifications
|
||||
- `laravel/horizon: ^5.39` - Queue management dashboard
|
||||
|
||||
### 2. Install Horizon Assets
|
||||
|
||||
```bash
|
||||
php artisan horizon:install
|
||||
```
|
||||
|
||||
This publishes Horizon's dashboard assets to `public/vendor/horizon`.
|
||||
|
||||
### 3. Run Database Migrations
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `push_subscriptions` table - Stores browser push subscriptions
|
||||
|
||||
### 4. Generate VAPID Keys
|
||||
|
||||
```bash
|
||||
php artisan webpush:vapid
|
||||
```
|
||||
|
||||
This generates VAPID keys for web push authentication and adds them to your `.env`:
|
||||
```
|
||||
VAPID_PUBLIC_KEY=...
|
||||
VAPID_PRIVATE_KEY=...
|
||||
```
|
||||
|
||||
**⚠️ IMPORTANT**:
|
||||
- Never commit VAPID keys to git
|
||||
- Generate different keys for each environment (local, staging, production)
|
||||
- Keys are environment-specific and can't be shared between environments
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Register HorizonServiceProvider
|
||||
|
||||
Add to `bootstrap/providers.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class, // Add this line
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Register Event Listener
|
||||
|
||||
In `app/Providers/AppServiceProvider.php` boot method:
|
||||
|
||||
```php
|
||||
use App\Events\HighIntentBuyerDetected;
|
||||
use App\Listeners\Analytics\SendHighIntentSignalPushNotification;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(
|
||||
HighIntentBuyerDetected::class,
|
||||
SendHighIntentSignalPushNotification::class
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Environment Variables
|
||||
|
||||
Ensure these are in your `.env`:
|
||||
|
||||
```env
|
||||
# Queue Configuration
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Horizon Configuration
|
||||
HORIZON_DOMAIN=your-domain.com
|
||||
HORIZON_PATH=horizon
|
||||
|
||||
# Web Push (generated by webpush:vapid)
|
||||
VAPID_PUBLIC_KEY=your-public-key
|
||||
VAPID_PRIVATE_KEY=your-private-key
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### 1. Start Required Services
|
||||
|
||||
```bash
|
||||
# Start Laravel Sail (includes Redis)
|
||||
./vendor/bin/sail up -d
|
||||
|
||||
# OR if using local Redis
|
||||
redis-server
|
||||
```
|
||||
|
||||
### 2. Start Horizon Queue Worker
|
||||
|
||||
```bash
|
||||
php artisan horizon
|
||||
|
||||
# OR with Sail
|
||||
./vendor/bin/sail artisan horizon
|
||||
```
|
||||
|
||||
**⚠️ Horizon must be running** for push notifications to be sent!
|
||||
|
||||
### 3. Seed Test Data (Local Only)
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=PushNotificationTestDataSeeder
|
||||
```
|
||||
|
||||
This creates:
|
||||
- ✅ 5 repeated product views
|
||||
- ✅ High engagement buyer score (95%)
|
||||
- ✅ 4 intent signals (various types)
|
||||
- ✅ Test notification event
|
||||
|
||||
### 4. Access Dashboards
|
||||
|
||||
- **Horizon Dashboard**: http://localhost/horizon
|
||||
- Monitor queued jobs
|
||||
- View failed jobs
|
||||
- See job metrics
|
||||
|
||||
- **Analytics Dashboard**: http://localhost/s/cannabrands/buyer-intelligence/buyers
|
||||
- View buyer engagement scores
|
||||
- See intent signals
|
||||
- Test push notifications
|
||||
|
||||
---
|
||||
|
||||
## Testing Push Notifications
|
||||
|
||||
### Browser Setup
|
||||
|
||||
1. **Navigate to your site** (must be HTTPS or localhost)
|
||||
2. **Grant notification permission** when prompted
|
||||
3. Browser will create a push subscription automatically
|
||||
|
||||
### Trigger Test Notification
|
||||
|
||||
Option 1: Use the seeder (creates test event):
|
||||
```bash
|
||||
php artisan db:seed --class=PushNotificationTestDataSeeder
|
||||
```
|
||||
|
||||
Option 2: Manually trigger via Tinker:
|
||||
```bash
|
||||
php artisan tinker
|
||||
```
|
||||
|
||||
```php
|
||||
use App\Events\HighIntentBuyerDetected;
|
||||
|
||||
event(new HighIntentBuyerDetected(
|
||||
sellerBusinessId: 1,
|
||||
buyerBusinessId: 2,
|
||||
signalType: 'high_engagement',
|
||||
signalStrength: 'very_high',
|
||||
metadata: ['engagement_score' => 95]
|
||||
));
|
||||
```
|
||||
|
||||
### Verify Notification Delivery
|
||||
|
||||
1. Check Horizon dashboard: `/horizon` - Job should show as processed
|
||||
2. Check browser - Should receive push notification
|
||||
3. Check Laravel logs: `storage/logs/laravel.log`
|
||||
|
||||
---
|
||||
|
||||
## Production/Staging Deployment
|
||||
|
||||
### Deployment Checklist
|
||||
|
||||
1. ✅ Run `composer install --no-dev --optimize-autoloader`
|
||||
2. ✅ Run `php artisan horizon:install`
|
||||
3. ✅ Run `php artisan migrate --force`
|
||||
4. ✅ Run `php artisan webpush:vapid` (generates environment-specific keys)
|
||||
5. ✅ Configure supervisor to keep Horizon running
|
||||
6. ✅ Set `HORIZON_DOMAIN` in `.env`
|
||||
7. ✅ **DO NOT** run test data seeder
|
||||
|
||||
### Supervisor Configuration
|
||||
|
||||
Create `/etc/supervisor/conf.d/horizon.conf`:
|
||||
|
||||
```ini
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s
|
||||
command=php /path/to/artisan horizon
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/path/to/storage/logs/horizon.log
|
||||
stopwaitsecs=3600
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update
|
||||
sudo supervisorctl start horizon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Notifications Not Sending
|
||||
|
||||
1. **Check Horizon is running**: Visit `/horizon` dashboard
|
||||
2. **Check queue connection**: `php artisan queue:monitor`
|
||||
3. **Check Redis**: `redis-cli ping` (should return PONG)
|
||||
4. **Check logs**: `tail -f storage/logs/laravel.log`
|
||||
|
||||
### VAPID Key Issues
|
||||
|
||||
```bash
|
||||
# Regenerate keys
|
||||
php artisan webpush:vapid --force
|
||||
|
||||
# Then restart Horizon
|
||||
php artisan horizon:terminate
|
||||
php artisan horizon
|
||||
```
|
||||
|
||||
### Browser Not Receiving Notifications
|
||||
|
||||
1. Check browser permissions: Allow notifications for your site
|
||||
2. Check HTTPS: Must be HTTPS or localhost
|
||||
3. Check subscription exists: `SELECT * FROM push_subscriptions;`
|
||||
4. Check browser console for errors
|
||||
|
||||
---
|
||||
|
||||
## What Triggers Push Notifications
|
||||
|
||||
Notifications are automatically sent when:
|
||||
|
||||
| Trigger | Threshold | Signal Type |
|
||||
|---------|-----------|-------------|
|
||||
| Repeated product views | 3+ views | `repeated_view` |
|
||||
| High engagement score | ≥ 60% | `high_engagement` |
|
||||
| Spec download | Any | `spec_download` |
|
||||
| Contact button click | Any | `contact_click` |
|
||||
|
||||
All triggers require `has_analytics = true` on the business.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User Action (e.g., views product 3x)
|
||||
↓
|
||||
Analytics Tracking System
|
||||
↓
|
||||
CalculateEngagementScore Job
|
||||
↓
|
||||
HighIntentBuyerDetected Event fired
|
||||
↓
|
||||
SendHighIntentSignalPushNotification Listener (queued)
|
||||
↓
|
||||
Horizon Queue Processing
|
||||
↓
|
||||
Push Notification Sent to Browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Added
|
||||
|
||||
- `app/Notifications/Analytics/HighIntentSignalNotification.php`
|
||||
- `app/Models/Analytics/PushSubscription.php`
|
||||
- `app/Listeners/Analytics/SendHighIntentSignalPushNotification.php`
|
||||
- `app/Providers/HorizonServiceProvider.php`
|
||||
- `database/migrations/2025_11_09_003106_create_push_subscriptions_table.php`
|
||||
- `database/seeders/PushNotificationTestDataSeeder.php` (test data only)
|
||||
- `config/webpush.php`
|
||||
- `config/horizon.php`
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- ✅ Push notifications only sent to users with permission
|
||||
- ✅ VAPID keys are environment-specific
|
||||
- ✅ Subscriptions tied to user accounts
|
||||
- ✅ All triggers respect `has_analytics` module flag
|
||||
- ⚠️ Never commit VAPID keys to version control
|
||||
- ⚠️ Never run test seeders in production
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **Laravel Horizon Docs**: https://laravel.com/docs/horizon
|
||||
- **Web Push Package**: https://github.com/laravel-notification-channels/webpush
|
||||
- **Web Push Protocol**: https://web.dev/push-notifications/
|
||||
@@ -1,501 +0,0 @@
|
||||
# Analytics Implementation - Quick Handoff for Claude Code
|
||||
|
||||
## 🎯 Implementation Guide Location
|
||||
|
||||
**Main Technical Guide:** `/mnt/user-data/outputs/analytics-implementation-guide-REVISED.md`
|
||||
|
||||
This is a **REVISED** implementation that matches your ACTUAL Cannabrands architecture.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL ARCHITECTURAL DIFFERENCES
|
||||
|
||||
Your setup is different from typical Laravel multi-tenant apps:
|
||||
|
||||
### 1. **business_id is bigInteger (not UUID)**
|
||||
```php
|
||||
// Migration
|
||||
$table->unsignedBigInteger('business_id')->index();
|
||||
$table->foreign('business_id')->references('id')->on('businesses');
|
||||
|
||||
// NOT UUID like:
|
||||
$table->uuid('tenant_id');
|
||||
```
|
||||
|
||||
### 2. **NO Global Scopes - Explicit Scoping Pattern**
|
||||
```php
|
||||
// ❌ WRONG - Security vulnerability!
|
||||
ProductView::findOrFail($id)
|
||||
|
||||
// ✅ RIGHT - Your pattern
|
||||
ProductView::where('business_id', $business->id)->findOrFail($id)
|
||||
|
||||
// All queries MUST explicitly scope by business_id
|
||||
```
|
||||
|
||||
### 3. **Permissions in business_user.permissions JSON Column**
|
||||
```php
|
||||
// NOT using Spatie permission routes yet
|
||||
// Permissions stored in: business_user pivot table
|
||||
// Column: 'permissions' => 'array' (JSON)
|
||||
|
||||
// Check permissions via helper:
|
||||
hasBusinessPermission('analytics.overview')
|
||||
|
||||
// NOT via:
|
||||
auth()->user()->can('analytics.overview') // ❌ Don't use this yet
|
||||
```
|
||||
|
||||
### 4. **Multi-Business Users**
|
||||
```php
|
||||
// Users can belong to MULTIPLE businesses
|
||||
auth()->user()->businesses // BelongsToMany
|
||||
|
||||
// Get current business:
|
||||
auth()->user()->primaryBusiness()
|
||||
|
||||
// Or use helper:
|
||||
currentBusiness()
|
||||
currentBusinessId()
|
||||
```
|
||||
|
||||
### 5. **Products → Brand → Business Hierarchy**
|
||||
```php
|
||||
// Products DON'T have direct business_id
|
||||
// They go through Brand:
|
||||
$product->brand->business_id
|
||||
|
||||
// For tracking product views, get seller's business:
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
### 6. **User Types via Middleware**
|
||||
```php
|
||||
// Routes use user_type middleware:
|
||||
Route::middleware(['auth', 'verified', 'buyer']) // Buyers
|
||||
Route::middleware(['auth', 'verified', 'seller']) // Sellers
|
||||
Route::middleware(['auth', 'admin']) // Admins
|
||||
|
||||
// user_type values:
|
||||
'buyer' => 'Buyer/Retailer'
|
||||
'seller' => 'Seller/Brand'
|
||||
'admin' => 'Super Admin'
|
||||
```
|
||||
|
||||
### 7. **Reverb IS Configured (Horizon is NOT)**
|
||||
```php
|
||||
// ✅ Use Reverb for real-time updates
|
||||
use App\Events\Analytics\HighIntentBuyerDetected;
|
||||
event(new HighIntentBuyerDetected(...));
|
||||
|
||||
// ✅ Use Redis queues (already available)
|
||||
CalculateEngagementScore::dispatch()->onQueue('analytics');
|
||||
|
||||
// ❌ Don't install Horizon (not needed yet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 WHAT YOU'RE BUILDING
|
||||
|
||||
### Database Tables (7 migrations):
|
||||
1. `analytics_events` - Raw event stream
|
||||
2. `product_views` - Product engagement tracking
|
||||
3. `email_campaigns` + `email_interactions` + `email_clicks` - Email tracking
|
||||
4. `click_tracking` - General click events
|
||||
5. `user_sessions` + `intent_signals` - Session & intent tracking
|
||||
6. `buyer_engagement_scores` - Calculated buyer scores
|
||||
7. `jobs` table for Redis queues
|
||||
|
||||
**Key Field:** Every table has `business_id` (bigInteger) with proper indexing
|
||||
|
||||
### Backend Components:
|
||||
- **Helper Functions:** `currentBusiness()`, `hasBusinessPermission()`
|
||||
- **AnalyticsTracker Service:** Main tracking service
|
||||
- **Queue Jobs:** Async engagement score calculations
|
||||
- **Events:** Reverb broadcasting for real-time updates
|
||||
- **Controllers:** Dashboard, Products, Marketing, Sales, Buyers
|
||||
- **Models:** 10 analytics models with explicit business scoping
|
||||
|
||||
### Frontend:
|
||||
- Permission management UI in existing business/users section
|
||||
- Analytics navigation (new top-level section)
|
||||
- Dashboard views with KPIs and charts
|
||||
- Real-time notifications via Reverb
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SECURITY PATTERN (CRITICAL!)
|
||||
|
||||
**EVERY query MUST scope by business_id:**
|
||||
|
||||
```php
|
||||
// ❌ NEVER do this - data leakage!
|
||||
AnalyticsEvent::find($id)
|
||||
ProductView::where('product_id', $productId)->get()
|
||||
|
||||
// ✅ ALWAYS do this - business isolated
|
||||
AnalyticsEvent::where('business_id', $business->id)->find($id)
|
||||
ProductView::where('business_id', $business->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
|
||||
// ✅ Or use scope helper in models
|
||||
ProductView::forBusiness($business->id)->get()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 IMPLEMENTATION STEPS
|
||||
|
||||
### 1. Create Helper Files First
|
||||
```bash
|
||||
# Create helpers
|
||||
mkdir -p app/Helpers
|
||||
# Copy BusinessHelper.php
|
||||
# Copy helpers.php
|
||||
# Update composer.json autoload.files
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
```bash
|
||||
# Copy all 7 migration files
|
||||
php artisan migrate
|
||||
|
||||
# Verify tables created
|
||||
php artisan tinker
|
||||
>>> DB::table('analytics_events')->count()
|
||||
>>> DB::table('product_views')->count()
|
||||
```
|
||||
|
||||
### 3. Create Models
|
||||
```bash
|
||||
mkdir -p app/Models/Analytics
|
||||
# Copy all model files (10 models)
|
||||
# Each model has explicit business scoping
|
||||
```
|
||||
|
||||
### 4. Create Services
|
||||
```bash
|
||||
mkdir -p app/Services/Analytics
|
||||
# Copy AnalyticsTracker service
|
||||
```
|
||||
|
||||
### 5. Create Jobs
|
||||
```bash
|
||||
mkdir -p app/Jobs/Analytics
|
||||
# Copy CalculateEngagementScore job
|
||||
```
|
||||
|
||||
### 6. Create Events
|
||||
```bash
|
||||
mkdir -p app/Events/Analytics
|
||||
# Copy HighIntentBuyerDetected event
|
||||
# Update routes/channels.php for broadcasting
|
||||
```
|
||||
|
||||
### 7. Create Controllers
|
||||
```bash
|
||||
mkdir -p app/Http/Controllers/Analytics
|
||||
# Copy all controller files
|
||||
```
|
||||
|
||||
### 8. Add Routes
|
||||
```bash
|
||||
# Update routes/web.php with analytics routes
|
||||
# Use existing middleware patterns (auth, verified)
|
||||
```
|
||||
|
||||
### 9. Update UI
|
||||
```bash
|
||||
# Add analytics navigation section
|
||||
# Add permission management tile to business/users
|
||||
# Create analytics dashboard views
|
||||
```
|
||||
|
||||
### 10. Configure Queues
|
||||
```bash
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics
|
||||
|
||||
# (Reverb should already be running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 TRACKING EXAMPLES
|
||||
|
||||
### Track Product View
|
||||
```php
|
||||
use App\Services\Analytics\AnalyticsTracker;
|
||||
|
||||
public function show(Product $product, Request $request)
|
||||
{
|
||||
$tracker = new AnalyticsTracker($request);
|
||||
$view = $tracker->trackProductView($product);
|
||||
|
||||
// Queue engagement score calculation if buyer
|
||||
if ($view && $view->buyer_business_id) {
|
||||
\App\Jobs\Analytics\CalculateEngagementScore::dispatch(
|
||||
$view->business_id,
|
||||
$view->buyer_business_id
|
||||
);
|
||||
}
|
||||
|
||||
return view('products.show', compact('product'));
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Click Tracking
|
||||
```javascript
|
||||
// Add to your main JS
|
||||
document.addEventListener('click', function(e) {
|
||||
const trackable = e.target.closest('[data-track-click]');
|
||||
if (trackable) {
|
||||
fetch('/api/analytics/track-click', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
element_type: trackable.dataset.trackClick,
|
||||
element_id: trackable.dataset.trackId
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### HTML Usage
|
||||
```blade
|
||||
<a href="{{ route('products.show', $product) }}"
|
||||
data-track-click="product_link"
|
||||
data-track-id="{{ $product->id }}">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
```
|
||||
|
||||
### Real-Time Notifications
|
||||
```javascript
|
||||
// In analytics dashboard
|
||||
const businessId = {{ $business->id }};
|
||||
|
||||
Echo.channel('analytics.business.' + businessId)
|
||||
.listen('.high-intent-buyer', (e) => {
|
||||
showNotification('🔥 Hot Lead!', `${e.buyer_name} showing high intent`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 TESTING BUSINESS ISOLATION
|
||||
|
||||
```php
|
||||
// In php artisan tinker
|
||||
|
||||
// 1. Login as user
|
||||
auth()->loginUsingId(1);
|
||||
$business = currentBusiness();
|
||||
|
||||
// 2. Test helper
|
||||
echo "Business ID: " . currentBusinessId();
|
||||
|
||||
// 3. Test permission
|
||||
echo hasBusinessPermission('analytics.overview') ? "✅ HAS" : "❌ NO";
|
||||
|
||||
// 4. Test scoping - should only return current business data
|
||||
$count = App\Models\Analytics\ProductView::where('business_id', $business->id)->count();
|
||||
echo "My views: $count";
|
||||
|
||||
// 5. Test auto-set business_id
|
||||
$event = App\Models\Analytics\AnalyticsEvent::create([
|
||||
'event_type' => 'test'
|
||||
]);
|
||||
echo $event->business_id === $business->id ? "✅ PASS" : "❌ FAIL";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 PERMISSION SETUP
|
||||
|
||||
Add permissions to a user:
|
||||
|
||||
```php
|
||||
// In tinker or seeder
|
||||
$user = User::find(1);
|
||||
$business = $user->businesses->first();
|
||||
|
||||
// Grant analytics permissions
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.products',
|
||||
'analytics.marketing',
|
||||
'analytics.sales',
|
||||
'analytics.buyers',
|
||||
'analytics.export'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify
|
||||
$pivot = $user->businesses()->find($business->id)->pivot;
|
||||
print_r($pivot->permissions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ QUEUE CONFIGURATION
|
||||
|
||||
Make sure Redis is running and queue worker is started:
|
||||
|
||||
```bash
|
||||
# Check Redis
|
||||
redis-cli ping
|
||||
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics --tries=3
|
||||
|
||||
# Or with supervisor (production):
|
||||
[program:cannabrands-analytics-queue]
|
||||
command=php /path/to/artisan queue:work --queue=analytics --tries=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 NAVIGATION UPDATE
|
||||
|
||||
Add to your sidebar navigation:
|
||||
|
||||
```blade
|
||||
<!-- Analytics Section (New Top-Level) -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-header">
|
||||
<svg>...</svg>
|
||||
Analytics
|
||||
</div>
|
||||
|
||||
@if(hasBusinessPermission('analytics.overview'))
|
||||
<a href="{{ route('analytics.dashboard') }}" class="nav-item">
|
||||
Overview
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if(hasBusinessPermission('analytics.products'))
|
||||
<a href="{{ route('analytics.products.index') }}" class="nav-item">
|
||||
Products
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<!-- Marketing, Sales, Buyers... -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 COMMON ISSUES
|
||||
|
||||
### Issue: "business_id cannot be null"
|
||||
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
|
||||
|
||||
### Issue: "Seeing other businesses' data"
|
||||
**Solution:** You forgot to scope by business_id! Check your query has `where('business_id', ...)`.
|
||||
|
||||
### Issue: "Permission check not working"
|
||||
**Solution:** Check the permissions array in business_user pivot table. Make sure it's a JSON array.
|
||||
|
||||
### Issue: "Product has no business_id"
|
||||
**Solution:** Products don't have direct business_id. Use `BusinessHelper::fromProduct($product)` to get seller's business.
|
||||
|
||||
---
|
||||
|
||||
## 📚 FILE STRUCTURE
|
||||
|
||||
```
|
||||
app/
|
||||
├── Events/Analytics/
|
||||
│ └── HighIntentBuyerDetected.php
|
||||
├── Helpers/
|
||||
│ ├── BusinessHelper.php
|
||||
│ └── helpers.php
|
||||
├── Http/Controllers/Analytics/
|
||||
│ ├── AnalyticsDashboardController.php
|
||||
│ ├── ProductAnalyticsController.php
|
||||
│ ├── MarketingAnalyticsController.php
|
||||
│ ├── SalesAnalyticsController.php
|
||||
│ └── BuyerIntelligenceController.php
|
||||
├── Jobs/Analytics/
|
||||
│ └── CalculateEngagementScore.php
|
||||
├── Models/Analytics/
|
||||
│ ├── AnalyticsEvent.php
|
||||
│ ├── ProductView.php
|
||||
│ ├── EmailCampaign.php
|
||||
│ ├── EmailInteraction.php
|
||||
│ ├── EmailClick.php
|
||||
│ ├── ClickTracking.php
|
||||
│ ├── UserSession.php
|
||||
│ ├── IntentSignal.php
|
||||
│ └── BuyerEngagementScore.php
|
||||
└── Services/Analytics/
|
||||
└── AnalyticsTracker.php
|
||||
|
||||
database/migrations/
|
||||
├── 2024_01_01_000001_create_analytics_events_table.php
|
||||
├── 2024_01_01_000002_create_product_views_table.php
|
||||
├── 2024_01_01_000003_create_email_tracking_tables.php
|
||||
├── 2024_01_01_000004_create_click_tracking_table.php
|
||||
├── 2024_01_01_000005_create_user_sessions_and_intent_tables.php
|
||||
├── 2024_01_01_000006_add_analytics_permissions_to_business_user.php
|
||||
└── 2024_01_01_000007_create_analytics_jobs_table.php
|
||||
|
||||
resources/views/analytics/
|
||||
├── dashboard.blade.php
|
||||
├── products/
|
||||
│ ├── index.blade.php
|
||||
│ └── show.blade.php
|
||||
├── marketing/
|
||||
├── sales/
|
||||
└── buyers/
|
||||
|
||||
routes/
|
||||
├── channels.php (add broadcasting channel)
|
||||
└── web.php (add analytics routes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ DEFINITION OF DONE
|
||||
|
||||
- [ ] All 7 migrations run successfully
|
||||
- [ ] BusinessHelper and helpers.php created and autoloaded
|
||||
- [ ] All 10 analytics models created with business scoping
|
||||
- [ ] AnalyticsTracker service working
|
||||
- [ ] Queue jobs configured and tested
|
||||
- [ ] Reverb events broadcasting
|
||||
- [ ] All 5 controllers created
|
||||
- [ ] Routes added with permission checks
|
||||
- [ ] Navigation updated with Analytics section
|
||||
- [ ] Permission UI tile added
|
||||
- [ ] At least one dashboard view working
|
||||
- [ ] Business isolation verified (no cross-business data)
|
||||
- [ ] Permission checking works via business_user pivot
|
||||
- [ ] Queue worker running for analytics jobs
|
||||
- [ ] Test data can be created and viewed
|
||||
|
||||
---
|
||||
|
||||
## 🎉 READY TO IMPLEMENT!
|
||||
|
||||
Everything in the main guide is tailored to YOUR actual architecture:
|
||||
- ✅ business_id (bigInteger) not UUID
|
||||
- ✅ Explicit scoping, no global scopes
|
||||
- ✅ business_user.permissions JSON
|
||||
- ✅ Multi-business user support
|
||||
- ✅ Product → Brand → Business hierarchy
|
||||
- ✅ Reverb for real-time
|
||||
- ✅ Redis queues (no Horizon needed)
|
||||
|
||||
**Estimated implementation time: 5-6 hours**
|
||||
|
||||
Start with helpers and migrations, then build up from there! 🚀
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cannabrands B2B Platform
|
||||
|
||||
A LeafLink-style cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture foundation.
|
||||
A comprehensive B2B cannabis marketplace platform built with Laravel, featuring business onboarding, compliance tracking, and multi-tenant architecture.
|
||||
|
||||
---
|
||||
|
||||
@@ -579,7 +579,7 @@ See `.env.production.example` for complete configuration template.
|
||||
- Follow PSR-12 coding standards
|
||||
- Use Pest for testing new features
|
||||
- Reference `/docs/APP_OVERVIEW.md` for development approach
|
||||
- All features should maintain LeafLink-style compliance focus
|
||||
- All features should maintain strong compliance and regulatory focus
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/home/kelly/Nextcloud/Claude Sessions/hub-session/SESSION_ACTIVE
|
||||
@@ -1,392 +0,0 @@
|
||||
# Session Summary - Dashboard Fixes & Security Improvements
|
||||
**Date:** November 14, 2025
|
||||
**Branch:** `feature/manufacturing-module`
|
||||
**Location:** `/home/kelly/git/hub` (main repo)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This session completed fixes from the previous session (Nov 13) and addressed critical errors in the dashboard and security vulnerabilities. All work was done in the main repository on `feature/manufacturing-module` branch.
|
||||
|
||||
---
|
||||
|
||||
## Completed Fixes
|
||||
|
||||
### 1. Dashboard TypeError Fix - Quality Calculation ✅
|
||||
|
||||
**Problem:** TypeError "Cannot access offset of type array on array" at line 526 in DashboardController
|
||||
**Root Cause:** Code assumed quality data existed in Stage 2 wash reports, but WashReportController doesn't collect quality grades yet
|
||||
|
||||
**Files Changed:**
|
||||
- `app/Http/Controllers/DashboardController.php` (lines 513-545)
|
||||
|
||||
**Solution:**
|
||||
- Made quality grade extraction defensive
|
||||
- Iterates through all yield types (works with both hash and rosin structures)
|
||||
- Returns `null` for `avg_hash_quality` when no quality data exists
|
||||
- Only calls `calculateAverageQuality()` when grades are available
|
||||
|
||||
**Code:**
|
||||
```php
|
||||
// Check all yield types for quality data (handles both hash and rosin structures)
|
||||
foreach ($stage2['yields'] as $yieldType => $yieldData) {
|
||||
if (isset($yieldData['quality']) && $yieldData['quality']) {
|
||||
$qualityGrades[] = $yieldData['quality'];
|
||||
}
|
||||
}
|
||||
|
||||
// Only include quality if we have the data
|
||||
if (empty($qualityGrades)) {
|
||||
$component->past_performance = [
|
||||
'has_data' => true,
|
||||
'wash_count' => $pastWashes->count(),
|
||||
'avg_yield' => round($avgYield, 1),
|
||||
'avg_hash_quality' => null, // No quality data tracked
|
||||
];
|
||||
} else {
|
||||
$avgQuality = $this->calculateAverageQuality($qualityGrades);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Department-Based Dashboard Visibility ✅
|
||||
|
||||
**Problem:** Owners and super admins saw sales metrics even when only in processing departments
|
||||
**Architecture Violation:** Dashboard blocks should be determined by department groups, not role overrides
|
||||
|
||||
**Files Changed:**
|
||||
- `app/Http/Controllers/DashboardController.php` (lines 56-60)
|
||||
|
||||
**Solution:**
|
||||
- Removed owner and super admin overrides: `|| $isOwner || $isSuperAdmin`
|
||||
- Dashboard blocks now determined ONLY by department assignments
|
||||
- Added clear documentation explaining this architectural decision
|
||||
|
||||
**Before:**
|
||||
```php
|
||||
$showSalesMetrics = $hasSales || $isOwner || $isSuperAdmin;
|
||||
```
|
||||
|
||||
**After:**
|
||||
```php
|
||||
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
|
||||
// Users see data for their assigned departments - add user to department for access
|
||||
$showSalesMetrics = $hasSales;
|
||||
$showProcessingMetrics = $hasSolventless;
|
||||
$showFleetMetrics = $hasDelivery;
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Vinny (LAZ-SOLV) → sees ONLY processing blocks
|
||||
- Sales team (CBD-SALES, CBD-MKTG) → sees ONLY sales blocks
|
||||
- Multi-department users → see blocks for ALL their departments
|
||||
- Ownership = business management, NOT data access
|
||||
|
||||
---
|
||||
|
||||
### 3. Dashboard View - Null Quality Handling ✅
|
||||
|
||||
**Problem:** View tried to display `null` quality in badge when quality data missing
|
||||
|
||||
**Files Changed:**
|
||||
- `resources/views/seller/dashboard.blade.php` (lines 538-553)
|
||||
|
||||
**Solution:**
|
||||
- Added check for both `has_data` AND `avg_hash_quality` before showing badge
|
||||
- Shows "Not tracked" when wash history exists but no quality data
|
||||
- Shows "—" when no wash history exists at all
|
||||
|
||||
**Code:**
|
||||
```blade
|
||||
@if($component->past_performance['has_data'] && $component->past_performance['avg_hash_quality'])
|
||||
<div class="badge badge-sm ...">
|
||||
{{ $component->past_performance['avg_hash_quality'] }}
|
||||
</div>
|
||||
@elseif($component->past_performance['has_data'])
|
||||
<span class="text-xs text-base-content/40">Not tracked</span>
|
||||
@else
|
||||
<span class="text-xs text-base-content/40">—</span>
|
||||
@endif
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Quality badges display correctly when data exists
|
||||
- Graceful fallback when quality not tracked
|
||||
- Clear distinction between "no history" vs "no quality data"
|
||||
|
||||
---
|
||||
|
||||
### 4. Filament Admin Middleware Registration ✅
|
||||
|
||||
**Problem:** Users with wrong user type getting 403 Forbidden when accessing `/admin`, requiring manual cookie deletion
|
||||
|
||||
**Files Changed:**
|
||||
- `app/Providers/Filament/AdminPanelProvider.php` (lines 8, 72)
|
||||
|
||||
**Solution:**
|
||||
- Imported custom middleware: `use App\Http\Middleware\FilamentAdminAuthenticate;`
|
||||
- Registered in authMiddleware: `FilamentAdminAuthenticate::class`
|
||||
- Middleware auto-logs out users without access and redirects to login
|
||||
|
||||
**Code:**
|
||||
```php
|
||||
// Added import
|
||||
use App\Http\Middleware\FilamentAdminAuthenticate;
|
||||
|
||||
// Changed auth middleware
|
||||
->authMiddleware([
|
||||
FilamentAdminAuthenticate::class, // Instead of Authenticate::class
|
||||
])
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Detects when authenticated user lacks panel access
|
||||
2. Logs them out completely (clears session)
|
||||
3. Redirects to login with message: "Please login with an account that has access to this panel."
|
||||
4. No more manual cookie deletion needed!
|
||||
|
||||
---
|
||||
|
||||
### 5. Parent Company Cross-Division Security ✅
|
||||
|
||||
**Problem:** Users could manually change URL slug to access divisions they're not assigned to
|
||||
|
||||
**Files Changed:**
|
||||
- `routes/seller.php` (lines 11-19)
|
||||
|
||||
**Solution:**
|
||||
- Enhanced route binding documentation
|
||||
- Clarified that existing check already prevents cross-division access
|
||||
- Check validates against `business_user` pivot table
|
||||
|
||||
**Security Checks:**
|
||||
1. Unauthorized access to any business → 403
|
||||
2. Parent company users accessing division URLs by changing slug → 403
|
||||
3. Division users accessing other divisions' URLs by changing slug → 403
|
||||
|
||||
**Code:**
|
||||
```php
|
||||
// Security: Verify user is explicitly assigned to this business
|
||||
// This prevents:
|
||||
// 1. Unauthorized access to any business
|
||||
// 2. Parent company users accessing division URLs by changing slug
|
||||
// 3. Division users accessing other divisions' URLs by changing slug
|
||||
// Users must be explicitly assigned via business_user pivot table
|
||||
if (! auth()->check() || ! auth()->user()->businesses->contains($business->id)) {
|
||||
abort(403, 'You do not have access to this business or division.');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `app/Http/Controllers/DashboardController.php`
|
||||
- Line 56-60: Removed owner override from dashboard visibility
|
||||
- Lines 513-545: Fixed quality grade extraction to be defensive
|
||||
|
||||
2. `resources/views/seller/dashboard.blade.php`
|
||||
- Lines 538-553: Added null quality handling in Idle Fresh Frozen table
|
||||
|
||||
3. `app/Providers/Filament/AdminPanelProvider.php`
|
||||
- Line 8: Added FilamentAdminAuthenticate import
|
||||
- Line 72: Registered custom middleware
|
||||
|
||||
4. `routes/seller.php`
|
||||
- Lines 11-19: Enhanced security documentation for route binding
|
||||
|
||||
---
|
||||
|
||||
## Context from Previous Session (Nov 13)
|
||||
|
||||
This session addressed incomplete tasks from `SESSION_SUMMARY_2025-11-13.md`:
|
||||
|
||||
### Completed from Nov 13 Backlog:
|
||||
1. ✅ Custom Middleware Registration (was created but not registered)
|
||||
2. ✅ Parent Company Security Fix (documentation clarified)
|
||||
|
||||
### Already Complete from Nov 13:
|
||||
- ✅ Manufacturing module implementation
|
||||
- ✅ Seeder architecture with production protection
|
||||
- ✅ Quick Switch impersonation feature
|
||||
- ✅ Idle Fresh Frozen dashboard with past performance metrics
|
||||
- ✅ Historical wash cycle data in Stage 1 form
|
||||
|
||||
### Low Priority (Not Blocking):
|
||||
- Missing demo user "Kelly" - other demo users (Vinny, Maria) work fine
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Block Visibility by Department
|
||||
|
||||
### Processing Department (LAZ-SOLV, CRG-SOLV):
|
||||
**Shows:**
|
||||
- ✅ Wash Reports, Average Yield, Active/Completed Work Orders stats
|
||||
- ✅ Idle Fresh Frozen with past performance metrics
|
||||
- ✅ Quick Actions: Start a New Wash, Review Wash Reports
|
||||
- ✅ Recent Washes table
|
||||
- ✅ Strain Performance section
|
||||
|
||||
**Hidden:**
|
||||
- ❌ Revenue Statistics chart
|
||||
- ❌ Low Stock Alerts (sales products)
|
||||
- ❌ Recent Orders
|
||||
- ❌ Top Performing Products
|
||||
|
||||
### Sales Department (CBD-SALES, CBD-MKTG):
|
||||
**Shows:**
|
||||
- ✅ Revenue Statistics chart
|
||||
- ✅ Quick Actions: Add New Product, View All Orders
|
||||
- ✅ Low Stock Alerts
|
||||
- ✅ Recent Orders table
|
||||
- ✅ Top Performing Products
|
||||
|
||||
**Hidden:**
|
||||
- ❌ Processing metrics
|
||||
- ❌ Idle Fresh Frozen
|
||||
- ❌ Strain Performance
|
||||
|
||||
### Fleet Department (CRG-DELV):
|
||||
**Shows:**
|
||||
- ✅ Drivers, Active Vehicles, Fleet Size, Deliveries Today stats
|
||||
- ✅ Quick Actions: Manage Drivers
|
||||
|
||||
**Hidden:**
|
||||
- ❌ Sales and processing content
|
||||
|
||||
---
|
||||
|
||||
## Idle Fresh Frozen Display
|
||||
|
||||
### Dashboard Table (Processing Department)
|
||||
| Material | Quantity | Past Avg Yield | Past Hash Quality | Action |
|
||||
|----------|----------|----------------|-------------------|---------|
|
||||
| Blue Dream - Fresh Frozen | 500g | **4.2%** (3 washes) | **Not tracked** | [Start Wash] |
|
||||
| Cherry Pie - Fresh Frozen | 750g | **5.8%** (5 washes) | **Not tracked** | [Start Wash] |
|
||||
|
||||
**Notes:**
|
||||
- "Past Avg Yield" calculates from historical wash data
|
||||
- "Past Hash Quality" shows "Not tracked" because WashReportController doesn't collect quality grades yet
|
||||
- "Start Wash" button links to Stage 1 form with strain pre-populated
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Admin Panel 403 Fix
|
||||
- [ ] Login as `seller@example.com` (non-admin)
|
||||
- [ ] Navigate to `/admin`
|
||||
- [ ] Expected: Auto-logout + redirect to login with message (no 403 error page)
|
||||
|
||||
### Cross-Division URL Protection
|
||||
- [ ] Login as Vinny (Leopard AZ user)
|
||||
- [ ] Go to `/s/leopard-az/dashboard` (should work)
|
||||
- [ ] Change URL to `/s/cannabrands-az/dashboard`
|
||||
- [ ] Expected: 403 error "You do not have access to this business or division."
|
||||
|
||||
### Dashboard Department Blocks
|
||||
- [ ] Login as Vinny (LAZ-SOLV department)
|
||||
- [ ] View dashboard
|
||||
- [ ] Verify processing metrics show, sales metrics hidden
|
||||
- [ ] Verify revenue chart is hidden
|
||||
|
||||
### Idle Fresh Frozen Performance Data
|
||||
- [ ] View processing dashboard
|
||||
- [ ] Check Idle Fresh Frozen section
|
||||
- [ ] Verify Past Avg Yield shows percentages
|
||||
- [ ] Verify Past Hash Quality shows "Not tracked"
|
||||
|
||||
### Dashboard TypeError Fix
|
||||
- [ ] Access dashboard as any processing user
|
||||
- [ ] Verify no TypeError when viewing Idle Fresh Frozen
|
||||
- [ ] Verify quality column displays gracefully
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. Department-Based Access Control
|
||||
**Decision:** Dashboard blocks determined ONLY by department assignments, not by roles or ownership.
|
||||
|
||||
**Rationale:**
|
||||
- Clearer separation of concerns
|
||||
- Easier to audit ("what does this user see?")
|
||||
- Scales better for multi-department users
|
||||
- Ownership = business management, not data access
|
||||
|
||||
**Implementation:**
|
||||
- User assigned to LAZ-SOLV → sees processing data only
|
||||
- User assigned to CBD-SALES → sees sales data only
|
||||
- User assigned to both → sees both
|
||||
|
||||
### 2. Working in Main Repo (Not Worktree)
|
||||
**Decision:** All work done in `/home/kelly/git/hub` on `feature/manufacturing-module` branch.
|
||||
|
||||
**Rationale:**
|
||||
- More traditional workflow
|
||||
- Simpler to understand and maintain
|
||||
- Worktree added complexity without clear benefit
|
||||
- Can merge/cherry-pick from worktree if needed later
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Future Enhancements
|
||||
|
||||
### 1. Quality Grade Collection Not Implemented
|
||||
**Status:** Deferred - not blocking
|
||||
**Issue:** WashReportController Stage 2 doesn't collect quality grades yet
|
||||
**Impact:** Dashboard shows "Not tracked" for all quality data
|
||||
**Future Work:** Update `WashReportController::storeStage2()` to:
|
||||
- Accept quality inputs: `quality_fresh_press_120u`, `quality_cold_cure_90u`, etc.
|
||||
- Store in `$metadata['stage_2']['yields'][...]['quality']`
|
||||
- Then dashboard will automatically show quality badges
|
||||
|
||||
### 2. Worktree Branch Status
|
||||
**Status:** Inactive but preserved
|
||||
**Location:** `/home/kelly/git/hub-worktrees/manufacturing-features`
|
||||
**Branch:** `feature/manufacturing-features`
|
||||
**Decision:** Keep as reference, all new work in main repo
|
||||
|
||||
---
|
||||
|
||||
## Cache Commands Run
|
||||
|
||||
```bash
|
||||
./vendor/bin/sail artisan view:clear
|
||||
./vendor/bin/sail artisan cache:clear
|
||||
./vendor/bin/sail artisan config:clear
|
||||
./vendor/bin/sail artisan route:clear
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (When Resuming)
|
||||
|
||||
1. **Test all fixes** using checklist above
|
||||
2. **Run test suite:** `php artisan test --parallel`
|
||||
3. **Run Pint:** `./vendor/bin/pint`
|
||||
4. **Decide on worktree:** Keep as backup or merge/delete
|
||||
5. **Future:** Implement quality grade collection in WashReportController
|
||||
|
||||
---
|
||||
|
||||
## Git Information
|
||||
|
||||
**Branch:** `feature/manufacturing-module`
|
||||
**Location:** `/home/kelly/git/hub`
|
||||
**Uncommitted Changes:** 4 files modified (ready to commit)
|
||||
|
||||
**Modified Files:**
|
||||
- `app/Http/Controllers/DashboardController.php`
|
||||
- `app/Providers/Filament/AdminPanelProvider.php`
|
||||
- `resources/views/seller/dashboard.blade.php`
|
||||
- `routes/seller.php`
|
||||
|
||||
---
|
||||
|
||||
**Session completed:** 2025-11-14
|
||||
**All fixes tested:** Pending user testing
|
||||
**Ready for commit:** Yes
|
||||
File diff suppressed because it is too large
Load Diff
193
app/Console/Commands/Ai/AiStatsCommand.php
Normal file
193
app/Console/Commands/Ai/AiStatsCommand.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Ai;
|
||||
|
||||
use App\Models\Ai\AiGeneratorState;
|
||||
use App\Models\Ai\AiPromptLog;
|
||||
use App\Models\Ai\AiSuggestion;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* AI Stats Command
|
||||
*
|
||||
* Display AI orchestrator statistics.
|
||||
*/
|
||||
class AiStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai:stats
|
||||
{--business= : Filter by business ID}
|
||||
{--days=30 : Number of days to analyze}';
|
||||
|
||||
protected $description = 'Display AI orchestrator statistics';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$days = (int) $this->option('days');
|
||||
|
||||
$this->info("AI Orchestrator Statistics (Last {$days} days)");
|
||||
$this->newLine();
|
||||
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
$this->error("Business not found: {$businessId}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info("Business: {$business->name}");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Suggestion stats
|
||||
$this->displaySuggestionStats($businessId, $days);
|
||||
|
||||
// LLM usage stats
|
||||
$this->displayLlmStats($businessId, $days);
|
||||
|
||||
// Generator performance
|
||||
if ($businessId) {
|
||||
$this->displayGeneratorStats((int) $businessId);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function displaySuggestionStats(?int $businessId, int $days): void
|
||||
{
|
||||
$this->info('📊 Suggestion Statistics');
|
||||
|
||||
$query = AiSuggestion::where('created_at', '>=', now()->subDays($days));
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$suggestions = $query->get();
|
||||
|
||||
$total = $suggestions->count();
|
||||
$pending = $suggestions->where('status', 'pending')->count();
|
||||
$actioned = $suggestions->where('status', 'actioned')->count();
|
||||
$dismissed = $suggestions->where('status', 'dismissed')->count();
|
||||
$expired = $suggestions->where('status', 'expired')->count();
|
||||
|
||||
$actionRate = $total > 0 ? round(($actioned / $total) * 100, 1) : 0;
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Total Suggestions', number_format($total)],
|
||||
['Pending', number_format($pending)],
|
||||
['Actioned', number_format($actioned)." ({$actionRate}%)"],
|
||||
['Dismissed', number_format($dismissed)],
|
||||
['Expired', number_format($expired)],
|
||||
]
|
||||
);
|
||||
|
||||
// By category
|
||||
$byCategory = $suggestions->groupBy('category')->map->count()->sortDesc();
|
||||
|
||||
if ($byCategory->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->line('By Category:');
|
||||
$this->table(
|
||||
['Category', 'Count'],
|
||||
$byCategory->map(fn ($count, $cat) => [$cat, $count])->values()
|
||||
);
|
||||
}
|
||||
|
||||
// By priority
|
||||
$byPriority = $suggestions->groupBy('priority')->map->count();
|
||||
|
||||
if ($byPriority->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->line('By Priority:');
|
||||
$this->table(
|
||||
['Priority', 'Count'],
|
||||
collect(['urgent', 'high', 'normal', 'low'])
|
||||
->map(fn ($p) => [$p, $byPriority[$p] ?? 0])
|
||||
);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
protected function displayLlmStats(?int $businessId, int $days): void
|
||||
{
|
||||
$this->info('🤖 LLM Usage Statistics');
|
||||
|
||||
$stats = AiPromptLog::getUsageStats($businessId ?? 0, $days);
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Total Requests', number_format($stats['total_requests'])],
|
||||
['Total Tokens', number_format($stats['total_tokens'])],
|
||||
['Total Cost', '$'.number_format($stats['total_cost'], 4)],
|
||||
['Avg Latency', round($stats['average_latency_ms'] ?? 0).'ms'],
|
||||
['Error Rate', number_format($stats['error_rate'], 1).'%'],
|
||||
['Cache Hit Rate', number_format($stats['cache_hit_rate'], 1).'%'],
|
||||
]
|
||||
);
|
||||
|
||||
// By provider
|
||||
if (! empty($stats['by_provider'])) {
|
||||
$this->newLine();
|
||||
$this->line('By Provider:');
|
||||
$this->table(
|
||||
['Provider', 'Requests', 'Tokens', 'Cost'],
|
||||
collect($stats['by_provider'])->map(fn ($data, $provider) => [
|
||||
$provider,
|
||||
number_format($data['requests']),
|
||||
number_format($data['tokens']),
|
||||
'$'.number_format($data['cost'], 4),
|
||||
])->values()
|
||||
);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
protected function displayGeneratorStats(int $businessId): void
|
||||
{
|
||||
$this->info('⚙️ Generator Performance');
|
||||
|
||||
$report = AiGeneratorState::getPerformanceReport($businessId);
|
||||
|
||||
if (empty($report['generators'])) {
|
||||
$this->line('No generator data available.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Generator', 'Context', 'Runs', 'Suggestions', 'Action Rate', 'Grade'],
|
||||
collect($report['generators'])->map(fn ($g) => [
|
||||
$g['type'],
|
||||
$g['context'],
|
||||
number_format($g['total_runs']),
|
||||
number_format($g['total_suggestions']),
|
||||
number_format($g['action_rate'], 1).'%',
|
||||
$g['performance_grade'],
|
||||
])
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->line('Summary:');
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Total Generators', $report['summary']['total_generators']],
|
||||
['Enabled', $report['summary']['enabled_generators']],
|
||||
['Total Suggestions', number_format($report['summary']['total_suggestions'])],
|
||||
['Overall Action Rate', number_format($report['summary']['overall_action_rate'], 1).'%'],
|
||||
['Avg Accuracy', $report['summary']['average_accuracy']
|
||||
? number_format($report['summary']['average_accuracy'] * 100, 1).'%'
|
||||
: 'N/A'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
198
app/Console/Commands/Ai/GenerateBriefingsCommand.php
Normal file
198
app/Console/Commands/Ai/GenerateBriefingsCommand.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Ai;
|
||||
|
||||
use App\Jobs\Ai\GenerateBriefingJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Generate Briefings Command
|
||||
*
|
||||
* Artisan command to generate AI briefings for users.
|
||||
*/
|
||||
class GenerateBriefingsCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai:generate-briefings
|
||||
{type=daily : Type of briefing (daily, weekly, monthly)}
|
||||
{--user= : Generate for specific user ID}
|
||||
{--business= : Generate for specific business ID}
|
||||
{--sync : Run synchronously instead of queuing}';
|
||||
|
||||
protected $description = 'Generate AI briefings for users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$type = $this->argument('type');
|
||||
|
||||
if (! in_array($type, ['daily', 'weekly', 'monthly'])) {
|
||||
$this->error("Invalid briefing type: {$type}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Check if feature is enabled
|
||||
if (! config('ai_orchestrator.briefings.enabled', true)) {
|
||||
$this->warn('Briefings are disabled in configuration.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! config("ai_orchestrator.briefings.{$type}.enabled", true)) {
|
||||
$this->warn("{$type} briefings are disabled in configuration.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$userId = $this->option('user');
|
||||
$businessId = $this->option('business');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
if ($userId) {
|
||||
return $this->generateForUser((int) $userId, $type, $sync);
|
||||
}
|
||||
|
||||
if ($businessId) {
|
||||
return $this->generateForBusiness((int) $businessId, $type, $sync);
|
||||
}
|
||||
|
||||
return $this->generateForAll($type, $sync);
|
||||
}
|
||||
|
||||
protected function generateForUser(int $userId, string $type, bool $sync): int
|
||||
{
|
||||
$user = User::with('business')->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User not found: {$userId}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $user->business_id) {
|
||||
$this->error('User has no associated business');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Generating {$type} briefing for user: {$user->name}");
|
||||
|
||||
if ($sync) {
|
||||
$this->dispatchSync($userId, $user->business_id, $type);
|
||||
} else {
|
||||
GenerateBriefingJob::dispatch($userId, $user->business_id, $type);
|
||||
}
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function generateForBusiness(int $businessId, string $type, bool $sync): int
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business not found: {$businessId}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Get users who should receive briefings (sellers/admins)
|
||||
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $businessId))
|
||||
->whereIn('user_type', ['seller', 'both'])
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->warn("No eligible users found for business: {$business->name}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Generating {$type} briefings for {$users->count()} users in {$business->name}");
|
||||
|
||||
$bar = $this->output->createProgressBar($users->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($sync) {
|
||||
$this->dispatchSync($user->id, $businessId, $type);
|
||||
} else {
|
||||
GenerateBriefingJob::dispatch($user->id, $businessId, $type);
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function generateForAll(string $type, bool $sync): int
|
||||
{
|
||||
// Get all active seller businesses
|
||||
$businesses = Business::where('type', 'seller')
|
||||
->orWhere('type', 'both')
|
||||
->get();
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->warn('No eligible businesses found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totalUsers = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$userCount = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->whereIn('user_type', ['seller', 'both'])
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
$totalUsers += $userCount;
|
||||
}
|
||||
|
||||
$this->info("Generating {$type} briefings for {$totalUsers} users across {$businesses->count()} businesses");
|
||||
|
||||
$bar = $this->output->createProgressBar($totalUsers);
|
||||
$bar->start();
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->whereIn('user_type', ['seller', 'both'])
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($sync) {
|
||||
$this->dispatchSync($user->id, $business->id, $type);
|
||||
} else {
|
||||
GenerateBriefingJob::dispatch($user->id, $business->id, $type);
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function dispatchSync(int $userId, int $businessId, string $type): void
|
||||
{
|
||||
try {
|
||||
$orchestrator = app(\App\Services\Ai\Contracts\AiOrchestratorContract::class);
|
||||
$orchestrator->generateBriefing($userId, $businessId, $type);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed for user {$userId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/Console/Commands/Ai/ProcessAiSuggestionsCommand.php
Normal file
128
app/Console/Commands/Ai/ProcessAiSuggestionsCommand.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Ai;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Ai\Contracts\AiOrchestratorContract;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Process AI Suggestions Command
|
||||
*
|
||||
* Artisan command to manually trigger AI suggestion generation.
|
||||
*/
|
||||
class ProcessAiSuggestionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai:process-suggestions
|
||||
{context : Context type (deal, order, thread, buyer)}
|
||||
{entity : Entity ID to process}
|
||||
{--business= : Business ID (required)}
|
||||
{--no-llm : Disable LLM generation, use rules only}';
|
||||
|
||||
protected $description = 'Manually process AI suggestions for an entity';
|
||||
|
||||
public function handle(AiOrchestratorContract $orchestrator): int
|
||||
{
|
||||
$contextType = $this->argument('context');
|
||||
$entityId = (int) $this->argument('entity');
|
||||
$businessId = (int) $this->option('business');
|
||||
$useLlm = ! $this->option('no-llm');
|
||||
|
||||
if (! $businessId) {
|
||||
$this->error('--business option is required');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
$this->error("Business not found: {$businessId}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$validContexts = ['deal', 'order', 'thread', 'buyer'];
|
||||
if (! in_array($contextType, $validContexts)) {
|
||||
$this->error('Invalid context type. Must be one of: '.implode(', ', $validContexts));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Processing {$contextType} #{$entityId} for business: {$business->name}");
|
||||
$this->info('LLM: '.($useLlm ? 'enabled' : 'disabled'));
|
||||
|
||||
try {
|
||||
// Build context
|
||||
$context = $orchestrator->buildContext($contextType, $entityId, $businessId);
|
||||
|
||||
$this->info('Context built successfully');
|
||||
$this->line(" Type: {$context->type}");
|
||||
$this->line(" Entity ID: {$context->entityId}");
|
||||
|
||||
// Generate suggestions
|
||||
$suggestions = $orchestrator->generateSuggestions($context, [
|
||||
'use_llm' => $useLlm,
|
||||
]);
|
||||
|
||||
$this->info("Generated {$suggestions->count()} suggestions");
|
||||
|
||||
if ($suggestions->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Title', 'Type', 'Priority', 'Confidence'],
|
||||
$suggestions->map(fn ($s) => [
|
||||
substr($s->title, 0, 40),
|
||||
$s->type->value,
|
||||
$s->priority->value,
|
||||
number_format($s->confidence * 100, 0).'%',
|
||||
])
|
||||
);
|
||||
|
||||
// Persist suggestions
|
||||
if ($this->confirm('Persist these suggestions?', true)) {
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$orchestrator->persistSuggestion($suggestion);
|
||||
}
|
||||
$this->info('Suggestions persisted.');
|
||||
}
|
||||
}
|
||||
|
||||
// Assess risks
|
||||
$risks = $orchestrator->assessRisks($context);
|
||||
|
||||
if ($risks->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->info("Identified {$risks->count()} risks");
|
||||
$this->table(
|
||||
['Title', 'Level', 'Score', 'Impact'],
|
||||
$risks->map(fn ($r) => [
|
||||
substr($r->title, 0, 40),
|
||||
$r->level,
|
||||
number_format($r->score * 100, 0).'%',
|
||||
$r->impactValue ? '$'.number_format($r->impactValue) : '-',
|
||||
])
|
||||
);
|
||||
|
||||
if ($this->confirm('Persist these risks?', true)) {
|
||||
foreach ($risks as $risk) {
|
||||
$orchestrator->persistRisk($risk);
|
||||
}
|
||||
$this->info('Risks persisted.');
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Error: {$e->getMessage()}");
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line($e->getTraceAsString());
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
app/Console/Commands/AutoTuneMissingBrandProfiles.php
Normal file
98
app/Console/Commands/AutoTuneMissingBrandProfiles.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Services\AI\BrandAiProfileGenerator;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AutoTuneMissingBrandProfiles extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'brand-ai-profiles:auto-tune-missing
|
||||
{--limit=0 : Maximum number of brands to process (0 = all)}
|
||||
{--dry-run : Show what would be processed without actually generating}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Auto-generate AI profiles for brands that don\'t have one';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(BrandAiProfileGenerator $generator): int
|
||||
{
|
||||
$this->info('Finding brands without AI profiles...');
|
||||
|
||||
// Get brands that don't have an AI profile
|
||||
$query = Brand::whereDoesntHave('aiProfile')
|
||||
->whereHas('business', function ($q) {
|
||||
$q->where('status', 'approved');
|
||||
})
|
||||
->orderBy('name');
|
||||
|
||||
$limit = (int) $this->option('limit');
|
||||
if ($limit > 0) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$brands = $query->get();
|
||||
|
||||
if ($brands->isEmpty()) {
|
||||
$this->info('All brands already have AI profiles. Nothing to do.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$brands->count()} brand(s) without AI profiles.");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('DRY RUN - No profiles will be generated.');
|
||||
$this->table(
|
||||
['ID', 'Brand Name', 'Business', 'Voice', 'Audience'],
|
||||
$brands->map(fn ($b) => [
|
||||
$b->id,
|
||||
$b->name,
|
||||
$b->business?->name ?? 'N/A',
|
||||
$b->brand_voice ?? 'N/A',
|
||||
$b->brand_audience ?? 'N/A',
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($brands->count());
|
||||
$bar->start();
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
try {
|
||||
$generator->generateForBrand($brand);
|
||||
$success++;
|
||||
$this->line(" <info>✓</info> {$brand->name}");
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$this->line(" <error>✗</error> {$brand->name}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->info("Completed: {$success} profiles generated, {$failed} failed.");
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CalculateDashboardMetrics;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CalculateDashboardMetricsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dashboard:calculate-metrics
|
||||
{--business= : Specific business ID to calculate (optional)}
|
||||
{--sync : Run synchronously instead of queuing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Pre-calculate dashboard metrics and store in Redis';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
$this->error("Business {$businessId} not found");
|
||||
|
||||
return 1;
|
||||
}
|
||||
$this->info("Calculating metrics for business: {$business->name}");
|
||||
} else {
|
||||
$count = Business::where('type', 'seller')->where('status', 'approved')->count();
|
||||
$this->info("Calculating metrics for {$count} businesses");
|
||||
}
|
||||
|
||||
$job = new CalculateDashboardMetrics($businessId ? (int) $businessId : null);
|
||||
|
||||
if ($sync) {
|
||||
$this->info('Running synchronously...');
|
||||
$job->handle();
|
||||
$this->info('Done!');
|
||||
} else {
|
||||
CalculateDashboardMetrics::dispatch($businessId ? (int) $businessId : null);
|
||||
$this->info('Job dispatched to queue');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
68
app/Console/Commands/ClearVarieties.php
Normal file
68
app/Console/Commands/ClearVarieties.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ClearVarieties extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'products:clear-varieties
|
||||
{--brand-id= : Limit to a specific brand ID}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clear all parent_product_id links (undo variety relationships)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$brandId = $this->option('brand-id');
|
||||
$force = $this->option('force');
|
||||
|
||||
$query = Product::query()->whereNotNull('parent_product_id');
|
||||
|
||||
if ($brandId) {
|
||||
$query->where('brand_id', $brandId);
|
||||
$this->info("Filtering to brand_id: {$brandId}");
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->info('No products have parent_product_id set. Nothing to clear.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$scope = $brandId ? "brand #{$brandId}" : 'all brands';
|
||||
$this->warn("This will clear parent_product_id for {$count} products in {$scope}.");
|
||||
|
||||
if (! $force && ! $this->confirm('Are you sure you want to continue?')) {
|
||||
$this->info('Operation cancelled.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
$updated = Product::query()
|
||||
->whereNotNull('parent_product_id')
|
||||
->when($brandId, fn ($q) => $q->where('brand_id', $brandId))
|
||||
->update(['parent_product_id' => null]);
|
||||
|
||||
$this->info("✓ Cleared parent_product_id for {$updated} products.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/CreateSystemMenusCommand.php
Normal file
59
app/Console/Commands/CreateSystemMenusCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Menu;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CreateSystemMenusCommand extends Command
|
||||
{
|
||||
protected $signature = 'menus:create-system {--brand= : Specific brand ID to create menus for}';
|
||||
|
||||
protected $description = 'Create system menus (Available Now, Promotions, Daily Deals, Best Sellers) for all brands';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$brandId = $this->option('brand');
|
||||
|
||||
if ($brandId) {
|
||||
$brands = Brand::where('id', $brandId)->get();
|
||||
if ($brands->isEmpty()) {
|
||||
$this->error("Brand with ID {$brandId} not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$brands = Brand::all();
|
||||
}
|
||||
|
||||
$this->info('Creating system menus for '.count($brands).' brand(s)...');
|
||||
|
||||
$bar = $this->output->createProgressBar(count($brands));
|
||||
$bar->start();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$menus = Menu::createSystemMenusForBrand($brand);
|
||||
|
||||
foreach ($menus as $menu) {
|
||||
if ($menu->wasRecentlyCreated) {
|
||||
$created++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->info("Done! Created {$created} new system menus, {$skipped} already existed.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
@@ -40,16 +40,16 @@ class CreateTestInvoiceForApproval extends Command
|
||||
|
||||
$this->info("✓ Using buyer: {$buyer->name} ({$buyer->email})");
|
||||
|
||||
// Get any company
|
||||
$company = Company::first();
|
||||
// Get any business
|
||||
$business = Business::first();
|
||||
|
||||
if (! $company) {
|
||||
$this->error('No company found. Please seed database first.');
|
||||
if (! $business) {
|
||||
$this->error('No business found. Please seed database first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("✓ Company: {$company->name}");
|
||||
$this->info("✓ Business: {$business->name}");
|
||||
|
||||
// Get some products that have inventory
|
||||
$products = Product::whereHas('inventoryItems', function ($q) {
|
||||
@@ -64,7 +64,7 @@ class CreateTestInvoiceForApproval extends Command
|
||||
$this->info("✓ Found {$products->count()} products for order");
|
||||
|
||||
// Create order
|
||||
$order = $this->createOrder($buyer, $company);
|
||||
$order = $this->createOrder($buyer, $business);
|
||||
$this->info("✓ Created order: {$order->order_number}");
|
||||
|
||||
// Add items to order
|
||||
@@ -127,11 +127,11 @@ class CreateTestInvoiceForApproval extends Command
|
||||
/**
|
||||
* Create a test order.
|
||||
*/
|
||||
protected function createOrder(User $buyer, Company $company): Order
|
||||
protected function createOrder(User $buyer, Business $business): Order
|
||||
{
|
||||
return Order::create([
|
||||
'order_number' => 'ORD-TEST-'.strtoupper(substr(md5(time()), 0, 10)),
|
||||
'company_id' => $company->id,
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $buyer->id,
|
||||
'subtotal' => 0, // Will be calculated
|
||||
'tax' => 0,
|
||||
|
||||
85
app/Console/Commands/DevSetup.php
Normal file
85
app/Console/Commands/DevSetup.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class DevSetup extends Command
|
||||
{
|
||||
protected $signature = 'dev:setup
|
||||
{--fresh : Drop all tables and re-run migrations (DESTRUCTIVE - requires confirmation)}
|
||||
{--skip-seed : Skip seeding dev fixtures}';
|
||||
|
||||
protected $description = 'Set up local development environment with migrations and dev fixtures';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (app()->environment('production')) {
|
||||
$this->error('This command cannot be run in production!');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Setting up development environment...');
|
||||
$this->newLine();
|
||||
|
||||
// Run migrations
|
||||
if ($this->option('fresh')) {
|
||||
$this->newLine();
|
||||
$this->error('WARNING: --fresh will DELETE ALL DATA in the database!');
|
||||
$this->warn('This includes development data being preserved for production release.');
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Are you SURE you want to drop all tables and lose all data?', false)) {
|
||||
$this->info('Aborted. Running normal migrations instead...');
|
||||
$this->call('migrate');
|
||||
} else {
|
||||
$this->warn('Dropping all tables and re-running migrations...');
|
||||
$this->call('migrate:fresh');
|
||||
}
|
||||
} else {
|
||||
$this->info('Running migrations...');
|
||||
$this->call('migrate');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Seed dev fixtures
|
||||
if (! $this->option('skip-seed')) {
|
||||
if ($this->confirm('Seed development fixtures (users, businesses, brands)?', true)) {
|
||||
$this->info('Seeding development fixtures...');
|
||||
$this->call('db:seed', ['--class' => 'ProductionSyncSeeder']);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Seeding dev suites and plans...');
|
||||
$this->call('db:seed', ['--class' => 'DevSuitesSeeder']);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Seeding brand profiles...');
|
||||
$this->call('db:seed', ['--class' => 'BrandProfilesSeeder']);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Seeding orchestrator profiles...');
|
||||
$this->call('orchestrator:seed-brand-profiles', ['--force' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Development setup complete!');
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Credential', 'Email', 'Password'],
|
||||
[
|
||||
['Super Admin', 'admin@cannabrands.com', 'password'],
|
||||
['Admin', 'admin@example.com', 'password'],
|
||||
['Seller', 'seller@example.com', 'password'],
|
||||
['Buyer', 'buyer@example.com', 'password'],
|
||||
['Cannabrands Owner', 'cannabrands-owner@example.com', 'password'],
|
||||
['Brand Manager', 'brand-manager@example.com', 'password'],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
|
||||
*
|
||||
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
|
||||
*/
|
||||
class DispatchScheduledCampaigns extends Command
|
||||
{
|
||||
protected $signature = 'marketing:dispatch-scheduled-campaigns';
|
||||
|
||||
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$campaigns = MarketingCampaign::readyToSend()->get();
|
||||
|
||||
if ($campaigns->isEmpty()) {
|
||||
$this->info('No scheduled campaigns ready to send.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
|
||||
|
||||
foreach ($campaigns as $campaign) {
|
||||
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
|
||||
|
||||
SendMarketingCampaignJob::dispatch($campaign->id);
|
||||
}
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
278
app/Console/Commands/ExportBusinessConfigSeeder.php
Normal file
278
app/Console/Commands/ExportBusinessConfigSeeder.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Exports current business configurations to a seeder file.
|
||||
*
|
||||
* This captures the current admin settings for all businesses:
|
||||
* - Suite assignments (business_suite pivot)
|
||||
* - Enterprise plan status
|
||||
* - Module flags
|
||||
* - Usage limits
|
||||
*
|
||||
* The generated seeder is IDEMPOTENT - it updates existing records
|
||||
* without deleting data.
|
||||
*/
|
||||
class ExportBusinessConfigSeeder extends Command
|
||||
{
|
||||
protected $signature = 'export:business-config-seeder
|
||||
{--output= : Output file path (default: database/seeders/BusinessConfigSeeder.php)}
|
||||
{--business= : Export only specific business by slug}';
|
||||
|
||||
protected $description = 'Export current business configurations to a seeder file';
|
||||
|
||||
/**
|
||||
* Configuration fields to export.
|
||||
*/
|
||||
private array $configFields = [
|
||||
// Enterprise plan
|
||||
'is_enterprise_plan',
|
||||
|
||||
// Legacy module flags
|
||||
'has_marketing',
|
||||
'has_analytics',
|
||||
'has_inventory',
|
||||
'has_manufacturing',
|
||||
'has_processing',
|
||||
'has_compliance',
|
||||
'has_crm',
|
||||
'has_buyer_intelligence',
|
||||
'copilot_enabled',
|
||||
'has_conversations',
|
||||
|
||||
// Legacy suite flags (kept for compatibility)
|
||||
'has_sales_suite',
|
||||
'has_processing_suite',
|
||||
'has_manufacturing_suite',
|
||||
'has_delivery_suite',
|
||||
'has_management_suite',
|
||||
'has_enterprise_suite',
|
||||
|
||||
// Navigation
|
||||
'use_suite_navigation',
|
||||
|
||||
// Usage limits
|
||||
'sales_suite_brand_limit',
|
||||
'sales_suite_sku_limit_per_brand',
|
||||
'sales_suite_menu_limit_per_brand',
|
||||
'sales_suite_message_limit_per_brand',
|
||||
'sales_suite_ai_credits_per_brand',
|
||||
'sales_suite_contact_limit_per_brand',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$outputPath = $this->option('output') ?? database_path('seeders/BusinessConfigSeeder.php');
|
||||
$specificBusiness = $this->option('business');
|
||||
|
||||
$this->info('Exporting business configurations...');
|
||||
|
||||
// Get businesses to export
|
||||
$query = Business::with('suites')->orderBy('name');
|
||||
if ($specificBusiness) {
|
||||
$query->where('slug', $specificBusiness);
|
||||
}
|
||||
$businesses = $query->get();
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->error('No businesses found to export.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Found {$businesses->count()} businesses to export.");
|
||||
|
||||
// Build the seeder content
|
||||
$seederContent = $this->generateSeederContent($businesses);
|
||||
|
||||
// Write to file
|
||||
File::put($outputPath, $seederContent);
|
||||
|
||||
$this->info("Seeder exported to: {$outputPath}");
|
||||
$this->newLine();
|
||||
|
||||
// Show summary
|
||||
$this->table(
|
||||
['Business', 'Type', 'Enterprise', 'Suites'],
|
||||
$businesses->map(fn ($b) => [
|
||||
$b->name,
|
||||
$b->type,
|
||||
$b->is_enterprise_plan ? 'Yes' : 'No',
|
||||
$b->suites->pluck('key')->implode(', ') ?: '-',
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function generateSeederContent($businesses): string
|
||||
{
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
$configs = [];
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$config = [
|
||||
'slug' => $business->slug,
|
||||
'name' => $business->name,
|
||||
'suites' => $business->suites->pluck('key')->toArray(),
|
||||
];
|
||||
|
||||
// Add only non-null/non-default config values
|
||||
foreach ($this->configFields as $field) {
|
||||
$value = $business->{$field};
|
||||
if ($value !== null && $value !== false && $value !== 0) {
|
||||
$config[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$configs[] = $config;
|
||||
}
|
||||
|
||||
$configsPhp = $this->arrayToPhp($configs, 2);
|
||||
|
||||
return <<<PHP
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Suite;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* BusinessConfigSeeder - Sets business configurations and suite assignments.
|
||||
*
|
||||
* Auto-generated on: {$timestamp}
|
||||
* Generated by: php artisan export:business-config-seeder
|
||||
*
|
||||
* This seeder is IDEMPOTENT - it updates existing records without deleting data.
|
||||
* Run with: php artisan db:seed --class=BusinessConfigSeeder
|
||||
*/
|
||||
class BusinessConfigSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Business configurations exported from admin settings.
|
||||
*/
|
||||
private array \$configs = {$configsPhp};
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
\$this->command->info('Applying business configurations...');
|
||||
|
||||
// Cache suite IDs by key for efficiency
|
||||
\$suiteIds = Suite::pluck('id', 'key')->toArray();
|
||||
|
||||
\$updated = 0;
|
||||
\$skipped = 0;
|
||||
|
||||
foreach (\$this->configs as \$config) {
|
||||
\$business = Business::where('slug', \$config['slug'])->first();
|
||||
|
||||
if (! \$business) {
|
||||
\$this->command->warn(" Skipping: {\$config['name']} (slug: {\$config['slug']}) - not found");
|
||||
\$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract suites from config
|
||||
\$suites = \$config['suites'] ?? [];
|
||||
unset(\$config['suites'], \$config['slug'], \$config['name']);
|
||||
|
||||
// Update business config fields
|
||||
\$business->update(\$config);
|
||||
|
||||
// Sync suite assignments (without detaching extras)
|
||||
\$suiteIdsToSync = collect(\$suites)
|
||||
->map(fn (\$key) => \$suiteIds[\$key] ?? null)
|
||||
->filter()
|
||||
->toArray();
|
||||
|
||||
if (! empty(\$suiteIdsToSync)) {
|
||||
\$business->suites()->syncWithoutDetaching(\$suiteIdsToSync);
|
||||
}
|
||||
|
||||
\$updated++;
|
||||
}
|
||||
|
||||
\$this->command->info(" Updated: {\$updated} businesses");
|
||||
if (\$skipped > 0) {
|
||||
\$this->command->warn(" Skipped: {\$skipped} businesses (not found)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PHP array to formatted PHP code string.
|
||||
*/
|
||||
private function arrayToPhp(array $array, int $indent = 1): string
|
||||
{
|
||||
$spaces = str_repeat(' ', $indent);
|
||||
$closingSpaces = str_repeat(' ', $indent - 1);
|
||||
|
||||
$items = [];
|
||||
foreach ($array as $key => $value) {
|
||||
$keyStr = is_int($key) ? '' : "'{$key}' => ";
|
||||
|
||||
if (is_array($value)) {
|
||||
if (empty($value)) {
|
||||
// Empty array
|
||||
$valueStr = '[]';
|
||||
} elseif ($this->isSequentialArray($value) && $this->isSimpleArray($value)) {
|
||||
// Simple sequential array - inline
|
||||
$valueStr = '['.implode(', ', array_map(fn ($v) => $this->valueToPhp($v), $value)).']';
|
||||
} else {
|
||||
// Complex array - multiline
|
||||
$valueStr = $this->arrayToPhp($value, $indent + 1);
|
||||
}
|
||||
} else {
|
||||
$valueStr = $this->valueToPhp($value);
|
||||
}
|
||||
|
||||
$items[] = $spaces.$keyStr.$valueStr;
|
||||
}
|
||||
|
||||
return "[\n".implode(",\n", $items).",\n{$closingSpaces}]";
|
||||
}
|
||||
|
||||
private function valueToPhp($value): string
|
||||
{
|
||||
if (is_null($value)) {
|
||||
return 'null';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return "'".addslashes($value)."'";
|
||||
}
|
||||
|
||||
private function isSequentialArray(array $array): bool
|
||||
{
|
||||
return array_keys($array) === range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
private function isSimpleArray(array $array): bool
|
||||
{
|
||||
foreach ($array as $value) {
|
||||
if (is_array($value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
176
app/Console/Commands/ExportCannabrandsData.php
Normal file
176
app/Console/Commands/ExportCannabrandsData.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Export Cannabrands data to PostgreSQL SQL dumps.
|
||||
*
|
||||
* This command exports current database data to SQL files in database/dumps/
|
||||
* for later restoration without requiring a MySQL connection.
|
||||
*
|
||||
* Usage:
|
||||
* - Configure your local database with the desired settings
|
||||
* - Run: php artisan db:export-cannabrands
|
||||
* - Commit the updated dump files (if they should be in git)
|
||||
*/
|
||||
class ExportCannabrandsData extends Command
|
||||
{
|
||||
protected $signature = 'db:export-cannabrands
|
||||
{--tables= : Comma-separated list of specific tables to export}';
|
||||
|
||||
protected $description = 'Export Cannabrands data to PostgreSQL SQL dumps';
|
||||
|
||||
// Tables to export (same as restore command)
|
||||
protected array $tables = [
|
||||
'strains',
|
||||
'product_categories',
|
||||
'businesses',
|
||||
'users',
|
||||
'brands',
|
||||
'locations',
|
||||
'contacts',
|
||||
'products',
|
||||
'orders',
|
||||
'order_items',
|
||||
'invoices',
|
||||
'business_user',
|
||||
'brand_user',
|
||||
'model_has_roles',
|
||||
'ai_settings',
|
||||
'orchestrator_sales_configs',
|
||||
'orchestrator_marketing_configs',
|
||||
];
|
||||
|
||||
protected string $dumpsPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dumpsPath = database_path('dumps');
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Exporting Cannabrands data to SQL dumps...');
|
||||
|
||||
// Create dumps directory if it doesn't exist
|
||||
if (! is_dir($this->dumpsPath)) {
|
||||
mkdir($this->dumpsPath, 0755, true);
|
||||
$this->info("Created dumps directory: {$this->dumpsPath}");
|
||||
}
|
||||
|
||||
// Determine which tables to export
|
||||
$tablesToExport = $this->tables;
|
||||
if ($this->option('tables')) {
|
||||
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
|
||||
$tablesToExport = array_intersect($this->tables, $requestedTables);
|
||||
|
||||
if (empty($tablesToExport)) {
|
||||
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Get database connection info
|
||||
$database = config('database.connections.pgsql.database');
|
||||
$username = config('database.connections.pgsql.username');
|
||||
$host = config('database.connections.pgsql.host');
|
||||
$port = config('database.connections.pgsql.port');
|
||||
|
||||
$exported = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($tablesToExport as $table) {
|
||||
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
|
||||
$this->line("Exporting {$table}...");
|
||||
|
||||
// Build pg_dump command
|
||||
// Using --column-inserts for portable SQL
|
||||
// Using --on-conflict-do-nothing for idempotent inserts
|
||||
$pgDumpArgs = sprintf(
|
||||
'--data-only --column-inserts --on-conflict-do-nothing --table=%s %s',
|
||||
escapeshellarg($table),
|
||||
escapeshellarg($database)
|
||||
);
|
||||
|
||||
// pg_dump with connection info
|
||||
// Works both inside Sail container (pgsql hostname) and natively
|
||||
$command = sprintf(
|
||||
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s',
|
||||
escapeshellarg(config('database.connections.pgsql.password')),
|
||||
escapeshellarg($host),
|
||||
escapeshellarg($port),
|
||||
escapeshellarg($username),
|
||||
$pgDumpArgs
|
||||
);
|
||||
|
||||
$result = Process::run($command);
|
||||
|
||||
if ($result->successful()) {
|
||||
// Extract only INSERT statements (remove pg_dump headers and SET commands)
|
||||
// Handle multi-line INSERTs by looking for the ending pattern
|
||||
$output = $result->output();
|
||||
$lines = explode("\n", $output);
|
||||
$inserts = [];
|
||||
$currentInsert = '';
|
||||
$inInsert = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), 'INSERT INTO')) {
|
||||
// Start of new INSERT
|
||||
$inInsert = true;
|
||||
$currentInsert = $line;
|
||||
|
||||
// Check if this INSERT ends on same line
|
||||
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
|
||||
$inserts[] = $currentInsert;
|
||||
$currentInsert = '';
|
||||
$inInsert = false;
|
||||
}
|
||||
} elseif ($inInsert) {
|
||||
// Continuation of current INSERT (multi-line due to embedded newlines in data)
|
||||
// We need to escape the actual newline in the SQL string value
|
||||
// Since we're inside a string value, replace with \n escape sequence
|
||||
$currentInsert .= "\n".$line;
|
||||
|
||||
// Check if this line ends the INSERT
|
||||
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
|
||||
$inserts[] = $currentInsert;
|
||||
$currentInsert = '';
|
||||
$inInsert = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last one if it didn't end properly
|
||||
if (! empty($currentInsert)) {
|
||||
$inserts[] = $currentInsert;
|
||||
}
|
||||
|
||||
$cleanOutput = implode("\n", $inserts);
|
||||
file_put_contents($dumpFile, $cleanOutput);
|
||||
|
||||
$this->info(' -> Exported '.count($inserts)." rows to {$table}.sql");
|
||||
$exported++;
|
||||
} else {
|
||||
$this->error("Failed to export {$table}: ".$result->errorOutput());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Exported {$exported} tables. Errors: {$errors}");
|
||||
|
||||
if ($exported > 0) {
|
||||
$this->newLine();
|
||||
$this->info('To restore this data on another machine:');
|
||||
$this->line(' php artisan db:restore-cannabrands');
|
||||
}
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
635
app/Console/Commands/GenerateMarketingOrchestratorTasks.php
Normal file
635
app/Console/Commands/GenerateMarketingOrchestratorTasks.php
Normal file
@@ -0,0 +1,635 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\MenuViewEvent;
|
||||
use App\Models\OrchestratorMarketingConfig;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Marketing Orchestrator - "Head of Marketing" automated playbooks.
|
||||
*
|
||||
* Generates actionable tasks for marketing teams based on engagement signals:
|
||||
* - Campaign blast candidates (high-engagement customers)
|
||||
* - Segment refinement suggestions
|
||||
* - Launch announcements for new brands/SKUs
|
||||
* - Holiday campaign opportunities
|
||||
* - New SKU feature suggestions
|
||||
* - Nurture sequence recommendations
|
||||
*/
|
||||
class GenerateMarketingOrchestratorTasks extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:generate-marketing-tasks
|
||||
{--business= : Limit to specific business ID}
|
||||
{--playbook= : Run only specific playbook}
|
||||
{--dry-run : Show what would be created without creating}';
|
||||
|
||||
protected $description = 'Generate Marketing Orchestrator tasks from automated playbooks (Head of Marketing)';
|
||||
|
||||
private int $tasksCreated = 0;
|
||||
|
||||
private bool $dryRun = false;
|
||||
|
||||
private OrchestratorMarketingConfig $config;
|
||||
|
||||
/**
|
||||
* Per-brand task counter for throttling.
|
||||
*/
|
||||
private array $brandTaskCount = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_GENERATE_MARKETING_TASKS);
|
||||
|
||||
$this->dryRun = $this->option('dry-run');
|
||||
$specificBusinessId = $this->option('business');
|
||||
$specificPlaybook = $this->option('playbook');
|
||||
|
||||
$this->info('📣 Marketing Orchestrator - Generating tasks...');
|
||||
if ($this->dryRun) {
|
||||
$this->warn(' (DRY RUN - no tasks will be created)');
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
// Load global config
|
||||
try {
|
||||
$this->config = OrchestratorMarketingConfig::getGlobal();
|
||||
} catch (\Exception $e) {
|
||||
$this->config = new OrchestratorMarketingConfig;
|
||||
}
|
||||
|
||||
$this->line(" Throttle: max {$this->config->getMaxTasksPerBrandPerRun()} tasks/brand/run");
|
||||
$this->line(" Cooldown: {$this->config->getCooldownDays()} days between touches");
|
||||
$this->newLine();
|
||||
|
||||
// Get seller businesses
|
||||
$businessQuery = Business::query()->where('type', '!=', 'buyer');
|
||||
|
||||
if ($specificBusinessId) {
|
||||
$businessQuery->where('id', $specificBusinessId);
|
||||
}
|
||||
|
||||
$businesses = $businessQuery->get();
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->brandTaskCount = [];
|
||||
$this->line("📊 Processing: {$business->name}");
|
||||
|
||||
// Run playbooks based on filter or all
|
||||
if (! $specificPlaybook || $specificPlaybook === 'campaign-blast') {
|
||||
$count = $this->playbook1CampaignBlastCandidates($business);
|
||||
$this->line(" ├─ Playbook 1 (Campaign Blast): {$count} tasks");
|
||||
}
|
||||
|
||||
if (! $specificPlaybook || $specificPlaybook === 'segment-refinement') {
|
||||
$count = $this->playbook2SegmentRefinement($business);
|
||||
$this->line(" ├─ Playbook 2 (Segment Refinement): {$count} tasks");
|
||||
}
|
||||
|
||||
if (! $specificPlaybook || $specificPlaybook === 'launch-announcement') {
|
||||
$count = $this->playbook3LaunchAnnouncement($business);
|
||||
$this->line(" ├─ Playbook 3 (Launch Announcement): {$count} tasks");
|
||||
}
|
||||
|
||||
if (! $specificPlaybook || $specificPlaybook === 'holiday-campaign') {
|
||||
$count = $this->playbook4HolidayCampaign($business);
|
||||
$this->line(" ├─ Playbook 4 (Holiday Campaign): {$count} tasks");
|
||||
}
|
||||
|
||||
if (! $specificPlaybook || $specificPlaybook === 'new-sku-feature') {
|
||||
$count = $this->playbook5NewSkuFeature($business);
|
||||
$this->line(" ├─ Playbook 5 (New SKU Feature): {$count} tasks");
|
||||
}
|
||||
|
||||
if (! $specificPlaybook || $specificPlaybook === 'nurture-sequence') {
|
||||
$count = $this->playbook6NurtureSequence($business);
|
||||
$this->line(" └─ Playbook 6 (Nurture Sequence): {$count} tasks");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->info("✅ Complete! Total marketing tasks created: {$this->tasksCreated}");
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_GENERATE_MARKETING_TASKS, [
|
||||
'tasks_created' => $this->tasksCreated,
|
||||
'businesses_processed' => $businesses->count(),
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook 1: Campaign Blast Candidates
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find high-engagement customers who haven't received marketing in a while.
|
||||
*/
|
||||
private function playbook1CampaignBlastCandidates(Business $business): int
|
||||
{
|
||||
if (! $this->config->isCampaignBlastEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$settings = $this->config->getCampaignBlastSettings();
|
||||
$brands = $business->brands;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
if (! $this->canCreateTaskForBrand($brand->id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find customers with high engagement (menu views + orders)
|
||||
$engagedCustomers = $this->getEngagedCustomers($brand, $settings['min_engagement_score']);
|
||||
|
||||
foreach ($engagedCustomers as $customer) {
|
||||
// Check if already has pending marketing task
|
||||
if (OrchestratorTask::existsPending(
|
||||
$business->id,
|
||||
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE,
|
||||
$customer->id
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check cooldown - no marketing sends in X days
|
||||
$lastMarketingSend = SendMenuLog::where('business_id', $business->id)
|
||||
->where('customer_id', $customer->id)
|
||||
->whereNotNull('orchestrator_task_id')
|
||||
->where('sent_at', '>=', now()->subDays($settings['days_since_last_send']))
|
||||
->exists();
|
||||
|
||||
if ($lastMarketingSend) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createTask([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $brand->id,
|
||||
'customer_id' => $customer->id,
|
||||
'type' => OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE,
|
||||
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'priority' => OrchestratorTask::PRIORITY_NORMAL,
|
||||
'due_at' => now()->addDays(3),
|
||||
'payload' => [
|
||||
'customer_name' => $customer->name,
|
||||
'brand_name' => $brand->name,
|
||||
'engagement_score' => $customer->engagement_score ?? 0,
|
||||
'reason' => "High-engagement customer ({$customer->name}) ready for campaign blast for {$brand->name}",
|
||||
'suggested_action' => 'Include in next email campaign or menu blast',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
$this->recordTaskForBrand($brand->id);
|
||||
|
||||
if ($count >= $settings['max_tasks_per_run']) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook 2: Segment Refinement
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find brands with many customers but no defined segments.
|
||||
*/
|
||||
private function playbook2SegmentRefinement(Business $business): int
|
||||
{
|
||||
if (! $this->config->isSegmentRefinementEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$settings = $this->config->getSegmentRefinementSettings();
|
||||
$brands = $business->brands;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
// Check if already has pending segment task
|
||||
if (OrchestratorTask::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->where('type', OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count unique customers who have engaged with this brand
|
||||
$customerCount = MenuViewEvent::where('brand_id', $brand->id)
|
||||
->whereNotNull('customer_id')
|
||||
->distinct('customer_id')
|
||||
->count('customer_id');
|
||||
|
||||
if ($customerCount < $settings['min_customers']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createTask([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $brand->id,
|
||||
'type' => OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT,
|
||||
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'priority' => OrchestratorTask::PRIORITY_LOW,
|
||||
'due_at' => now()->addWeek(),
|
||||
'payload' => [
|
||||
'brand_name' => $brand->name,
|
||||
'customer_count' => $customerCount,
|
||||
'reason' => "{$brand->name} has {$customerCount} engaged customers - consider creating marketing segments",
|
||||
'suggested_action' => 'Define customer segments (VIP, Regular, New) for targeted campaigns',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook 3: Launch Announcement
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Suggest launch campaigns for new brands.
|
||||
*/
|
||||
private function playbook3LaunchAnnouncement(Business $business): int
|
||||
{
|
||||
if (! $this->config->isLaunchAnnouncementEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$settings = $this->config->getLaunchAnnouncementSettings();
|
||||
|
||||
// Find brands created recently
|
||||
$newBrands = $business->brands()
|
||||
->where('created_at', '>=', now()->subDays($settings['days_new']))
|
||||
->get();
|
||||
|
||||
foreach ($newBrands as $brand) {
|
||||
// Check if already has pending launch task
|
||||
if (OrchestratorTask::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->where('type', OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createTask([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $brand->id,
|
||||
'type' => OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT,
|
||||
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'priority' => OrchestratorTask::PRIORITY_HIGH,
|
||||
'due_at' => now()->addDays(2),
|
||||
'payload' => [
|
||||
'brand_name' => $brand->name,
|
||||
'brand_created_at' => $brand->created_at->toDateString(),
|
||||
'days_since_launch' => now()->diffInDays($brand->created_at),
|
||||
'reason' => "New brand '{$brand->name}' launched - create announcement campaign",
|
||||
'suggested_action' => 'Send launch email to customer list, update website banners',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook 4: Holiday Campaign
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Suggest holiday campaigns based on upcoming holidays.
|
||||
*/
|
||||
private function playbook4HolidayCampaign(Business $business): int
|
||||
{
|
||||
if (! $this->config->isHolidayCampaignEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$settings = $this->config->getHolidayCampaignSettings();
|
||||
|
||||
// Define holidays relevant to the cannabis industry
|
||||
$holidays = $this->getUpcomingHolidays($settings['days_before']);
|
||||
|
||||
if (empty($holidays)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($holidays as $holiday) {
|
||||
// Check if already has pending holiday task for this business
|
||||
$existingTask = OrchestratorTask::where('business_id', $business->id)
|
||||
->where('type', OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->whereJsonContains('payload->holiday_name', $holiday['name'])
|
||||
->exists();
|
||||
|
||||
if ($existingTask) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createTask([
|
||||
'business_id' => $business->id,
|
||||
'type' => OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN,
|
||||
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'priority' => OrchestratorTask::PRIORITY_HIGH,
|
||||
'due_at' => $holiday['date']->copy()->subDays(7),
|
||||
'payload' => [
|
||||
'holiday_name' => $holiday['name'],
|
||||
'holiday_date' => $holiday['date']->toDateString(),
|
||||
'days_until' => now()->diffInDays($holiday['date']),
|
||||
'reason' => "{$holiday['name']} is in {$holiday['days_until']} days - prepare campaign",
|
||||
'suggested_action' => $holiday['suggested_action'] ?? 'Create themed email campaign and promotions',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook 5: New SKU Feature
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Suggest featuring new SKUs in marketing materials.
|
||||
*/
|
||||
private function playbook5NewSkuFeature(Business $business): int
|
||||
{
|
||||
if (! $this->config->isNewSkuFeatureEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$settings = $this->config->getNewSkuFeatureSettings();
|
||||
$brands = $business->brands;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
// Find new products for this brand
|
||||
$newProducts = Product::where('brand_id', $brand->id)
|
||||
->where('created_at', '>=', now()->subDays($settings['days_new']))
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($newProducts->count() < $settings['min_products']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already has pending new SKU task
|
||||
if (OrchestratorTask::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->where('type', OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productNames = $newProducts->pluck('name')->take(5)->toArray();
|
||||
|
||||
$this->createTask([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $brand->id,
|
||||
'type' => OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE,
|
||||
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'priority' => OrchestratorTask::PRIORITY_NORMAL,
|
||||
'due_at' => now()->addDays(5),
|
||||
'payload' => [
|
||||
'brand_name' => $brand->name,
|
||||
'new_product_count' => $newProducts->count(),
|
||||
'product_names' => $productNames,
|
||||
'reason' => "{$brand->name} has {$newProducts->count()} new products to feature",
|
||||
'suggested_action' => 'Create "New Arrivals" email or update product showcase',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook 6: Nurture Sequence
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Suggest nurture sequences for new customers.
|
||||
*/
|
||||
private function playbook6NurtureSequence(Business $business): int
|
||||
{
|
||||
if (! $this->config->isNurtureSequenceEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$settings = $this->config->getNurtureSequenceSettings();
|
||||
|
||||
if (! Schema::hasTable('orders')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$brandIds = $business->brands()->pluck('id');
|
||||
if ($brandIds->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find customers with first order in date range and limited orders
|
||||
$nurtureCandiates = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->whereIn('products.brand_id', $brandIds)
|
||||
->select('orders.business_id as customer_id')
|
||||
->groupBy('orders.business_id')
|
||||
->havingRaw('COUNT(DISTINCT orders.id) <= ?', [$settings['max_orders']])
|
||||
->havingRaw('MIN(orders.created_at) <= ?', [now()->subDays($settings['days_since_first_order'])])
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
foreach ($nurtureCandiates as $candidate) {
|
||||
// Check if already has pending nurture task
|
||||
if (OrchestratorTask::existsPending(
|
||||
$business->id,
|
||||
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE,
|
||||
$candidate->customer_id
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$customer = Business::find($candidate->customer_id);
|
||||
if (! $customer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createTask([
|
||||
'business_id' => $business->id,
|
||||
'customer_id' => $customer->id,
|
||||
'type' => OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE,
|
||||
'owner_role' => OrchestratorTask::ROLE_MARKETING,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'priority' => OrchestratorTask::PRIORITY_LOW,
|
||||
'due_at' => now()->addWeek(),
|
||||
'payload' => [
|
||||
'customer_name' => $customer->name,
|
||||
'reason' => "New customer '{$customer->name}' ready for nurture sequence",
|
||||
'suggested_action' => 'Add to welcome email series or educational content drip',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get engaged customers for a brand.
|
||||
*/
|
||||
private function getEngagedCustomers(Brand $brand, int $minScore): \Illuminate\Support\Collection
|
||||
{
|
||||
// Get customers who have viewed menus multiple times
|
||||
return MenuViewEvent::where('brand_id', $brand->id)
|
||||
->whereNotNull('customer_id')
|
||||
->select('customer_id', DB::raw('COUNT(*) as view_count'))
|
||||
->groupBy('customer_id')
|
||||
->havingRaw('COUNT(*) >= ?', [3])
|
||||
->orderByDesc('view_count')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$customer = Business::find($item->customer_id);
|
||||
if ($customer) {
|
||||
$customer->engagement_score = min(100, $item->view_count * 10);
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->filter(fn ($c) => ($c->engagement_score ?? 0) >= $minScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming holidays.
|
||||
*/
|
||||
private function getUpcomingHolidays(int $daysAhead): array
|
||||
{
|
||||
$holidays = [
|
||||
['name' => '4/20', 'date' => now()->setMonth(4)->setDay(20), 'suggested_action' => 'Major cannabis holiday - plan big promotional campaign'],
|
||||
['name' => 'Green Wednesday', 'date' => $this->getGreenWednesday(), 'suggested_action' => 'Day before Thanksgiving - high sales day'],
|
||||
['name' => 'Black Friday', 'date' => $this->getBlackFriday(), 'suggested_action' => 'Major sales event - prepare deals and promotions'],
|
||||
['name' => '7/10 (Dab Day)', 'date' => now()->setMonth(7)->setDay(10), 'suggested_action' => 'Concentrate holiday - feature extracts and dabs'],
|
||||
];
|
||||
|
||||
$upcoming = [];
|
||||
foreach ($holidays as $holiday) {
|
||||
$date = $holiday['date'];
|
||||
|
||||
// If date is in past this year, check next year
|
||||
if ($date->isPast()) {
|
||||
$date = $date->addYear();
|
||||
}
|
||||
|
||||
$daysUntil = now()->diffInDays($date, false);
|
||||
|
||||
if ($daysUntil > 0 && $daysUntil <= $daysAhead) {
|
||||
$holiday['date'] = $date;
|
||||
$holiday['days_until'] = $daysUntil;
|
||||
$upcoming[] = $holiday;
|
||||
}
|
||||
}
|
||||
|
||||
return $upcoming;
|
||||
}
|
||||
|
||||
private function getGreenWednesday(): \Carbon\Carbon
|
||||
{
|
||||
// Wednesday before Thanksgiving (4th Thursday of November)
|
||||
$november = now()->setMonth(11)->startOfMonth();
|
||||
$thanksgiving = $november->copy()->nthOfMonth(4, \Carbon\Carbon::THURSDAY);
|
||||
|
||||
return $thanksgiving->copy()->subDay();
|
||||
}
|
||||
|
||||
private function getBlackFriday(): \Carbon\Carbon
|
||||
{
|
||||
$november = now()->setMonth(11)->startOfMonth();
|
||||
$thanksgiving = $november->copy()->nthOfMonth(4, \Carbon\Carbon::THURSDAY);
|
||||
|
||||
return $thanksgiving->copy()->addDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can create a task for this brand (throttling).
|
||||
*/
|
||||
private function canCreateTaskForBrand(int $brandId): bool
|
||||
{
|
||||
$currentCount = $this->brandTaskCount[$brandId] ?? 0;
|
||||
|
||||
return $currentCount < $this->config->getMaxTasksPerBrandPerRun();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that we created a task for this brand.
|
||||
*/
|
||||
private function recordTaskForBrand(int $brandId): void
|
||||
{
|
||||
if (! isset($this->brandTaskCount[$brandId])) {
|
||||
$this->brandTaskCount[$brandId] = 0;
|
||||
}
|
||||
$this->brandTaskCount[$brandId]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a marketing task.
|
||||
*/
|
||||
private function createTask(array $taskData): ?OrchestratorTask
|
||||
{
|
||||
// Ensure marketing role
|
||||
$taskData['owner_role'] = OrchestratorTask::ROLE_MARKETING;
|
||||
|
||||
// Marketing tasks are admin-only (not visible to seller reps)
|
||||
$taskData['visible_to_reps'] = false;
|
||||
$taskData['approval_state'] = OrchestratorTask::APPROVAL_AUTO;
|
||||
|
||||
if ($this->dryRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->tasksCreated++;
|
||||
|
||||
return OrchestratorTask::create($taskData);
|
||||
}
|
||||
}
|
||||
228
app/Console/Commands/GenerateMenuFollowups.php
Normal file
228
app/Console/Commands/GenerateMenuFollowups.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\MenuViewEvent;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateMenuFollowups extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'orchestrator:generate-menu-followups
|
||||
{--days-no-view=3 : Days after send with no view to trigger followup}
|
||||
{--days-viewed-no-order=3 : Days after view with no order to trigger followup}
|
||||
{--business= : Limit to specific business ID}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate Orchestrator followup tasks for menu sends without views or orders';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$daysNoView = (int) $this->option('days-no-view');
|
||||
$daysViewedNoOrder = (int) $this->option('days-viewed-no-order');
|
||||
$specificBusinessId = $this->option('business');
|
||||
|
||||
$this->info('Generating menu followup tasks...');
|
||||
$this->info(" - No view threshold: {$daysNoView} days");
|
||||
$this->info(" - Viewed no order threshold: {$daysViewedNoOrder} days");
|
||||
|
||||
$businessQuery = Business::query()
|
||||
->where('type', '!=', 'buyer'); // Only process seller businesses
|
||||
|
||||
if ($specificBusinessId) {
|
||||
$businessQuery->where('id', $specificBusinessId);
|
||||
}
|
||||
|
||||
$businesses = $businessQuery->get();
|
||||
$totalNoView = 0;
|
||||
$totalViewedNoOrder = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->line("Processing business: {$business->name} (ID: {$business->id})");
|
||||
|
||||
// Case A: No view after send
|
||||
$noViewCount = $this->generateNoViewFollowups($business, $daysNoView);
|
||||
$totalNoView += $noViewCount;
|
||||
|
||||
// Case B: Viewed but no order
|
||||
$viewedNoOrderCount = $this->generateViewedNoOrderFollowups($business, $daysViewedNoOrder);
|
||||
$totalViewedNoOrder += $viewedNoOrderCount;
|
||||
|
||||
$this->line(" - Created {$noViewCount} no-view followups");
|
||||
$this->line(" - Created {$viewedNoOrderCount} viewed-no-order followups");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Complete! Total tasks created:');
|
||||
$this->info(" - No view followups: {$totalNoView}");
|
||||
$this->info(" - Viewed no order followups: {$totalViewedNoOrder}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate followup tasks for menus sent but never viewed.
|
||||
*/
|
||||
protected function generateNoViewFollowups(Business $business, int $daysThreshold): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
// Find SendMenuLog rows where:
|
||||
// - sent_at is {daysThreshold} to {daysThreshold + 2} days ago (window)
|
||||
// - There is no MenuViewEvent for the same business_id, menu_id, customer_id after sent_at
|
||||
// - There is no existing OrchestratorTask of type menu_followup_no_view in pending status
|
||||
$cutoffStart = now()->subDays($daysThreshold + 2);
|
||||
$cutoffEnd = now()->subDays($daysThreshold);
|
||||
|
||||
$sendLogs = SendMenuLog::query()
|
||||
->where('business_id', $business->id)
|
||||
->whereBetween('sent_at', [$cutoffStart, $cutoffEnd])
|
||||
->whereNotNull('customer_id')
|
||||
->whereNotNull('menu_id')
|
||||
->get();
|
||||
|
||||
foreach ($sendLogs as $log) {
|
||||
// Check if there's been a view after the send
|
||||
$hasView = MenuViewEvent::hasViewAfter(
|
||||
$business->id,
|
||||
$log->menu_id,
|
||||
$log->customer_id,
|
||||
$log->sent_at
|
||||
);
|
||||
|
||||
if ($hasView) {
|
||||
continue; // Menu was viewed, skip
|
||||
}
|
||||
|
||||
// Check if task already exists
|
||||
$existingTask = OrchestratorTask::query()
|
||||
->where('business_id', $business->id)
|
||||
->where('customer_id', $log->customer_id)
|
||||
->where('menu_id', $log->menu_id)
|
||||
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->exists();
|
||||
|
||||
if ($existingTask) {
|
||||
continue; // Task already exists
|
||||
}
|
||||
|
||||
// Create the followup task
|
||||
OrchestratorTask::create([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $log->brand_id,
|
||||
'menu_id' => $log->menu_id,
|
||||
'customer_id' => $log->customer_id,
|
||||
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'due_at' => now(),
|
||||
'payload' => [
|
||||
'send_menu_log_id' => $log->id,
|
||||
'recipient_name' => $log->meta['recipient_name'] ?? 'Unknown',
|
||||
'recipient_type' => $log->recipient_type,
|
||||
'recipient_id' => $log->recipient_id,
|
||||
'channel' => $log->channel,
|
||||
'original_sent_at' => $log->sent_at->toIso8601String(),
|
||||
'suggested_message' => 'Hi! Just checking if you had a chance to look at the menu I sent over. Let me know if you have any questions!',
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate followup tasks for menus viewed but no order placed.
|
||||
*/
|
||||
protected function generateViewedNoOrderFollowups(Business $business, int $daysThreshold): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
// Find MenuViewEvent rows where:
|
||||
// - viewed_at is {daysThreshold} to {daysThreshold + 2} days ago
|
||||
// - There was a SendMenuLog for that menu/customer earlier
|
||||
// - There is no order yet (simplified: we just check if task exists)
|
||||
// - There is no existing OrchestratorTask of type menu_followup_viewed_no_order in pending status
|
||||
$cutoffStart = now()->subDays($daysThreshold + 2);
|
||||
$cutoffEnd = now()->subDays($daysThreshold);
|
||||
|
||||
$viewEvents = MenuViewEvent::query()
|
||||
->where('business_id', $business->id)
|
||||
->whereBetween('viewed_at', [$cutoffStart, $cutoffEnd])
|
||||
->whereNotNull('customer_id')
|
||||
->whereNotNull('menu_id')
|
||||
->get();
|
||||
|
||||
foreach ($viewEvents as $viewEvent) {
|
||||
// Check if there was a SendMenuLog for this menu/customer
|
||||
$sendLog = SendMenuLog::query()
|
||||
->where('business_id', $business->id)
|
||||
->where('menu_id', $viewEvent->menu_id)
|
||||
->where('customer_id', $viewEvent->customer_id)
|
||||
->where('sent_at', '<', $viewEvent->viewed_at)
|
||||
->orderByDesc('sent_at')
|
||||
->first();
|
||||
|
||||
if (! $sendLog) {
|
||||
continue; // No prior send, this was a direct view
|
||||
}
|
||||
|
||||
// Check if task already exists
|
||||
$existingTask = OrchestratorTask::query()
|
||||
->where('business_id', $business->id)
|
||||
->where('customer_id', $viewEvent->customer_id)
|
||||
->where('menu_id', $viewEvent->menu_id)
|
||||
->where('type', OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->exists();
|
||||
|
||||
if ($existingTask) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For V1.3, we skip order checking (would need to hook into orders table)
|
||||
// In future: check Order::where('business_id', $customer_id)->where('created_at', '>', $viewEvent->viewed_at)->exists()
|
||||
|
||||
// Create the followup task
|
||||
OrchestratorTask::create([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $viewEvent->brand_id,
|
||||
'menu_id' => $viewEvent->menu_id,
|
||||
'customer_id' => $viewEvent->customer_id,
|
||||
'type' => OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER,
|
||||
'status' => OrchestratorTask::STATUS_PENDING,
|
||||
'due_at' => now(),
|
||||
'payload' => [
|
||||
'send_menu_log_id' => $sendLog->id,
|
||||
'recipient_name' => $sendLog->meta['recipient_name'] ?? 'Unknown',
|
||||
'recipient_type' => $sendLog->recipient_type,
|
||||
'recipient_id' => $sendLog->recipient_id,
|
||||
'channel' => $sendLog->channel,
|
||||
'original_sent_at' => $sendLog->sent_at->toIso8601String(),
|
||||
'viewed_at' => $viewEvent->viewed_at->toIso8601String(),
|
||||
'suggested_message' => "I saw you checked out the menu I sent over. Is there anything specific you're looking for? Happy to help!",
|
||||
],
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
1554
app/Console/Commands/GenerateSalesOrchestratorTasks.php
Normal file
1554
app/Console/Commands/GenerateSalesOrchestratorTasks.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Intelligence;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Intelligence\BrandPlacementSignalsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Compute Brand Placement Signals
|
||||
*
|
||||
* Nightly job to compute brand coverage and sales opportunities
|
||||
* for internal sales intelligence.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan intelligence:compute-brand-placement # All sellers
|
||||
* php artisan intelligence:compute-brand-placement --seller=4 # Specific seller
|
||||
*/
|
||||
class ComputeBrandPlacementSignals extends Command
|
||||
{
|
||||
protected $signature = 'intelligence:compute-brand-placement
|
||||
{--seller= : Specific seller business ID to compute}
|
||||
{--dry-run : Show what would be computed without saving}';
|
||||
|
||||
protected $description = 'Compute brand placement signals for sales intelligence';
|
||||
|
||||
public function handle(BrandPlacementSignalsService $service): int
|
||||
{
|
||||
$this->info('🧠 Computing Brand Placement Signals...');
|
||||
$this->newLine();
|
||||
|
||||
$sellerId = $this->option('seller');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No data will be saved');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Get seller(s) to process
|
||||
if ($sellerId) {
|
||||
$sellers = Business::where('id', $sellerId)
|
||||
->whereIn('type', ['seller', 'both'])
|
||||
->get();
|
||||
|
||||
if ($sellers->isEmpty()) {
|
||||
$this->error("Seller business #{$sellerId} not found or not a seller type.");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
// Get all seller businesses with brands
|
||||
$sellers = Business::whereIn('type', ['seller', 'both'])
|
||||
->whereHas('brands')
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($sellers->isEmpty()) {
|
||||
$this->warn('No seller businesses found with brands.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Processing {$sellers->count()} seller(s)...");
|
||||
$this->newLine();
|
||||
|
||||
$totalCoverage = 0;
|
||||
$totalOpportunities = 0;
|
||||
|
||||
$progressBar = $this->output->createProgressBar($sellers->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($sellers as $seller) {
|
||||
if ($dryRun) {
|
||||
$brandCount = $seller->brands()->where('is_active', true)->count();
|
||||
$this->line(" Would process: {$seller->name} ({$brandCount} brands)");
|
||||
} else {
|
||||
$result = $service->computeForSeller($seller->id);
|
||||
$totalCoverage += $result['coverage_updated'];
|
||||
$totalOpportunities += $result['opportunities_created'];
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info('✅ Computation complete!');
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Sellers Processed', $sellers->count()],
|
||||
['Store Coverage Records', $totalCoverage],
|
||||
['Opportunities Identified', $totalOpportunities],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
143
app/Console/Commands/LinkVarietiesFromSku.php
Normal file
143
app/Console/Commands/LinkVarietiesFromSku.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LinkVarietiesFromSku extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'products:link-varieties-from-sku
|
||||
{--dry-run : Preview changes without writing to the database}
|
||||
{--brand-id= : Limit to a specific brand ID}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Link variety products to their parent based on SKU pattern (e.g., AZ3G → AZ1G)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$brandId = $this->option('brand-id');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('🔍 DRY RUN MODE - No changes will be made');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$query = Product::query()
|
||||
->whereNotNull('sku')
|
||||
->whereNull('parent_product_id'); // Only process unlinked products
|
||||
|
||||
if ($brandId) {
|
||||
$query->where('brand_id', $brandId);
|
||||
$this->info("Filtering to brand_id: {$brandId}");
|
||||
}
|
||||
|
||||
$linked = 0;
|
||||
$skipped = 0;
|
||||
$noParent = 0;
|
||||
|
||||
$this->info('Scanning products for variety patterns...');
|
||||
$this->newLine();
|
||||
|
||||
// Process in chunks for memory efficiency
|
||||
$query->chunk(100, function ($products) use ($dryRun, &$linked, &$skipped, &$noParent) {
|
||||
foreach ($products as $product) {
|
||||
$result = $this->processProduct($product, $dryRun);
|
||||
|
||||
match ($result) {
|
||||
'linked' => $linked++,
|
||||
'skipped' => $skipped++,
|
||||
'no_parent' => $noParent++,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info('Summary:');
|
||||
$this->info(" ✓ Linked: {$linked}");
|
||||
$this->info(" ○ Skipped (not variety pattern): {$skipped}");
|
||||
$this->info(" ✗ No parent found: {$noParent}");
|
||||
|
||||
if ($dryRun && $linked > 0) {
|
||||
$this->newLine();
|
||||
$this->warn('Run without --dry-run to apply these changes.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single product to determine if it's a variety and link it.
|
||||
*/
|
||||
private function processProduct(Product $product, bool $dryRun): string
|
||||
{
|
||||
$sku = $product->sku;
|
||||
$parts = explode('-', $sku);
|
||||
|
||||
// Guard: need at least 3 parts (e.g., TB-BM-AZ3G)
|
||||
if (count($parts) < 3) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Check if third segment matches pattern like AZ1G, AZ3G, AZ5G
|
||||
if (! preg_match('/^([A-Z]+)(\d+)G$/', $parts[2], $matches)) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$state = $matches[1]; // e.g., 'AZ'
|
||||
$qty = (int) $matches[2]; // e.g., 1, 3, 5
|
||||
|
||||
// If qty === 1, this is a parent, not a variety
|
||||
if ($qty === 1) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// This is a variety candidate (qty > 1)
|
||||
// Build the parent's third segment and base SKU
|
||||
$parentThird = $state.'1G'; // e.g., 'AZ1G'
|
||||
$parentPrefix = "{$parts[0]}-{$parts[1]}-{$parentThird}"; // e.g., 'TB-BM-AZ1G'
|
||||
|
||||
// Look up parent within the same brand
|
||||
$parent = Product::where('brand_id', $product->brand_id)
|
||||
->where(function ($q) use ($parentPrefix) {
|
||||
$q->where('sku', $parentPrefix)
|
||||
->orWhere('sku', 'like', $parentPrefix.'-%');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (! $parent) {
|
||||
$this->warn(" ✗ No parent found for #{$product->id} ({$sku})");
|
||||
|
||||
return 'no_parent';
|
||||
}
|
||||
|
||||
// Link the variety to its parent
|
||||
if ($dryRun) {
|
||||
$this->line(" → Would link #{$product->id} (<fg=cyan>{$sku}</>) → parent #{$parent->id} (<fg=green>{$parent->sku}</>)");
|
||||
} else {
|
||||
DB::transaction(function () use ($product, $parent) {
|
||||
$product->parent_product_id = $parent->id;
|
||||
$product->save();
|
||||
});
|
||||
$this->line(" ✓ Linked #{$product->id} (<fg=cyan>{$sku}</>) → parent #{$parent->id} (<fg=green>{$parent->sku}</>)");
|
||||
}
|
||||
|
||||
return 'linked';
|
||||
}
|
||||
}
|
||||
356
app/Console/Commands/ListFeatureRoutes.php
Normal file
356
app/Console/Commands/ListFeatureRoutes.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Debug command to list seller-side feature routes and check legacy menu coverage.
|
||||
*
|
||||
* This helps developers identify implemented features that may not appear
|
||||
* in the current navigation menu, ensuring nothing is forgotten when
|
||||
* building suite-based menus.
|
||||
*
|
||||
* @see docs/architecture/SUITES_AND_PRICING_MODEL.md
|
||||
*/
|
||||
class ListFeatureRoutes extends Command
|
||||
{
|
||||
protected $signature = 'debug:list-feature-routes
|
||||
{--show-routes : Show all routes for each feature}
|
||||
{--json : Output as JSON}';
|
||||
|
||||
protected $description = 'List seller-side feature domains and check if they appear in the legacy menu';
|
||||
|
||||
/**
|
||||
* Legacy menu feature references.
|
||||
* These are patterns/keywords that appear in the legacy seller-sidebar.blade.php
|
||||
* Used to determine if a feature domain is represented in the current menu.
|
||||
*/
|
||||
protected array $legacyMenuFeatures = [
|
||||
'dashboard' => ['seller.business.dashboard', 'seller.dashboard'],
|
||||
'brands' => ['seller.business.brands'],
|
||||
'analytics' => ['seller.business.dashboard.analytics', 'analytics'],
|
||||
'orchestrator' => ['seller.business.orchestrator'],
|
||||
'conversations' => ['seller.business.messaging', 'conversations'],
|
||||
'contacts' => ['seller.business.contacts'],
|
||||
'sales' => ['seller.business.dashboard.sales'],
|
||||
'customers' => ['seller.business.customers'],
|
||||
'orders' => ['seller.business.orders'],
|
||||
'invoices' => ['seller.business.invoices'],
|
||||
'backorders' => ['seller.business.backorders'],
|
||||
'promotions' => ['seller.business.promotions'],
|
||||
'products' => ['seller.business.products'],
|
||||
'components' => ['seller.business.components'],
|
||||
'inventory' => ['seller.business.inventory'],
|
||||
'processing' => ['seller.business.processing'],
|
||||
'manufacturing' => ['seller.business.manufacturing'],
|
||||
'fleet' => ['seller.business.fleet'],
|
||||
'marketing' => ['seller.business.marketing'],
|
||||
'crm' => ['seller.business.crm'],
|
||||
'settings' => ['seller.business.settings'],
|
||||
'reports' => ['seller.business.processing.wash-reports'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Feature grouping rules based on route name patterns.
|
||||
* Maps route name segments to feature keys.
|
||||
*/
|
||||
protected array $featurePatterns = [
|
||||
'dashboard' => ['dashboard'],
|
||||
'brands' => ['brands'],
|
||||
'products' => ['products'],
|
||||
'components' => ['components'],
|
||||
'inventory' => ['inventory.items', 'inventory.movements', 'inventory.alerts', 'inventory.dashboard'],
|
||||
'orders' => ['orders'],
|
||||
'invoices' => ['invoices'],
|
||||
'backorders' => ['backorders'],
|
||||
'customers' => ['customers'],
|
||||
'contacts' => ['contacts'],
|
||||
'conversations' => ['messaging'],
|
||||
'promotions' => ['promotions'],
|
||||
'menus' => ['menus'],
|
||||
'orchestrator' => ['orchestrator'],
|
||||
'copilot' => ['copilot'],
|
||||
'processing' => ['processing', 'batches'],
|
||||
'manufacturing' => ['manufacturing'],
|
||||
'fleet' => ['fleet'],
|
||||
'marketing' => ['marketing'],
|
||||
'crm' => ['crm'],
|
||||
'settings' => ['settings'],
|
||||
'compliance' => ['compliance'],
|
||||
'bulk-actions' => ['bulk-actions', 'bulk'],
|
||||
'api' => ['api'],
|
||||
'webhooks' => ['webhooks'],
|
||||
'integrations' => ['integrations'],
|
||||
'onboarding' => ['onboarding'],
|
||||
'impersonate' => ['impersonate'],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Scanning seller-side routes...');
|
||||
$this->newLine();
|
||||
|
||||
// Collect all seller routes
|
||||
$sellerRoutes = $this->collectSellerRoutes();
|
||||
|
||||
// Group by feature
|
||||
$features = $this->groupByFeature($sellerRoutes);
|
||||
|
||||
// Check legacy menu coverage
|
||||
$results = $this->analyzeFeatures($features);
|
||||
|
||||
// Output results
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($results, JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$this->outputTable($results);
|
||||
|
||||
if ($this->option('show-routes')) {
|
||||
$this->outputDetailedRoutes($features);
|
||||
}
|
||||
|
||||
$this->outputSummary($results);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all routes that belong to the seller area.
|
||||
*/
|
||||
protected function collectSellerRoutes(): array
|
||||
{
|
||||
$routes = [];
|
||||
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
$name = $route->getName();
|
||||
$uri = $route->uri();
|
||||
|
||||
// Filter to seller routes by URI prefix or route name
|
||||
$isSellerRoute = Str::startsWith($uri, 's/')
|
||||
|| Str::startsWith($uri, 's/{')
|
||||
|| Str::startsWith($name ?? '', 'seller.');
|
||||
|
||||
if (! $isSellerRoute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Livewire and internal routes
|
||||
if (Str::contains($uri, ['livewire', '__clockwork'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$routes[] = [
|
||||
'name' => $name,
|
||||
'uri' => $uri,
|
||||
'methods' => implode('|', $route->methods()),
|
||||
'controller' => $route->getActionName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group routes by inferred feature key.
|
||||
*/
|
||||
protected function groupByFeature(array $routes): array
|
||||
{
|
||||
$features = [];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$featureKey = $this->inferFeatureKey($route);
|
||||
|
||||
if (! isset($features[$featureKey])) {
|
||||
$features[$featureKey] = [];
|
||||
}
|
||||
|
||||
$features[$featureKey][] = $route;
|
||||
}
|
||||
|
||||
// Sort by feature name
|
||||
ksort($features);
|
||||
|
||||
return $features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the feature key from a route.
|
||||
*/
|
||||
protected function inferFeatureKey(array $route): string
|
||||
{
|
||||
$name = $route['name'] ?? '';
|
||||
$uri = $route['uri'];
|
||||
|
||||
// Try to match against known feature patterns
|
||||
foreach ($this->featurePatterns as $feature => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (Str::contains($name, $pattern) || Str::contains($uri, $pattern)) {
|
||||
return $feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to extracting from route name
|
||||
// seller.business.{feature}.action -> extract {feature}
|
||||
if (preg_match('/^seller\.business\.([a-z-]+)/', $name, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Extract from URI: s/{business}/{feature}/...
|
||||
if (preg_match('/^s\/\{[^}]+\}\/([a-z-]+)/', $uri, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Extract from URI without business param: s/{feature}/...
|
||||
if (preg_match('/^s\/([a-z-]+)/', $uri, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze features and check legacy menu coverage.
|
||||
*/
|
||||
protected function analyzeFeatures(array $features): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($features as $featureKey => $routes) {
|
||||
$inLegacyMenu = $this->isInLegacyMenu($featureKey, $routes);
|
||||
|
||||
// Get example routes (up to 3)
|
||||
$examples = array_slice(
|
||||
array_map(fn ($r) => $r['name'] ?: $r['uri'], $routes),
|
||||
0,
|
||||
3
|
||||
);
|
||||
|
||||
$results[] = [
|
||||
'feature' => $featureKey,
|
||||
'route_count' => count($routes),
|
||||
'in_legacy_menu' => $inLegacyMenu,
|
||||
'examples' => $examples,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort: features NOT in legacy menu first (to highlight gaps)
|
||||
usort($results, function ($a, $b) {
|
||||
if ($a['in_legacy_menu'] !== $b['in_legacy_menu']) {
|
||||
return $a['in_legacy_menu'] ? 1 : -1;
|
||||
}
|
||||
|
||||
return strcmp($a['feature'], $b['feature']);
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature appears in the legacy menu.
|
||||
*/
|
||||
protected function isInLegacyMenu(string $featureKey, array $routes): bool
|
||||
{
|
||||
// Check direct feature mapping
|
||||
if (isset($this->legacyMenuFeatures[$featureKey])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any route matches known legacy menu patterns
|
||||
foreach ($routes as $route) {
|
||||
$routeName = $route['name'] ?? '';
|
||||
foreach ($this->legacyMenuFeatures as $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (Str::startsWith($routeName, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output results as a table.
|
||||
*/
|
||||
protected function outputTable(array $results): void
|
||||
{
|
||||
$tableData = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
$tableData[] = [
|
||||
$result['feature'],
|
||||
$result['route_count'],
|
||||
$result['in_legacy_menu'] ? '<fg=green>YES</>' : '<fg=yellow>NO</>',
|
||||
Str::limit(implode(', ', $result['examples']), 60),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Feature', 'Routes', 'In Menu?', 'Example Routes'],
|
||||
$tableData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output detailed routes for each feature.
|
||||
*/
|
||||
protected function outputDetailedRoutes(array $features): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('Detailed Routes by Feature:');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($features as $feature => $routes) {
|
||||
$this->line("<fg=cyan>[$feature]</> (".count($routes).' routes)');
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$methods = $route['methods'];
|
||||
$name = $route['name'] ?: '(unnamed)';
|
||||
$uri = $route['uri'];
|
||||
|
||||
$this->line(" <fg=gray>{$methods}</> {$uri}");
|
||||
$this->line(" <fg=gray>→ {$name}</>");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output summary statistics.
|
||||
*/
|
||||
protected function outputSummary(array $results): void
|
||||
{
|
||||
$totalFeatures = count($results);
|
||||
$inMenu = count(array_filter($results, fn ($r) => $r['in_legacy_menu']));
|
||||
$notInMenu = $totalFeatures - $inMenu;
|
||||
$totalRoutes = array_sum(array_column($results, 'route_count'));
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Summary:');
|
||||
$this->line(" Total seller routes: <fg=white>{$totalRoutes}</>");
|
||||
$this->line(" Feature domains: <fg=white>{$totalFeatures}</>");
|
||||
$this->line(" In legacy menu: <fg=green>{$inMenu}</>");
|
||||
$this->line(" Not in legacy menu: <fg=yellow>{$notInMenu}</>");
|
||||
|
||||
if ($notInMenu > 0) {
|
||||
$this->newLine();
|
||||
$this->warn('Features not in legacy menu may need to be added to suite menus:');
|
||||
foreach ($results as $result) {
|
||||
if (! $result['in_legacy_menu']) {
|
||||
$this->line(" - <fg=yellow>{$result['feature']}</> ({$result['route_count']} routes)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('<fg=gray>Tip: Use --show-routes to see all routes per feature</>');
|
||||
$this->line('<fg=gray>Tip: Use --json for machine-readable output</>');
|
||||
}
|
||||
}
|
||||
144
app/Console/Commands/MigrateDbaData.php
Normal file
144
app/Console/Commands/MigrateDbaData.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Migrate existing business DBA data to the new business_dbas table.
|
||||
*
|
||||
* This command creates DBA records from existing business fields:
|
||||
* - dba_name
|
||||
* - invoice_payable_company_name, invoice_payable_address, etc.
|
||||
* - ap_contact_* fields
|
||||
* - primary_contact_* fields
|
||||
*/
|
||||
class MigrateDbaData extends Command
|
||||
{
|
||||
protected $signature = 'dba:migrate
|
||||
{--dry-run : Show what would be created without actually creating records}
|
||||
{--business= : Migrate only a specific business by ID or slug}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('DBA Data Migration');
|
||||
$this->line('==================');
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$specificBusiness = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No records will be created');
|
||||
}
|
||||
|
||||
// Build query
|
||||
$query = Business::query()
|
||||
->whereNotNull('dba_name')
|
||||
->where('dba_name', '!=', '');
|
||||
|
||||
if ($specificBusiness) {
|
||||
$query->where(function ($q) use ($specificBusiness) {
|
||||
$q->where('id', $specificBusiness)
|
||||
->orWhere('slug', $specificBusiness);
|
||||
});
|
||||
}
|
||||
|
||||
$businesses = $query->get();
|
||||
$this->info("Found {$businesses->count()} businesses with dba_name set.");
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->info('No businesses to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
|
||||
$businesses->map(fn ($b) => [
|
||||
$b->id,
|
||||
\Illuminate\Support\Str::limit($b->name, 30),
|
||||
\Illuminate\Support\Str::limit($b->dba_name, 30),
|
||||
$b->invoice_payable_address ? 'Yes' : 'No',
|
||||
$b->dbas()->exists() ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
if (! $dryRun && ! $this->option('force')) {
|
||||
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
|
||||
$this->info('Aborted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
// Skip if business already has DBAs
|
||||
if ($business->dbas()->exists()) {
|
||||
$this->line(" Skipping {$business->name} - already has DBAs");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
|
||||
$created++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create DBA from existing business fields
|
||||
$dba = BusinessDba::create([
|
||||
'business_id' => $business->id,
|
||||
'trade_name' => $business->dba_name,
|
||||
|
||||
// Address - prefer invoice_payable fields, fall back to physical
|
||||
'address' => $business->invoice_payable_address ?: $business->physical_address,
|
||||
'city' => $business->invoice_payable_city ?: $business->physical_city,
|
||||
'state' => $business->invoice_payable_state ?: $business->physical_state,
|
||||
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
|
||||
|
||||
// License
|
||||
'license_number' => $business->license_number,
|
||||
'license_type' => $business->license_type,
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
|
||||
'primary_contact_email' => $business->primary_contact_email,
|
||||
'primary_contact_phone' => $business->primary_contact_phone,
|
||||
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
|
||||
'ap_contact_email' => $business->ap_contact_email,
|
||||
'ap_contact_phone' => $business->ap_contact_phone,
|
||||
|
||||
// Invoice Settings
|
||||
'invoice_footer' => $business->order_invoice_footer,
|
||||
|
||||
// Status
|
||||
'is_default' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Summary: {$created} created, {$skipped} skipped");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Run without --dry-run to actually create records.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
216
app/Console/Commands/MigrateFlagsToSuites.php
Normal file
216
app/Console/Commands/MigrateFlagsToSuites.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Suite;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Migrates the legacy has_* flags on businesses to the new suites pivot table.
|
||||
*
|
||||
* This command maps the old feature flags (has_analytics, has_manufacturing, etc.)
|
||||
* to suite assignments in the business_suite pivot table.
|
||||
*/
|
||||
class MigrateFlagsToSuites extends Command
|
||||
{
|
||||
protected $signature = 'migrate:flags-to-suites
|
||||
{--dry-run : Run without making changes}
|
||||
{--force : Force migration without confirmation}';
|
||||
|
||||
protected $description = 'Migrate legacy has_* flags to business_suite pivot table';
|
||||
|
||||
/**
|
||||
* Map feature flags to suite keys.
|
||||
*
|
||||
* Some feature flags map to specific suites, others are deprecated or
|
||||
* will be handled by the new granular permissions system.
|
||||
*/
|
||||
private const FLAG_TO_SUITE_MAP = [
|
||||
// Feature flags that map directly to suites
|
||||
'has_manufacturing' => 'manufacturing',
|
||||
'has_processing' => 'processing',
|
||||
'has_marketing' => 'marketing',
|
||||
'has_compliance' => 'compliance',
|
||||
'has_inventory' => 'inventory',
|
||||
'has_accounting' => 'finance', // accounting maps to finance suite
|
||||
|
||||
// Feature flags that are part of the Sales suite
|
||||
'has_analytics' => 'sales',
|
||||
'has_crm' => 'sales',
|
||||
'has_assemblies' => 'inventory', // assemblies is part of inventory
|
||||
'has_conversations' => 'inbox', // conversations maps to inbox suite
|
||||
'has_buyer_intelligence' => 'sales',
|
||||
|
||||
// Legacy suite flags (already named as suites)
|
||||
'has_sales_suite' => 'sales',
|
||||
'has_processing_suite' => 'processing',
|
||||
'has_manufacturing_suite' => 'manufacturing',
|
||||
'has_delivery_suite' => 'distribution',
|
||||
'has_management_suite' => 'management',
|
||||
'has_enterprise_suite' => 'enterprise',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
|
||||
$this->info('Starting has_* flags to suites migration...');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
if (! $force && ! $isDryRun) {
|
||||
if (! $this->confirm('This will migrate has_* flags to the business_suite pivot table. Continue?')) {
|
||||
$this->info('Migration cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure suites exist
|
||||
$suites = Suite::all()->keyBy('key');
|
||||
if ($suites->isEmpty()) {
|
||||
$this->error('No suites found. Please run: php artisan db:seed --class=SuitesSeeder');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Found '.$suites->count().' suites in database');
|
||||
|
||||
// Get all businesses with any has_* flags enabled
|
||||
$businesses = Business::query()
|
||||
->where(function ($query) {
|
||||
foreach (array_keys(self::FLAG_TO_SUITE_MAP) as $flag) {
|
||||
$query->orWhere($flag, true);
|
||||
}
|
||||
})
|
||||
->get();
|
||||
|
||||
$this->info("Found {$businesses->count()} businesses with enabled flags");
|
||||
$this->newLine();
|
||||
|
||||
$stats = [
|
||||
'total_businesses' => 0,
|
||||
'total_suite_assignments' => 0,
|
||||
'skipped_existing' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$progressBar = $this->output->createProgressBar($businesses->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$stats['total_businesses']++;
|
||||
|
||||
try {
|
||||
$suitesToAssign = $this->determineSuitesForBusiness($business, $suites);
|
||||
|
||||
if (empty($suitesToAssign)) {
|
||||
$progressBar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($suitesToAssign as $suiteKey) {
|
||||
$suite = $suites->get($suiteKey);
|
||||
if (! $suite) {
|
||||
$this->newLine();
|
||||
$this->warn(" Suite '{$suiteKey}' not found for business {$business->name}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already assigned
|
||||
$existingAssignment = DB::table('business_suite')
|
||||
->where('business_id', $business->id)
|
||||
->where('suite_id', $suite->id)
|
||||
->exists();
|
||||
|
||||
if ($existingAssignment) {
|
||||
$stats['skipped_existing']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $isDryRun) {
|
||||
DB::table('business_suite')->insert([
|
||||
'business_id' => $business->id,
|
||||
'suite_id' => $suite->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$stats['total_suite_assignments']++;
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$stats['errors']++;
|
||||
$this->newLine();
|
||||
$this->error(" Error processing {$business->name}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// Display stats
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total Businesses Processed', $stats['total_businesses']],
|
||||
['Suite Assignments Created', $isDryRun ? "{$stats['total_suite_assignments']} (would create)" : $stats['total_suite_assignments']],
|
||||
['Skipped (Already Assigned)', $stats['skipped_existing']],
|
||||
['Errors', $stats['errors']],
|
||||
]
|
||||
);
|
||||
|
||||
$this->info('Migration completed!');
|
||||
|
||||
if (! $isDryRun) {
|
||||
$this->newLine();
|
||||
$this->info('Note: The has_* flags remain on the businesses for backwards compatibility.');
|
||||
$this->info('They can be deprecated once all code uses the suites system.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which suites a business should have based on its flags.
|
||||
*/
|
||||
private function determineSuitesForBusiness(Business $business, $suites): array
|
||||
{
|
||||
$assignedSuites = [];
|
||||
|
||||
foreach (self::FLAG_TO_SUITE_MAP as $flag => $suiteKey) {
|
||||
// Check if the business has this flag enabled
|
||||
if ($business->getAttribute($flag)) {
|
||||
// Don't duplicate suite assignments
|
||||
if (! in_array($suiteKey, $assignedSuites)) {
|
||||
$assignedSuites[] = $suiteKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enterprise suite gets all suites
|
||||
if (in_array('enterprise', $assignedSuites)) {
|
||||
// Add all non-internal suites
|
||||
foreach ($suites as $suite) {
|
||||
if (! $suite->is_internal && ! in_array($suite->key, $assignedSuites)) {
|
||||
$assignedSuites[] = $suite->key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $assignedSuites;
|
||||
}
|
||||
}
|
||||
304
app/Console/Commands/OrchestratorAnalyzeTiming.php
Normal file
304
app/Console/Commands/OrchestratorAnalyzeTiming.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\MenuViewEvent;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\OrchestratorTimingInsight;
|
||||
use App\Models\Order;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* OrchestratorAnalyzeTiming - Analyzes timing patterns for optimal send windows.
|
||||
*
|
||||
* Computes engagement metrics by hour-of-day and playbook type,
|
||||
* storing results in orchestrator_timing_insights for visualization.
|
||||
*/
|
||||
class OrchestratorAnalyzeTiming extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:analyze-timing
|
||||
{--days=90 : Number of days back to analyze}
|
||||
{--business= : Analyze for specific business ID}
|
||||
{--dry-run : Show what would be computed without saving}';
|
||||
|
||||
protected $description = 'Analyze menu send timing patterns to identify optimal engagement windows';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_ANALYZE_TIMING);
|
||||
|
||||
$days = (int) $this->option('days');
|
||||
$businessId = $this->option('business');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info("Analyzing timing patterns from last {$days} days...");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN - No data will be saved.');
|
||||
}
|
||||
|
||||
$startDate = now()->subDays($days);
|
||||
|
||||
// Get all send menu logs with outcomes
|
||||
$query = SendMenuLog::query()
|
||||
->whereNotNull('sent_at')
|
||||
->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('outcome_checked_at');
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$logs = $query->get();
|
||||
|
||||
$this->info("Found {$logs->count()} send logs with outcomes to analyze.");
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
$this->warn('No data to analyze. Run orchestrator:evaluate-outcomes first.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Group by hour-of-day
|
||||
$byHour = $logs->groupBy(fn ($log) => $log->sent_at->hour);
|
||||
|
||||
// Also compute by playbook type if orchestrator_task_id is present
|
||||
$byPlaybook = $this->groupByPlaybook($logs);
|
||||
|
||||
// Compute global insights (all playbooks)
|
||||
$this->computeInsights(
|
||||
$byHour,
|
||||
null, // business_id = null for global
|
||||
'all',
|
||||
$dryRun
|
||||
);
|
||||
|
||||
// Compute per-playbook insights
|
||||
foreach ($byPlaybook as $playbookType => $playbookLogs) {
|
||||
$byHourForPlaybook = $playbookLogs->groupBy(fn ($log) => $log->sent_at->hour);
|
||||
$this->computeInsights(
|
||||
$byHourForPlaybook,
|
||||
null,
|
||||
$playbookType,
|
||||
$dryRun
|
||||
);
|
||||
}
|
||||
|
||||
// If specific business, also compute business-specific insights
|
||||
if ($businessId) {
|
||||
$this->info("Computing insights for business ID {$businessId}...");
|
||||
$this->computeInsights(
|
||||
$byHour,
|
||||
(int) $businessId,
|
||||
'all',
|
||||
$dryRun
|
||||
);
|
||||
|
||||
foreach ($byPlaybook as $playbookType => $playbookLogs) {
|
||||
$byHourForPlaybook = $playbookLogs->groupBy(fn ($log) => $log->sent_at->hour);
|
||||
$this->computeInsights(
|
||||
$byHourForPlaybook,
|
||||
(int) $businessId,
|
||||
$playbookType,
|
||||
$dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Timing analysis complete!');
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_ANALYZE_TIMING, [
|
||||
'logs_analyzed' => $logs->count(),
|
||||
'days_analyzed' => $days,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group logs by playbook type (via orchestrator_task_id).
|
||||
*/
|
||||
private function groupByPlaybook($logs): \Illuminate\Support\Collection
|
||||
{
|
||||
$logsWithTask = $logs->whereNotNull('orchestrator_task_id');
|
||||
|
||||
if ($logsWithTask->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$taskIds = $logsWithTask->pluck('orchestrator_task_id')->unique();
|
||||
$tasks = OrchestratorTask::whereIn('id', $taskIds)->pluck('type', 'id');
|
||||
|
||||
return $logsWithTask->groupBy(function ($log) use ($tasks) {
|
||||
return $tasks[$log->orchestrator_task_id] ?? 'unknown';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and store insights for a grouping.
|
||||
*/
|
||||
private function computeInsights(
|
||||
\Illuminate\Support\Collection $byHour,
|
||||
?int $businessId,
|
||||
string $playbookType,
|
||||
bool $dryRun
|
||||
): void {
|
||||
$insights = [];
|
||||
|
||||
for ($hour = 0; $hour < 24; $hour++) {
|
||||
$hourLogs = $byHour->get($hour, collect());
|
||||
|
||||
if ($hourLogs->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total = $hourLogs->count();
|
||||
$views = $hourLogs->where('resulted_in_view', true)->count();
|
||||
$orders = $hourLogs->where('resulted_in_order', true)->count();
|
||||
|
||||
// Calculate average time to view (for logs that resulted in view)
|
||||
$viewedLogs = $hourLogs->where('resulted_in_view', true);
|
||||
$avgHoursToView = null;
|
||||
|
||||
if ($viewedLogs->isNotEmpty()) {
|
||||
// Get matching menu view events
|
||||
$avgHoursToView = $this->calculateAvgHoursToView($viewedLogs);
|
||||
}
|
||||
|
||||
// Calculate average time to order
|
||||
$orderedLogs = $hourLogs->where('resulted_in_order', true);
|
||||
$avgHoursToOrder = null;
|
||||
|
||||
if ($orderedLogs->isNotEmpty()) {
|
||||
$avgHoursToOrder = $this->calculateAvgHoursToOrder($orderedLogs);
|
||||
}
|
||||
|
||||
$viewRate = $total > 0 ? round(($views / $total) * 100, 2) : 0;
|
||||
$orderRate = $total > 0 ? round(($orders / $total) * 100, 2) : 0;
|
||||
|
||||
$insights[$hour] = [
|
||||
'business_id' => $businessId,
|
||||
'playbook_type' => $playbookType,
|
||||
'hour_of_day' => $hour,
|
||||
'avg_view_rate' => $viewRate,
|
||||
'avg_order_rate' => $orderRate,
|
||||
'avg_hours_to_view' => $avgHoursToView,
|
||||
'avg_hours_to_order' => $avgHoursToOrder,
|
||||
'sample_size' => $total,
|
||||
'computed_at' => now(),
|
||||
];
|
||||
|
||||
if (! $dryRun) {
|
||||
OrchestratorTimingInsight::updateOrCreate(
|
||||
[
|
||||
'business_id' => $businessId,
|
||||
'playbook_type' => $playbookType,
|
||||
'hour_of_day' => $hour,
|
||||
'day_of_week' => null, // Future: add day-of-week breakdowns
|
||||
],
|
||||
$insights[$hour]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Display summary
|
||||
$this->displaySummary($insights, $businessId, $playbookType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average hours between send and first view.
|
||||
*/
|
||||
private function calculateAvgHoursToView($logs): ?float
|
||||
{
|
||||
$totalHours = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
// Find first view after send
|
||||
$firstView = MenuViewEvent::where('menu_id', $log->menu_id)
|
||||
->where('customer_id', $log->customer_id)
|
||||
->where('viewed_at', '>', $log->sent_at)
|
||||
->orderBy('viewed_at')
|
||||
->first();
|
||||
|
||||
if ($firstView) {
|
||||
$hours = $log->sent_at->diffInHours($firstView->viewed_at, true);
|
||||
$totalHours += $hours;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? round($totalHours / $count, 2) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average hours between send and order.
|
||||
*/
|
||||
private function calculateAvgHoursToOrder($logs): ?float
|
||||
{
|
||||
$totalHours = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
// Find first order after send (within 7 days)
|
||||
$order = Order::where('business_id', $log->customer_id)
|
||||
->where('created_at', '>', $log->sent_at)
|
||||
->where('created_at', '<=', $log->sent_at->copy()->addDays(7))
|
||||
->orderBy('created_at')
|
||||
->first();
|
||||
|
||||
if ($order) {
|
||||
$hours = $log->sent_at->diffInHours($order->created_at, true);
|
||||
$totalHours += $hours;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? round($totalHours / $count, 2) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display timing summary.
|
||||
*/
|
||||
private function displaySummary(array $insights, ?int $businessId, string $playbookType): void
|
||||
{
|
||||
if (empty($insights)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope = $businessId ? "Business #{$businessId}" : 'Global';
|
||||
$this->newLine();
|
||||
$this->info("=== {$scope} / {$playbookType} ===");
|
||||
|
||||
// Find top 3 hours by order rate
|
||||
$sorted = collect($insights)->sortByDesc('avg_order_rate')->take(3);
|
||||
|
||||
$this->table(
|
||||
['Hour', 'View Rate', 'Order Rate', 'Avg Hrs to View', 'Sample Size'],
|
||||
$sorted->map(fn ($row) => [
|
||||
$this->formatHour($row['hour_of_day']),
|
||||
$row['avg_view_rate'].'%',
|
||||
$row['avg_order_rate'].'%',
|
||||
$row['avg_hours_to_view'] ?? '-',
|
||||
$row['sample_size'],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private function formatHour(int $hour): string
|
||||
{
|
||||
if ($hour === 0) {
|
||||
return '12 AM';
|
||||
}
|
||||
if ($hour < 12) {
|
||||
return $hour.' AM';
|
||||
}
|
||||
if ($hour === 12) {
|
||||
return '12 PM';
|
||||
}
|
||||
|
||||
return ($hour - 12).' PM';
|
||||
}
|
||||
}
|
||||
235
app/Console/Commands/OrchestratorCheckHorizon.php
Normal file
235
app/Console/Commands/OrchestratorCheckHorizon.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\SystemAlert;
|
||||
use App\Notifications\OrchestratorCriticalAlert;
|
||||
use App\Services\OrchestratorGovernanceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class OrchestratorCheckHorizon extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:check-horizon
|
||||
{--attempt-restart : Attempt to restart Horizon if down}
|
||||
{--notify : Send notifications for critical issues}';
|
||||
|
||||
protected $description = 'Check Horizon and queue health status';
|
||||
|
||||
private OrchestratorGovernanceService $governance;
|
||||
|
||||
private int $consecutiveFailures = 0;
|
||||
|
||||
public function __construct(OrchestratorGovernanceService $governance)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->governance = $governance;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_CHECK_HORIZON);
|
||||
|
||||
$this->info('Checking Horizon and queue health...');
|
||||
|
||||
try {
|
||||
$health = $this->governance->checkHorizonHealth();
|
||||
|
||||
$this->displayHealth($health);
|
||||
|
||||
// Process alerts
|
||||
$alertsCreated = 0;
|
||||
foreach ($health['alerts'] as $alertData) {
|
||||
$alert = SystemAlert::createAlert(
|
||||
$alertData['type'],
|
||||
$alertData['severity'],
|
||||
SystemAlert::SOURCE_HORIZON,
|
||||
$alertData['title'],
|
||||
$alertData['message'],
|
||||
$alertData['context'] ?? [],
|
||||
30 // Dedupe window: 30 minutes
|
||||
);
|
||||
|
||||
if ($alert) {
|
||||
$alertsCreated++;
|
||||
|
||||
// Send notification for critical alerts
|
||||
if ($this->option('notify') && $alert->isCritical()) {
|
||||
$this->sendCriticalNotification($alert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle restart if needed
|
||||
if ($health['status'] === 'critical' && $this->option('attempt-restart')) {
|
||||
$this->attemptRestart();
|
||||
}
|
||||
|
||||
// Auto-resolve alerts if health is now good
|
||||
if ($health['status'] === 'healthy') {
|
||||
$resolved = SystemAlert::autoResolve(
|
||||
SystemAlert::TYPE_HORIZON_DOWN,
|
||||
SystemAlert::SOURCE_HORIZON,
|
||||
'Auto-resolved: Horizon is healthy'
|
||||
);
|
||||
|
||||
$resolved += SystemAlert::autoResolve(
|
||||
SystemAlert::TYPE_HORIZON_DEGRADED,
|
||||
SystemAlert::SOURCE_HORIZON,
|
||||
'Auto-resolved: Horizon is healthy'
|
||||
);
|
||||
|
||||
if ($resolved > 0) {
|
||||
$this->info("Auto-resolved {$resolved} previous alerts.");
|
||||
}
|
||||
}
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_CHECK_HORIZON, [
|
||||
'status' => $health['status'],
|
||||
'alerts_created' => $alertsCreated,
|
||||
'checks' => array_keys($health['checks']),
|
||||
]);
|
||||
|
||||
return $health['status'] === 'critical' ? self::FAILURE : self::SUCCESS;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
AutomationRunLog::recordFailure(AutomationRunLog::CMD_CHECK_HORIZON, $e->getMessage());
|
||||
|
||||
$this->error('Health check failed: '.$e->getMessage());
|
||||
|
||||
// Create alert for check failure
|
||||
SystemAlert::critical(
|
||||
SystemAlert::TYPE_HORIZON_DOWN,
|
||||
SystemAlert::SOURCE_HORIZON,
|
||||
'Horizon health check failed',
|
||||
'Unable to check Horizon health: '.$e->getMessage()
|
||||
);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function displayHealth(array $health): void
|
||||
{
|
||||
$statusColor = match ($health['status']) {
|
||||
'healthy' => 'green',
|
||||
'warning' => 'yellow',
|
||||
'critical' => 'red',
|
||||
default => 'white',
|
||||
};
|
||||
|
||||
$this->newLine();
|
||||
$this->line("Overall Status: <fg={$statusColor}>{$health['status']}</>");
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Check', 'Status', 'Details'],
|
||||
collect($health['checks'])->map(function ($check, $name) {
|
||||
$status = $check['status'] ?? 'unknown';
|
||||
$statusColor = match ($status) {
|
||||
'healthy' => 'green',
|
||||
'warning' => 'yellow',
|
||||
'critical' => 'red',
|
||||
default => 'white',
|
||||
};
|
||||
|
||||
$details = collect($check)
|
||||
->except('status', 'error')
|
||||
->map(fn ($v, $k) => "{$k}: {$v}")
|
||||
->implode(', ');
|
||||
|
||||
if (isset($check['error'])) {
|
||||
$details = "Error: {$check['error']}";
|
||||
}
|
||||
|
||||
return [
|
||||
$name,
|
||||
"<fg={$statusColor}>{$status}</>",
|
||||
$details ?: '-',
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
|
||||
if (! empty($health['alerts'])) {
|
||||
$this->newLine();
|
||||
$this->warn('Alerts:');
|
||||
foreach ($health['alerts'] as $alert) {
|
||||
$icon = $alert['severity'] === 'critical' ? '!!!' : '!';
|
||||
$this->line(" [{$icon}] {$alert['title']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function attemptRestart(): void
|
||||
{
|
||||
$this->warn('Attempting to restart Horizon...');
|
||||
|
||||
// Check consecutive failure count
|
||||
$failureKey = 'horizon_restart_failures';
|
||||
$this->consecutiveFailures = (int) Cache::get($failureKey, 0);
|
||||
|
||||
if ($this->consecutiveFailures >= 2) {
|
||||
$this->error('Restart failed twice. Escalating critical alert.');
|
||||
|
||||
SystemAlert::critical(
|
||||
SystemAlert::TYPE_HORIZON_DOWN,
|
||||
SystemAlert::SOURCE_HORIZON,
|
||||
'Horizon restart failed multiple times',
|
||||
'Horizon has failed to restart after 2 attempts. Manual intervention required.',
|
||||
['consecutive_failures' => $this->consecutiveFailures]
|
||||
);
|
||||
|
||||
// Reset counter
|
||||
Cache::forget($failureKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to terminate and restart via Artisan
|
||||
Artisan::call('horizon:terminate');
|
||||
$this->info('Horizon terminate signal sent.');
|
||||
|
||||
// The actual restart would be handled by supervisor/docker
|
||||
// We just send the terminate signal here
|
||||
|
||||
// Wait a moment and check again
|
||||
sleep(5);
|
||||
|
||||
$health = $this->governance->checkHorizonHealth();
|
||||
|
||||
if ($health['status'] === 'healthy') {
|
||||
$this->info('Horizon restarted successfully.');
|
||||
Cache::forget($failureKey);
|
||||
} else {
|
||||
Cache::put($failureKey, $this->consecutiveFailures + 1, now()->addHours(1));
|
||||
$this->warn('Horizon still unhealthy after restart attempt.');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Cache::put($failureKey, $this->consecutiveFailures + 1, now()->addHours(1));
|
||||
$this->error('Restart attempt failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function sendCriticalNotification(SystemAlert $alert): void
|
||||
{
|
||||
try {
|
||||
// Get admin users to notify
|
||||
$admins = \App\Models\User::where('user_type', 'admin')
|
||||
->orWhere('user_type', 'superadmin')
|
||||
->get();
|
||||
|
||||
if ($admins->isNotEmpty()) {
|
||||
Notification::send($admins, new OrchestratorCriticalAlert($alert));
|
||||
$alert->markNotificationSent();
|
||||
$this->info('Critical alert notification sent.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->warn('Failed to send notification: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
255
app/Console/Commands/OrchestratorEvaluateOutcomes.php
Normal file
255
app/Console/Commands/OrchestratorEvaluateOutcomes.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\MenuViewEvent;
|
||||
use App\Models\OrchestratorMessageVariantStat;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\Order;
|
||||
use App\Models\SendMenuLog;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* OrchestratorEvaluateOutcomes - Evaluates outcomes of menu sends.
|
||||
*
|
||||
* This command runs periodically to determine if menu sends resulted in:
|
||||
* - Menu views (buyer viewed the menu after send)
|
||||
* - Orders (buyer placed an order after send)
|
||||
*
|
||||
* The outcomes are stored on both SendMenuLog and OrchestratorTask for analytics.
|
||||
*/
|
||||
class OrchestratorEvaluateOutcomes extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:evaluate-outcomes
|
||||
{--days=30 : Number of days back to evaluate}
|
||||
{--dry-run : Show what would be updated without making changes}';
|
||||
|
||||
protected $description = 'Evaluate outcomes (views/orders) for menu sends and orchestrator tasks';
|
||||
|
||||
protected int $viewsFound = 0;
|
||||
|
||||
protected int $ordersFound = 0;
|
||||
|
||||
protected int $logsUpdated = 0;
|
||||
|
||||
protected int $tasksUpdated = 0;
|
||||
|
||||
protected int $variantStatsRecorded = 0;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_EVALUATE_OUTCOMES);
|
||||
|
||||
$days = (int) $this->option('days');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info("Evaluating outcomes for send_menu_logs from the last {$days} days...");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN - No changes will be made.');
|
||||
}
|
||||
|
||||
// Get send menu logs that haven't been evaluated yet
|
||||
$logs = SendMenuLog::query()
|
||||
->whereNull('outcome_checked_at')
|
||||
->where('sent_at', '>=', now()->subDays($days))
|
||||
->with(['brand', 'menu', 'customer'])
|
||||
->orderBy('sent_at', 'asc')
|
||||
->get();
|
||||
|
||||
$this->info("Found {$logs->count()} logs to evaluate.");
|
||||
|
||||
$progressBar = $this->output->createProgressBar($logs->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$this->evaluateSendMenuLog($log, $dryRun);
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// Summary
|
||||
$this->info('Evaluation complete:');
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Logs evaluated', $logs->count()],
|
||||
['Views found', $this->viewsFound],
|
||||
['Orders found', $this->ordersFound],
|
||||
['Send logs updated', $this->logsUpdated],
|
||||
['Orchestrator tasks updated', $this->tasksUpdated],
|
||||
['Variant stats recorded', $this->variantStatsRecorded],
|
||||
]
|
||||
);
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_EVALUATE_OUTCOMES, [
|
||||
'logs_evaluated' => $logs->count(),
|
||||
'views_found' => $this->viewsFound,
|
||||
'orders_found' => $this->ordersFound,
|
||||
'tasks_updated' => $this->tasksUpdated,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single SendMenuLog for outcomes.
|
||||
*/
|
||||
protected function evaluateSendMenuLog(SendMenuLog $log, bool $dryRun): void
|
||||
{
|
||||
$resultedInView = false;
|
||||
$resultedInOrder = false;
|
||||
|
||||
// Look for menu view events after the send
|
||||
$viewExists = MenuViewEvent::query()
|
||||
->where('business_id', $log->brand?->business_id)
|
||||
->where('customer_id', $log->customer_id)
|
||||
->where('menu_id', $log->menu_id)
|
||||
->where('viewed_at', '>', $log->sent_at)
|
||||
->exists();
|
||||
|
||||
if ($viewExists) {
|
||||
$resultedInView = true;
|
||||
$this->viewsFound++;
|
||||
}
|
||||
|
||||
// Look for orders after the send (if Order table has the columns we need)
|
||||
if (Schema::hasColumn('orders', 'business_id')) {
|
||||
$orderExists = Order::query()
|
||||
->where('business_id', $log->customer_id) // buyer's business
|
||||
->whereHas('items.product.brand', function ($q) use ($log) {
|
||||
$q->where('business_id', $log->brand?->business_id);
|
||||
})
|
||||
->where('created_at', '>', $log->sent_at)
|
||||
->where('created_at', '<=', $log->sent_at->copy()->addDays(7)) // Within 7 days
|
||||
->exists();
|
||||
|
||||
if ($orderExists) {
|
||||
$resultedInOrder = true;
|
||||
$this->ordersFound++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update SendMenuLog
|
||||
if (! $dryRun) {
|
||||
$log->update([
|
||||
'resulted_in_view' => $resultedInView,
|
||||
'resulted_in_order' => $resultedInOrder,
|
||||
'outcome_checked_at' => now(),
|
||||
]);
|
||||
$this->logsUpdated++;
|
||||
}
|
||||
|
||||
// Update related OrchestratorTask if one exists
|
||||
$this->updateRelatedTask($log, $resultedInView, $resultedInOrder, $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the orchestrator task associated with this send, if any.
|
||||
*/
|
||||
protected function updateRelatedTask(SendMenuLog $log, bool $resultedInView, bool $resultedInOrder, bool $dryRun): void
|
||||
{
|
||||
// Check if outcome columns exist on orchestrator_tasks
|
||||
if (! Schema::hasColumn('orchestrator_tasks', 'resulted_in_view')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find tasks that reference this send_menu_log_id in their payload
|
||||
$tasks = OrchestratorTask::query()
|
||||
->where('business_id', $log->brand?->business_id)
|
||||
->where('customer_id', $log->customer_id)
|
||||
->whereJsonContains('payload->send_menu_log_id', $log->id)
|
||||
->whereNull('outcome_checked_at')
|
||||
->get();
|
||||
|
||||
if ($tasks->isEmpty()) {
|
||||
// Also try finding by menu_id and rough time match
|
||||
$tasks = OrchestratorTask::query()
|
||||
->where('business_id', $log->brand?->business_id)
|
||||
->where('customer_id', $log->customer_id)
|
||||
->whereJsonContains('payload->menu_id', $log->menu_id)
|
||||
->where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||
->where('completed_at', '>=', $log->sent_at->copy()->subMinutes(30))
|
||||
->where('completed_at', '<=', $log->sent_at->copy()->addMinutes(30))
|
||||
->whereNull('outcome_checked_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
if (! $dryRun) {
|
||||
$task->update([
|
||||
'resulted_in_view' => $resultedInView,
|
||||
'resulted_in_order' => $resultedInOrder,
|
||||
'outcome_checked_at' => now(),
|
||||
]);
|
||||
$this->tasksUpdated++;
|
||||
|
||||
// Track A/B variant stats if this task used a variant
|
||||
$this->recordVariantStats($task, $resultedInView, $resultedInOrder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record A/B variant statistics for a task.
|
||||
*
|
||||
* Increments view and/or order counts for the message variant used by this task.
|
||||
* Send count is recorded separately when tasks are completed (via SendMenuLog tracking).
|
||||
*/
|
||||
protected function recordVariantStats(OrchestratorTask $task, bool $resultedInView, bool $resultedInOrder): void
|
||||
{
|
||||
// Check if variant stats table exists
|
||||
if (! Schema::hasTable('orchestrator_message_variant_stats')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $task->payload ?? [];
|
||||
$variantKey = $payload['message_variant_key'] ?? null;
|
||||
|
||||
if (! $variantKey) {
|
||||
return; // Task didn't use a variant
|
||||
}
|
||||
|
||||
$businessId = $task->business_id;
|
||||
$brandId = $task->brand_id;
|
||||
$playbookType = $task->type;
|
||||
|
||||
// Record view stat
|
||||
if ($resultedInView) {
|
||||
OrchestratorMessageVariantStat::incrementView(
|
||||
$businessId,
|
||||
$brandId,
|
||||
$playbookType,
|
||||
$variantKey
|
||||
);
|
||||
$this->variantStatsRecorded++;
|
||||
}
|
||||
|
||||
// Record order stat
|
||||
if ($resultedInOrder) {
|
||||
OrchestratorMessageVariantStat::incrementOrder(
|
||||
$businessId,
|
||||
$brandId,
|
||||
$playbookType,
|
||||
$variantKey
|
||||
);
|
||||
$this->variantStatsRecorded++;
|
||||
}
|
||||
|
||||
// Record send stat (task was completed = message was sent)
|
||||
// Only count once per task, check if this task was just now having its outcome evaluated
|
||||
if ($task->status === OrchestratorTask::STATUS_COMPLETED) {
|
||||
OrchestratorMessageVariantStat::incrementSend(
|
||||
$businessId,
|
||||
$brandId,
|
||||
$playbookType,
|
||||
$variantKey
|
||||
);
|
||||
$this->variantStatsRecorded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/OrchestratorEvaluatePlaybooks.php
Normal file
149
app/Console/Commands/OrchestratorEvaluatePlaybooks.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\OrchestratorPlaybookStatus;
|
||||
use App\Models\OrchestratorTask;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* OrchestratorEvaluatePlaybooks - Evaluates playbook performance and auto-quarantines.
|
||||
*
|
||||
* Analyzes 30-day rolling metrics for each playbook and quarantines those
|
||||
* that fall below performance thresholds. This prevents misbehaving playbooks
|
||||
* from generating poor suggestions.
|
||||
*/
|
||||
class OrchestratorEvaluatePlaybooks extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:evaluate-playbooks
|
||||
{--dry-run : Show what would happen without making changes}
|
||||
{--business= : Evaluate for specific business ID}
|
||||
{--auto-quarantine : Actually quarantine underperformers (default: report only)}';
|
||||
|
||||
protected $description = 'Evaluate playbook performance and auto-quarantine underperformers';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_EVALUATE_PLAYBOOKS);
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$businessId = $this->option('business');
|
||||
$autoQuarantine = $this->option('auto-quarantine');
|
||||
|
||||
$this->info('Evaluating playbook performance...');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN - No changes will be made.');
|
||||
}
|
||||
|
||||
$playbooks = OrchestratorPlaybookStatus::getAllPlaybookTypes();
|
||||
$thirtyDaysAgo = now()->subDays(30);
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($playbooks as $playbookType) {
|
||||
$this->line("Analyzing: {$playbookType}");
|
||||
|
||||
// Get task metrics
|
||||
$query = OrchestratorTask::forType($playbookType)
|
||||
->where('created_at', '>=', $thirtyDaysAgo);
|
||||
|
||||
if ($businessId) {
|
||||
$query->forBusiness((int) $businessId);
|
||||
}
|
||||
|
||||
$created = $query->count();
|
||||
$completed = (clone $query)->completed()->count();
|
||||
$dismissed = (clone $query)->dismissed()->count();
|
||||
$resultedInView = (clone $query)->where('resulted_in_view', true)->count();
|
||||
$resultedInOrder = (clone $query)->where('resulted_in_order', true)->count();
|
||||
|
||||
// Calculate rates
|
||||
$resolved = $completed + $dismissed;
|
||||
$viewRate = $resolved > 0 ? round(($resultedInView / $resolved) * 100, 2) : 0;
|
||||
$orderRate = $resolved > 0 ? round(($resultedInOrder / $resolved) * 100, 2) : 0;
|
||||
$dismissalRate = $resolved > 0 ? round(($dismissed / $resolved) * 100, 2) : 0;
|
||||
|
||||
// Get or create status record
|
||||
$status = OrchestratorPlaybookStatus::getOrCreate($playbookType, $businessId ? (int) $businessId : null);
|
||||
|
||||
// Update metrics
|
||||
if (! $dryRun) {
|
||||
$status->updateMetrics([
|
||||
'tasks_created_30d' => $created,
|
||||
'tasks_completed_30d' => $completed,
|
||||
'tasks_dismissed_30d' => $dismissed,
|
||||
'resulted_in_view_30d' => $resultedInView,
|
||||
'resulted_in_order_30d' => $resultedInOrder,
|
||||
'view_rate_30d' => $viewRate,
|
||||
'order_rate_30d' => $orderRate,
|
||||
'dismissal_rate_30d' => $dismissalRate,
|
||||
]);
|
||||
}
|
||||
|
||||
// Check quarantine conditions
|
||||
$shouldQuarantine = $status->shouldQuarantine();
|
||||
$healthStatus = $status->getHealthStatus();
|
||||
|
||||
$results[] = [
|
||||
'playbook' => $this->formatPlaybookName($playbookType),
|
||||
'status' => $status->status,
|
||||
'created' => $created,
|
||||
'completed' => $completed,
|
||||
'dismissed' => $dismissed,
|
||||
'view_rate' => $viewRate.'%',
|
||||
'order_rate' => $orderRate.'%',
|
||||
'dismissal' => $dismissalRate.'%',
|
||||
'health' => $healthStatus,
|
||||
'action' => $shouldQuarantine ? 'QUARANTINE' : '-',
|
||||
];
|
||||
|
||||
// Auto-quarantine if enabled
|
||||
if ($shouldQuarantine && $autoQuarantine && ! $dryRun && $status->isActive()) {
|
||||
$status->quarantine($shouldQuarantine);
|
||||
$this->error(" QUARANTINED: {$shouldQuarantine}");
|
||||
} elseif ($shouldQuarantine) {
|
||||
$this->warn(" Would quarantine: {$shouldQuarantine}");
|
||||
}
|
||||
}
|
||||
|
||||
// Display results table
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Playbook', 'Status', 'Created', 'Done', 'Dismissed', 'View%', 'Order%', 'Dismiss%', 'Health', 'Action'],
|
||||
$results
|
||||
);
|
||||
|
||||
// Summary
|
||||
$quarantined = collect($results)->where('action', 'QUARANTINE')->count();
|
||||
$this->newLine();
|
||||
$this->info("Evaluation complete. {$quarantined} playbook(s) flagged for quarantine.");
|
||||
|
||||
if ($quarantined > 0 && ! $autoQuarantine) {
|
||||
$this->warn('Run with --auto-quarantine to actually quarantine underperformers.');
|
||||
}
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_EVALUATE_PLAYBOOKS, [
|
||||
'playbooks_evaluated' => count($playbooks),
|
||||
'quarantined' => $quarantined,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatPlaybookName(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
OrchestratorTask::TYPE_MENU_FOLLOWUP_NO_VIEW => 'No View',
|
||||
OrchestratorTask::TYPE_MENU_FOLLOWUP_VIEWED_NO_ORDER => 'Viewed No Order',
|
||||
OrchestratorTask::TYPE_REACTIVATION_NO_ORDER_30D => 'Reactivation',
|
||||
OrchestratorTask::TYPE_PROMOTION_BROADCAST_SUGGESTION => 'New Menu',
|
||||
OrchestratorTask::TYPE_HIGH_INTENT_BUYER => 'High Intent',
|
||||
OrchestratorTask::TYPE_VIP_BUYER => 'VIP Buyer',
|
||||
OrchestratorTask::TYPE_GHOSTED_BUYER_RESCUE => 'Ghosted',
|
||||
OrchestratorTask::TYPE_AT_RISK_ACCOUNT => 'At-Risk',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
}
|
||||
413
app/Console/Commands/OrchestratorSelfAudit.php
Normal file
413
app/Console/Commands/OrchestratorSelfAudit.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\BrandOrchestratorProfile;
|
||||
use App\Models\OrchestratorMessageVariant;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\SystemAlert;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class OrchestratorSelfAudit extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:self-audit
|
||||
{--fix : Attempt to fix issues where possible}
|
||||
{--days=30 : Days to look back for stale tasks}';
|
||||
|
||||
protected $description = 'Audit orchestrator data for integrity issues';
|
||||
|
||||
private array $issues = [];
|
||||
|
||||
private array $fixes = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_SELF_AUDIT);
|
||||
|
||||
$this->info('Running orchestrator self-audit...');
|
||||
$this->newLine();
|
||||
|
||||
$days = (int) $this->option('days');
|
||||
$shouldFix = $this->option('fix');
|
||||
|
||||
try {
|
||||
// Run all audit checks
|
||||
$this->auditStaleTasks($days, $shouldFix);
|
||||
$this->auditMissingPayloadFields($shouldFix);
|
||||
$this->auditImpossibleStates($shouldFix);
|
||||
$this->auditBrandProfiles();
|
||||
$this->auditMessageVariants();
|
||||
$this->auditMissingOutcomes($days);
|
||||
$this->auditOrphanedRecords();
|
||||
|
||||
// Display results
|
||||
$this->displayResults();
|
||||
|
||||
// Create system alerts for significant issues
|
||||
$this->createAlertsForIssues();
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_SELF_AUDIT, [
|
||||
'issues_found' => count($this->issues),
|
||||
'fixes_applied' => count($this->fixes),
|
||||
'days_audited' => $days,
|
||||
]);
|
||||
|
||||
return count($this->issues) > 0 ? self::FAILURE : self::SUCCESS;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
AutomationRunLog::recordFailure(AutomationRunLog::CMD_SELF_AUDIT, $e->getMessage());
|
||||
|
||||
$this->error('Self-audit failed: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function auditStaleTasks(int $days, bool $fix): void
|
||||
{
|
||||
$this->info('Checking for stale uncompleted tasks...');
|
||||
|
||||
$staleCount = OrchestratorTask::where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->where('created_at', '<', now()->subDays($days))
|
||||
->count();
|
||||
|
||||
if ($staleCount > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'stale_tasks',
|
||||
'severity' => $staleCount > 100 ? 'warning' : 'info',
|
||||
'message' => "{$staleCount} tasks pending for more than {$days} days",
|
||||
'count' => $staleCount,
|
||||
];
|
||||
|
||||
if ($fix && $staleCount > 0) {
|
||||
// Auto-dismiss very old tasks (older than 60 days)
|
||||
$veryOld = OrchestratorTask::where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->where('created_at', '<', now()->subDays(60))
|
||||
->update([
|
||||
'status' => OrchestratorTask::STATUS_DISMISSED,
|
||||
'dismissed_reason' => 'Auto-dismissed by self-audit (>60 days old)',
|
||||
]);
|
||||
|
||||
if ($veryOld > 0) {
|
||||
$this->fixes[] = "Auto-dismissed {$veryOld} tasks older than 60 days";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(" Found {$staleCount} stale tasks");
|
||||
}
|
||||
|
||||
private function auditMissingPayloadFields(bool $fix): void
|
||||
{
|
||||
$this->info('Checking for tasks with missing payload fields...');
|
||||
|
||||
// Tasks should have suggested_message in payload
|
||||
$missingMessage = OrchestratorTask::whereNull('payload')
|
||||
->orWhereRaw("payload::text = '{}'")
|
||||
->orWhereRaw("payload::text = 'null'")
|
||||
->count();
|
||||
|
||||
if ($missingMessage > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'missing_payload',
|
||||
'severity' => 'info',
|
||||
'message' => "{$missingMessage} tasks have empty or null payload",
|
||||
'count' => $missingMessage,
|
||||
];
|
||||
}
|
||||
|
||||
$this->line(" Found {$missingMessage} tasks with missing payload");
|
||||
}
|
||||
|
||||
private function auditImpossibleStates(bool $fix): void
|
||||
{
|
||||
$this->info('Checking for impossible task states...');
|
||||
|
||||
$issues = 0;
|
||||
|
||||
// Approved but not visible to reps
|
||||
if (Schema::hasColumn('orchestrator_tasks', 'approval_state')) {
|
||||
$approvedInvisible = OrchestratorTask::where('approval_state', 'approved')
|
||||
->where('visible_to_reps', false)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->count();
|
||||
|
||||
if ($approvedInvisible > 0) {
|
||||
$issues += $approvedInvisible;
|
||||
$this->issues[] = [
|
||||
'type' => 'impossible_state',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$approvedInvisible} tasks are approved but not visible to reps",
|
||||
'count' => $approvedInvisible,
|
||||
];
|
||||
|
||||
if ($fix) {
|
||||
$fixed = OrchestratorTask::where('approval_state', 'approved')
|
||||
->where('visible_to_reps', false)
|
||||
->where('status', OrchestratorTask::STATUS_PENDING)
|
||||
->update(['visible_to_reps' => true]);
|
||||
|
||||
if ($fixed > 0) {
|
||||
$this->fixes[] = "Fixed {$fixed} approved tasks to be visible";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked but still visible
|
||||
$blockedVisible = OrchestratorTask::where('approval_state', 'blocked')
|
||||
->where('visible_to_reps', true)
|
||||
->count();
|
||||
|
||||
if ($blockedVisible > 0) {
|
||||
$issues += $blockedVisible;
|
||||
$this->issues[] = [
|
||||
'type' => 'impossible_state',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$blockedVisible} tasks are blocked but still visible to reps",
|
||||
'count' => $blockedVisible,
|
||||
];
|
||||
|
||||
if ($fix) {
|
||||
$fixed = OrchestratorTask::where('approval_state', 'blocked')
|
||||
->where('visible_to_reps', true)
|
||||
->update(['visible_to_reps' => false]);
|
||||
|
||||
if ($fixed > 0) {
|
||||
$this->fixes[] = "Fixed {$fixed} blocked tasks to be invisible";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completed but no completed_at
|
||||
$completedNoDate = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||
->whereNull('completed_at')
|
||||
->count();
|
||||
|
||||
if ($completedNoDate > 0) {
|
||||
$issues += $completedNoDate;
|
||||
$this->issues[] = [
|
||||
'type' => 'impossible_state',
|
||||
'severity' => 'info',
|
||||
'message' => "{$completedNoDate} completed tasks have no completed_at timestamp",
|
||||
'count' => $completedNoDate,
|
||||
];
|
||||
|
||||
if ($fix) {
|
||||
$fixed = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||
->whereNull('completed_at')
|
||||
->update(['completed_at' => DB::raw('updated_at')]);
|
||||
|
||||
if ($fixed > 0) {
|
||||
$this->fixes[] = "Set completed_at for {$fixed} completed tasks";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(" Found {$issues} impossible state issues");
|
||||
}
|
||||
|
||||
private function auditBrandProfiles(): void
|
||||
{
|
||||
$this->info('Checking brand orchestrator profiles...');
|
||||
|
||||
if (! Schema::hasTable('brand_orchestrator_profiles')) {
|
||||
$this->line(' Brand profiles table not found (skipping)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for profiles with invalid brand_id
|
||||
$orphanedProfiles = BrandOrchestratorProfile::whereDoesntHave('brand')->count();
|
||||
|
||||
if ($orphanedProfiles > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'orphaned_profiles',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$orphanedProfiles} brand profiles reference non-existent brands",
|
||||
'count' => $orphanedProfiles,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for duplicate brand profiles
|
||||
$duplicates = DB::table('brand_orchestrator_profiles')
|
||||
->select('brand_id', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('brand_id')
|
||||
->having('count', '>', 1)
|
||||
->count();
|
||||
|
||||
if ($duplicates > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'duplicate_profiles',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$duplicates} brands have duplicate orchestrator profiles",
|
||||
'count' => $duplicates,
|
||||
];
|
||||
}
|
||||
|
||||
$this->line(" Found {$orphanedProfiles} orphaned, {$duplicates} duplicate profiles");
|
||||
}
|
||||
|
||||
private function auditMessageVariants(): void
|
||||
{
|
||||
$this->info('Checking message variants...');
|
||||
|
||||
if (! Schema::hasTable('orchestrator_message_variants')) {
|
||||
$this->line(' Message variants table not found (skipping)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for variants with invalid business_id
|
||||
$orphanedVariants = OrchestratorMessageVariant::whereDoesntHave('business')->count();
|
||||
|
||||
if ($orphanedVariants > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'orphaned_variants',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$orphanedVariants} message variants reference non-existent businesses",
|
||||
'count' => $orphanedVariants,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for variants with empty body
|
||||
$emptyBody = OrchestratorMessageVariant::where(function ($q) {
|
||||
$q->whereNull('body')
|
||||
->orWhere('body', '');
|
||||
})->count();
|
||||
|
||||
if ($emptyBody > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'empty_variants',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$emptyBody} message variants have empty body text",
|
||||
'count' => $emptyBody,
|
||||
];
|
||||
}
|
||||
|
||||
$this->line(" Found {$orphanedVariants} orphaned, {$emptyBody} empty variants");
|
||||
}
|
||||
|
||||
private function auditMissingOutcomes(int $days): void
|
||||
{
|
||||
$this->info('Checking for tasks missing outcome evaluation...');
|
||||
|
||||
if (! Schema::hasColumn('orchestrator_tasks', 'outcome_checked_at')) {
|
||||
$this->line(' Outcome columns not found (skipping)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Tasks completed more than 7 days ago but never checked for outcomes
|
||||
$missingOutcomes = OrchestratorTask::where('status', OrchestratorTask::STATUS_COMPLETED)
|
||||
->whereNull('outcome_checked_at')
|
||||
->where('completed_at', '<', now()->subDays(7))
|
||||
->where('completed_at', '>=', now()->subDays($days))
|
||||
->count();
|
||||
|
||||
if ($missingOutcomes > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'missing_outcomes',
|
||||
'severity' => 'info',
|
||||
'message' => "{$missingOutcomes} completed tasks never had outcomes evaluated",
|
||||
'count' => $missingOutcomes,
|
||||
];
|
||||
}
|
||||
|
||||
$this->line(" Found {$missingOutcomes} tasks missing outcome evaluation");
|
||||
}
|
||||
|
||||
private function auditOrphanedRecords(): void
|
||||
{
|
||||
$this->info('Checking for orphaned records...');
|
||||
|
||||
// Tasks referencing non-existent businesses
|
||||
$orphanedByBusiness = OrchestratorTask::whereDoesntHave('business')->count();
|
||||
|
||||
if ($orphanedByBusiness > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'orphaned_tasks',
|
||||
'severity' => 'warning',
|
||||
'message' => "{$orphanedByBusiness} tasks reference non-existent businesses",
|
||||
'count' => $orphanedByBusiness,
|
||||
];
|
||||
}
|
||||
|
||||
// Tasks referencing non-existent customers
|
||||
$orphanedByCustomer = OrchestratorTask::whereNotNull('customer_id')
|
||||
->whereDoesntHave('customer')
|
||||
->count();
|
||||
|
||||
if ($orphanedByCustomer > 0) {
|
||||
$this->issues[] = [
|
||||
'type' => 'orphaned_tasks',
|
||||
'severity' => 'info',
|
||||
'message' => "{$orphanedByCustomer} tasks reference non-existent customers",
|
||||
'count' => $orphanedByCustomer,
|
||||
];
|
||||
}
|
||||
|
||||
$this->line(" Found {$orphanedByBusiness} orphaned by business, {$orphanedByCustomer} by customer");
|
||||
}
|
||||
|
||||
private function displayResults(): void
|
||||
{
|
||||
$this->newLine();
|
||||
|
||||
if (empty($this->issues)) {
|
||||
$this->info('No issues found. Data integrity looks good!');
|
||||
} else {
|
||||
$this->warn('Issues Found:');
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Type', 'Severity', 'Count', 'Message'],
|
||||
collect($this->issues)->map(fn ($issue) => [
|
||||
$issue['type'],
|
||||
$issue['severity'],
|
||||
$issue['count'] ?? '-',
|
||||
$issue['message'],
|
||||
])->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($this->fixes)) {
|
||||
$this->newLine();
|
||||
$this->info('Fixes Applied:');
|
||||
foreach ($this->fixes as $fix) {
|
||||
$this->line(" - {$fix}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('Summary: '.count($this->issues).' issues found, '.count($this->fixes).' fixes applied');
|
||||
}
|
||||
|
||||
private function createAlertsForIssues(): void
|
||||
{
|
||||
// Only create alerts for significant issues
|
||||
$significantIssues = collect($this->issues)
|
||||
->filter(fn ($issue) => $issue['severity'] === 'warning' || ($issue['count'] ?? 0) > 50);
|
||||
|
||||
if ($significantIssues->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$summary = $significantIssues
|
||||
->map(fn ($issue) => $issue['message'])
|
||||
->implode('; ');
|
||||
|
||||
SystemAlert::createAlert(
|
||||
SystemAlert::TYPE_AUDIT_ISSUE,
|
||||
SystemAlert::SEVERITY_WARNING,
|
||||
SystemAlert::SOURCE_SELF_AUDIT,
|
||||
'Self-audit found '.count($this->issues).' issues',
|
||||
$summary,
|
||||
['issues' => $this->issues, 'fixes' => $this->fixes],
|
||||
1440 // Dedupe: 24 hours
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/Console/Commands/OrchestratorSendDailyReport.php
Normal file
120
app/Console/Commands/OrchestratorSendDailyReport.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\DailySalesOpsReport;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class OrchestratorSendDailyReport extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:send-daily-report
|
||||
{--to= : Email address to send to (overrides default)}
|
||||
{--preview : Display report in console instead of sending}';
|
||||
|
||||
protected $description = 'Send the daily sales ops report email';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Preparing daily sales ops report...');
|
||||
|
||||
$report = new DailySalesOpsReport;
|
||||
|
||||
if ($this->option('preview')) {
|
||||
$this->displayPreview($report);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$recipients = $this->getRecipients();
|
||||
|
||||
if (empty($recipients)) {
|
||||
$this->warn('No recipients configured. Use --to option or configure admin emails.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Sending report to: '.implode(', ', $recipients));
|
||||
|
||||
try {
|
||||
Mail::to($recipients)->send($report);
|
||||
|
||||
$this->info('Daily report sent successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to send report: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function getRecipients(): array
|
||||
{
|
||||
// Check for override
|
||||
if ($to = $this->option('to')) {
|
||||
return [$to];
|
||||
}
|
||||
|
||||
// Get admin/superadmin users
|
||||
$admins = User::where('user_type', 'admin')
|
||||
->orWhere('user_type', 'superadmin')
|
||||
->whereNotNull('email')
|
||||
->pluck('email')
|
||||
->toArray();
|
||||
|
||||
// Could also check BusinessMailSettings for specific ops email
|
||||
// $opsEmail = config('orchestrator.daily_report_email');
|
||||
// if ($opsEmail) {
|
||||
// $admins[] = $opsEmail;
|
||||
// }
|
||||
|
||||
return array_unique($admins);
|
||||
}
|
||||
|
||||
private function displayPreview(DailySalesOpsReport $report): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('=== Daily Sales Ops Report Preview ===');
|
||||
$this->newLine();
|
||||
|
||||
$summary = $report->summary;
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Tasks Created (24h)', $summary['tasks_24h']['created']],
|
||||
['Completed', $summary['tasks_24h']['completed']],
|
||||
['Dismissed', $summary['tasks_24h']['dismissed']],
|
||||
['Pending', $summary['tasks_24h']['pending']],
|
||||
['High-Priority Pending', $summary['tasks_24h']['high_priority_pending']],
|
||||
['Awaiting Approval', $summary['approvals']['pending_count']],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Alerts:');
|
||||
$this->table(
|
||||
['Severity', 'Count'],
|
||||
[
|
||||
['Critical', $summary['alerts']['critical']],
|
||||
['Warning', $summary['alerts']['warning']],
|
||||
['Info', $summary['alerts']['info']],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Queue Health: '.$summary['queue_health']['status']);
|
||||
$this->info('Automation Health: '.$summary['automation_health']['overall_status']);
|
||||
|
||||
if (! empty($summary['alerts']['items'])) {
|
||||
$this->newLine();
|
||||
$this->warn('Alert Details:');
|
||||
foreach (array_slice($summary['alerts']['items'], 0, 5) as $alert) {
|
||||
$this->line(" [{$alert['severity']}] {$alert['title']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app/Console/Commands/OrchestratorWatchdog.php
Normal file
204
app/Console/Commands/OrchestratorWatchdog.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\SystemAlert;
|
||||
use App\Notifications\OrchestratorCriticalAlert;
|
||||
use App\Services\OrchestratorGovernanceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class OrchestratorWatchdog extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:watchdog
|
||||
{--notify : Send notifications for critical issues}';
|
||||
|
||||
protected $description = 'Watch automation schedules and alert on stale processes';
|
||||
|
||||
private OrchestratorGovernanceService $governance;
|
||||
|
||||
public function __construct(OrchestratorGovernanceService $governance)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->governance = $governance;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
AutomationRunLog::recordStart(AutomationRunLog::CMD_WATCHDOG);
|
||||
|
||||
$this->info('Running orchestrator watchdog checks...');
|
||||
$this->newLine();
|
||||
|
||||
$issues = [];
|
||||
$alertsCreated = 0;
|
||||
|
||||
try {
|
||||
// Check all automation schedules
|
||||
$statuses = AutomationRunLog::getAllStatuses();
|
||||
|
||||
$this->table(
|
||||
['Automation', 'Last Run', 'Status', 'Health'],
|
||||
collect($statuses)->map(function ($status) {
|
||||
$lastRun = $status['last_run_at']
|
||||
? $status['last_run_at']->diffForHumans()
|
||||
: 'Never';
|
||||
|
||||
$statusColor = match ($status['last_status']) {
|
||||
AutomationRunLog::STATUS_SUCCESS => 'green',
|
||||
AutomationRunLog::STATUS_FAILED => 'red',
|
||||
AutomationRunLog::STATUS_RUNNING => 'yellow',
|
||||
default => 'gray',
|
||||
};
|
||||
|
||||
$healthColor = $status['health'] === 'healthy' ? 'green' : 'red';
|
||||
|
||||
return [
|
||||
$status['command'],
|
||||
$lastRun,
|
||||
"<fg={$statusColor}>{$status['last_status']}</>",
|
||||
"<fg={$healthColor}>{$status['health']}</>",
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
|
||||
// Create alerts for unhealthy automations
|
||||
foreach ($statuses as $command => $status) {
|
||||
if ($status['health'] === 'unhealthy') {
|
||||
$issues[] = $command;
|
||||
|
||||
$alert = $this->createStaleAlert($command, $status);
|
||||
if ($alert) {
|
||||
$alertsCreated++;
|
||||
|
||||
if ($this->option('notify') && $status['is_failing']) {
|
||||
$this->sendNotification($alert);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-resolve previous alerts for this command
|
||||
SystemAlert::autoResolve(
|
||||
SystemAlert::TYPE_AUTOMATION_STALE,
|
||||
SystemAlert::SOURCE_WATCHDOG,
|
||||
"Auto-resolved: {$command} is running"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Run governance checks
|
||||
$this->newLine();
|
||||
$this->info('Running governance checks...');
|
||||
|
||||
$governanceAlerts = $this->governance->runAllChecks();
|
||||
|
||||
foreach ($governanceAlerts as $alertData) {
|
||||
$alert = SystemAlert::createAlert(
|
||||
$alertData['type'],
|
||||
$alertData['severity'],
|
||||
SystemAlert::SOURCE_GOVERNANCE,
|
||||
$alertData['title'],
|
||||
$alertData['message'],
|
||||
$alertData['context'] ?? [],
|
||||
60 // Dedupe: 1 hour
|
||||
);
|
||||
|
||||
if ($alert) {
|
||||
$alertsCreated++;
|
||||
$this->line(" [!] {$alertData['title']}");
|
||||
|
||||
if ($this->option('notify') && $alert->isCritical()) {
|
||||
$this->sendNotification($alert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
$this->newLine();
|
||||
$unhealthyCount = count($issues);
|
||||
$governanceIssues = count($governanceAlerts);
|
||||
|
||||
if ($unhealthyCount === 0 && $governanceIssues === 0) {
|
||||
$this->info('All systems healthy.');
|
||||
} else {
|
||||
$this->warn("Issues found: {$unhealthyCount} stale automations, {$governanceIssues} governance alerts");
|
||||
}
|
||||
|
||||
AutomationRunLog::recordSuccess(AutomationRunLog::CMD_WATCHDOG, [
|
||||
'unhealthy_automations' => $unhealthyCount,
|
||||
'governance_alerts' => $governanceIssues,
|
||||
'alerts_created' => $alertsCreated,
|
||||
]);
|
||||
|
||||
return $unhealthyCount > 0 ? self::FAILURE : self::SUCCESS;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
AutomationRunLog::recordFailure(AutomationRunLog::CMD_WATCHDOG, $e->getMessage());
|
||||
|
||||
$this->error('Watchdog failed: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function createStaleAlert(string $command, array $status): ?SystemAlert
|
||||
{
|
||||
$label = $this->getCommandLabel($command);
|
||||
$severity = $status['is_failing']
|
||||
? SystemAlert::SEVERITY_CRITICAL
|
||||
: SystemAlert::SEVERITY_WARNING;
|
||||
|
||||
$message = $status['is_stale']
|
||||
? "{$label} has not run in over {$status['expected_interval_minutes']} minutes."
|
||||
: "{$label} is failing. Last error: ".($status['last_error'] ?? 'Unknown');
|
||||
|
||||
return SystemAlert::createAlert(
|
||||
SystemAlert::TYPE_AUTOMATION_STALE,
|
||||
$severity,
|
||||
SystemAlert::SOURCE_WATCHDOG,
|
||||
"{$label} is unhealthy",
|
||||
$message,
|
||||
[
|
||||
'command' => $command,
|
||||
'last_run_at' => $status['last_run_at']?->toIso8601String(),
|
||||
'last_status' => $status['last_status'],
|
||||
'consecutive_failures' => $status['consecutive_failures'],
|
||||
'expected_interval' => $status['expected_interval_minutes'],
|
||||
],
|
||||
30 // Dedupe: 30 minutes
|
||||
);
|
||||
}
|
||||
|
||||
private function getCommandLabel(string $command): string
|
||||
{
|
||||
return match ($command) {
|
||||
AutomationRunLog::CMD_GENERATE_SALES_TASKS => 'Sales Task Generation',
|
||||
AutomationRunLog::CMD_GENERATE_MARKETING_TASKS => 'Marketing Task Generation',
|
||||
AutomationRunLog::CMD_EVALUATE_OUTCOMES => 'Outcome Evaluation',
|
||||
AutomationRunLog::CMD_ANALYZE_TIMING => 'Timing Analysis',
|
||||
AutomationRunLog::CMD_EVALUATE_PLAYBOOKS => 'Playbook Evaluation',
|
||||
AutomationRunLog::CMD_CHECK_HORIZON => 'Horizon Health Check',
|
||||
AutomationRunLog::CMD_WATCHDOG => 'Watchdog',
|
||||
AutomationRunLog::CMD_SELF_AUDIT => 'Self Audit',
|
||||
AutomationRunLog::CMD_BUYER_SCORING => 'Buyer Scoring',
|
||||
default => $command,
|
||||
};
|
||||
}
|
||||
|
||||
private function sendNotification(SystemAlert $alert): void
|
||||
{
|
||||
try {
|
||||
$admins = \App\Models\User::where('user_type', 'admin')
|
||||
->orWhere('user_type', 'superadmin')
|
||||
->get();
|
||||
|
||||
if ($admins->isNotEmpty()) {
|
||||
Notification::send($admins, new OrchestratorCriticalAlert($alert));
|
||||
$alert->markNotificationSent();
|
||||
$this->info('Notification sent for: '.$alert->title);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->warn('Failed to send notification: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/Console/Commands/PruneAudits.php
Normal file
207
app/Console/Commands/PruneAudits.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditPruningSettings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PruneAudits extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'audits:prune {--business= : Prune audits for specific business ID} {--dry-run : Show what would be deleted without actually deleting}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Prune old audit logs based on configured retention policies';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$businessId = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('🔍 DRY RUN MODE - No audits will be deleted');
|
||||
}
|
||||
|
||||
// Get businesses with pruning enabled
|
||||
$query = AuditPruningSettings::where('enabled', true);
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$settings = $query->get();
|
||||
|
||||
if ($settings->isEmpty()) {
|
||||
$this->info('No businesses have audit pruning enabled.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalDeleted = 0;
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
$business = $setting->business;
|
||||
$businessName = $business ? $business->name : 'Global';
|
||||
|
||||
$this->info("Processing: {$businessName}");
|
||||
$this->line(" Strategy: {$setting->strategy}");
|
||||
|
||||
$deleted = 0;
|
||||
|
||||
try {
|
||||
switch ($setting->strategy) {
|
||||
case 'revisions':
|
||||
$deleted = $this->pruneByRevisions($setting, $dryRun);
|
||||
break;
|
||||
|
||||
case 'time':
|
||||
$deleted = $this->pruneByTime($setting, $dryRun);
|
||||
break;
|
||||
|
||||
case 'hybrid':
|
||||
$deleted = $this->pruneHybrid($setting, $dryRun);
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$setting->update([
|
||||
'last_pruned_at' => now(),
|
||||
'last_pruned_count' => $deleted,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->line(" Deleted: {$deleted} audits");
|
||||
$totalDeleted += $deleted;
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" Error: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("✓ Pruning complete! Total deleted: {$totalDeleted}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune audits keeping only last N revisions per record
|
||||
*/
|
||||
protected function pruneByRevisions(AuditPruningSettings $setting, bool $dryRun = false): int
|
||||
{
|
||||
$keepRevisions = $setting->keep_revisions;
|
||||
$businessId = $setting->business_id;
|
||||
|
||||
// Get all unique auditable records
|
||||
$auditableRecords = DB::table('audits')
|
||||
->select('auditable_type', 'auditable_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
$totalDeleted = 0;
|
||||
|
||||
foreach ($auditableRecords as $record) {
|
||||
// Get IDs of audits to keep (last N revisions)
|
||||
$keepIds = DB::table('audits')
|
||||
->where('auditable_type', $record->auditable_type)
|
||||
->where('auditable_id', $record->auditable_id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($keepRevisions)
|
||||
->pluck('id');
|
||||
|
||||
// Delete older audits
|
||||
$query = DB::table('audits')
|
||||
->where('auditable_type', $record->auditable_type)
|
||||
->where('auditable_id', $record->auditable_id)
|
||||
->whereNotIn('id', $keepIds);
|
||||
|
||||
if ($dryRun) {
|
||||
$totalDeleted += $query->count();
|
||||
} else {
|
||||
$totalDeleted += $query->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return $totalDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune audits older than N days
|
||||
*/
|
||||
protected function pruneByTime(AuditPruningSettings $setting, bool $dryRun = false): int
|
||||
{
|
||||
$keepDays = $setting->keep_days;
|
||||
$cutoffDate = now()->subDays($keepDays);
|
||||
|
||||
$query = DB::table('audits')
|
||||
->where('created_at', '<', $cutoffDate);
|
||||
|
||||
if ($dryRun) {
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid: Keep last N revisions OR audits from last M days (union, whichever keeps more)
|
||||
*/
|
||||
protected function pruneHybrid(AuditPruningSettings $setting, bool $dryRun = false): int
|
||||
{
|
||||
$keepRevisions = $setting->keep_revisions;
|
||||
$keepDays = $setting->keep_days;
|
||||
$cutoffDate = now()->subDays($keepDays);
|
||||
|
||||
// Get all unique auditable records
|
||||
$auditableRecords = DB::table('audits')
|
||||
->select('auditable_type', 'auditable_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
$totalDeleted = 0;
|
||||
|
||||
foreach ($auditableRecords as $record) {
|
||||
// Get IDs to keep from both strategies
|
||||
$keepByRevision = DB::table('audits')
|
||||
->where('auditable_type', $record->auditable_type)
|
||||
->where('auditable_id', $record->auditable_id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($keepRevisions)
|
||||
->pluck('id');
|
||||
|
||||
$keepByTime = DB::table('audits')
|
||||
->where('auditable_type', $record->auditable_type)
|
||||
->where('auditable_id', $record->auditable_id)
|
||||
->where('created_at', '>=', $cutoffDate)
|
||||
->pluck('id');
|
||||
|
||||
// Union of both (keep if matches either rule)
|
||||
$keepIds = $keepByRevision->merge($keepByTime)->unique();
|
||||
|
||||
// Delete everything NOT in keep list
|
||||
$query = DB::table('audits')
|
||||
->where('auditable_type', $record->auditable_type)
|
||||
->where('auditable_id', $record->auditable_id)
|
||||
->whereNotIn('id', $keepIds);
|
||||
|
||||
if ($dryRun) {
|
||||
$totalDeleted += $query->count();
|
||||
} else {
|
||||
$totalDeleted += $query->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return $totalDeleted;
|
||||
}
|
||||
}
|
||||
159
app/Console/Commands/RestoreBrandMenus.php
Normal file
159
app/Console/Commands/RestoreBrandMenus.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Menu;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RestoreBrandMenus extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'restore:brand-menus
|
||||
{--business= : Business slug or ID to restore menus for}
|
||||
{--dry-run : Preview what would be created without saving}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Restore the 3 default menus for all brands under a business (idempotent)';
|
||||
|
||||
/**
|
||||
* The three default menus to create per brand.
|
||||
*/
|
||||
protected array $defaultMenus = [
|
||||
[
|
||||
'slug' => 'default',
|
||||
'name' => 'Default Menu',
|
||||
'description' => 'The default product menu for this brand',
|
||||
'type' => 'catalog',
|
||||
],
|
||||
[
|
||||
'slug' => 'promotions',
|
||||
'name' => 'On Sale',
|
||||
'description' => 'Products currently on promotion',
|
||||
'type' => 'promotional',
|
||||
],
|
||||
[
|
||||
'slug' => 'new-drops',
|
||||
'name' => 'New & Featured',
|
||||
'description' => 'New arrivals and featured products',
|
||||
'type' => 'featured',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessSlugOrId = $this->option('business');
|
||||
$isDryRun = $this->option('dry-run');
|
||||
|
||||
if (! $businessSlugOrId) {
|
||||
$this->error('Please specify a business with --business=<slug or id>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Resolve business by slug first, then by ID if numeric
|
||||
$business = Business::where('slug', $businessSlugOrId)->first();
|
||||
if (! $business && is_numeric($businessSlugOrId)) {
|
||||
$business = Business::find($businessSlugOrId);
|
||||
}
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business not found: {$businessSlugOrId}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Restoring brand menus for business: {$business->name} (ID: {$business->id})");
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('DRY RUN - no changes will be made');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Load all brands for this business
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
|
||||
if ($brands->isEmpty()) {
|
||||
$this->warn('No brands found for this business.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$brands->count()} brand(s)");
|
||||
$this->newLine();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$this->line("Brand: {$brand->name} (ID: {$brand->id})");
|
||||
$this->line(str_repeat('─', 50));
|
||||
|
||||
foreach ($this->defaultMenus as $menuData) {
|
||||
$exists = Menu::where('business_id', $business->id)
|
||||
->where('brand_id', $brand->id)
|
||||
->where('slug', $menuData['slug'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$this->line(" ⏭ {$menuData['name']} ({$menuData['slug']}) - already exists");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->line(" ✓ {$menuData['name']} ({$menuData['slug']}) - would create");
|
||||
$created++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Menu::create([
|
||||
'business_id' => $business->id,
|
||||
'brand_id' => $brand->id,
|
||||
'slug' => $menuData['slug'],
|
||||
'name' => $menuData['name'],
|
||||
'description' => $menuData['description'],
|
||||
'type' => $menuData['type'],
|
||||
'is_system' => false,
|
||||
'status' => 'active',
|
||||
'visibility' => 'public',
|
||||
'position' => array_search($menuData, $this->defaultMenus) + 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✓ {$menuData['name']} ({$menuData['slug']}) - CREATED");
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Summary
|
||||
$this->newLine();
|
||||
$this->info('Summary');
|
||||
$this->line(str_repeat('═', 50));
|
||||
$this->line(" Created: {$created} menu(s)");
|
||||
$this->line(" Skipped: {$skipped} (already exist)");
|
||||
$this->line(str_repeat('─', 50));
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
186
app/Console/Commands/RestoreCannabrandsData.php
Normal file
186
app/Console/Commands/RestoreCannabrandsData.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Restore Cannabrands data from PostgreSQL SQL dumps.
|
||||
*
|
||||
* This command loads data from pre-exported SQL files in database/dumps/
|
||||
* without requiring a MySQL connection. Data was originally imported from
|
||||
* the MySQL hub_cannabrands database.
|
||||
*
|
||||
* Order of restoration matters due to foreign key constraints:
|
||||
* 1. strains (no dependencies)
|
||||
* 2. product_categories (self-referential via parent_id)
|
||||
* 3. businesses (no dependencies)
|
||||
* 4. users (no dependencies)
|
||||
* 5. brands (depends on businesses)
|
||||
* 6. locations (depends on businesses)
|
||||
* 7. contacts (depends on businesses, locations)
|
||||
* 8. products (depends on brands, strains, product_categories)
|
||||
* 9. orders (depends on businesses)
|
||||
* 10. order_items (depends on orders, products)
|
||||
* 11. invoices (depends on orders, businesses)
|
||||
* 12. business_user (depends on businesses, users)
|
||||
* 13. brand_user (depends on brands, users)
|
||||
* 14. model_has_roles (depends on users, roles)
|
||||
* 15. ai_settings (depends on businesses)
|
||||
* 16. orchestrator_sales_configs (depends on businesses)
|
||||
* 17. orchestrator_marketing_configs (depends on businesses)
|
||||
*/
|
||||
class RestoreCannabrandsData extends Command
|
||||
{
|
||||
protected $signature = 'db:restore-cannabrands
|
||||
{--fresh : Truncate tables before restoring}
|
||||
{--tables= : Comma-separated list of specific tables to restore}';
|
||||
|
||||
protected $description = 'Restore Cannabrands data from PostgreSQL SQL dumps';
|
||||
|
||||
// Tables in dependency order
|
||||
protected array $tables = [
|
||||
'strains',
|
||||
'product_categories',
|
||||
'businesses',
|
||||
'users',
|
||||
'brands',
|
||||
'locations',
|
||||
'contacts',
|
||||
'products',
|
||||
'orders',
|
||||
'order_items',
|
||||
'invoices',
|
||||
'business_user',
|
||||
'brand_user',
|
||||
'model_has_roles',
|
||||
'ai_settings',
|
||||
'orchestrator_sales_configs',
|
||||
'orchestrator_marketing_configs',
|
||||
];
|
||||
|
||||
protected string $dumpsPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dumpsPath = database_path('dumps');
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Restoring Cannabrands data from SQL dumps...');
|
||||
|
||||
// Check if dumps directory exists
|
||||
if (! is_dir($this->dumpsPath)) {
|
||||
$this->error("Dumps directory not found: {$this->dumpsPath}");
|
||||
$this->error('Run the MySQL import seeders first to create the dumps.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Determine which tables to restore
|
||||
$tablesToRestore = $this->tables;
|
||||
if ($this->option('tables')) {
|
||||
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
|
||||
$tablesToRestore = array_intersect($this->tables, $requestedTables);
|
||||
|
||||
if (empty($tablesToRestore)) {
|
||||
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh option - truncate tables in reverse order
|
||||
if ($this->option('fresh')) {
|
||||
$this->warn('Truncating tables before restore...');
|
||||
DB::statement('SET session_replication_role = replica;'); // Disable FK checks
|
||||
|
||||
foreach (array_reverse($tablesToRestore) as $table) {
|
||||
$this->line("Truncating {$table}...");
|
||||
DB::table($table)->truncate();
|
||||
}
|
||||
|
||||
DB::statement('SET session_replication_role = DEFAULT;'); // Re-enable FK checks
|
||||
}
|
||||
|
||||
// Restore each table
|
||||
$restored = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($tablesToRestore as $table) {
|
||||
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
|
||||
|
||||
if (! file_exists($dumpFile)) {
|
||||
$this->warn("Dump file not found for {$table}: {$dumpFile}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line("Restoring {$table}...");
|
||||
|
||||
try {
|
||||
$sql = file_get_contents($dumpFile);
|
||||
|
||||
if (empty(trim($sql))) {
|
||||
$this->info(' -> 0 rows (empty file)');
|
||||
$restored++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Disable FK checks for this session to allow loading in any order
|
||||
DB::statement('SET session_replication_role = replica;');
|
||||
|
||||
// Execute all statements at once
|
||||
DB::unprepared($sql);
|
||||
|
||||
// Re-enable FK checks
|
||||
DB::statement('SET session_replication_role = DEFAULT;');
|
||||
|
||||
// Count rows
|
||||
$count = DB::table($table)->count();
|
||||
$this->info(" -> {$count} rows in {$table}");
|
||||
$restored++;
|
||||
} catch (\Exception $e) {
|
||||
// Re-enable FK checks even on error
|
||||
try {
|
||||
DB::statement('SET session_replication_role = DEFAULT;');
|
||||
} catch (\Exception $ignored) {
|
||||
}
|
||||
|
||||
$this->error("Failed to restore {$table}: ".$e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sequences to max ID + 1 for each table
|
||||
$this->info('Resetting sequence counters...');
|
||||
foreach ($tablesToRestore as $table) {
|
||||
$this->resetSequence($table);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Restored {$restored} tables. Errors: {$errors}");
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the sequence for a table to max ID + 1.
|
||||
*/
|
||||
protected function resetSequence(string $table): void
|
||||
{
|
||||
try {
|
||||
$maxId = DB::table($table)->max('id');
|
||||
if ($maxId) {
|
||||
$sequence = "{$table}_id_seq";
|
||||
DB::statement("SELECT setval('{$sequence}', ?)", [$maxId]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Sequence might not exist for this table
|
||||
}
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\RunMarketingAutomationJob;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunDueMarketingAutomations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'marketing:run-due-automations
|
||||
{--business= : Only process automations for a specific business ID}
|
||||
{--dry-run : Show which automations would run without executing them}
|
||||
{--sync : Run synchronously instead of dispatching to queue}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check and run all due marketing automations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
$this->info('Checking for due marketing automations...');
|
||||
|
||||
// Query active automations
|
||||
$query = MarketingAutomation::where('is_active', true)
|
||||
->whereIn('trigger_type', [
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
|
||||
]);
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$automations = $query->get();
|
||||
|
||||
if ($automations->isEmpty()) {
|
||||
$this->info('No active automations found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$automations->count()} active automation(s).");
|
||||
|
||||
$dueCount = 0;
|
||||
|
||||
foreach ($automations as $automation) {
|
||||
if (! $automation->isDue()) {
|
||||
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dueCount++;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
|
||||
$this->line(" Trigger: {$automation->trigger_type_label}");
|
||||
$this->line(" Frequency: {$automation->frequency_label}");
|
||||
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
|
||||
|
||||
if ($sync) {
|
||||
// Run synchronously
|
||||
try {
|
||||
$job = new RunMarketingAutomationJob($automation->id);
|
||||
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
|
||||
$this->line(' <info>Completed</info>');
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" Failed: {$e->getMessage()}");
|
||||
}
|
||||
} else {
|
||||
// Dispatch to queue
|
||||
RunMarketingAutomationJob::dispatch($automation->id);
|
||||
$this->line(' <info>Dispatched to queue</info>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\FixedAssetService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Run monthly depreciation for fixed assets.
|
||||
*
|
||||
* This command calculates and posts depreciation entries for all
|
||||
* eligible fixed assets. Can be run for a specific business or all
|
||||
* businesses with Management Suite enabled.
|
||||
*
|
||||
* Safe to run multiple times in the same month - assets that have
|
||||
* already been depreciated for the period will be skipped.
|
||||
*/
|
||||
class RunFixedAssetDepreciation extends Command
|
||||
{
|
||||
protected $signature = 'fixed-assets:run-depreciation
|
||||
{business_id? : Specific business ID to run for}
|
||||
{--period= : Period date (Y-m-d format, defaults to end of current month)}
|
||||
{--dry-run : Show what would be depreciated without making changes}';
|
||||
|
||||
protected $description = 'Run monthly depreciation for fixed assets';
|
||||
|
||||
public function __construct(
|
||||
protected FixedAssetService $assetService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->argument('business_id');
|
||||
$periodOption = $this->option('period');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// Parse period date
|
||||
$periodDate = $periodOption
|
||||
? Carbon::parse($periodOption)->endOfMonth()
|
||||
: Carbon::now()->endOfMonth();
|
||||
|
||||
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Get businesses to process
|
||||
$businesses = $this->getBusinesses($businessId);
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->warn('No businesses found to process.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRuns = 0;
|
||||
$totalAmount = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->line('');
|
||||
$this->info("Processing: {$business->name}");
|
||||
|
||||
if ($dryRun) {
|
||||
$results = $this->previewDepreciation($business, $periodDate);
|
||||
} else {
|
||||
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
|
||||
}
|
||||
|
||||
$count = $results->count();
|
||||
$amount = $results->sum('depreciation_amount');
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line(" - Depreciated {$count} assets");
|
||||
$this->line(" - Total amount: \${$amount}");
|
||||
$totalRuns += $count;
|
||||
$totalAmount += $amount;
|
||||
} else {
|
||||
$this->line(' - No assets to depreciate');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info('=== Summary ===');
|
||||
$this->info("Total assets depreciated: {$totalRuns}");
|
||||
$this->info("Total depreciation amount: \${$totalAmount}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get businesses to process.
|
||||
*/
|
||||
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business with ID {$businessId} not found.");
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
if (! $business->hasManagementSuite()) {
|
||||
$this->warn("Business {$business->name} does not have Management Suite enabled.");
|
||||
}
|
||||
|
||||
return collect([$business]);
|
||||
}
|
||||
|
||||
// Get all businesses with Management Suite
|
||||
return Business::whereHas('suites', function ($query) {
|
||||
$query->where('key', 'management');
|
||||
})->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview depreciation without making changes.
|
||||
*/
|
||||
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
|
||||
{
|
||||
$period = $periodDate->format('Y-m');
|
||||
|
||||
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
|
||||
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
|
||||
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
|
||||
->get();
|
||||
|
||||
$results = collect();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
// Skip if already depreciated for this period
|
||||
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
|
||||
->where('period', $period)
|
||||
->where('is_reversed', false)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if fully depreciated
|
||||
if ($asset->book_value <= $asset->salvage_value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depreciationAmount = $asset->monthly_depreciation;
|
||||
$maxDepreciation = $asset->book_value - $asset->salvage_value;
|
||||
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
|
||||
|
||||
if ($depreciationAmount > 0) {
|
||||
$results->push((object) [
|
||||
'fixed_asset_id' => $asset->id,
|
||||
'asset_name' => $asset->name,
|
||||
'depreciation_amount' => $depreciationAmount,
|
||||
]);
|
||||
|
||||
$this->line(" - {$asset->name}: \${$depreciationAmount}");
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Accounting\RecurringSchedulerService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunRecurringSchedules extends Command
|
||||
{
|
||||
protected $signature = 'recurring:run
|
||||
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
|
||||
{--business= : Specific business ID to run schedules for}
|
||||
{--dry-run : Preview what would be generated without actually creating transactions}';
|
||||
|
||||
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
|
||||
|
||||
public function __construct(
|
||||
protected RecurringSchedulerService $schedulerService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateString = $this->option('date');
|
||||
$businessId = $this->option('business') ? (int) $this->option('business') : null;
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$date = $dateString ? Carbon::parse($dateString) : now();
|
||||
|
||||
$this->info("Running recurring schedules for {$date->toDateString()}...");
|
||||
|
||||
if ($businessId) {
|
||||
$this->info("Filtering to business ID: {$businessId}");
|
||||
}
|
||||
|
||||
// Get due schedules
|
||||
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
|
||||
|
||||
if ($dueSchedules->isEmpty()) {
|
||||
$this->info('No schedules are due for execution.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No transactions will be created.');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
|
||||
$dueSchedules->map(fn ($s) => [
|
||||
$s->id,
|
||||
$s->name,
|
||||
$s->type_label,
|
||||
$s->business->name ?? 'N/A',
|
||||
$s->next_run_date->toDateString(),
|
||||
$s->auto_post ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Run all due schedules
|
||||
$results = $this->schedulerService->runAllDue($date, $businessId);
|
||||
|
||||
// Output results
|
||||
$this->newLine();
|
||||
$this->info('Execution Summary:');
|
||||
$this->line(" Processed: {$results['processed']}");
|
||||
$this->line(" Successful: {$results['success']}");
|
||||
$this->line(" Failed: {$results['failed']}");
|
||||
|
||||
if (! empty($results['generated'])) {
|
||||
$this->newLine();
|
||||
$this->info('Generated Transactions:');
|
||||
$this->table(
|
||||
['Schedule', 'Type', 'Result ID'],
|
||||
collect($results['generated'])->map(fn ($g) => [
|
||||
$g['schedule_name'],
|
||||
$g['type'],
|
||||
$g['result_id'],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($results['errors'])) {
|
||||
$this->newLine();
|
||||
$this->error('Errors:');
|
||||
foreach ($results['errors'] as $error) {
|
||||
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/SafeFreshCommand.php
Normal file
43
app/Console/Commands/SafeFreshCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Database\Console\Migrations\FreshCommand;
|
||||
|
||||
/**
|
||||
* Override migrate:fresh to prevent accidental data loss.
|
||||
*
|
||||
* This command blocks migrate:fresh in all environments except when
|
||||
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
|
||||
*/
|
||||
class SafeFreshCommand extends FreshCommand
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
// Check both config and direct env (env var may not be in config yet)
|
||||
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
|
||||
|
||||
// Allow migrate:fresh ONLY for test databases
|
||||
$isTestDatabase = $database === 'testing'
|
||||
|| str_contains($database, '_test_')
|
||||
|| str_contains($database, 'testing_');
|
||||
|
||||
if (! $isTestDatabase) {
|
||||
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
|
||||
$this->components->warn("Database: {$database}");
|
||||
$this->newLine();
|
||||
$this->components->bulletList([
|
||||
'This command drops ALL tables and destroys ALL data.',
|
||||
'It is blocked in local, dev, staging, and production.',
|
||||
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
|
||||
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->components->info("Running migrate:fresh on TEST database: {$database}");
|
||||
|
||||
return parent::handle();
|
||||
}
|
||||
}
|
||||
652
app/Console/Commands/SeedBaselinePromos.php
Normal file
652
app/Console/Commands/SeedBaselinePromos.php
Normal file
@@ -0,0 +1,652 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\PromoRecommendation;
|
||||
use App\Services\Promo\PromoCalculator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SeedBaselinePromos extends Command
|
||||
{
|
||||
protected $signature = 'promos:seed-baseline
|
||||
{--brand=* : Specific brand IDs to seed}
|
||||
{--business= : Business ID or slug to seed all brands for}
|
||||
{--dry-run : Preview recommendations without saving}
|
||||
{--force : Clear existing pending recommendations first}
|
||||
{--types=* : Limit to specific types: edlp, percent_off, bogo, bxgy, bundle}';
|
||||
|
||||
protected $description = 'Generate baseline promotion recommendations for products (draft-only, not auto-activated)';
|
||||
|
||||
protected PromoCalculator $promoCalculator;
|
||||
|
||||
protected int $created = 0;
|
||||
|
||||
protected int $skipped = 0;
|
||||
|
||||
protected int $duplicates = 0;
|
||||
|
||||
protected array $priorityCounts = ['high' => 0, 'medium' => 0, 'low' => 0];
|
||||
|
||||
protected bool $isDryRun = false;
|
||||
|
||||
// Recommendation expiration (days)
|
||||
protected const EXPIRES_AFTER_DAYS = 30;
|
||||
|
||||
public function handle(PromoCalculator $promoCalculator): int
|
||||
{
|
||||
// Check if promo_recommendations table exists
|
||||
if (! \Schema::hasTable('promo_recommendations')) {
|
||||
$this->error('The promo_recommendations table does not exist. Please run migrations first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->promoCalculator = $promoCalculator;
|
||||
$this->isDryRun = $this->option('dry-run');
|
||||
|
||||
$brandIds = $this->option('brand');
|
||||
$businessOption = $this->option('business');
|
||||
$types = $this->option('types') ?: ['edlp', 'percent_off', 'bogo', 'bxgy', 'bundle'];
|
||||
|
||||
// Get brands to process
|
||||
$brands = $this->getBrandsToProcess($brandIds, $businessOption);
|
||||
|
||||
if ($brands === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($brands->isEmpty()) {
|
||||
$this->info('No active brands found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Force mode: clear existing pending recommendations
|
||||
if ($this->option('force') && ! $this->isDryRun) {
|
||||
$this->clearPendingRecommendations($brandIds);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s baseline promos for %d brand(s)...',
|
||||
$this->isDryRun ? 'Previewing' : 'Seeding',
|
||||
$brands->count()
|
||||
));
|
||||
$this->newLine();
|
||||
|
||||
// Log start of seeding
|
||||
if (! $this->isDryRun) {
|
||||
Log::info('Promo Engine V3: Starting baseline seed', [
|
||||
'brands_count' => $brands->count(),
|
||||
'types' => $types,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$this->processBrand($brand, $types);
|
||||
}
|
||||
|
||||
$this->printSummary();
|
||||
|
||||
// Log completion
|
||||
if (! $this->isDryRun) {
|
||||
Log::info('Promo Engine V3: Baseline seed complete', [
|
||||
'created' => $this->created,
|
||||
'skipped' => $this->skipped,
|
||||
'duplicates' => $this->duplicates,
|
||||
'priority_breakdown' => $this->priorityCounts,
|
||||
]);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brands to process based on options.
|
||||
*/
|
||||
protected function getBrandsToProcess(?array $brandIds, ?string $businessOption)
|
||||
{
|
||||
// If specific brand IDs provided
|
||||
if (! empty($brandIds)) {
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
if ($brands->isEmpty()) {
|
||||
$this->error('No brands found with the provided IDs.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $brands;
|
||||
}
|
||||
|
||||
// If business option provided (ID or slug)
|
||||
if ($businessOption) {
|
||||
$business = is_numeric($businessOption)
|
||||
? Business::find($businessOption)
|
||||
: Business::where('slug', $businessOption)->first();
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business not found: {$businessOption}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->info("Filtering to business: {$business->name}");
|
||||
|
||||
return Brand::where('business_id', $business->id)
|
||||
->active()
|
||||
->get();
|
||||
}
|
||||
|
||||
// Default: all active brands
|
||||
return Brand::active()->get();
|
||||
}
|
||||
|
||||
protected function processBrand(Brand $brand, array $types): void
|
||||
{
|
||||
$this->info("Brand: {$brand->name} (ID: {$brand->id})");
|
||||
$this->line(str_repeat('─', 50));
|
||||
|
||||
$products = Product::where('brand_id', $brand->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($products->isEmpty()) {
|
||||
$this->warn(' No active products found.');
|
||||
$this->newLine();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info(" Analyzing {$products->count()} active products...");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($products as $product) {
|
||||
$this->processProduct($product, $brand, $types);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
protected function processProduct(Product $product, Brand $brand, array $types): void
|
||||
{
|
||||
// Validate product has required pricing
|
||||
if (! $this->hasValidPricing($product)) {
|
||||
$this->line(" <fg=yellow>✗</> {$product->name}: missing pricing data");
|
||||
$this->skipped++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$currentMargins = $this->promoCalculator->getCurrentMargins($product);
|
||||
|
||||
// Calculate inventory metrics for priority
|
||||
$metrics = $this->calculateProductMetrics($product);
|
||||
|
||||
foreach ($types as $type) {
|
||||
$this->generateRecommendation($product, $brand, $type, $currentMargins, $metrics);
|
||||
}
|
||||
}
|
||||
|
||||
protected function generateRecommendation(
|
||||
Product $product,
|
||||
Brand $brand,
|
||||
string $type,
|
||||
array $currentMargins,
|
||||
array $metrics
|
||||
): void {
|
||||
// Check for existing pending recommendation
|
||||
if ($this->hasPendingRecommendation($product, $type)) {
|
||||
$this->duplicates++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recommendation = match ($type) {
|
||||
'edlp' => $this->generateEdlpRecommendation($product, $currentMargins, $metrics),
|
||||
'percent_off' => $this->generatePercentOffRecommendation($product, $currentMargins, $metrics),
|
||||
'bogo' => $this->generateBogoRecommendation($product, $currentMargins, $metrics),
|
||||
'bxgy' => $this->generateBxgyRecommendation($product, $currentMargins, $metrics),
|
||||
'bundle' => $this->generateBundleRecommendation($product, $currentMargins, $metrics),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $recommendation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add common fields
|
||||
$recommendation['business_id'] = $brand->business_id;
|
||||
$recommendation['brand_id'] = $brand->id;
|
||||
$recommendation['product_id'] = $product->id;
|
||||
$recommendation['status'] = 'pending';
|
||||
$recommendation['expires_at'] = Carbon::now()->addDays(self::EXPIRES_AFTER_DAYS);
|
||||
|
||||
// Output
|
||||
$this->outputRecommendation($product, $recommendation);
|
||||
|
||||
// Save if not dry run
|
||||
if (! $this->isDryRun) {
|
||||
PromoRecommendation::create($recommendation);
|
||||
}
|
||||
|
||||
$this->created++;
|
||||
$this->priorityCounts[$recommendation['priority']]++;
|
||||
}
|
||||
|
||||
protected function generateEdlpRecommendation(
|
||||
Product $product,
|
||||
array $currentMargins,
|
||||
array $metrics
|
||||
): ?array {
|
||||
$minSafePrice = $this->promoCalculator->minSafeEdlpPrice($product);
|
||||
$currentMsrp = (float) $product->msrp;
|
||||
|
||||
if ($minSafePrice <= 0 || $minSafePrice >= $currentMsrp) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Suggest 10% above the minimum safe price (conservative)
|
||||
$suggestedPrice = round($minSafePrice * 1.10, 2);
|
||||
|
||||
// Ensure suggested price is still a discount
|
||||
if ($suggestedPrice >= $currentMsrp) {
|
||||
$suggestedPrice = round(($minSafePrice + $currentMsrp) / 2, 2);
|
||||
}
|
||||
|
||||
// Validate the suggestion
|
||||
$result = $this->promoCalculator->checkEdlp($product, $suggestedPrice);
|
||||
if (! $result->approved) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$discountPercent = round((1 - $suggestedPrice / $currentMsrp) * 100, 1);
|
||||
|
||||
return [
|
||||
'recommendation_type' => 'edlp',
|
||||
'parameters' => [
|
||||
'suggested_value' => $suggestedPrice,
|
||||
'min_safe_value' => $minSafePrice,
|
||||
'current_msrp' => $currentMsrp,
|
||||
'discount_percent' => $discountPercent,
|
||||
],
|
||||
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||
'priority' => $this->calculatePriority($metrics),
|
||||
'priority_reason' => $this->getPriorityReason($metrics),
|
||||
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||
'velocity_score' => $metrics['velocity_score'],
|
||||
'days_of_supply' => $metrics['days_of_supply'],
|
||||
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function generatePercentOffRecommendation(
|
||||
Product $product,
|
||||
array $currentMargins,
|
||||
array $metrics
|
||||
): ?array {
|
||||
$maxSafePercent = $this->promoCalculator->maxSafePercentOff($product);
|
||||
|
||||
if ($maxSafePercent <= 5) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Suggest 75% of max safe (conservative)
|
||||
$suggestedPercent = round($maxSafePercent * 0.75, 0);
|
||||
|
||||
// Minimum 5% to be meaningful
|
||||
if ($suggestedPercent < 5) {
|
||||
$suggestedPercent = 5;
|
||||
}
|
||||
|
||||
// Validate
|
||||
$result = $this->promoCalculator->checkPercentOff($product, $suggestedPercent);
|
||||
if (! $result->approved) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'recommendation_type' => 'percent_off',
|
||||
'parameters' => [
|
||||
'suggested_value' => $suggestedPercent,
|
||||
'max_safe_value' => $maxSafePercent,
|
||||
],
|
||||
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||
'priority' => $this->calculatePriority($metrics),
|
||||
'priority_reason' => $this->getPriorityReason($metrics),
|
||||
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||
'velocity_score' => $metrics['velocity_score'],
|
||||
'days_of_supply' => $metrics['days_of_supply'],
|
||||
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function generateBogoRecommendation(
|
||||
Product $product,
|
||||
array $currentMargins,
|
||||
array $metrics
|
||||
): ?array {
|
||||
// Standard BOGO: Buy 1 Get 1 Free
|
||||
$result = $this->promoCalculator->checkBogo($product, 1, 1, 100);
|
||||
|
||||
if (! $result->approved) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'recommendation_type' => 'bogo',
|
||||
'parameters' => [
|
||||
'suggested_value' => null,
|
||||
'buy_qty' => 1,
|
||||
'get_qty' => 1,
|
||||
'get_discount_percent' => 100,
|
||||
],
|
||||
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||
'priority' => $this->calculatePriority($metrics),
|
||||
'priority_reason' => $this->getPriorityReason($metrics),
|
||||
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||
'velocity_score' => $metrics['velocity_score'],
|
||||
'days_of_supply' => $metrics['days_of_supply'],
|
||||
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function generateBxgyRecommendation(
|
||||
Product $product,
|
||||
array $currentMargins,
|
||||
array $metrics
|
||||
): ?array {
|
||||
// Try Buy 2 Get 1 Free first (more sustainable than BOGO)
|
||||
$result = $this->promoCalculator->checkBogo($product, 2, 1, 100);
|
||||
|
||||
if (! $result->approved) {
|
||||
// Try Buy 3 Get 1 Free
|
||||
$result = $this->promoCalculator->checkBogo($product, 3, 1, 100);
|
||||
if (! $result->approved) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
$buyQty = 3;
|
||||
} else {
|
||||
$buyQty = 2;
|
||||
}
|
||||
|
||||
return [
|
||||
'recommendation_type' => 'bxgy',
|
||||
'parameters' => [
|
||||
'suggested_value' => null,
|
||||
'buy_qty' => $buyQty,
|
||||
'get_qty' => 1,
|
||||
'get_discount_percent' => 100,
|
||||
],
|
||||
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||
'priority' => $this->calculatePriority($metrics),
|
||||
'priority_reason' => $this->getPriorityReason($metrics),
|
||||
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||
'velocity_score' => $metrics['velocity_score'],
|
||||
'days_of_supply' => $metrics['days_of_supply'],
|
||||
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function generateBundleRecommendation(
|
||||
Product $product,
|
||||
array $currentMargins,
|
||||
array $metrics
|
||||
): ?array {
|
||||
// Create a 3-pack bundle with ~10% discount
|
||||
$packSize = 3;
|
||||
$regularTotal = (float) $product->wholesale_price * $packSize;
|
||||
$suggestedPrice = round($regularTotal * 0.90, 2);
|
||||
|
||||
// Create a collection with quantity attribute for bundle validation
|
||||
$bundleProducts = collect([
|
||||
(clone $product)->setAttribute('bundle_quantity', $packSize),
|
||||
]);
|
||||
|
||||
$result = $this->promoCalculator->checkBundle($bundleProducts, $suggestedPrice);
|
||||
|
||||
if (! $result->approved) {
|
||||
$this->skipped++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$minSafe = $this->promoCalculator->minSafeBundlePrice($bundleProducts);
|
||||
|
||||
return [
|
||||
'recommendation_type' => 'bundle',
|
||||
'parameters' => [
|
||||
'suggested_value' => $suggestedPrice,
|
||||
'min_safe_value' => $minSafe,
|
||||
'pack_size' => $packSize,
|
||||
'individual_price' => (float) $product->wholesale_price,
|
||||
'regular_total' => $regularTotal,
|
||||
],
|
||||
'estimated_company_margin' => $result->companyMarginPercent(),
|
||||
'estimated_dispensary_margin' => $result->dispensaryMarginPercent(),
|
||||
'priority' => $this->calculatePriority($metrics),
|
||||
'priority_reason' => $this->getPriorityReason($metrics),
|
||||
'confidence' => $this->calculateConfidence($currentMargins, $result),
|
||||
'velocity_score' => $metrics['velocity_score'],
|
||||
'days_of_supply' => $metrics['days_of_supply'],
|
||||
'units_sold_30d' => $metrics['units_sold_30d'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function hasValidPricing(Product $product): bool
|
||||
{
|
||||
return $product->cost_per_unit > 0
|
||||
&& $product->wholesale_price > 0
|
||||
&& $product->msrp_price > 0;
|
||||
}
|
||||
|
||||
protected function hasPendingRecommendation(Product $product, string $type): bool
|
||||
{
|
||||
return PromoRecommendation::where('product_id', $product->id)
|
||||
->where('recommendation_type', $type)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->exists();
|
||||
}
|
||||
|
||||
protected function calculateProductMetrics(Product $product): array
|
||||
{
|
||||
// Calculate velocity score based on available data
|
||||
$availableQty = $product->available_quantity ?? 0;
|
||||
$unitsSold30d = 0; // Would come from order history if tracked
|
||||
|
||||
// Simple days of supply calculation
|
||||
$daysOfSupply = null;
|
||||
if ($unitsSold30d > 0 && $availableQty > 0) {
|
||||
$dailyVelocity = $unitsSold30d / 30;
|
||||
$daysOfSupply = (int) ($availableQty / $dailyVelocity);
|
||||
} elseif ($availableQty > 0) {
|
||||
// No sales data - assume slow mover
|
||||
$daysOfSupply = 999;
|
||||
}
|
||||
|
||||
// Determine velocity score
|
||||
$velocityScore = match (true) {
|
||||
$unitsSold30d >= 50 => 'fast',
|
||||
$unitsSold30d >= 20 => 'medium',
|
||||
$unitsSold30d >= 5 => 'slow',
|
||||
default => 'stale',
|
||||
};
|
||||
|
||||
return [
|
||||
'velocity_score' => $velocityScore,
|
||||
'days_of_supply' => $daysOfSupply,
|
||||
'units_sold_30d' => $unitsSold30d,
|
||||
'available_quantity' => $availableQty,
|
||||
];
|
||||
}
|
||||
|
||||
protected function calculatePriority(array $metrics): string
|
||||
{
|
||||
$daysOfSupply = $metrics['days_of_supply'];
|
||||
$velocityScore = $metrics['velocity_score'];
|
||||
|
||||
// High priority: excess inventory or stale products
|
||||
if ($daysOfSupply && $daysOfSupply > 90) {
|
||||
return 'high';
|
||||
}
|
||||
if ($velocityScore === 'stale') {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
// Medium priority: building inventory or slow movers
|
||||
if ($daysOfSupply && $daysOfSupply > 45) {
|
||||
return 'medium';
|
||||
}
|
||||
if ($velocityScore === 'slow') {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
// Low priority: healthy inventory
|
||||
return 'low';
|
||||
}
|
||||
|
||||
protected function getPriorityReason(array $metrics): string
|
||||
{
|
||||
$daysOfSupply = $metrics['days_of_supply'];
|
||||
$velocityScore = $metrics['velocity_score'];
|
||||
|
||||
if ($daysOfSupply && $daysOfSupply > 90) {
|
||||
return "High inventory ({$daysOfSupply} days of supply)";
|
||||
}
|
||||
|
||||
if ($velocityScore === 'stale') {
|
||||
return 'Stale inventory, no recent sales';
|
||||
}
|
||||
|
||||
if ($velocityScore === 'slow') {
|
||||
return 'Slow-moving product';
|
||||
}
|
||||
|
||||
if ($daysOfSupply && $daysOfSupply > 45) {
|
||||
return "Building inventory ({$daysOfSupply} days of supply)";
|
||||
}
|
||||
|
||||
return 'Standard recommendation';
|
||||
}
|
||||
|
||||
protected function calculateConfidence(array $currentMargins, $result): float
|
||||
{
|
||||
// Base confidence on data quality and margin headroom
|
||||
$confidence = 0.50;
|
||||
|
||||
// Boost for complete pricing data
|
||||
if ($currentMargins['valid']) {
|
||||
$confidence += 0.15;
|
||||
}
|
||||
|
||||
// Boost for healthy margin headroom
|
||||
$minMargin = min($result->companyMarginPercent(), $result->dispensaryMarginPercent());
|
||||
if ($minMargin >= 60) {
|
||||
$confidence += 0.20;
|
||||
} elseif ($minMargin >= 55) {
|
||||
$confidence += 0.10;
|
||||
}
|
||||
|
||||
// Cap at 0.95
|
||||
return min(0.95, round($confidence, 2));
|
||||
}
|
||||
|
||||
protected function outputRecommendation(Product $product, array $recommendation): void
|
||||
{
|
||||
$type = $recommendation['recommendation_type'];
|
||||
$params = $recommendation['parameters'];
|
||||
$margin = min(
|
||||
$recommendation['estimated_company_margin'],
|
||||
$recommendation['estimated_dispensary_margin']
|
||||
);
|
||||
|
||||
$description = match ($type) {
|
||||
'edlp' => sprintf(
|
||||
'$%.2f → $%.2f',
|
||||
$params['current_msrp'],
|
||||
$params['suggested_value']
|
||||
),
|
||||
'percent_off' => sprintf('%.0f%% off (max: %.0f%%)', $params['suggested_value'], $params['max_safe_value']),
|
||||
'bogo' => 'B1G1 Free',
|
||||
'bxgy' => sprintf('B%dG%d Free', $params['buy_qty'], $params['get_qty']),
|
||||
'bundle' => sprintf('%d-pack $%.2f', $params['pack_size'], $params['suggested_value']),
|
||||
default => $type,
|
||||
};
|
||||
|
||||
$priority = $recommendation['priority'];
|
||||
$priorityColor = match ($priority) {
|
||||
'high' => 'red',
|
||||
'medium' => 'yellow',
|
||||
default => 'green',
|
||||
};
|
||||
|
||||
$this->line(sprintf(
|
||||
' <fg=green>✓</> %-25s [%s] %s <fg=%s>(margin: %.0f%%, %s)</>',
|
||||
substr($product->name, 0, 25),
|
||||
strtoupper($type),
|
||||
$description,
|
||||
$priorityColor,
|
||||
$margin,
|
||||
$priority
|
||||
));
|
||||
}
|
||||
|
||||
protected function clearPendingRecommendations(?array $brandIds): void
|
||||
{
|
||||
$query = PromoRecommendation::pending();
|
||||
|
||||
if (! empty($brandIds)) {
|
||||
$query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
$query->delete();
|
||||
|
||||
$this->warn("Cleared {$count} existing pending recommendations.");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
protected function printSummary(): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('Summary');
|
||||
$this->line(str_repeat('═', 50));
|
||||
|
||||
$label = $this->isDryRun ? 'Would create' : 'Created';
|
||||
$this->line(sprintf(' %-20s %d recommendations', "{$label}:", $this->created));
|
||||
$this->line(sprintf(' %-20s %d (insufficient margin)', 'Skipped:', $this->skipped));
|
||||
$this->line(sprintf(' %-20s %d (already pending)', 'Duplicates:', $this->duplicates));
|
||||
|
||||
$this->line(str_repeat('─', 50));
|
||||
$this->info('Priority breakdown:');
|
||||
$this->line(sprintf(' <fg=red>High:</> %d', $this->priorityCounts['high']));
|
||||
$this->line(sprintf(' <fg=yellow>Medium:</> %d', $this->priorityCounts['medium']));
|
||||
$this->line(sprintf(' <fg=green>Low:</> %d', $this->priorityCounts['low']));
|
||||
|
||||
if ($this->isDryRun) {
|
||||
$this->newLine();
|
||||
$this->warn('This was a dry run. No recommendations were saved.');
|
||||
$this->info('Run without --dry-run to save recommendations.');
|
||||
}
|
||||
}
|
||||
}
|
||||
404
app/Console/Commands/SeedBrandOrchestratorProfiles.php
Normal file
404
app/Console/Commands/SeedBrandOrchestratorProfiles.php
Normal file
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\BrandOrchestratorProfile;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Seed/Update BrandOrchestratorProfile records with brand-specific behavior presets.
|
||||
*
|
||||
* This command is IDEMPOTENT - safe to run multiple times.
|
||||
* It uses updateOrCreate to either create new profiles or update existing ones.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan orchestrator:seed-brand-profiles
|
||||
* php artisan orchestrator:seed-brand-profiles --force (skip confirmation)
|
||||
*/
|
||||
class SeedBrandOrchestratorProfiles extends Command
|
||||
{
|
||||
protected $signature = 'orchestrator:seed-brand-profiles
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Seed or update BrandOrchestratorProfile records with brand-specific configurations';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('');
|
||||
$this->info('╔══════════════════════════════════════════════════════════════╗');
|
||||
$this->info('║ Brand Orchestrator Profile Seeder ║');
|
||||
$this->info('╚══════════════════════════════════════════════════════════════╝');
|
||||
$this->info('');
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('This will create/update BrandOrchestratorProfile records. Continue?')) {
|
||||
$this->warn('Aborted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$profiles = $this->getBrandProfiles();
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($profiles as $brandName => $config) {
|
||||
$brand = Brand::where('name', $brandName)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->warn(" ⚠ Brand not found: '{$brandName}' - skipping");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$business = $brand->business;
|
||||
if (! $business) {
|
||||
$this->warn(" ⚠ Brand '{$brandName}' has no business - skipping");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if profile exists
|
||||
$existingProfile = BrandOrchestratorProfile::where('brand_id', $brand->id)
|
||||
->where('business_id', $business->id)
|
||||
->first();
|
||||
|
||||
// Prepare data for update/create
|
||||
$data = [
|
||||
'behavior_profile' => $config['behavior_profile'],
|
||||
'max_tasks_per_customer_per_run' => $config['max_tasks_per_customer_per_run'] ?? null,
|
||||
'cooldown_hours' => $config['cooldown_hours'] ?? null,
|
||||
'max_pending_per_customer' => $config['max_pending_per_customer'] ?? null,
|
||||
'auto_approval_high_intent' => $config['auto_approval_high_intent'] ?? null,
|
||||
'auto_approval_vip' => $config['auto_approval_vip'] ?? null,
|
||||
'auto_approval_ghosted' => $config['auto_approval_ghosted'] ?? null,
|
||||
'auto_approval_at_risk' => $config['auto_approval_at_risk'] ?? null,
|
||||
'auto_approval_menu_followup_no_view' => $config['auto_approval_menu_followup_no_view'] ?? null,
|
||||
'auto_approval_menu_followup_viewed_no_order' => $config['auto_approval_menu_followup_viewed_no_order'] ?? null,
|
||||
'auto_approval_reactivation' => $config['auto_approval_reactivation'] ?? null,
|
||||
'auto_approval_new_menu' => $config['auto_approval_new_menu'] ?? null,
|
||||
];
|
||||
|
||||
BrandOrchestratorProfile::updateOrCreate(
|
||||
[
|
||||
'brand_id' => $brand->id,
|
||||
'business_id' => $business->id,
|
||||
],
|
||||
$data
|
||||
);
|
||||
|
||||
if ($existingProfile) {
|
||||
$this->line(" ✓ <fg=yellow>Updated</> {$brandName} ({$config['behavior_profile']})");
|
||||
$updated++;
|
||||
} else {
|
||||
$this->line(" ✓ <fg=green>Created</> {$brandName} ({$config['behavior_profile']})");
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('');
|
||||
$this->info('══════════════════════════════════════════════════════════════');
|
||||
$this->info(" Created: {$created} | Updated: {$updated} | Skipped: {$skipped}");
|
||||
$this->info('══════════════════════════════════════════════════════════════');
|
||||
$this->info('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand-specific orchestrator profile configurations.
|
||||
*
|
||||
* Profile types:
|
||||
* - aggressive: Lower cooldowns, higher task caps, more auto-approval
|
||||
* - balanced: Uses global settings, light customization
|
||||
* - conservative: Higher cooldowns, lower task caps, more manager review
|
||||
*/
|
||||
private function getBrandProfiles(): array
|
||||
{
|
||||
return [
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 1. THUNDER BUD - Value, high-volume prerolls
|
||||
// "Volume mover" - assertive about followups, promos, reactivations
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Thunder Bud' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||
'max_tasks_per_customer_per_run' => 4,
|
||||
'cooldown_hours' => 24,
|
||||
'max_pending_per_customer' => 5,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 2. DOOBZ - Hash-infused prerolls, premium but still pushy
|
||||
// Strong push on menu followups and reactivation, slightly less
|
||||
// spammy than Thunder Bud on the same buyer
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Doobz' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||
'max_tasks_per_customer_per_run' => 3,
|
||||
'cooldown_hours' => 36,
|
||||
'max_pending_per_customer' => 4,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 3. TWISTIES - Rosin jam infused prerolls, craft-leaning
|
||||
// Proactive with engaged buyers and menus, but keep ghosted
|
||||
// accounts from getting hammered
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Twisties' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => null, // use global
|
||||
'cooldown_hours' => null,
|
||||
'max_pending_per_customer' => null,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => false, // Keep ghosted from getting hammered
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 4. HIGH EXPECTATIONS - Hash holes, top-shelf, limited
|
||||
// Prestige line - still follow up, but prefer rep review on
|
||||
// at-risk and reactivation flows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'High Expectations' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||
'max_tasks_per_customer_per_run' => 2,
|
||||
'cooldown_hours' => 72,
|
||||
'max_pending_per_customer' => 3,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => false,
|
||||
'auto_approval_at_risk' => false, // Rep review
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => false, // Rep review
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 5. PROPER COCK - Premium solventless concentrates
|
||||
// Similar to High Expectations: careful, high-touch, more
|
||||
// manual control from reps
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Proper Cock' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||
'max_tasks_per_customer_per_run' => 2,
|
||||
'cooldown_hours' => 72,
|
||||
'max_pending_per_customer' => 3,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => false,
|
||||
'auto_approval_at_risk' => false,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => false,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 6. HASH FACTORY - Artisan hash & rosin, craft brand
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Hash Factory' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => 3,
|
||||
'cooldown_hours' => 48,
|
||||
'max_pending_per_customer' => 4,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => false,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 7. JUST VAPE - Hash rosin carts / disposables, needs growth
|
||||
// Should behave similar to Thunder Bud on orchestration
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Just Vape' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||
'max_tasks_per_customer_per_run' => 4,
|
||||
'cooldown_hours' => 24,
|
||||
'max_pending_per_customer' => 5,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 8. CANNA RSO - RSO / medical-leaning product line
|
||||
// Less aggressive, more thoughtful outreach
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Canna RSO' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_CONSERVATIVE,
|
||||
'max_tasks_per_customer_per_run' => 2,
|
||||
'cooldown_hours' => 72,
|
||||
'max_pending_per_customer' => 3,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => false,
|
||||
'auto_approval_at_risk' => false,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => false,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 9. OUTLAW CANNABIS - Balanced with full auto-approval
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Outlaw Cannabis' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => 3,
|
||||
'cooldown_hours' => 48,
|
||||
'max_pending_per_customer' => 4,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 10. ALOHA TYMEMACHINE - Balanced with full auto-approval
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Aloha TymeMachine' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => 3,
|
||||
'cooldown_hours' => 48,
|
||||
'max_pending_per_customer' => 4,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 11. DOINKS - Aggressive with full auto-approval
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Doinks' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_AGGRESSIVE,
|
||||
'max_tasks_per_customer_per_run' => 4,
|
||||
'cooldown_hours' => 24,
|
||||
'max_pending_per_customer' => 5,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 12. NUVATA - Balanced, use global throttling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Nuvata' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => null,
|
||||
'cooldown_hours' => null,
|
||||
'max_pending_per_customer' => null,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 13. DAIRY2DANK - Balanced, use global throttling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Dairy2Dank' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => null,
|
||||
'cooldown_hours' => null,
|
||||
'max_pending_per_customer' => null,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 14. BLITZD - Balanced, use global throttling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'Blitzd' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => null,
|
||||
'cooldown_hours' => null,
|
||||
'max_pending_per_customer' => null,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 15. WHITE LABEL CANNA - Balanced, use global throttling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'White Label Canna' => [
|
||||
'behavior_profile' => BrandOrchestratorProfile::PROFILE_BALANCED,
|
||||
'max_tasks_per_customer_per_run' => null,
|
||||
'cooldown_hours' => null,
|
||||
'max_pending_per_customer' => null,
|
||||
'auto_approval_high_intent' => true,
|
||||
'auto_approval_vip' => true,
|
||||
'auto_approval_ghosted' => true,
|
||||
'auto_approval_at_risk' => true,
|
||||
'auto_approval_menu_followup_no_view' => true,
|
||||
'auto_approval_menu_followup_viewed_no_order' => true,
|
||||
'auto_approval_reactivation' => true,
|
||||
'auto_approval_new_menu' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
257
app/Console/Commands/SendCrmDailyDigest.php
Normal file
257
app/Console/Commands/SendCrmDailyDigest.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\CalendarEvent;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CrmDailyDigestNotification;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* CRM Daily Digest Command
|
||||
*
|
||||
* Sends daily summary emails to businesses with CRM enabled. This command
|
||||
* is scheduled to run at 7 AM daily via the Laravel scheduler (Kernel.php).
|
||||
*
|
||||
* Digest Contents:
|
||||
* - New conversations from the last 24 hours
|
||||
* - Tasks due today
|
||||
* - Overdue tasks (up to 10)
|
||||
* - Today's calendar events
|
||||
* - Pipeline summary stats
|
||||
*
|
||||
* Feature Gating:
|
||||
* - Only processes businesses where has_crm = true
|
||||
* - Only sends to businesses where crm_daily_digest_enabled = true
|
||||
* - Skips businesses with no actionable items to report
|
||||
*
|
||||
* Recipients:
|
||||
* - If business.crm_notification_emails is set: those specific emails
|
||||
* - Otherwise: business owner or first admin (up to 3 recipients)
|
||||
*
|
||||
* Queue Configuration:
|
||||
* - Notifications are queued on the 'crm' queue
|
||||
* - Processed by Horizon's CRM worker pool
|
||||
*
|
||||
* Usage:
|
||||
* php artisan crm:send-daily-digests # Normal run
|
||||
* php artisan crm:send-daily-digests --dry-run # Preview without sending
|
||||
* php artisan crm:send-daily-digests --business=123 # Single business
|
||||
*
|
||||
* Testing:
|
||||
* ./vendor/bin/sail artisan crm:send-daily-digests --dry-run
|
||||
*
|
||||
* @see \App\Console\Kernel::schedule() for scheduler configuration
|
||||
* @see \App\Notifications\CrmDailyDigestNotification for email template
|
||||
*/
|
||||
class SendCrmDailyDigest extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'crm:send-daily-digests
|
||||
{--business= : Process only a specific business ID}
|
||||
{--dry-run : Show what would be sent without actually sending}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Send daily CRM digest emails to businesses with the feature enabled';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessQuery = Business::where('has_crm', true)
|
||||
->where('crm_daily_digest_enabled', true);
|
||||
|
||||
if ($businessId = $this->option('business')) {
|
||||
$businessQuery->where('id', $businessId);
|
||||
}
|
||||
|
||||
$businesses = $businessQuery->get();
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->info('No businesses with CRM daily digest enabled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Processing daily digests for {$businesses->count()} business(es)...");
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->processBusinessDigest($business);
|
||||
}
|
||||
|
||||
$this->info('Daily digest processing complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and send the digest for a single business.
|
||||
*/
|
||||
protected function processBusinessDigest(Business $business): void
|
||||
{
|
||||
$this->line("Processing: {$business->name}");
|
||||
|
||||
// Get the digest data
|
||||
$digestData = $this->gatherDigestData($business);
|
||||
|
||||
// Check if there's anything to report
|
||||
if ($this->isDigestEmpty($digestData)) {
|
||||
$this->line(' - No updates to report, skipping.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get recipients (business owner or first admin)
|
||||
$recipients = $this->getDigestRecipients($business);
|
||||
|
||||
if ($recipients->isEmpty()) {
|
||||
$this->warn(' - No recipients found, skipping.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info(" - [DRY RUN] Would send to: {$recipients->pluck('email')->join(', ')}");
|
||||
$this->displayDigestSummary($digestData);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Send notifications
|
||||
foreach ($recipients as $recipient) {
|
||||
try {
|
||||
$recipient->notify(new CrmDailyDigestNotification($business, $digestData));
|
||||
$this->line(" - Sent to: {$recipient->email}");
|
||||
|
||||
Log::info('CRM daily digest sent', [
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $recipient->id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" - Failed to send to {$recipient->email}: {$e->getMessage()}");
|
||||
|
||||
Log::error('Failed to send CRM daily digest', [
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $recipient->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather all the data for the digest.
|
||||
*/
|
||||
protected function gatherDigestData(Business $business): array
|
||||
{
|
||||
$yesterday = now()->subDay();
|
||||
|
||||
return [
|
||||
// New conversations in the last 24 hours
|
||||
'new_conversations' => Conversation::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->where('created_at', '>=', $yesterday)
|
||||
->with('primaryContact')
|
||||
->get(),
|
||||
|
||||
// Tasks due today
|
||||
'tasks_due_today' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->whereDate('due_at', today())
|
||||
->with(['assignee', 'contact'])
|
||||
->get(),
|
||||
|
||||
// Overdue tasks
|
||||
'overdue_tasks' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->whereNotNull('due_at')
|
||||
->where('due_at', '<', now())
|
||||
->with(['assignee', 'contact'])
|
||||
->limit(10)
|
||||
->get(),
|
||||
|
||||
// Events today
|
||||
'events_today' => CalendarEvent::where('seller_business_id', $business->id)
|
||||
->whereDate('start_at', today())
|
||||
->where('status', 'scheduled')
|
||||
->with(['assignee', 'contact'])
|
||||
->get(),
|
||||
|
||||
// Stage changes in the last 24 hours (opportunities moved)
|
||||
'stage_changes' => SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('updated_at', '>=', $yesterday)
|
||||
->whereColumn('stage_id', '!=', 'original_stage_id')
|
||||
->with(['stage', 'business'])
|
||||
->limit(10)
|
||||
->get(),
|
||||
|
||||
// Summary stats
|
||||
'stats' => [
|
||||
'open_opportunities' => SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('status', 'open')
|
||||
->count(),
|
||||
'total_pipeline_value' => SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('status', 'open')
|
||||
->sum('value'),
|
||||
'open_tasks' => CrmTask::where('seller_business_id', $business->id)
|
||||
->whereNull('completed_at')
|
||||
->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the digest has any meaningful content.
|
||||
*/
|
||||
protected function isDigestEmpty(array $data): bool
|
||||
{
|
||||
return $data['new_conversations']->isEmpty()
|
||||
&& $data['tasks_due_today']->isEmpty()
|
||||
&& $data['overdue_tasks']->isEmpty()
|
||||
&& $data['events_today']->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the users who should receive the digest.
|
||||
*/
|
||||
protected function getDigestRecipients(Business $business): \Illuminate\Support\Collection
|
||||
{
|
||||
// If specific emails are set, find those users
|
||||
if ($business->crm_notification_emails) {
|
||||
$emails = array_map('trim', explode(',', $business->crm_notification_emails));
|
||||
|
||||
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->whereIn('email', $emails)
|
||||
->get();
|
||||
}
|
||||
|
||||
// Otherwise, send to the business owner or first admin
|
||||
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->where(function ($q) {
|
||||
$q->where('is_business_owner', true)
|
||||
->orWhere('user_type', 'admin');
|
||||
})
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a summary for dry-run mode.
|
||||
*/
|
||||
protected function displayDigestSummary(array $data): void
|
||||
{
|
||||
$this->line(" - New conversations: {$data['new_conversations']->count()}");
|
||||
$this->line(" - Tasks due today: {$data['tasks_due_today']->count()}");
|
||||
$this->line(" - Overdue tasks: {$data['overdue_tasks']->count()}");
|
||||
$this->line(" - Events today: {$data['events_today']->count()}");
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SyncBrandMediaPaths extends Command
|
||||
{
|
||||
protected $signature = 'brands:sync-media-paths
|
||||
{--dry-run : Preview changes without applying}
|
||||
{--business= : Limit to specific business slug}';
|
||||
|
||||
protected $description = 'Sync brand logo_path and banner_path from MinIO storage';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$businessFilter = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN - No changes will be made');
|
||||
}
|
||||
|
||||
$this->info('Scanning MinIO for brand media...');
|
||||
|
||||
$businessDirs = Storage::directories('businesses');
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businessDirs as $businessDir) {
|
||||
$businessSlug = basename($businessDir);
|
||||
|
||||
if ($businessFilter && $businessSlug !== $businessFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brandsDir = $businessDir.'/brands';
|
||||
if (! Storage::exists($brandsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brandDirs = Storage::directories($brandsDir);
|
||||
|
||||
foreach ($brandDirs as $brandDir) {
|
||||
$brandSlug = basename($brandDir);
|
||||
$brandingDir = $brandDir.'/branding';
|
||||
|
||||
if (! Storage::exists($brandingDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand = Brand::where('slug', $brandSlug)->first();
|
||||
if (! $brand) {
|
||||
$this->line(" <fg=yellow>?</> {$brandSlug} - not found in database");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = Storage::files($brandingDir);
|
||||
$logoPath = null;
|
||||
$bannerPath = null;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = strtolower(basename($file));
|
||||
if (str_starts_with($filename, 'logo.')) {
|
||||
$logoPath = $file;
|
||||
} elseif (str_starts_with($filename, 'banner.')) {
|
||||
$bannerPath = $file;
|
||||
}
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
if ($logoPath && $brand->logo_path !== $logoPath) {
|
||||
$changes[] = "logo: {$logoPath}";
|
||||
}
|
||||
if ($bannerPath && $brand->banner_path !== $bannerPath) {
|
||||
$changes[] = "banner: {$bannerPath}";
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
$this->line(" <fg=green>✓</> {$brandSlug} - already synced");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
if ($logoPath) {
|
||||
$brand->logo_path = $logoPath;
|
||||
}
|
||||
if ($bannerPath) {
|
||||
$brand->banner_path = $bannerPath;
|
||||
}
|
||||
$brand->save();
|
||||
}
|
||||
|
||||
$this->line(" <fg=blue>↻</> {$brandSlug} - ".implode(', ', $changes));
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Updated: {$updated} | Skipped: {$skipped}");
|
||||
|
||||
if ($dryRun && $updated > 0) {
|
||||
$this->warn('Run without --dry-run to apply changes');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,151 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// MINUTE-LEVEL JOBS
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Check for scheduled broadcasts every minute
|
||||
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Send CRM task and event reminders every minute
|
||||
$schedule->job(new \App\Jobs\Crm\SendCrmRemindersJob)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// SALES ORCHESTRATOR - "HEAD OF SALES" AUTOMATION
|
||||
// See: docs/HEAD_OF_SALES_ORCHESTRATOR.md
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Generate sales tasks - runs hourly during business hours (weekdays 8AM-6PM)
|
||||
// Monitors buyer behavior and creates actionable OrchestratorTask records
|
||||
$schedule->command('orchestrator:generate-sales-tasks')
|
||||
->hourlyAt(5)
|
||||
->weekdays()
|
||||
->between('08:00', '18:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Generate marketing tasks - runs hourly during business hours (weekdays 8AM-6PM)
|
||||
// Creates campaign suggestions for marketing team
|
||||
$schedule->command('orchestrator:generate-marketing-tasks')
|
||||
->hourlyAt(15)
|
||||
->weekdays()
|
||||
->between('08:00', '18:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Evaluate outcomes - runs every 4 hours
|
||||
// Links completed tasks to subsequent views/orders for learning loop
|
||||
$schedule->command('orchestrator:evaluate-outcomes')
|
||||
->everyFourHours()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Analyze timing - runs daily at 3 AM
|
||||
// Determines best send times per brand based on historical outcomes
|
||||
$schedule->command('orchestrator:analyze-timing')
|
||||
->dailyAt('03:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// TODO: Buyer scoring currently happens via BuyerScoringService on-demand.
|
||||
// Consider creating orchestrator:score-buyers command for batch scoring if needed.
|
||||
// $schedule->command('orchestrator:score-buyers')
|
||||
// ->dailyAt('04:00')
|
||||
// ->withoutOverlapping()
|
||||
// ->runInBackground();
|
||||
|
||||
// Check Horizon health - runs every 5 minutes
|
||||
// Monitors Redis/Horizon status, creates alerts on failure
|
||||
$schedule->command('orchestrator:check-horizon')
|
||||
->everyFiveMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Watchdog - runs every 15 minutes
|
||||
// Monitors that all orchestrator commands are running on schedule
|
||||
$schedule->command('orchestrator:watchdog')
|
||||
->everyFifteenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Self-audit - runs daily at 5 AM
|
||||
// Checks for data integrity issues, impossible states, stale tasks
|
||||
$schedule->command('orchestrator:self-audit')
|
||||
->dailyAt('05:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Evaluate playbook performance - runs daily at 4 AM
|
||||
// Updates 30-day metrics per playbook, can auto-quarantine underperformers
|
||||
$schedule->command('orchestrator:evaluate-playbooks')
|
||||
->dailyAt('04:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Daily report - runs weekdays at 7 AM
|
||||
// Sends summary email to admins with task stats and alerts
|
||||
$schedule->command('orchestrator:send-daily-report')
|
||||
->weekdays()
|
||||
->dailyAt('07:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// DASHBOARD METRICS PRE-CALCULATION
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Pre-calculate dashboard metrics every 10 minutes
|
||||
// Stores aggregations in Redis for instant page loads
|
||||
$schedule->job(new \App\Jobs\CalculateDashboardMetrics)
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// BANNER ADS
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Update banner ad statuses (activate scheduled, expire ended) - every minute
|
||||
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Rollup daily banner ad stats - daily at 2 AM
|
||||
$schedule->job(new \App\Jobs\RollupBannerAdStats)
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// HOUSEKEEPING & MAINTENANCE
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Clean up temporary files older than 24 hours (runs daily at 2 AM)
|
||||
$schedule->command('media:cleanup-temp')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Prune old audit logs based on business settings (runs daily at 3 AM)
|
||||
$schedule->command('audits:prune')
|
||||
->dailyAt('03:00')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Send CRM daily digest emails at 7 AM
|
||||
$schedule->command('crm:send-daily-digests')
|
||||
->dailyAt('07:00')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
// Generate baseline promo recommendations (Promo Engine V3)
|
||||
// Runs daily at 3:30 AM to generate margin-safe promo suggestions
|
||||
$schedule->command('promos:seed-baseline')
|
||||
->dailyAt('03:30')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
41
app/Enums/BannerAdStatus.php
Normal file
41
app/Enums/BannerAdStatus.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BannerAdStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case ACTIVE = 'active';
|
||||
case SCHEDULED = 'scheduled';
|
||||
case PAUSED = 'paused';
|
||||
case EXPIRED = 'expired';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'Draft',
|
||||
self::ACTIVE => 'Active',
|
||||
self::SCHEDULED => 'Scheduled',
|
||||
self::PAUSED => 'Paused',
|
||||
self::EXPIRED => 'Expired',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'gray',
|
||||
self::ACTIVE => 'success',
|
||||
self::SCHEDULED => 'info',
|
||||
self::PAUSED => 'warning',
|
||||
self::EXPIRED => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $status) => [
|
||||
$status->value => $status->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
51
app/Enums/BannerAdZone.php
Normal file
51
app/Enums/BannerAdZone.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BannerAdZone: string
|
||||
{
|
||||
case MARKETPLACE_HERO = 'marketplace_hero';
|
||||
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
|
||||
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
|
||||
case MARKETPLACE_INLINE = 'marketplace_inline';
|
||||
case BRAND_PAGE_BANNER = 'brand_page_banner';
|
||||
case DEALS_PAGE_HERO = 'deals_page_hero';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
|
||||
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
|
||||
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
|
||||
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
|
||||
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
|
||||
self::DEALS_PAGE_HERO => 'Deals Page Hero',
|
||||
};
|
||||
}
|
||||
|
||||
public function dimensions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
|
||||
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
|
||||
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
|
||||
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
|
||||
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
|
||||
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
public static function optionsSimple(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||
$zone->value => $zone->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
41
app/Events/CrmAgentStatusChanged.php
Normal file
41
app/Events/CrmAgentStatusChanged.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\AgentStatus;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CrmAgentStatusChanged implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public AgentStatus $agentStatus
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("crm-inbox.{$this->agentStatus->business_id}")];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'agent.status';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => $this->agentStatus->user_id,
|
||||
'user_name' => $this->agentStatus->user?->name,
|
||||
'status' => $this->agentStatus->status,
|
||||
'status_label' => AgentStatus::statuses()[$this->agentStatus->status] ?? $this->agentStatus->status,
|
||||
'status_message' => $this->agentStatus->status_message,
|
||||
'last_seen_at' => $this->agentStatus->last_seen_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
80
app/Events/CrmThreadMessageSent.php
Normal file
80
app/Events/CrmThreadMessageSent.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CrmThreadMessageSent implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmChannelMessage $message,
|
||||
public CrmThread $thread
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$channels = [
|
||||
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
|
||||
new PrivateChannel("crm-thread.{$this->thread->id}"),
|
||||
];
|
||||
|
||||
// For marketplace B2B threads, also broadcast to buyer/seller businesses
|
||||
if ($this->thread->buyer_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
|
||||
}
|
||||
if ($this->thread->seller_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.new';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => [
|
||||
'id' => $this->message->id,
|
||||
'thread_id' => $this->message->thread_id,
|
||||
'body' => $this->message->body,
|
||||
'body_html' => $this->message->body_html,
|
||||
'direction' => $this->message->direction,
|
||||
'channel_type' => $this->message->channel_type,
|
||||
'sender_id' => $this->message->user_id,
|
||||
'sender_name' => $this->message->user?->name ?? ($this->message->direction === 'inbound' ? $this->thread->contact?->getFullName() : 'System'),
|
||||
'status' => $this->message->status,
|
||||
'created_at' => $this->message->created_at->toIso8601String(),
|
||||
'attachments' => $this->message->attachments->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'filename' => $a->original_filename ?? $a->filename,
|
||||
'mime_type' => $a->mime_type,
|
||||
'size' => $a->size,
|
||||
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
|
||||
])->toArray(),
|
||||
],
|
||||
'thread' => [
|
||||
'id' => $this->thread->id,
|
||||
'subject' => $this->thread->subject,
|
||||
'status' => $this->thread->status,
|
||||
'priority' => $this->thread->priority,
|
||||
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
|
||||
'last_message_preview' => $this->message->body ? \Str::limit(strip_tags($this->message->body), 100) : null,
|
||||
'last_message_direction' => $this->message->direction,
|
||||
'last_channel_type' => $this->message->channel_type,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Events/CrmThreadUpdated.php
Normal file
73
app/Events/CrmThreadUpdated.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CrmThreadUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public const UPDATE_ASSIGNED = 'assigned';
|
||||
|
||||
public const UPDATE_CLOSED = 'closed';
|
||||
|
||||
public const UPDATE_REOPENED = 'reopened';
|
||||
|
||||
public const UPDATE_SNOOZED = 'snoozed';
|
||||
|
||||
public const UPDATE_PRIORITY = 'priority';
|
||||
|
||||
public const UPDATE_STATUS = 'status';
|
||||
|
||||
public function __construct(
|
||||
public CrmThread $thread,
|
||||
public string $updateType
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$channels = [
|
||||
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
|
||||
new PrivateChannel("crm-thread.{$this->thread->id}"),
|
||||
];
|
||||
|
||||
// For marketplace B2B threads, also broadcast to buyer/seller businesses
|
||||
if ($this->thread->buyer_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
|
||||
}
|
||||
if ($this->thread->seller_business_id) {
|
||||
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'thread.updated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'thread' => [
|
||||
'id' => $this->thread->id,
|
||||
'subject' => $this->thread->subject,
|
||||
'status' => $this->thread->status,
|
||||
'priority' => $this->thread->priority,
|
||||
'assigned_to' => $this->thread->assigned_to,
|
||||
'assignee_name' => $this->thread->assignee?->name,
|
||||
'snoozed_until' => $this->thread->snoozed_until?->toIso8601String(),
|
||||
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
|
||||
],
|
||||
'update_type' => $this->updateType,
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Events/CrmTypingIndicator.php
Normal file
41
app/Events/CrmTypingIndicator.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CrmTypingIndicator implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $threadId,
|
||||
public int $userId,
|
||||
public string $userName,
|
||||
public bool $isTyping
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("crm-thread.{$this->threadId}")];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'typing';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => $this->userId,
|
||||
'user_name' => $this->userName,
|
||||
'is_typing' => $this->isTyping,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Events/NewMarketplaceMessage.php
Normal file
79
app/Events/NewMarketplaceMessage.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NewMarketplaceMessage implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmChannelMessage $message,
|
||||
public CrmThread $thread
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
$channels = [];
|
||||
|
||||
if ($this->thread->buyer_business_id) {
|
||||
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->buyer_business_id}");
|
||||
}
|
||||
|
||||
if ($this->thread->seller_business_id) {
|
||||
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->seller_business_id}");
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => [
|
||||
'id' => $this->message->id,
|
||||
'thread_id' => $this->message->thread_id,
|
||||
'body' => $this->message->body,
|
||||
'sender_id' => $this->message->sender_id,
|
||||
'sender_name' => $this->message->sender
|
||||
? trim($this->message->sender->first_name.' '.$this->message->sender->last_name)
|
||||
: 'Unknown',
|
||||
'direction' => $this->message->direction,
|
||||
'created_at' => $this->message->created_at->toIso8601String(),
|
||||
'attachments' => $this->message->attachments,
|
||||
],
|
||||
'thread' => [
|
||||
'id' => $this->thread->id,
|
||||
'subject' => $this->thread->subject,
|
||||
'buyer_business_id' => $this->thread->buyer_business_id,
|
||||
'seller_business_id' => $this->thread->seller_business_id,
|
||||
'order_id' => $this->thread->order_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.new';
|
||||
}
|
||||
}
|
||||
47
app/Events/TeamMessageSent.php
Normal file
47
app/Events/TeamMessageSent.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\TeamMessage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TeamMessageSent implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public TeamMessage $message
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
// Broadcast to the team conversation channel
|
||||
return [
|
||||
new PrivateChannel('team-conversation.'.$this->message->conversation_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.sent';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->message->id,
|
||||
'conversation_id' => $this->message->conversation_id,
|
||||
'sender_id' => $this->message->sender_id,
|
||||
'sender_name' => $this->message->getSenderName(),
|
||||
'sender_initials' => $this->message->getSenderInitials(),
|
||||
'body' => $this->message->body,
|
||||
'type' => $this->message->type,
|
||||
'metadata' => $this->message->metadata,
|
||||
'created_at' => $this->message->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Exceptions/PeriodLockedException.php
Normal file
24
app/Exceptions/PeriodLockedException.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Models\Accounting\AccountingPeriod;
|
||||
|
||||
class PeriodLockedException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?AccountingPeriod $period = null,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getPeriod(): ?AccountingPeriod
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
}
|
||||
455
app/Filament/Pages/AiSettings.php
Normal file
455
app/Filament/Pages/AiSettings.php
Normal file
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\AiSetting;
|
||||
use App\Services\AiClient;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected string $view = 'filament.pages.ai-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'AI Settings (Old)';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
// Hide from navigation - replaced by AiConnectionResource
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
/**
|
||||
* Only Superadmins can access AI Settings.
|
||||
* This page is hidden from navigation but still protected.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
$this->form->fill([
|
||||
'is_enabled' => $settings->is_enabled ?? false,
|
||||
'ai_provider' => $settings->ai_provider ?? '',
|
||||
'anthropic_api_key' => '', // Never show the full key
|
||||
'openai_api_key' => '', // Never show the full key
|
||||
'perplexity_api_key' => '', // Never show the full key
|
||||
'canva_api_key' => '', // Never show the full key
|
||||
'jasper_api_key' => '', // Never show the full key
|
||||
'anthropic_model' => $settings->anthropic_model ?? last(config('ai.providers.anthropic.models')),
|
||||
'openai_model' => $settings->openai_model ?? last(config('ai.providers.openai.models')),
|
||||
'perplexity_model' => $settings->perplexity_model ?? last(config('ai.providers.perplexity.models')),
|
||||
'canva_model' => $settings->canva_model ?? last(config('ai.providers.canva.models')),
|
||||
'jasper_model' => $settings->jasper_model ?? last(config('ai.providers.jasper.models')),
|
||||
'max_tokens_per_request' => $settings->max_tokens_per_request ?? 4096,
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('AI Copilot Configuration')
|
||||
->description('Configure Cannabrands content suggestions for brand settings')
|
||||
->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enable AI Copilot')
|
||||
->helperText('Enable Cannabrands content suggestions across the platform')
|
||||
->default(false),
|
||||
|
||||
Select::make('ai_provider')
|
||||
->label('AI Provider')
|
||||
->options([
|
||||
'anthropic' => 'Anthropic / Claude',
|
||||
'openai' => 'OpenAI / ChatGPT',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
])
|
||||
->placeholder('Select an AI provider')
|
||||
->required()
|
||||
->live()
|
||||
->helperText('Choose your preferred AI provider'),
|
||||
|
||||
// Anthropic fields (shown when provider is 'anthropic')
|
||||
TextInput::make('anthropic_api_key')
|
||||
->label('Anthropic API Key')
|
||||
->helperText('Enter your Anthropic API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('sk-ant-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
|
||||
|
||||
Select::make('anthropic_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.anthropic.models'),
|
||||
config('ai.providers.anthropic.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.anthropic.models')))
|
||||
->required()
|
||||
->helperText('Claude model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
|
||||
|
||||
// OpenAI fields (shown when provider is 'openai')
|
||||
TextInput::make('openai_api_key')
|
||||
->label('OpenAI API Key')
|
||||
->helperText('Enter your OpenAI API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('sk-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'openai'),
|
||||
|
||||
Select::make('openai_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.openai.models'),
|
||||
config('ai.providers.openai.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.openai.models')))
|
||||
->required()
|
||||
->helperText('ChatGPT / GPT model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'openai'),
|
||||
|
||||
// Perplexity fields (shown when provider is 'perplexity')
|
||||
TextInput::make('perplexity_api_key')
|
||||
->label('Perplexity API Key')
|
||||
->helperText('Enter your Perplexity API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('pplx-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
|
||||
|
||||
Select::make('perplexity_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.perplexity.models'),
|
||||
config('ai.providers.perplexity.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.perplexity.models')))
|
||||
->required()
|
||||
->helperText('Perplexity model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
|
||||
|
||||
// Canva fields (shown when provider is 'canva')
|
||||
TextInput::make('canva_api_key')
|
||||
->label('Canva API Key')
|
||||
->helperText('Enter your Canva API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('canva-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'canva'),
|
||||
|
||||
Select::make('canva_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.canva.models'),
|
||||
config('ai.providers.canva.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.canva.models')))
|
||||
->required()
|
||||
->helperText('Canva model/feature to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'canva'),
|
||||
|
||||
// Jasper fields (shown when provider is 'jasper')
|
||||
TextInput::make('jasper_api_key')
|
||||
->label('Jasper API Key')
|
||||
->helperText('Enter your Jasper API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('jasper-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
|
||||
|
||||
Select::make('jasper_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.jasper.models'),
|
||||
config('ai.providers.jasper.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.jasper.models')))
|
||||
->required()
|
||||
->helperText('Jasper model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
|
||||
|
||||
TextInput::make('max_tokens_per_request')
|
||||
->label('Max Tokens Per Request')
|
||||
->helperText('Maximum number of tokens to request from the AI model')
|
||||
->numeric()
|
||||
->default(4096)
|
||||
->required(),
|
||||
|
||||
// Existing Connections Summary
|
||||
Placeholder::make('connections_summary')
|
||||
->label('Existing Connections')
|
||||
->content(fn () => view('filament.components.ai-connections-summary', [
|
||||
'anthropic_configured' => $settings->anthropic_api_key_configured,
|
||||
'openai_configured' => $settings->openai_api_key_configured,
|
||||
'perplexity_configured' => $settings->perplexity_api_key_configured,
|
||||
'canva_configured' => $settings->canva_api_key_configured,
|
||||
'jasper_configured' => $settings->jasper_api_key_configured,
|
||||
])),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
// Update basic settings
|
||||
$settings->is_enabled = $data['is_enabled'];
|
||||
$settings->ai_provider = $data['ai_provider'];
|
||||
$settings->max_tokens_per_request = $data['max_tokens_per_request'];
|
||||
|
||||
// Always save all model fields (preserve values when switching providers)
|
||||
$settings->anthropic_model = $data['anthropic_model'] ?? $settings->anthropic_model;
|
||||
$settings->openai_model = $data['openai_model'] ?? $settings->openai_model;
|
||||
$settings->perplexity_model = $data['perplexity_model'] ?? $settings->perplexity_model;
|
||||
$settings->canva_model = $data['canva_model'] ?? $settings->canva_model;
|
||||
$settings->jasper_model = $data['jasper_model'] ?? $settings->jasper_model;
|
||||
|
||||
// Update API keys only if provided (don't overwrite with empty string)
|
||||
if (! empty($data['anthropic_api_key'])) {
|
||||
$settings->anthropic_api_key = $data['anthropic_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['openai_api_key'])) {
|
||||
$settings->openai_api_key = $data['openai_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['perplexity_api_key'])) {
|
||||
$settings->perplexity_api_key = $data['perplexity_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['canva_api_key'])) {
|
||||
$settings->canva_api_key = $data['canva_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['jasper_api_key'])) {
|
||||
$settings->jasper_api_key = $data['jasper_api_key'];
|
||||
}
|
||||
|
||||
$settings->save();
|
||||
|
||||
// Clear the AI config cache
|
||||
app(AiClient::class)->clearCache();
|
||||
|
||||
// Refresh the form with saved data
|
||||
$this->mount();
|
||||
|
||||
Notification::make()
|
||||
->title('AI Settings saved successfully')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
$provider = $data['ai_provider'];
|
||||
|
||||
if (! $provider) {
|
||||
Notification::make()
|
||||
->title('Please select a provider first')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
try {
|
||||
$success = false;
|
||||
$message = '';
|
||||
|
||||
switch ($provider) {
|
||||
case 'anthropic':
|
||||
if (empty($settings->anthropic_api_key)) {
|
||||
throw new \Exception('Anthropic API key not configured');
|
||||
}
|
||||
$success = $this->testAnthropicConnection($settings->anthropic_api_key);
|
||||
$message = $success ? 'Anthropic connection successful' : 'Anthropic connection failed';
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
if (empty($settings->openai_api_key)) {
|
||||
throw new \Exception('OpenAI API key not configured');
|
||||
}
|
||||
$success = $this->testOpenAiConnection($settings->openai_api_key);
|
||||
$message = $success ? 'OpenAI connection successful' : 'OpenAI connection failed';
|
||||
break;
|
||||
|
||||
case 'perplexity':
|
||||
if (empty($settings->perplexity_api_key)) {
|
||||
throw new \Exception('Perplexity API key not configured');
|
||||
}
|
||||
$success = $this->testPerplexityConnection($settings->perplexity_api_key);
|
||||
$message = $success ? 'Perplexity connection successful' : 'Perplexity connection failed';
|
||||
break;
|
||||
|
||||
case 'canva':
|
||||
if (empty($settings->canva_api_key)) {
|
||||
throw new \Exception('Canva API key not configured');
|
||||
}
|
||||
$success = $this->testCanvaConnection($settings->canva_api_key);
|
||||
$message = $success ? 'Canva connection successful' : 'Canva connection failed';
|
||||
break;
|
||||
|
||||
case 'jasper':
|
||||
if (empty($settings->jasper_api_key)) {
|
||||
throw new \Exception('Jasper API key not configured');
|
||||
}
|
||||
$success = $this->testJasperConnection($settings->jasper_api_key);
|
||||
$message = $success ? 'Jasper connection successful' : 'Jasper connection failed';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \Exception('Unknown provider: '.$provider);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
Notification::make()
|
||||
->title($message)
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title($message)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Connection test failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
private function testAnthropicConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->post('https://api.anthropic.com/v1/messages', [
|
||||
'headers' => [
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'model' => 'claude-3-5-sonnet-20241022',
|
||||
'max_tokens' => 10,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => 'Hi'],
|
||||
],
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testOpenAiConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->get('https://api.openai.com/v1/models', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testPerplexityConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->post('https://api.perplexity.ai/chat/completions', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'model' => 'sonar-small',
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => 'Hi'],
|
||||
],
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testCanvaConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->get('https://api.canva.com/v1/users/me', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testJasperConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->get('https://api.jasper.ai/v1/account', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
app/Filament/Pages/CannaiqSettings.php
Normal file
203
app/Filament/Pages/CannaiqSettings.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CannaiqSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||
|
||||
protected string $view = 'filament.pages.cannaiq-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
|
||||
|
||||
protected static ?string $navigationLabel = 'CannaiQ';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $title = 'CannaiQ Settings';
|
||||
|
||||
protected static ?string $slug = 'cannaiq-settings';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->check();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'base_url' => config('services.cannaiq.base_url'),
|
||||
'api_key' => '', // Never show the actual key
|
||||
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
|
||||
$baseUrl = config('services.cannaiq.base_url');
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('CannaiQ Integration')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Placeholder::make('status')
|
||||
->label('Connection Status')
|
||||
->content(function () use ($apiKeyConfigured, $baseUrl) {
|
||||
$statusHtml = '<div class="space-y-2">';
|
||||
|
||||
// API Key status
|
||||
if ($apiKeyConfigured) {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
|
||||
'<span class="text-lg">✓</span>'.
|
||||
'<span>API Key configured</span>'.
|
||||
'</div>';
|
||||
} else {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
|
||||
'<span class="text-lg">⚠</span>'.
|
||||
'<span>API Key not configured (using trusted origin auth)</span>'.
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Base URL
|
||||
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
|
||||
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
|
||||
'</div>';
|
||||
|
||||
$statusHtml .= '</div>';
|
||||
|
||||
return new HtmlString($statusHtml);
|
||||
}),
|
||||
|
||||
Placeholder::make('features')
|
||||
->label('Features Enabled')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
|
||||
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
|
||||
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
|
||||
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
|
||||
'</ul>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
|
||||
Section::make('Configuration')
|
||||
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
|
||||
->schema([
|
||||
TextInput::make('base_url')
|
||||
->label('Base URL')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label('Cache TTL (seconds)')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
|
||||
|
||||
Placeholder::make('env_example')
|
||||
->label('Environment Variables')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
|
||||
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
|
||||
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
|
||||
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
|
||||
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
])
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Business Access')
|
||||
->description('CannaiQ features must be enabled per-business in the Business settings.')
|
||||
->schema([
|
||||
Placeholder::make('business_info')
|
||||
->label('')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
|
||||
'<div class="flex items-start gap-3">'.
|
||||
'<span class="text-info-600 dark:text-info-400 text-lg">ⓘ</span>'.
|
||||
'<div class="text-sm">'.
|
||||
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
|
||||
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
|
||||
'<li>Go to <strong>Users → Businesses</strong></li>'.
|
||||
'<li>Edit the business</li>'.
|
||||
'<li>Go to the <strong>Integrations</strong> tab</li>'.
|
||||
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
|
||||
'</ol>'.
|
||||
'</div>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
try {
|
||||
$client = app(CannaiqClient::class);
|
||||
|
||||
// Try to fetch something from the API to verify connection
|
||||
// We'll use a simple health check or fetch minimal data
|
||||
$response = $client->getBrandAnalysis('test-brand', 'test-business');
|
||||
|
||||
// If we get here without exception, connection works
|
||||
// (even if the response is empty/error from CannaiQ side)
|
||||
Notification::make()
|
||||
->title('Connection Test')
|
||||
->body('Successfully connected to CannaiQ API')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Connection Failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
// Clear all CannaiQ-related cache keys
|
||||
$patterns = [
|
||||
'cannaiq:*',
|
||||
'brand_analysis:*',
|
||||
];
|
||||
|
||||
$cleared = 0;
|
||||
foreach ($patterns as $pattern) {
|
||||
// Note: This is a simplified clear - in production you might want
|
||||
// to use Redis SCAN for pattern matching
|
||||
Cache::forget($pattern);
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Cache Cleared')
|
||||
->body('CannaiQ cache has been cleared')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
216
app/Filament/Pages/CrmSettings.php
Normal file
216
app/Filament/Pages/CrmSettings.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\CalendarEvent;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\SalesOpportunity;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CrmSettings extends Page implements HasForms, HasTable
|
||||
{
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-briefcase';
|
||||
|
||||
protected string $view = 'filament.pages.crm-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Modules';
|
||||
|
||||
protected static ?string $navigationLabel = 'CRM Module';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
// Hide from navigation - CRM module settings are now in Business > Modules tab
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Load default pipeline stages template
|
||||
$this->form->fill([
|
||||
'default_pipeline_stages' => $this->getDefaultPipelineStages(),
|
||||
'ai_commands_enabled' => true,
|
||||
'reminder_lead_time_minutes' => 30,
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('CRM Module Configuration')
|
||||
->description('Global settings for the CRM module')
|
||||
->schema([
|
||||
Toggle::make('ai_commands_enabled')
|
||||
->label('Enable AI Commands')
|
||||
->helperText('Allow Cannabrands command parsing from messages'),
|
||||
|
||||
TextInput::make('reminder_lead_time_minutes')
|
||||
->label('Default Reminder Lead Time (minutes)')
|
||||
->helperText('How many minutes before an event/task to send reminders')
|
||||
->numeric()
|
||||
->default(30)
|
||||
->minValue(5)
|
||||
->maxValue(1440),
|
||||
]),
|
||||
|
||||
Section::make('Default Pipeline Stages')
|
||||
->description('Template stages for new business pipelines')
|
||||
->schema([
|
||||
Repeater::make('default_pipeline_stages')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(50),
|
||||
TextInput::make('color')
|
||||
->type('color')
|
||||
->default('#6366F1'),
|
||||
TextInput::make('probability')
|
||||
->numeric()
|
||||
->suffix('%')
|
||||
->default(50)
|
||||
->minValue(0)
|
||||
->maxValue(100),
|
||||
])
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->reorderable()
|
||||
->addActionLabel('Add Stage')
|
||||
->defaultItems(0),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('save')
|
||||
->label('Save Settings')
|
||||
->color('primary')
|
||||
->action('saveSettingsAction'),
|
||||
];
|
||||
}
|
||||
|
||||
public function saveSettingsAction(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
// Store settings in config cache or database
|
||||
// For now, we'll just show a success notification
|
||||
// In production, this would persist to a settings table
|
||||
|
||||
Notification::make()
|
||||
->title('CRM Settings saved successfully')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function getDefaultPipelineStages(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Lead', 'color' => '#94A3B8', 'probability' => 10],
|
||||
['name' => 'Qualified', 'color' => '#3B82F6', 'probability' => 25],
|
||||
['name' => 'Proposal', 'color' => '#8B5CF6', 'probability' => 50],
|
||||
['name' => 'Negotiation', 'color' => '#F59E0B', 'probability' => 75],
|
||||
['name' => 'Closed Won', 'color' => '#10B981', 'probability' => 100],
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('CRM Module Statistics by Business')
|
||||
->columns([
|
||||
TextColumn::make('business_name')
|
||||
->label('Business')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('has_crm')
|
||||
->label('CRM Enabled')
|
||||
->badge()
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray')
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No'),
|
||||
TextColumn::make('opportunities_count')
|
||||
->label('Opportunities')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('tasks_count')
|
||||
->label('Tasks')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('events_count')
|
||||
->label('Events')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('activities_count')
|
||||
->label('Activities')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('pipeline_value')
|
||||
->label('Pipeline Value')
|
||||
->money('USD')
|
||||
->sortable(),
|
||||
])
|
||||
->query(fn () => $this->getCrmStats())
|
||||
->defaultSort('business_name');
|
||||
}
|
||||
|
||||
protected function getCrmStats()
|
||||
{
|
||||
// Get all seller businesses with CRM stats
|
||||
return Business::where('business_type', 'seller')
|
||||
->orWhere('business_type', 'both')
|
||||
->select([
|
||||
'businesses.id',
|
||||
'businesses.name as business_name',
|
||||
'businesses.has_crm',
|
||||
])
|
||||
->withCount([
|
||||
'sellerOpportunities as opportunities_count',
|
||||
])
|
||||
->get()
|
||||
->map(function ($business) {
|
||||
// Add additional counts that can't be done via withCount due to custom foreign keys
|
||||
$business->tasks_count = CrmTask::where('seller_business_id', $business->id)->count();
|
||||
$business->events_count = CalendarEvent::where('seller_business_id', $business->id)->count();
|
||||
$business->activities_count = Activity::where('seller_business_id', $business->id)->count();
|
||||
$business->pipeline_value = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('status', 'open')
|
||||
->sum('value');
|
||||
|
||||
return $business;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Show count of businesses with CRM enabled
|
||||
$count = Business::where('has_crm', true)->count();
|
||||
|
||||
return $count > 0 ? (string) $count : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
1294
app/Filament/Pages/HeadOfSalesOrchestrator.php
Normal file
1294
app/Filament/Pages/HeadOfSalesOrchestrator.php
Normal file
File diff suppressed because it is too large
Load Diff
604
app/Filament/Pages/MarketingOrchestrator.php
Normal file
604
app/Filament/Pages/MarketingOrchestrator.php
Normal file
@@ -0,0 +1,604 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\AutomationRunLog;
|
||||
use App\Models\OrchestratorMarketingConfig;
|
||||
use App\Models\OrchestratorTask;
|
||||
use App\Models\SystemAlert;
|
||||
use App\Services\OrchestratorGovernanceService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class MarketingOrchestrator extends Page implements HasForms, HasTable
|
||||
{
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
|
||||
|
||||
protected static ?string $navigationLabel = 'Head of Marketing';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Orchestrator';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
protected string $view = 'filament.pages.marketing-orchestrator';
|
||||
|
||||
protected static ?string $title = 'Marketing Orchestrator – Head of Marketing';
|
||||
|
||||
protected static ?string $slug = 'orchestrator/head-of-marketing';
|
||||
|
||||
// Form state for playbook settings
|
||||
public ?array $playbookData = [];
|
||||
|
||||
// Current view mode
|
||||
public string $activeTab = 'tasks';
|
||||
|
||||
// Timeframe filter (7, 30, 90 days)
|
||||
public int $timeframe = 30;
|
||||
|
||||
public function updatedTimeframe(): void
|
||||
{
|
||||
// Refresh data
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
try {
|
||||
$config = OrchestratorMarketingConfig::getGlobal();
|
||||
$this->form->fill($config->toArray());
|
||||
} catch (\Exception $e) {
|
||||
$this->form->fill([]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('admin')->user();
|
||||
|
||||
return $user && in_array($user->user_type, ['admin', 'superadmin']);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// KPI Data
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getKpiData(): array
|
||||
{
|
||||
$startDate = now()->subDays($this->timeframe);
|
||||
|
||||
$createdCount = OrchestratorTask::marketing()
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count();
|
||||
|
||||
$pendingCount = OrchestratorTask::marketing()
|
||||
->pending()
|
||||
->count();
|
||||
|
||||
$completedCount = OrchestratorTask::marketing()
|
||||
->completed()
|
||||
->where('completed_at', '>=', $startDate)
|
||||
->count();
|
||||
|
||||
$dismissedCount = OrchestratorTask::marketing()
|
||||
->dismissed()
|
||||
->where('completed_at', '>=', $startDate)
|
||||
->count();
|
||||
|
||||
// By playbook type
|
||||
$byPlaybook = [
|
||||
'campaign_blast' => OrchestratorTask::marketing()
|
||||
->forType(OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE)
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count(),
|
||||
'segment_refinement' => OrchestratorTask::marketing()
|
||||
->forType(OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT)
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count(),
|
||||
'launch_announcement' => OrchestratorTask::marketing()
|
||||
->forType(OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT)
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count(),
|
||||
'holiday_campaign' => OrchestratorTask::marketing()
|
||||
->forType(OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN)
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count(),
|
||||
'new_sku_feature' => OrchestratorTask::marketing()
|
||||
->forType(OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE)
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count(),
|
||||
'nurture_sequence' => OrchestratorTask::marketing()
|
||||
->forType(OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE)
|
||||
->where('created_at', '>=', $startDate)
|
||||
->count(),
|
||||
];
|
||||
|
||||
return [
|
||||
'timeframe' => $this->timeframe,
|
||||
'created' => $createdCount,
|
||||
'pending' => $pendingCount,
|
||||
'completed' => $completedCount,
|
||||
'dismissed' => $dismissedCount,
|
||||
'by_playbook' => $byPlaybook,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Pending Tasks Table
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
OrchestratorTask::query()
|
||||
->marketing()
|
||||
->pending()
|
||||
->with(['business', 'brand', 'customer'])
|
||||
)
|
||||
->defaultSort('due_at', 'asc')
|
||||
->columns([
|
||||
TextColumn::make('type')
|
||||
->label('Type')
|
||||
->formatStateUsing(fn ($state) => match ($state) {
|
||||
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
|
||||
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
|
||||
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch',
|
||||
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday',
|
||||
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU',
|
||||
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture',
|
||||
default => $state,
|
||||
})
|
||||
->badge()
|
||||
->color(fn ($state) => match ($state) {
|
||||
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'primary',
|
||||
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'gray',
|
||||
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'success',
|
||||
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'info',
|
||||
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'warning',
|
||||
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('business.name')
|
||||
->label('Seller')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('brand.name')
|
||||
->label('Brand')
|
||||
->placeholder('-')
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('customer.name')
|
||||
->label('Customer')
|
||||
->placeholder('-')
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('priority')
|
||||
->badge()
|
||||
->color(fn ($state) => match ($state) {
|
||||
'high' => 'danger',
|
||||
'normal' => 'gray',
|
||||
'low' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('due_at')
|
||||
->label('Due')
|
||||
->dateTime('M j, g:i A')
|
||||
->sortable()
|
||||
->color(fn ($record) => $record->due_at && $record->due_at->isPast() ? 'danger' : null),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('type')
|
||||
->options([
|
||||
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
|
||||
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
|
||||
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch Announcement',
|
||||
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday Campaign',
|
||||
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU Feature',
|
||||
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture Sequence',
|
||||
]),
|
||||
SelectFilter::make('priority')
|
||||
->options([
|
||||
'high' => 'High',
|
||||
'normal' => 'Normal',
|
||||
'low' => 'Low',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('complete')
|
||||
->label('Done')
|
||||
->icon('heroicon-o-check')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(fn (OrchestratorTask $record) => $record->complete()),
|
||||
|
||||
\Filament\Actions\Action::make('dismiss')
|
||||
->label('Dismiss')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->action(fn (OrchestratorTask $record) => $record->dismiss()),
|
||||
|
||||
\Filament\Actions\Action::make('snooze')
|
||||
->label('Snooze')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('warning')
|
||||
->form([
|
||||
\Filament\Forms\Components\Select::make('days')
|
||||
->label('Snooze for')
|
||||
->options([
|
||||
1 => '1 day',
|
||||
3 => '3 days',
|
||||
7 => '7 days',
|
||||
])
|
||||
->required(),
|
||||
])
|
||||
->action(fn (OrchestratorTask $record, array $data) => $record->snooze((int) $data['days'])),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No pending marketing tasks')
|
||||
->emptyStateDescription('The Marketing Orchestrator has no pending tasks at this time.');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Activity Log Data
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getActivityLogData(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return OrchestratorTask::marketing()
|
||||
->resolved()
|
||||
->with(['business', 'brand', 'customer', 'completedByUser'])
|
||||
->orderByDesc('completed_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Performance Data
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getPerformanceData(): array
|
||||
{
|
||||
$thirtyDaysAgo = now()->subDays(30);
|
||||
|
||||
$playbooks = [
|
||||
OrchestratorTask::TYPE_MARKETING_CAMPAIGN_BLAST_CANDIDATE => 'Campaign Blast',
|
||||
OrchestratorTask::TYPE_MARKETING_SEGMENT_REFINEMENT => 'Segment Refinement',
|
||||
OrchestratorTask::TYPE_MARKETING_LAUNCH_ANNOUNCEMENT => 'Launch Announcement',
|
||||
OrchestratorTask::TYPE_MARKETING_HOLIDAY_CAMPAIGN => 'Holiday Campaign',
|
||||
OrchestratorTask::TYPE_MARKETING_NEW_SKU_FEATURE => 'New SKU Feature',
|
||||
OrchestratorTask::TYPE_MARKETING_NURTURE_SEQUENCE => 'Nurture Sequence',
|
||||
];
|
||||
|
||||
$metrics = [];
|
||||
|
||||
foreach ($playbooks as $type => $label) {
|
||||
$created = OrchestratorTask::marketing()
|
||||
->forType($type)
|
||||
->where('created_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
$completed = OrchestratorTask::marketing()
|
||||
->forType($type)
|
||||
->completed()
|
||||
->where('completed_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
$dismissed = OrchestratorTask::marketing()
|
||||
->forType($type)
|
||||
->dismissed()
|
||||
->where('completed_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
$metrics[$type] = [
|
||||
'label' => $label,
|
||||
'created_30d' => $created,
|
||||
'completed_30d' => $completed,
|
||||
'dismissed_30d' => $dismissed,
|
||||
'completion_rate' => $created > 0 ? round(($completed / $created) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$totalCreated = OrchestratorTask::marketing()
|
||||
->where('created_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
$totalCompleted = OrchestratorTask::marketing()
|
||||
->completed()
|
||||
->where('completed_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'by_playbook' => $metrics,
|
||||
'total_created_30d' => $totalCreated,
|
||||
'total_completed_30d' => $totalCompleted,
|
||||
'overall_completion_rate' => $totalCreated > 0
|
||||
? round(($totalCompleted / $totalCreated) * 100, 1)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Governance & Health Data
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get active system alerts for the dashboard.
|
||||
*/
|
||||
public function getActiveAlerts(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
if (! \Schema::hasTable('system_alerts')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return SystemAlert::unresolved()
|
||||
->orderByRaw("CASE severity WHEN 'critical' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END")
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert summary counts.
|
||||
*/
|
||||
public function getAlertSummary(): array
|
||||
{
|
||||
if (! \Schema::hasTable('system_alerts')) {
|
||||
return ['critical' => 0, 'warning' => 0, 'info' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
return SystemAlert::getSummaryCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue/Horizon health data.
|
||||
*/
|
||||
public function getQueueHealthData(): array
|
||||
{
|
||||
try {
|
||||
$governance = app(OrchestratorGovernanceService::class);
|
||||
|
||||
return $governance->checkHorizonHealth();
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'status' => 'unknown',
|
||||
'checks' => [],
|
||||
'alerts' => [],
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get automation health summary.
|
||||
*/
|
||||
public function getAutomationHealthData(): array
|
||||
{
|
||||
if (! \Schema::hasTable('automation_run_logs')) {
|
||||
return [
|
||||
'healthy' => 0,
|
||||
'unhealthy' => 0,
|
||||
'unknown' => 0,
|
||||
'total' => 0,
|
||||
'overall_status' => 'unknown',
|
||||
'statuses' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return AutomationRunLog::getAllStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an alert (action from UI).
|
||||
*/
|
||||
public function resolveAlert(int $alertId): void
|
||||
{
|
||||
$alert = SystemAlert::find($alertId);
|
||||
|
||||
if ($alert) {
|
||||
$alert->resolve(auth()->id(), 'Resolved from marketing dashboard');
|
||||
|
||||
Notification::make()
|
||||
->title('Alert resolved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Playbook Settings Form
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function form(Schema $form): Schema
|
||||
{
|
||||
return $form
|
||||
->statePath('playbookData')
|
||||
->schema([
|
||||
Section::make('Global Settings')
|
||||
->description('Control throttling and cooldown for marketing tasks')
|
||||
->schema([
|
||||
TextInput::make('max_tasks_per_brand_per_run')
|
||||
->label('Max tasks per brand per run')
|
||||
->numeric()
|
||||
->default(5)
|
||||
->minValue(1)
|
||||
->maxValue(20),
|
||||
TextInput::make('cooldown_days')
|
||||
->label('Cooldown (days)')
|
||||
->helperText('Minimum days between marketing touches to same customer')
|
||||
->numeric()
|
||||
->default(7)
|
||||
->minValue(1)
|
||||
->maxValue(30),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Campaign Blast Candidates')
|
||||
->description('Find high-engagement customers for campaign blasts')
|
||||
->schema([
|
||||
Toggle::make('campaign_blast_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('campaign_blast_min_engagement_score')
|
||||
->label('Min engagement score')
|
||||
->numeric()
|
||||
->default(50)
|
||||
->minValue(0)
|
||||
->maxValue(100),
|
||||
TextInput::make('campaign_blast_days_since_last_send')
|
||||
->label('Days since last send')
|
||||
->numeric()
|
||||
->default(14)
|
||||
->minValue(7)
|
||||
->maxValue(60),
|
||||
])
|
||||
->columns(3),
|
||||
|
||||
Section::make('Segment Refinement')
|
||||
->description('Suggest customer segmentation for brands with many customers')
|
||||
->schema([
|
||||
Toggle::make('segment_refinement_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('segment_refinement_min_customers')
|
||||
->label('Min customers')
|
||||
->numeric()
|
||||
->default(50)
|
||||
->minValue(10)
|
||||
->maxValue(500),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Launch Announcement')
|
||||
->description('Suggest campaigns for new brands')
|
||||
->schema([
|
||||
Toggle::make('launch_announcement_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('launch_announcement_days_new')
|
||||
->label('Days new')
|
||||
->helperText('Brands created within this window are "new"')
|
||||
->numeric()
|
||||
->default(30)
|
||||
->minValue(7)
|
||||
->maxValue(90),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Holiday Campaign')
|
||||
->description('Suggest holiday-themed campaigns')
|
||||
->schema([
|
||||
Toggle::make('holiday_campaign_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('holiday_campaign_days_before')
|
||||
->label('Days before holiday')
|
||||
->numeric()
|
||||
->default(14)
|
||||
->minValue(7)
|
||||
->maxValue(30),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('New SKU Feature')
|
||||
->description('Suggest featuring new products')
|
||||
->schema([
|
||||
Toggle::make('new_sku_feature_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('new_sku_feature_days_new')
|
||||
->label('Days new')
|
||||
->numeric()
|
||||
->default(7)
|
||||
->minValue(1)
|
||||
->maxValue(30),
|
||||
TextInput::make('new_sku_feature_min_products')
|
||||
->label('Min products')
|
||||
->numeric()
|
||||
->default(3)
|
||||
->minValue(1)
|
||||
->maxValue(20),
|
||||
])
|
||||
->columns(3),
|
||||
|
||||
Section::make('Nurture Sequence')
|
||||
->description('Suggest nurture sequences for new customers')
|
||||
->schema([
|
||||
Toggle::make('nurture_sequence_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('nurture_sequence_days_since_first_order')
|
||||
->label('Days since first order')
|
||||
->numeric()
|
||||
->default(30)
|
||||
->minValue(7)
|
||||
->maxValue(90),
|
||||
TextInput::make('nurture_sequence_max_orders')
|
||||
->label('Max orders')
|
||||
->helperText('Only target customers with this many orders or fewer')
|
||||
->numeric()
|
||||
->default(2)
|
||||
->minValue(1)
|
||||
->maxValue(5),
|
||||
])
|
||||
->columns(3),
|
||||
]);
|
||||
}
|
||||
|
||||
public function savePlaybookSettings(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
OrchestratorMarketingConfig::updateGlobal($data);
|
||||
|
||||
Notification::make()
|
||||
->title('Marketing playbook settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('run_playbooks')
|
||||
->label('Run Playbooks Now')
|
||||
->icon('heroicon-o-play')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Run Marketing Orchestrator Playbooks')
|
||||
->modalDescription('This will generate new marketing tasks based on current signals. Continue?')
|
||||
->action(function () {
|
||||
\Artisan::call('orchestrator:generate-marketing-tasks');
|
||||
Notification::make()
|
||||
->title('Playbooks executed')
|
||||
->body('Marketing Orchestrator tasks have been generated.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
104
app/Filament/Pages/MigrationHealth.php
Normal file
104
app/Filament/Pages/MigrationHealth.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class MigrationHealth extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||
|
||||
protected string $view = 'filament.pages.migration-health';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Migrations';
|
||||
|
||||
protected static ?int $navigationSort = 97;
|
||||
|
||||
public array $migrations = [];
|
||||
|
||||
public bool $hasPending = false;
|
||||
|
||||
public int $totalMigrations = 0;
|
||||
|
||||
public int $ranMigrations = 0;
|
||||
|
||||
public int $pendingMigrations = 0;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadMigrations();
|
||||
}
|
||||
|
||||
protected function loadMigrations(): void
|
||||
{
|
||||
// Get all migration files from database/migrations
|
||||
$migrationsPath = database_path('migrations');
|
||||
$files = File::files($migrationsPath);
|
||||
|
||||
// Get ran migrations from database
|
||||
$ranMigrations = DB::table('migrations')
|
||||
->select('migration', 'batch')
|
||||
->get()
|
||||
->keyBy('migration')
|
||||
->toArray();
|
||||
|
||||
$migrations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
|
||||
// Skip non-PHP files
|
||||
if (! str_ends_with($filename, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract migration name (without .php extension)
|
||||
$migrationName = str_replace('.php', '', $filename);
|
||||
|
||||
$ran = isset($ranMigrations[$migrationName]);
|
||||
$batch = $ran ? $ranMigrations[$migrationName]->batch : null;
|
||||
|
||||
$migrations[] = [
|
||||
'name' => $migrationName,
|
||||
'ran' => $ran,
|
||||
'batch' => $batch,
|
||||
'ran_at' => null, // migrations table doesn't have ran_at by default
|
||||
];
|
||||
|
||||
if (! $ran) {
|
||||
$this->hasPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort migrations by name (chronological order due to timestamp prefix)
|
||||
usort($migrations, fn ($a, $b) => strcmp($a['name'], $b['name']));
|
||||
|
||||
$this->migrations = $migrations;
|
||||
$this->totalMigrations = count($migrations);
|
||||
$this->ranMigrations = count(array_filter($migrations, fn ($m) => $m['ran']));
|
||||
$this->pendingMigrations = $this->totalMigrations - $this->ranMigrations;
|
||||
}
|
||||
|
||||
public function getStatusColor(): string
|
||||
{
|
||||
return $this->hasPending ? 'warning' : 'success';
|
||||
}
|
||||
|
||||
public function getStatusMessage(): string
|
||||
{
|
||||
if ($this->hasPending) {
|
||||
return 'Pending migrations detected. Please back up your database and run php artisan migrate from the terminal.';
|
||||
}
|
||||
|
||||
return 'All migrations are up to date.';
|
||||
}
|
||||
|
||||
public function getStatusIcon(): string
|
||||
{
|
||||
return $this->hasPending ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle';
|
||||
}
|
||||
}
|
||||
187
app/Filament/Pages/ModuleUsageReport.php
Normal file
187
app/Filament/Pages/ModuleUsageReport.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Ai\AiPromptLog;
|
||||
use App\Models\Business;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ModuleUsageReport extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static ?string $navigationLabel = 'Usage Reports';
|
||||
|
||||
protected static ?string $title = 'Module Usage Reports';
|
||||
|
||||
protected string $view = 'filament.pages.module-usage-report';
|
||||
|
||||
public string $dateRange = '30';
|
||||
|
||||
/**
|
||||
* Only Superadmins can access Module Usage Reports.
|
||||
* Admin Staff will not see this page in navigation.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->dateRange = request()->get('days', '30');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
Business::query()
|
||||
->where('copilot_enabled', true)
|
||||
->withCount([
|
||||
'aiPromptLogs as copilot_requests' => function (Builder $query) {
|
||||
$query->where('operation', 'copilot')
|
||||
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
|
||||
},
|
||||
])
|
||||
->withSum([
|
||||
'aiPromptLogs as copilot_tokens' => function (Builder $query) {
|
||||
$query->where('operation', 'copilot')
|
||||
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
|
||||
},
|
||||
], 'total_tokens')
|
||||
->withSum([
|
||||
'aiPromptLogs as copilot_cost' => function (Builder $query) {
|
||||
$query->where('operation', 'copilot')
|
||||
->where('created_at', '>=', now()->subDays((int) $this->dateRange));
|
||||
},
|
||||
], 'estimated_cost')
|
||||
)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Business')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('copilot_tier')
|
||||
->label('Tier')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'basic' => 'info',
|
||||
'premium' => 'success',
|
||||
'custom' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\TextColumn::make('copilot_requests')
|
||||
->label('Requests')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->alignEnd(),
|
||||
|
||||
Tables\Columns\TextColumn::make('copilot_tokens')
|
||||
->label('Tokens')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->alignEnd()
|
||||
->formatStateUsing(fn ($state) => number_format($state ?? 0)),
|
||||
|
||||
Tables\Columns\TextColumn::make('copilot_cost')
|
||||
->label('Cost')
|
||||
->money('usd')
|
||||
->sortable()
|
||||
->alignEnd(),
|
||||
|
||||
Tables\Columns\TextColumn::make('last_copilot_use')
|
||||
->label('Last Used')
|
||||
->getStateUsing(function (Business $record) {
|
||||
$lastLog = AiPromptLog::forBusiness($record->id)
|
||||
->where('operation', 'copilot')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
return $lastLog?->created_at;
|
||||
})
|
||||
->dateTime()
|
||||
->sortable(query: function (Builder $query, string $direction) {
|
||||
// This is a computed column, so we can't sort directly
|
||||
return $query;
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('copilot_tier')
|
||||
->label('Tier')
|
||||
->options([
|
||||
'basic' => 'Basic',
|
||||
'premium' => 'Premium',
|
||||
'custom' => 'Custom',
|
||||
]),
|
||||
])
|
||||
->recordUrl(fn (Business $record) => route('filament.admin.resources.businesses.edit', $record))
|
||||
->defaultSort('copilot_requests', 'desc')
|
||||
->striped()
|
||||
->paginated([10, 25, 50, 100]);
|
||||
}
|
||||
|
||||
public function getOverviewStats(): array
|
||||
{
|
||||
$days = (int) $this->dateRange;
|
||||
$startDate = now()->subDays($days);
|
||||
|
||||
$stats = AiPromptLog::where('operation', 'copilot')
|
||||
->where('created_at', '>=', $startDate)
|
||||
->selectRaw('
|
||||
COUNT(*) as total_requests,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(estimated_cost) as total_cost,
|
||||
AVG(latency_ms) as avg_latency,
|
||||
COUNT(DISTINCT business_id) as active_businesses,
|
||||
SUM(CASE WHEN is_error = true THEN 1 ELSE 0 END) as error_count
|
||||
')
|
||||
->first();
|
||||
|
||||
$totalBusinessesWithCopilot = Business::where('copilot_enabled', true)->count();
|
||||
|
||||
return [
|
||||
'total_requests' => $stats->total_requests ?? 0,
|
||||
'total_tokens' => $stats->total_tokens ?? 0,
|
||||
'total_cost' => $stats->total_cost ?? 0,
|
||||
'avg_latency' => $stats->avg_latency ?? 0,
|
||||
'active_businesses' => $stats->active_businesses ?? 0,
|
||||
'total_businesses' => $totalBusinessesWithCopilot,
|
||||
'error_count' => $stats->error_count ?? 0,
|
||||
'error_rate' => $stats->total_requests > 0
|
||||
? round(($stats->error_count / $stats->total_requests) * 100, 2)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDailyUsage(): array
|
||||
{
|
||||
$days = (int) $this->dateRange;
|
||||
|
||||
return AiPromptLog::where('operation', 'copilot')
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as requests, SUM(total_tokens) as tokens, SUM(estimated_cost) as cost')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function updatedDateRange(): void
|
||||
{
|
||||
$this->resetTable();
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,16 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NotificationSettings extends Page
|
||||
class NotificationSettings extends Page implements HasForms
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||
use InteractsWithForms;
|
||||
|
||||
protected string $view = 'filament.pages.notification-settings';
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
@@ -22,6 +23,11 @@ class NotificationSettings extends Page
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
// Mail settings
|
||||
@@ -48,134 +54,142 @@ class NotificationSettings extends Page
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Form $form): Form
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Tabs::make('Notification Providers')
|
||||
->tabs([
|
||||
Forms\Components\Tabs\Tab::make('Email')
|
||||
->icon('heroicon-o-envelope')
|
||||
->schema([
|
||||
Forms\Components\Section::make('Email Provider Configuration')
|
||||
->description('Configure your email provider for sending transactional emails')
|
||||
->schema([
|
||||
Forms\Components\Select::make('mail_driver')
|
||||
->label('Mail Driver')
|
||||
->options([
|
||||
'smtp' => 'SMTP',
|
||||
'sendmail' => 'Sendmail',
|
||||
'mailgun' => 'Mailgun',
|
||||
'ses' => 'Amazon SES',
|
||||
'postmark' => 'Postmark',
|
||||
])
|
||||
->required()
|
||||
->reactive(),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('mail_host')
|
||||
->label('SMTP Host')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_port')
|
||||
->label('SMTP Port')
|
||||
->required()
|
||||
->numeric()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_username')
|
||||
->label('Username')
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->revealable()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\Select::make('mail_encryption')
|
||||
->label('Encryption')
|
||||
->options([
|
||||
'tls' => 'TLS',
|
||||
'ssl' => 'SSL',
|
||||
'' => 'None',
|
||||
])
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_from_address')
|
||||
->label('From Address')
|
||||
->email()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('mail_from_name')
|
||||
->label('From Name')
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('SMS')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Forms\Components\Section::make('SMS Provider Configuration')
|
||||
->description('Configure your SMS provider for sending text messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('sms_enabled')
|
||||
->label('Enable SMS Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('sms_provider')
|
||||
->label('SMS Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio',
|
||||
'nexmo' => 'Vonage (Nexmo)',
|
||||
'aws_sns' => 'AWS SNS',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('sms_enabled')),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('twilio_sid')
|
||||
->label('Twilio Account SID')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_auth_token')
|
||||
->label('Twilio Auth Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_phone_number')
|
||||
->label('Twilio Phone Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('WhatsApp')
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->schema([
|
||||
Forms\Components\Section::make('WhatsApp Configuration')
|
||||
->description('Configure WhatsApp Business API for sending messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('whatsapp_enabled')
|
||||
->label('Enable WhatsApp Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('whatsapp_provider')
|
||||
->label('WhatsApp Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio WhatsApp',
|
||||
'whatsapp_cloud' => 'WhatsApp Cloud API',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
Forms\Components\TextInput::make('whatsapp_business_number')
|
||||
->label('WhatsApp Business Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->statePath('data');
|
||||
return [
|
||||
Forms\Components\Tabs::make('Notification Providers')
|
||||
->tabs([
|
||||
Forms\Components\Tabs\Tab::make('Email')
|
||||
->icon('heroicon-o-envelope')
|
||||
->schema([
|
||||
Forms\Components\Section::make('Email Provider Configuration')
|
||||
->description('Configure your email provider for sending transactional emails')
|
||||
->schema([
|
||||
Forms\Components\Select::make('mail_driver')
|
||||
->label('Mail Driver')
|
||||
->options([
|
||||
'smtp' => 'SMTP',
|
||||
'sendmail' => 'Sendmail',
|
||||
'mailgun' => 'Mailgun',
|
||||
'ses' => 'Amazon SES',
|
||||
'postmark' => 'Postmark',
|
||||
])
|
||||
->required()
|
||||
->reactive(),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('mail_host')
|
||||
->label('SMTP Host')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_port')
|
||||
->label('SMTP Port')
|
||||
->required()
|
||||
->numeric()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_username')
|
||||
->label('Username')
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->revealable()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\Select::make('mail_encryption')
|
||||
->label('Encryption')
|
||||
->options([
|
||||
'tls' => 'TLS',
|
||||
'ssl' => 'SSL',
|
||||
'' => 'None',
|
||||
])
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_from_address')
|
||||
->label('From Address')
|
||||
->email()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('mail_from_name')
|
||||
->label('From Name')
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('SMS')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Forms\Components\Section::make('SMS Provider Configuration')
|
||||
->description('Configure your SMS provider for sending text messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('sms_enabled')
|
||||
->label('Enable SMS Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('sms_provider')
|
||||
->label('SMS Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio',
|
||||
'nexmo' => 'Vonage (Nexmo)',
|
||||
'aws_sns' => 'AWS SNS',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('sms_enabled')),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('twilio_sid')
|
||||
->label('Twilio Account SID')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_auth_token')
|
||||
->label('Twilio Auth Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_phone_number')
|
||||
->label('Twilio Phone Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('WhatsApp')
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->schema([
|
||||
Forms\Components\Section::make('WhatsApp Configuration')
|
||||
->description('Configure WhatsApp Business API for sending messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('whatsapp_enabled')
|
||||
->label('Enable WhatsApp Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('whatsapp_provider')
|
||||
->label('WhatsApp Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio WhatsApp',
|
||||
'whatsapp_cloud' => 'WhatsApp Cloud API',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
Forms\Components\TextInput::make('whatsapp_business_number')
|
||||
->label('WhatsApp Business Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormStatePath(): ?string
|
||||
{
|
||||
return 'data';
|
||||
}
|
||||
|
||||
public function getView(): string
|
||||
{
|
||||
return 'filament.pages.notification-settings';
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
|
||||
28
app/Filament/Pages/Queues.php
Normal file
28
app/Filament/Pages/Queues.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Queues extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||
|
||||
protected string $view = 'filament.pages.queues';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Queues';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->redirect('/admin/horizon', navigate: false);
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
208
app/Filament/Pages/SiteBranding.php
Normal file
208
app/Filament/Pages/SiteBranding.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class SiteBranding extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static ?string $navigationLabel = 'Site Branding';
|
||||
|
||||
protected static ?string $title = 'Site Branding';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected string $view = 'filament.pages.site-branding';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'site_name' => SiteSetting::get('site_name', 'Cannabrands Hub'),
|
||||
'favicon' => SiteSetting::get('favicon_path') ? [SiteSetting::get('favicon_path')] : [],
|
||||
'logo_light' => SiteSetting::get('logo_light_path') ? [SiteSetting::get('logo_light_path')] : [],
|
||||
'logo_dark' => SiteSetting::get('logo_dark_path') ? [SiteSetting::get('logo_dark_path')] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Site Identity')
|
||||
->description('Configure the site name and branding assets.')
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label('Site Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Displayed in browser tabs and emails.'),
|
||||
]),
|
||||
|
||||
Section::make('Favicon')
|
||||
->description('The small icon displayed in browser tabs. Recommended: 32x32 or 64x64 PNG/ICO.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_favicon')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('favicon_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getFaviconUrl().'" alt="Favicon" class="w-8 h-8">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('favicon')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->acceptedFileTypes(['image/png', 'image/x-icon', 'image/ico', 'image/vnd.microsoft.icon'])
|
||||
->maxSize(512)
|
||||
->imagePreviewHeight('64')
|
||||
->helperText('Upload a PNG or ICO file (max 512KB).'),
|
||||
]),
|
||||
|
||||
Section::make('Logos')
|
||||
->description('Upload logo variants for different backgrounds.')
|
||||
->schema([
|
||||
Section::make('Logo (Light/White)')
|
||||
->description('For dark backgrounds (sidebar, etc.)')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_light')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_light_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-800 rounded-lg border-2 border-dashed border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoLightUrl().'" alt="Logo Light" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_light')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
|
||||
Section::make('Logo (Dark)')
|
||||
->description('For light backgrounds.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_dark')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_dark_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-100 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoDarkUrl().'" alt="Logo Dark" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_dark')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
// Save site name
|
||||
SiteSetting::set('site_name', $data['site_name']);
|
||||
|
||||
// Save file paths
|
||||
$this->saveFileSetting('favicon_path', $data['favicon'] ?? []);
|
||||
$this->saveFileSetting('logo_light_path', $data['logo_light'] ?? []);
|
||||
$this->saveFileSetting('logo_dark_path', $data['logo_dark'] ?? []);
|
||||
|
||||
// Clear cache
|
||||
SiteSetting::clearCache();
|
||||
|
||||
Notification::make()
|
||||
->title('Branding settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function saveFileSetting(string $key, array $files): void
|
||||
{
|
||||
$path = ! empty($files) ? $files[0] : null;
|
||||
|
||||
// Handle TemporaryUploadedFile objects
|
||||
if ($path instanceof TemporaryUploadedFile) {
|
||||
$path = $path->store('branding', 'public');
|
||||
}
|
||||
|
||||
SiteSetting::set($key, $path);
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [
|
||||
Forms\Components\Actions\Action::make('save')
|
||||
->label('Save Changes')
|
||||
->submit('save'),
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Filament/Pages/Telescope.php
Normal file
26
app/Filament/Pages/Telescope.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Telescope extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Telescope Debug Tool';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->redirect('/telescope', navigate: false);
|
||||
}
|
||||
|
||||
public function getView(): string
|
||||
{
|
||||
return 'filament.pages.telescope';
|
||||
}
|
||||
}
|
||||
161
app/Filament/Pages/UsageDashboard.php
Normal file
161
app/Filament/Pages/UsageDashboard.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessUsageCounter;
|
||||
use App\Models\PlanUsageMetric;
|
||||
use App\Models\UsageMetric;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Usage Dashboard - Global view of usage across all businesses
|
||||
*
|
||||
* Shows:
|
||||
* - High-level cards with total usage across platform
|
||||
* - Table of businesses with their usage vs included amounts
|
||||
* - Visual status indicators (OK, Warning, Over)
|
||||
*
|
||||
* This is READ-ONLY analytics. It does NOT enforce limits.
|
||||
*
|
||||
* @see docs/USAGE_BASED_BILLING.md
|
||||
*/
|
||||
class UsageDashboard extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||
|
||||
protected static ?string $navigationLabel = 'Usage Analytics';
|
||||
|
||||
protected static ?string $title = 'Usage Analytics';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected string $view = 'filament.pages.usage-dashboard';
|
||||
|
||||
public function getViewData(): array
|
||||
{
|
||||
$periodStart = Carbon::now()->startOfMonth()->toDateString();
|
||||
$metrics = UsageMetric::active()->ordered()->get();
|
||||
|
||||
// Get all current usage counters
|
||||
$counters = BusinessUsageCounter::with(['business.plan', 'usageMetric'])
|
||||
->where('period_start', $periodStart)
|
||||
->get();
|
||||
|
||||
// Build summary cards
|
||||
$summaryCards = $this->buildSummaryCards($counters, $metrics);
|
||||
|
||||
// Build business usage table data
|
||||
$businessUsage = $this->buildBusinessUsageData($counters, $metrics);
|
||||
|
||||
return [
|
||||
'summaryCards' => $summaryCards,
|
||||
'businessUsage' => $businessUsage,
|
||||
'metrics' => $metrics,
|
||||
'periodLabel' => Carbon::now()->format('F Y'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildSummaryCards(Collection $counters, Collection $metrics): array
|
||||
{
|
||||
$cards = [];
|
||||
|
||||
foreach ($metrics as $metric) {
|
||||
$total = $counters->where('usage_metric_id', $metric->id)->sum('quantity');
|
||||
$cards[] = [
|
||||
'name' => $metric->name,
|
||||
'slug' => $metric->slug,
|
||||
'total' => number_format($total),
|
||||
'unit' => $metric->unit_label,
|
||||
'icon' => $this->getMetricIcon($metric->slug),
|
||||
];
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
protected function buildBusinessUsageData(Collection $counters, Collection $metrics): array
|
||||
{
|
||||
$businesses = Business::with('plan')
|
||||
->whereHas('plan')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$isEnterprise = $business->is_enterprise_plan || ($business->plan && $business->plan->is_enterprise);
|
||||
|
||||
$metricsData = [];
|
||||
foreach ($metrics as $metric) {
|
||||
$counter = $counters
|
||||
->where('business_id', $business->id)
|
||||
->where('usage_metric_id', $metric->id)
|
||||
->first();
|
||||
|
||||
$usage = $counter?->quantity ?? 0;
|
||||
|
||||
// Get included amount from plan
|
||||
$included = null;
|
||||
$percentage = null;
|
||||
$status = 'unlimited';
|
||||
|
||||
if (! $isEnterprise && $business->plan) {
|
||||
$planMetric = PlanUsageMetric::where('plan_id', $business->plan_id)
|
||||
->where('usage_metric_id', $metric->id)
|
||||
->first();
|
||||
|
||||
if ($planMetric && $planMetric->included_per_month !== null) {
|
||||
$included = $planMetric->included_per_month;
|
||||
$percentage = $included > 0 ? round(($usage / $included) * 100, 1) : 100;
|
||||
|
||||
if ($percentage >= 100) {
|
||||
$status = 'over';
|
||||
} elseif ($percentage >= 80) {
|
||||
$status = 'warning';
|
||||
} else {
|
||||
$status = 'ok';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$metricsData[$metric->slug] = [
|
||||
'usage' => $usage,
|
||||
'included' => $included,
|
||||
'percentage' => $percentage,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'business' => $business,
|
||||
'plan_code' => $business->plan?->code ?? 'none',
|
||||
'is_enterprise' => $isEnterprise,
|
||||
'metrics' => $metricsData,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getMetricIcon(string $slug): string
|
||||
{
|
||||
return match ($slug) {
|
||||
'menus_sent' => 'heroicon-o-document-text',
|
||||
'conversations' => 'heroicon-o-chat-bubble-left-right',
|
||||
'promos_active' => 'heroicon-o-megaphone',
|
||||
'contacts' => 'heroicon-o-users',
|
||||
'buyers' => 'heroicon-o-building-storefront',
|
||||
'ai_actions' => 'heroicon-o-sparkles',
|
||||
'campaigns' => 'heroicon-o-paper-airplane',
|
||||
'products' => 'heroicon-o-cube',
|
||||
default => 'heroicon-o-chart-bar',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections;
|
||||
|
||||
use App\Filament\Resources\AiConnections\Pages\CreateAiConnection;
|
||||
use App\Filament\Resources\AiConnections\Pages\EditAiConnection;
|
||||
use App\Filament\Resources\AiConnections\Pages\ListAiConnections;
|
||||
use App\Filament\Resources\AiConnections\Schemas\AiConnectionForm;
|
||||
use App\Filament\Resources\AiConnections\Tables\AiConnectionsTable;
|
||||
use App\Models\AiConnection;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class AiConnectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiConnection::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static ?string $navigationLabel = 'AI Settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
/**
|
||||
* Only Superadmins can access AI Connection settings.
|
||||
* Admin Staff will not see this resource in navigation.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return AiConnectionForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return AiConnectionsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListAiConnections::route('/'),
|
||||
'create' => CreateAiConnection::route('/create'),
|
||||
'edit' => EditAiConnection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAiConnection extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAiConnection extends EditRecord
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use App\Filament\Widgets\AiStatsOverview;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAiConnections extends ListRecords
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
AiStatsOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiConnectionForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Connection Details')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Friendly name for this connection (e.g., "Primary OpenAI", "Anthropic Drafts")'),
|
||||
|
||||
Select::make('provider')
|
||||
->required()
|
||||
->options([
|
||||
'anthropic' => 'Anthropic / Claude',
|
||||
'openai' => 'OpenAI / ChatGPT',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
])
|
||||
->live()
|
||||
->helperText('AI provider for this connection'),
|
||||
|
||||
TextInput::make('api_key')
|
||||
->required()
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(1000)
|
||||
->helperText('API key for this provider (stored encrypted)')
|
||||
->placeholder('Enter API key...'),
|
||||
|
||||
Select::make('model')
|
||||
->options(function ($get) {
|
||||
$provider = $get('provider');
|
||||
if (! $provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = config("ai.providers.{$provider}.models", []);
|
||||
|
||||
return array_combine($models, $models);
|
||||
})
|
||||
->helperText('Specific model to use')
|
||||
->placeholder('Select a model')
|
||||
->visible(fn ($get) => ! empty($get('provider'))),
|
||||
|
||||
TextInput::make('max_tokens')
|
||||
->numeric()
|
||||
->default(4096)
|
||||
->helperText('Maximum tokens for requests')
|
||||
->placeholder('e.g., 4096'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->helperText('Whether this connection is active and can be used')
|
||||
->default(true),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Tables;
|
||||
|
||||
use App\Models\AiConnection;
|
||||
use App\Services\AiConnectionTestService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AiConnectionsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('medium'),
|
||||
|
||||
BadgeColumn::make('provider')
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'anthropic' => 'Anthropic',
|
||||
'openai' => 'OpenAI',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
default => $state,
|
||||
})
|
||||
->colors([
|
||||
'primary' => 'anthropic',
|
||||
'success' => 'openai',
|
||||
'warning' => 'perplexity',
|
||||
'danger' => 'canva',
|
||||
'info' => 'jasper',
|
||||
]),
|
||||
|
||||
TextColumn::make('model')
|
||||
->placeholder('Default')
|
||||
->limit(30),
|
||||
|
||||
BadgeColumn::make('status')
|
||||
->colors([
|
||||
'success' => 'ok',
|
||||
'danger' => 'error',
|
||||
'gray' => 'disabled',
|
||||
]),
|
||||
|
||||
TextColumn::make('recent_usage_stats.requests')
|
||||
->label('Requests')
|
||||
->numeric()
|
||||
->suffix(' reqs')
|
||||
->description('Last 30 days'),
|
||||
|
||||
TextColumn::make('recent_usage_stats.total_tokens')
|
||||
->label('Tokens')
|
||||
->numeric()
|
||||
->description('Last 30 days'),
|
||||
|
||||
TextColumn::make('last_used_at')
|
||||
->dateTime()
|
||||
->since()
|
||||
->placeholder('Never'),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('gray'),
|
||||
])
|
||||
->filters([
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('test')
|
||||
->label('Test')
|
||||
->icon('heroicon-m-bolt')
|
||||
->color('warning')
|
||||
->action(function (AiConnection $record) {
|
||||
$result = AiConnectionTestService::testConnection($record);
|
||||
|
||||
$record->update([
|
||||
'last_tested_at' => now(),
|
||||
'status' => $result->success ? 'ok' : 'error',
|
||||
'last_error' => $result->success ? null : $result->message,
|
||||
]);
|
||||
|
||||
if ($result->success) {
|
||||
Notification::make()
|
||||
->title('Connection test successful')
|
||||
->body("Response time: {$result->responseTime}ms")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Connection test failed')
|
||||
->body($result->message)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
265
app/Filament/Resources/AiContentRuleResource.php
Normal file
265
app/Filament/Resources/AiContentRuleResource.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
use App\Models\Ai\AiContentRule;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AiContentRuleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiContentRule::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'AI Settings';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static ?string $navigationLabel = 'Content Rules';
|
||||
|
||||
protected static ?string $modelLabel = 'Content Rule';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Content Rules';
|
||||
|
||||
/**
|
||||
* Only Superadmins can access AI Content Rules.
|
||||
* Admin Staff will not see this resource in navigation.
|
||||
*/
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->user()?->canManageAi() ?? false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Content Type')
|
||||
->schema([
|
||||
TextInput::make('content_type_key')
|
||||
->label('Content Type Key')
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Format: context.field (e.g., product.short_description)')
|
||||
->placeholder('product.short_description')
|
||||
->disabled(fn ($record) => $record && ! $record->is_custom),
|
||||
|
||||
TextInput::make('label')
|
||||
->label('Display Label')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Human-readable name for this content type'),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(2)
|
||||
->maxLength(500)
|
||||
->helperText('Help text shown to admins'),
|
||||
])
|
||||
->columns(1),
|
||||
|
||||
Section::make('Character Limits')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('min_length')
|
||||
->label('Minimum Length')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->suffix('characters')
|
||||
->helperText('Leave empty for no minimum'),
|
||||
|
||||
TextInput::make('max_length')
|
||||
->label('Maximum Length')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->suffix('characters')
|
||||
->helperText('Leave empty for no maximum'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Writing Style')
|
||||
->schema([
|
||||
Select::make('tone')
|
||||
->label('Tone')
|
||||
->options(AiContentRule::TONES)
|
||||
->required()
|
||||
->default('professional')
|
||||
->helperText('The writing style AI should use'),
|
||||
|
||||
Textarea::make('system_prompt')
|
||||
->label('System Prompt')
|
||||
->rows(4)
|
||||
->helperText('Additional instructions for AI. This is appended to the base system prompt.'),
|
||||
|
||||
TagsInput::make('examples')
|
||||
->label('Example Outputs')
|
||||
->helperText('Sample outputs to guide AI. Press Enter after each example.')
|
||||
->placeholder('Add an example...'),
|
||||
]),
|
||||
|
||||
Section::make('Context Fields')
|
||||
->schema([
|
||||
TagsInput::make('context_fields')
|
||||
->label('Available Context Fields')
|
||||
->helperText('Data fields that can be used for personalization (e.g., product_name, brand_name)')
|
||||
->placeholder('Add a field...'),
|
||||
]),
|
||||
|
||||
Section::make('Settings')
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Inactive rules will use config defaults'),
|
||||
|
||||
Toggle::make('is_custom')
|
||||
->label('Custom Rule')
|
||||
->default(true)
|
||||
->helperText('Custom rules are admin-created, non-custom are synced from config')
|
||||
->disabled(),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('content_type_key')
|
||||
->label('Content Type')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->copyable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('label')
|
||||
->label('Label')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('limit_string')
|
||||
->label('Limits')
|
||||
->badge()
|
||||
->color('info'),
|
||||
|
||||
Tables\Columns\TextColumn::make('tone')
|
||||
->label('Tone')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'professional' => 'success',
|
||||
'casual' => 'info',
|
||||
'technical' => 'warning',
|
||||
'marketing' => 'primary',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_custom')
|
||||
->label('Custom')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-pencil')
|
||||
->falseIcon('heroicon-o-cog')
|
||||
->trueColor('warning')
|
||||
->falseColor('gray'),
|
||||
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Updated')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tone')
|
||||
->options(AiContentRule::TONES),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active'),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_custom')
|
||||
->label('Custom'),
|
||||
|
||||
Tables\Filters\SelectFilter::make('context')
|
||||
->label('Context')
|
||||
->options([
|
||||
'product' => 'Product',
|
||||
'brand' => 'Brand',
|
||||
'email' => 'Email',
|
||||
'menu' => 'Menu',
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if (! empty($data['value'])) {
|
||||
$query->where('content_type_key', 'ilike', $data['value'].'.%');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->visible(fn ($record) => $record->is_custom),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
// Only allow deleting custom rules
|
||||
return $records->filter(fn ($record) => $record->is_custom);
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('sync_from_config')
|
||||
->label('Sync from Config')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Sync Content Rules from Config')
|
||||
->modalDescription('This will create database entries for any content types defined in config that don\'t already exist. Existing customizations will not be overwritten.')
|
||||
->action(function () {
|
||||
$result = AiContentRule::syncFromConfig();
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Sync Complete')
|
||||
->body('Created '.count($result['created']).' new rules, skipped '.count($result['skipped']).' existing rules.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->defaultSort('content_type_key');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAiContentRules::route('/'),
|
||||
'create' => Pages\CreateAiContentRule::route('/create'),
|
||||
'edit' => Pages\EditAiContentRule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource;
|
||||
use App\Services\Ai\AiContentTypeRegistry;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAiContentRule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AiContentRuleResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['is_custom'] = true;
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
// Clear the content type registry cache
|
||||
app(AiContentTypeRegistry::class)->clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiContentRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AiContentRuleResource;
|
||||
use App\Services\Ai\AiContentTypeRegistry;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAiContentRule extends EditRecord
|
||||
{
|
||||
protected static string $resource = AiContentRuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->visible(fn () => $this->record->is_custom),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
// Mark as custom if it was originally from config and is being modified
|
||||
if (! $this->record->is_custom) {
|
||||
$data['is_custom'] = true;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Clear the content type registry cache
|
||||
app(AiContentTypeRegistry::class)->clearCache();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user