mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 14:53:33 -04:00
Compare commits
1661 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b6c30b987 | ||
|
|
8da9bd8883 | ||
|
|
bdbce67423 | ||
|
|
e2cdfc01cd | ||
|
|
d2c9d12b39 | ||
|
|
09736cd49b | ||
|
|
f16bef97dc | ||
|
|
92d4222f96 | ||
|
|
008110b015 | ||
|
|
29ace2d2e5 | ||
|
|
07f927d738 | ||
|
|
993117ba72 | ||
|
|
c8801a0235 | ||
|
|
93cd105871 | ||
|
|
4f98d99dd9 | ||
|
|
b46d39d9b7 | ||
|
|
5ea095662b | ||
|
|
2400d50247 | ||
|
|
8ffd69ff15 | ||
|
|
0b721ec7b9 | ||
|
|
2019c835a0 | ||
|
|
ff23a8c1bc | ||
|
|
df775e9aa3 | ||
|
|
d121c92708 | ||
|
|
2db74bf256 | ||
|
|
0d758eb5b1 | ||
|
|
223387129e | ||
|
|
01944f9a73 | ||
|
|
f9a1dce7b5 | ||
|
|
98271784b0 | ||
|
|
2311fcbf90 | ||
|
|
f6fef171fa | ||
|
|
7565d373e9 | ||
|
|
c4fb694c71 | ||
|
|
bdf8793d4a | ||
|
|
05bd25c9ab | ||
|
|
5dc98fcbcf | ||
|
|
91f11161a2 | ||
|
|
0c60e8a865 | ||
|
|
3c8566a9e8 | ||
|
|
15f0dd5aaf | ||
|
|
fc199a918a | ||
|
|
b936466a9d | ||
|
|
781b5ec674 | ||
|
|
a3dfcb4080 | ||
|
|
0fe1ffe78e | ||
|
|
e280b1f5e0 | ||
|
|
cc43a8f8af | ||
|
|
3caf56247f | ||
|
|
032c70e086 | ||
|
|
903679eaa7 | ||
|
|
2908a1f16d | ||
|
|
b5deefbec1 | ||
|
|
0cb6226643 | ||
|
|
b19f6b81de | ||
|
|
401fd816dd | ||
|
|
a31765b7ae | ||
|
|
a7ae62a14b | ||
|
|
007bc1f7b0 | ||
|
|
bded8cc1fe | ||
|
|
cfaac3e3fa | ||
|
|
9637c80dd7 | ||
|
|
6a9739ab31 | ||
|
|
a51bd344a2 | ||
|
|
50f9a00689 | ||
|
|
54fb8c4b61 | ||
|
|
809a996870 | ||
|
|
85c96ce430 | ||
|
|
b670de9c73 | ||
|
|
626b28f4eb | ||
|
|
9c3dd65fc9 | ||
|
|
1e5c7b07c2 | ||
|
|
686c5777fd | ||
|
|
611e07a02b | ||
|
|
3c514de79d | ||
|
|
8319d21dea | ||
|
|
e4a231b0c5 | ||
|
|
b1efd042cf | ||
|
|
8a7d2120c4 | ||
|
|
e0b3f2d81a | ||
|
|
9f1eaef30c | ||
|
|
0218f4e705 | ||
|
|
3171f32cec | ||
|
|
156d2870cb | ||
|
|
2229d01aa1 | ||
|
|
6e4c6f29b5 | ||
|
|
ad51aea069 | ||
|
|
b8963c99a7 | ||
|
|
b270ce706b | ||
|
|
2c04b2a6aa | ||
|
|
a00b966e8b | ||
|
|
8ac3259f41 | ||
|
|
56f0423d5a | ||
|
|
e83ec6506c | ||
|
|
be22658883 | ||
|
|
bdafa43472 | ||
|
|
891b91d0ba | ||
|
|
b85b9380c3 | ||
|
|
cfeab0278c | ||
|
|
c8735b8b01 | ||
|
|
5ecbd3ba91 | ||
|
|
f83dbf349e | ||
|
|
3d1e91d100 | ||
|
|
0353a530d4 | ||
|
|
0592c7e766 | ||
|
|
5a8c698ed5 | ||
|
|
7ff38bd693 | ||
|
|
6c84926033 | ||
|
|
f35af89f07 | ||
|
|
b1f01fd873 | ||
|
|
96e22136a4 | ||
|
|
f81932e14e | ||
|
|
6b1813e129 | ||
|
|
307076d88e | ||
|
|
a84ad62ea2 | ||
|
|
c3f21058e5 | ||
|
|
d3634d8b1c | ||
|
|
cdbba244d0 | ||
|
|
6daeea908e | ||
|
|
b835a5f137 | ||
|
|
25358a9463 | ||
|
|
11063611bf | ||
|
|
6a03c5cc83 | ||
|
|
37ddf5bab1 | ||
|
|
f741648522 | ||
|
|
57e6395641 | ||
|
|
bc31df7d0a | ||
|
|
1e4b836985 | ||
|
|
5c1b18b4d9 | ||
|
|
f90b2179b5 | ||
|
|
0da158ce54 | ||
|
|
a360b51e12 | ||
|
|
3de6adae2e | ||
|
|
01a6a6e298 | ||
|
|
54ce5891f6 | ||
|
|
8a2bcad190 | ||
|
|
a88ba2dd3c | ||
|
|
3503110c64 | ||
|
|
33436c84a4 | ||
|
|
52f0f04194 | ||
|
|
3d56bb4fe0 | ||
|
|
b0c84687d2 | ||
|
|
0ef5ecea30 | ||
|
|
670e863bae | ||
|
|
3309ed1988 | ||
|
|
3365445538 | ||
|
|
9808b4a2e7 | ||
|
|
f3bd28e29f | ||
|
|
da9e950e5f | ||
|
|
dbf12a2ab2 | ||
|
|
1a2aa16645 | ||
|
|
a1df61edaa | ||
|
|
9e58e12013 | ||
|
|
a71c0beb11 | ||
|
|
93e6b18b49 | ||
|
|
6aae66f0d2 | ||
|
|
46f751b712 | ||
|
|
ccce2cc8b0 | ||
|
|
c38d752dc8 | ||
|
|
6f45c03a29 | ||
|
|
5819f08aec | ||
|
|
a322ea77fc | ||
|
|
9a5f47f905 | ||
|
|
a02ba9767b | ||
|
|
57edf80bfd | ||
|
|
3a2db534ab | ||
|
|
8eac568446 | ||
|
|
5e5015018f | ||
|
|
1fce55aebc | ||
|
|
cae5b96b1e | ||
|
|
3c72bca496 | ||
|
|
ba7e158af8 | ||
|
|
34a13458db | ||
|
|
3200e3cf79 | ||
|
|
de9c28965f | ||
|
|
4e61669361 | ||
|
|
09607ba7c7 | ||
|
|
24d2fe9c32 | ||
|
|
94585b1f1d | ||
|
|
155406935d | ||
|
|
b445db6116 | ||
|
|
d4de56873f | ||
|
|
6c54a9adf0 | ||
|
|
74f948e6ca | ||
|
|
0ea9ee969e | ||
|
|
6ae1af3c1f | ||
|
|
4ac4ab0ba2 | ||
|
|
d29edae5fe | ||
|
|
066a620bd4 | ||
|
|
7ad464150b | ||
|
|
596e70e031 | ||
|
|
13f103a3ca | ||
|
|
a699447e9e | ||
|
|
159e634a1a | ||
|
|
0533fdd2ed | ||
|
|
5119c458d8 | ||
|
|
d8a39f7101 | ||
|
|
3a0712c193 | ||
|
|
cb9dd51afc | ||
|
|
89ce1a248e | ||
|
|
ffc717c86b | ||
|
|
30248fd0be | ||
|
|
c270e6c3d7 | ||
|
|
e8bc9cda1d | ||
|
|
0bc82a3bcf | ||
|
|
a5f7898c37 | ||
|
|
1165927337 | ||
|
|
2d5690cea2 | ||
|
|
ace1b2823a | ||
|
|
a50323cd94 | ||
|
|
008bc11ebf | ||
|
|
3ddf81d935 | ||
|
|
67e95be2d4 | ||
|
|
0bb0a394ae | ||
|
|
cc74945371 | ||
|
|
2ea34189a8 | ||
|
|
66800d949b | ||
|
|
2eb80b97d5 | ||
|
|
f4d8822dbe | ||
|
|
0655266366 | ||
|
|
4b58e72607 | ||
|
|
29445a8aa7 | ||
|
|
c4a498227f | ||
|
|
295fa1dee4 | ||
|
|
ca582f0134 | ||
|
|
43abe21e45 | ||
|
|
c1df4c1307 | ||
|
|
73802c9c1d | ||
|
|
2825ded7c0 | ||
|
|
69161612f6 | ||
|
|
2cc6af1f37 | ||
|
|
7a52f55b05 | ||
|
|
f854476614 | ||
|
|
7165553080 | ||
|
|
fbda1caaf7 | ||
|
|
1c3ed84791 | ||
|
|
6008a5d3a5 | ||
|
|
205354c6c4 | ||
|
|
cb8ef458c2 | ||
|
|
d54f0052df | ||
|
|
d979a81b95 | ||
|
|
bf8ce092af | ||
|
|
c53039d97a | ||
|
|
5f32e5d025 | ||
|
|
b3d203afa2 | ||
|
|
0379c95f9b | ||
|
|
512406837d | ||
|
|
32e8c8570b | ||
|
|
6c86de75ec | ||
|
|
4bc91a5311 | ||
|
|
822f9150b8 | ||
|
|
6117e59226 | ||
|
|
bf9d25731c | ||
|
|
8686ac4090 | ||
|
|
dcacf98ea3 | ||
|
|
cd9fcb8176 | ||
|
|
d158eadf0d | ||
|
|
e115df8e11 | ||
|
|
140ace55bf | ||
|
|
7a44f3d23c | ||
|
|
2f38d54ea5 | ||
|
|
01deda29e7 | ||
|
|
b59b0edd4a | ||
|
|
be358d8517 | ||
|
|
4e3f31dd1c | ||
|
|
ab439bd85d | ||
|
|
aa7e32cb77 | ||
|
|
863fd2c0ae | ||
|
|
a18d308248 | ||
|
|
bfed23769e | ||
|
|
54a8f0e59b | ||
|
|
90d25c7152 | ||
|
|
9ccdeb6997 | ||
|
|
ad1b3e813e | ||
|
|
50949199f4 | ||
|
|
2d477e498f | ||
|
|
f9f9da63a0 | ||
|
|
000daa3021 | ||
|
|
40c30ede11 | ||
|
|
54fdf67bcf | ||
|
|
1bf9b83693 | ||
|
|
32e97f9da5 | ||
|
|
677f8bf207 | ||
|
|
ab92cf2aa9 | ||
|
|
0dff23991a | ||
|
|
04af8966b5 | ||
|
|
dd239f7b30 | ||
|
|
d2195d7c16 | ||
|
|
60269b66a7 | ||
|
|
2cad2fca17 | ||
|
|
a10ae2d253 | ||
|
|
4a4bee658d | ||
|
|
75a689707d | ||
|
|
6f638a22a3 | ||
|
|
18b63f1eb3 | ||
|
|
39689b1bfa | ||
|
|
44bc25f061 | ||
|
|
e2d9c06362 | ||
|
|
165a7e5663 | ||
|
|
8f83011011 | ||
|
|
9924c3debf | ||
|
|
836f8f1bfb | ||
|
|
fae76195ec | ||
|
|
0d8d81e09c | ||
|
|
699890a0be | ||
|
|
8c68312597 | ||
|
|
55bb9919f7 | ||
|
|
f8ca4bcbfc | ||
|
|
7037362b40 | ||
|
|
e29bc0ab82 | ||
|
|
b3cf542fd5 | ||
|
|
59ddb4360e | ||
|
|
4411ef25b0 | ||
|
|
0d57760d25 | ||
|
|
77f3b550d0 | ||
|
|
79e0421281 | ||
|
|
f54e18feb1 | ||
|
|
18d2c59479 | ||
|
|
33d7308cfa | ||
|
|
579ed6de7c | ||
|
|
8a1ee5cdca | ||
|
|
edaf119d9e | ||
|
|
1aad769d93 | ||
|
|
b5b2bafc78 | ||
|
|
8ba8604d83 | ||
|
|
9fdd21e03a | ||
|
|
418b2c7e52 | ||
|
|
49237bbe18 | ||
|
|
049fffe27f | ||
|
|
8d80245adf | ||
|
|
8d235e89f0 | ||
|
|
b030dd1a52 | ||
|
|
17faf2fe26 | ||
|
|
b18a6d7ff3 | ||
|
|
c69a438d08 | ||
|
|
309cbe7f2b | ||
|
|
c0df0028d3 | ||
|
|
4722088fd0 | ||
|
|
27c03330c8 | ||
|
|
740a7cc6f9 | ||
|
|
53ae605e9d | ||
|
|
84da8b7ad3 | ||
|
|
8b283cc5ce | ||
|
|
8548b85d03 | ||
|
|
fbb10344e1 | ||
|
|
615033547c | ||
|
|
8f854995c5 | ||
|
|
f8bb15e376 | ||
|
|
f6a65e875b | ||
|
|
74fc6a744e | ||
|
|
6920b8293d | ||
|
|
6c71022ed6 | ||
|
|
d0176c2b65 | ||
|
|
5ce38fea65 | ||
|
|
19ee048536 | ||
|
|
2531ee6fe4 | ||
|
|
1722cb531f | ||
|
|
f59325cbed | ||
|
|
1c595d3e33 | ||
|
|
4f2df43232 | ||
|
|
b7fe357fb2 | ||
|
|
59d52d4c11 | ||
|
|
8c1a75e26b | ||
|
|
ce8e7d141a | ||
|
|
0a2e4a7df1 | ||
|
|
26bc68e4db | ||
|
|
945f4a0d82 | ||
|
|
41da848714 | ||
|
|
5a92c47c99 | ||
|
|
69f181e8ee | ||
|
|
f374fef4f9 | ||
|
|
263e38f23e | ||
|
|
ddd5cd6823 | ||
|
|
7f8f394d58 | ||
|
|
57d9d2df38 | ||
|
|
b7500ba634 | ||
|
|
615d56131f | ||
|
|
c6f4b05e2a | ||
|
|
366581bcb1 | ||
|
|
35d96fff5d | ||
|
|
24e088b599 | ||
|
|
432d2df449 | ||
|
|
68a79c4b90 | ||
|
|
fa922bdcbe | ||
|
|
1086f6b4e2 | ||
|
|
44d3e7577b | ||
|
|
4015db7fda | ||
|
|
04f41c42c4 | ||
|
|
67fb98c672 | ||
|
|
8c21d2990f | ||
|
|
32201b6dfa | ||
|
|
4326c870a8 | ||
|
|
e03c714555 | ||
|
|
59cb649540 | ||
|
|
77e40c088c | ||
|
|
e5aeda92c8 | ||
|
|
0e99f75b73 | ||
|
|
9ec5fe549b | ||
|
|
47cf7bd3b2 | ||
|
|
c848007874 | ||
|
|
abba1260be | ||
|
|
cfc6b45a5b | ||
|
|
5e8f8167d1 | ||
|
|
dce0821b1a | ||
|
|
129dde8713 | ||
|
|
5d368a61eb | ||
|
|
2d0a869b00 | ||
|
|
88efbe4666 | ||
|
|
46aba5dc8d | ||
|
|
5321dd5690 | ||
|
|
2b0cd7ad3a | ||
|
|
3c08ba3cae | ||
|
|
f850b4ada5 | ||
|
|
1dbe4ecdef | ||
|
|
2b31ed3164 | ||
|
|
df2473929a | ||
|
|
80a877301a | ||
|
|
1aae688f3f | ||
|
|
337ce4523f | ||
|
|
e0b26af2bc | ||
|
|
1e2c2bbb8f | ||
|
|
ab0fd0aea4 | ||
|
|
1310c423bd | ||
|
|
32fadcc194 | ||
|
|
a2959a773e | ||
|
|
cadb4a4fd5 | ||
|
|
8decdab0c3 | ||
|
|
b710fa9f60 | ||
|
|
3cb5d08801 | ||
|
|
aa785c1539 | ||
|
|
f0c645b16d | ||
|
|
9870ac5029 | ||
|
|
cd067cd1a9 | ||
|
|
e1805d9d9e | ||
|
|
23a8aa6712 | ||
|
|
f70a74d20e | ||
|
|
02ea9131e4 | ||
|
|
cd8096f524 | ||
|
|
52819f3259 | ||
|
|
55c1b3983d | ||
|
|
27ac1539cb | ||
|
|
119bd9699d | ||
|
|
607285f25d | ||
|
|
192a944f4b | ||
|
|
c085e8f6dd | ||
|
|
79f855d39a | ||
|
|
f3cb8d51fc | ||
|
|
1bb9ac2006 | ||
|
|
07c8f74de7 | ||
|
|
e305fba93d | ||
|
|
ce680d6c8a | ||
|
|
5362403a04 | ||
|
|
abd77c4c4b | ||
|
|
e4a9337fab | ||
|
|
a9d9f0a190 | ||
|
|
a112350ad4 | ||
|
|
243e602fda | ||
|
|
b6d7955130 | ||
|
|
0277cc390d | ||
|
|
2d85b5c25a | ||
|
|
837a45dd91 | ||
|
|
b941960013 | ||
|
|
650b3f2469 | ||
|
|
3a1273dfac | ||
|
|
b7ab669adc | ||
|
|
affea2ce26 | ||
|
|
85ea90b9c8 | ||
|
|
0bfc714a41 | ||
|
|
ba9b3cfb06 | ||
|
|
1994697fa1 | ||
|
|
1c26b6381e | ||
|
|
f5daa3cdc8 | ||
|
|
8c71f6231a | ||
|
|
2f8e6147f6 | ||
|
|
2ff778a387 | ||
|
|
70c3493057 | ||
|
|
e62dac276a | ||
|
|
1115b4f1b0 | ||
|
|
c05af9ffcb | ||
|
|
2bb84d3992 | ||
|
|
2c90fc6cc8 | ||
|
|
d89bf2d92a | ||
|
|
f591cb13bb | ||
|
|
52720d2520 | ||
|
|
45473e0679 | ||
|
|
5e3c10d26a | ||
|
|
d0c58f753b | ||
|
|
20ba6f7ea9 | ||
|
|
27e93da06a | ||
|
|
c1a204e3be | ||
|
|
6be69b74e8 | ||
|
|
4b894363af | ||
|
|
cf843f66c4 | ||
|
|
cb98314bec | ||
|
|
4046cc19ef | ||
|
|
6787cdffa3 | ||
|
|
d583dbb945 | ||
|
|
bbd395b904 | ||
|
|
e1077e95a2 | ||
|
|
28f33da6d0 | ||
|
|
dbdaa9d7ec | ||
|
|
f4f009dc4a | ||
|
|
aacdd380c4 | ||
|
|
01f774ef7e | ||
|
|
57d7df63c8 | ||
|
|
f41c05a338 | ||
|
|
66526abeef | ||
|
|
3772d28129 | ||
|
|
3cc7222309 | ||
|
|
46082c5f64 | ||
|
|
cad1fd72e3 | ||
|
|
3a2e932628 | ||
|
|
1b3b40917b | ||
|
|
c181c571c4 | ||
|
|
8b094769e4 | ||
|
|
4b2b46d4f7 | ||
|
|
fc6628be08 | ||
|
|
07ce2780c6 | ||
|
|
c8b7b85391 | ||
|
|
0af6b9aff5 | ||
|
|
494e62720d | ||
|
|
8df2be1969 | ||
|
|
3c59be763a | ||
|
|
60a44b2ed1 | ||
|
|
e58a3ecb43 | ||
|
|
20025c825f | ||
|
|
aec30cd5b5 | ||
|
|
771cf887d5 | ||
|
|
78325cddfa | ||
|
|
1428ef1687 | ||
|
|
145f544ff1 | ||
|
|
28a582d9c4 | ||
|
|
19fc2b798b | ||
|
|
922decd252 | ||
|
|
0188a4ab51 | ||
|
|
c47e693e69 | ||
|
|
dd049dfb88 | ||
|
|
e6d64f2668 | ||
|
|
ee1e894e2f | ||
|
|
b3365cd773 | ||
|
|
920e0f37f2 | ||
|
|
fd7b4edc02 | ||
|
|
e689d0253c | ||
|
|
9efd3e4510 | ||
|
|
4fda21c582 | ||
|
|
76abe4ad28 | ||
|
|
e92f8afb46 | ||
|
|
e3907a3e64 | ||
|
|
5c9aa4f9f0 | ||
|
|
ae3b045772 | ||
|
|
6638d2b184 | ||
|
|
6a97045bad | ||
|
|
ba5beea17e | ||
|
|
7df3b301c6 | ||
|
|
ab81949927 | ||
|
|
f7c0d77e9c | ||
|
|
b7d1fd9903 | ||
|
|
e3b743f87b | ||
|
|
54e1106237 | ||
|
|
6a18d482e2 | ||
|
|
c46fc2a5bd | ||
|
|
e28263ff1f | ||
|
|
117b2d22e4 | ||
|
|
d61133ef74 | ||
|
|
ccf68b80c0 | ||
|
|
d2929a94ce | ||
|
|
7fd509b7e4 | ||
|
|
2c3314becc | ||
|
|
6815513ac3 | ||
|
|
e8cfc77902 | ||
|
|
7928e58d3b | ||
|
|
d8743a4b1c | ||
|
|
6626f23ef3 | ||
|
|
a0c1cf964b | ||
|
|
7437a2fb45 | ||
|
|
8ff5b8a577 | ||
|
|
cd434e2fb5 | ||
|
|
7734b689a3 | ||
|
|
ca55a93d5f | ||
|
|
6b7e3acf93 | ||
|
|
e865f75d95 | ||
|
|
af95174929 | ||
|
|
b6598f917c | ||
|
|
702d4ea1a2 | ||
|
|
dbe45d5181 | ||
|
|
4e11cc7ed1 | ||
|
|
2d5184a0ee | ||
|
|
1d2a2beb7a | ||
|
|
60dba612ba | ||
|
|
ea4047bc47 | ||
|
|
fab2316f28 | ||
|
|
ae708313aa | ||
|
|
812f6acac7 | ||
|
|
f56373fd73 | ||
|
|
5d9a1c1bf8 | ||
|
|
a3257cd4c0 | ||
|
|
67f8861d12 | ||
|
|
3fd37b21f3 | ||
|
|
9c1fbbd258 | ||
|
|
32fb79e9a2 | ||
|
|
ebecf18b6c | ||
|
|
403af8106f | ||
|
|
85ceee0b0d | ||
|
|
33f75420a2 | ||
|
|
a20767761b | ||
|
|
b13a40f892 | ||
|
|
a86f841b7e | ||
|
|
41c9da04b1 | ||
|
|
f42a1ad64f | ||
|
|
9c00fffb89 | ||
|
|
7b07610613 | ||
|
|
f1bc2f9dcb | ||
|
|
4a317018cb | ||
|
|
05219b2938 | ||
|
|
32edb4697c | ||
|
|
7e790ca353 | ||
|
|
939767cb42 | ||
|
|
31dc39fad7 | ||
|
|
243d22822a | ||
|
|
17d0fba831 | ||
|
|
05d003535b | ||
|
|
bd39bc290c | ||
|
|
4b203ae686 | ||
|
|
4e306af344 | ||
|
|
46489e25ff | ||
|
|
56d76922dc | ||
|
|
6ad3d3051f | ||
|
|
7598f32c56 | ||
|
|
46fcf042ad | ||
|
|
5962512a83 | ||
|
|
eaaae99dff | ||
|
|
688bbffd5e | ||
|
|
079ec8fc7e | ||
|
|
fba4436a08 | ||
|
|
d7f7e046ed | ||
|
|
e001787e5c | ||
|
|
cc4277a85c | ||
|
|
b49838c49a | ||
|
|
33ce2c9624 | ||
|
|
52d09aedd2 | ||
|
|
cf67ad0d81 | ||
|
|
20006049d0 | ||
|
|
a0ce449abe | ||
|
|
bd8b371c13 | ||
|
|
83433be1c8 | ||
|
|
7596906531 | ||
|
|
cb35813d16 | ||
|
|
55027c790a | ||
|
|
09ff56ad61 | ||
|
|
63e516fb4b | ||
|
|
0b2b60bd49 | ||
|
|
3e8470a216 | ||
|
|
e04746cde3 | ||
|
|
8c293c69eb | ||
|
|
cb96528752 | ||
|
|
8348953510 | ||
|
|
6d25937b56 | ||
|
|
d1f1fe945f | ||
|
|
4155beb7db | ||
|
|
374143172d | ||
|
|
6b4b0a9459 | ||
|
|
21789101fd | ||
|
|
8bfed9a6cc | ||
|
|
c36ceb7ed9 | ||
|
|
e80d8d701a | ||
|
|
f336e61304 | ||
|
|
70194541db | ||
|
|
8570f74a24 | ||
|
|
5b43382fc7 | ||
|
|
237ed0b5bf | ||
|
|
b01dcc6bde | ||
|
|
a433d1e095 | ||
|
|
ccc68f00ae | ||
|
|
756f0036eb | ||
|
|
54b8ab34bd | ||
|
|
59ebd539f1 | ||
|
|
7fad10d203 | ||
|
|
acb29c0600 | ||
|
|
398c7b92b7 | ||
|
|
95a9d8a50a | ||
|
|
95c018786d | ||
|
|
d7053dee14 | ||
|
|
f878521a40 | ||
|
|
40bbdb3fd9 | ||
|
|
7ac933160d | ||
|
|
0ac5ce7b41 | ||
|
|
152ed99727 | ||
|
|
a48633a074 | ||
|
|
b2ebf7e78e | ||
|
|
a5259f4c61 | ||
|
|
542152eadb | ||
|
|
ffc966f3b3 | ||
|
|
e45b24880f | ||
|
|
beac17cecf | ||
|
|
7ca682a4f9 | ||
|
|
07bc32d781 | ||
|
|
d59961f6cc | ||
|
|
945febffb6 | ||
|
|
bda34dddd0 | ||
|
|
ccb2eaaddf | ||
|
|
7880c391f1 | ||
|
|
ad4ea7328f | ||
|
|
763b74ef31 | ||
|
|
851d945545 | ||
|
|
80af2e9aeb | ||
|
|
473734bbd4 | ||
|
|
afe36f98be | ||
|
|
0888eb1a81 | ||
|
|
e2cbb5969d | ||
|
|
026a938e6f | ||
|
|
2da7e6d5d9 | ||
|
|
fd331dfaf9 | ||
|
|
e4a3e7a525 | ||
|
|
71695aba6c | ||
|
|
1aff8c1009 | ||
|
|
61a2e31175 | ||
|
|
c3d547f090 | ||
|
|
33d6dc1455 | ||
|
|
43c3ef591c | ||
|
|
79d56b0b4b | ||
|
|
1d505a2ae3 | ||
|
|
218de89583 | ||
|
|
94298e4609 | ||
|
|
274ea45cb8 | ||
|
|
d5322e1863 | ||
|
|
7131a5edaf | ||
|
|
ae977fbde7 | ||
|
|
f68321690a | ||
|
|
016841d6c2 | ||
|
|
635538b640 | ||
|
|
cf6989fcd7 | ||
|
|
9e324150c4 | ||
|
|
f6bf4b5b23 | ||
|
|
111f8809ca | ||
|
|
548c1d3ad9 | ||
|
|
c39dddf1db | ||
|
|
39d856f368 | ||
|
|
b2c4e46180 | ||
|
|
7622e864cb | ||
|
|
bc66c0334a | ||
|
|
babef5f032 | ||
|
|
f7f67b0ab0 | ||
|
|
9219671430 | ||
|
|
a4675246b2 | ||
|
|
e2d78722d8 | ||
|
|
d5c2f06784 | ||
|
|
26d9851c9e | ||
|
|
fbfb959de2 | ||
|
|
449b32d4bc | ||
|
|
a3c479ff92 | ||
|
|
51c97adce4 | ||
|
|
cde8a0d97f | ||
|
|
e257a10fdb | ||
|
|
6df9c5519a | ||
|
|
33a50b7e7e | ||
|
|
5029af9c5f | ||
|
|
7b61c70f6d | ||
|
|
87059494a3 | ||
|
|
e7f348e34e | ||
|
|
f953b5c10b | ||
|
|
d75f179a46 | ||
|
|
87713a7803 | ||
|
|
ac7091ae06 | ||
|
|
f4f3aa80f1 | ||
|
|
fa82a30907 | ||
|
|
2e81ef25f1 | ||
|
|
12ef6a891b | ||
|
|
fb3f5e5ace | ||
|
|
9537500fe1 | ||
|
|
bb423ba0e4 | ||
|
|
21db5cb481 | ||
|
|
078425f1b2 | ||
|
|
77e3b56b65 | ||
|
|
2ed23e05fa | ||
|
|
486a4a81f7 | ||
|
|
9a0f0c0892 | ||
|
|
4abed529d3 | ||
|
|
4293a8c24b | ||
|
|
60721eefb2 | ||
|
|
e5c8650df0 | ||
|
|
8e6d6584ea | ||
|
|
077f3ac1a3 | ||
|
|
04b8e21769 | ||
|
|
bc672db79a | ||
|
|
dc7be2d334 | ||
|
|
08c9f60010 | ||
|
|
0bb52e72f7 | ||
|
|
593c2ac024 | ||
|
|
8712424b89 | ||
|
|
c408d895b1 | ||
|
|
33c2121c8d | ||
|
|
fa151e79d3 | ||
|
|
58e65baa47 | ||
|
|
7bc57d8380 | ||
|
|
eed3e71113 | ||
|
|
08d98468c5 | ||
|
|
0ee66a264e | ||
|
|
c10f8ceb91 | ||
|
|
e3cb85271c | ||
|
|
2c43f3e1e7 | ||
|
|
29ea3a600a | ||
|
|
3ace0e80e8 | ||
|
|
79f460b9a2 | ||
|
|
7d41846b0a | ||
|
|
d8997f64cb | ||
|
|
8a9139633d | ||
|
|
a5191b440e | ||
|
|
c7d58905b5 | ||
|
|
b6df79b836 | ||
|
|
9975113eff | ||
|
|
e8431845b1 | ||
|
|
a3e51f06e3 | ||
|
|
ce447db6b5 | ||
|
|
8c14212e10 | ||
|
|
9976e085c1 | ||
|
|
6da973dd0c | ||
|
|
1dcf86e5ba | ||
|
|
3232e783e3 | ||
|
|
c66d82a06d | ||
|
|
a46ff44f99 | ||
|
|
669a95bfa9 | ||
|
|
4287fbae85 | ||
|
|
f2845eab91 | ||
|
|
40702e7832 | ||
|
|
9909bd41cb | ||
|
|
0dec7e1987 | ||
|
|
c5965fff89 | ||
|
|
53a1b4c415 | ||
|
|
773551e41d | ||
|
|
2df0e3df88 | ||
|
|
df39c2ee70 | ||
|
|
0bfd4d47b9 | ||
|
|
097617cfa0 | ||
|
|
b2968166df | ||
|
|
9116144de4 | ||
|
|
47e5e8bb28 | ||
|
|
32cae16045 | ||
|
|
4e4c946acf | ||
|
|
3e06b61eba | ||
|
|
04e2376829 | ||
|
|
709ef3f4ad | ||
|
|
2e7de541aa | ||
|
|
dc073ef0f9 | ||
|
|
a677df2fdd | ||
|
|
58c005194c | ||
|
|
16a5c3e408 | ||
|
|
8a4fb040e1 | ||
|
|
eab140e51d | ||
|
|
009d4bf91f | ||
|
|
ed094bbeca | ||
|
|
704bcb4619 | ||
|
|
fbdc594fa9 | ||
|
|
6a511adcf5 | ||
|
|
d55b04d22d | ||
|
|
96d0c90f8c | ||
|
|
2253a734b1 | ||
|
|
dca08a6d81 | ||
|
|
8a4dc44fbc | ||
|
|
e231f016d6 | ||
|
|
05250285c0 | ||
|
|
ce9ac99894 | ||
|
|
f76c39bd1c | ||
|
|
9ccb8d002b | ||
|
|
d112d4e739 | ||
|
|
7dfec75ac0 | ||
|
|
3fd27131e2 | ||
|
|
355fd7e6d1 | ||
|
|
c0b8df4ef9 | ||
|
|
cc3af74676 | ||
|
|
40a1e079a1 | ||
|
|
764769366c | ||
|
|
b433668628 | ||
|
|
cf27794776 | ||
|
|
238fed617f | ||
|
|
0d026d36d1 | ||
|
|
d699ceae38 | ||
|
|
762dd93042 | ||
|
|
1f5ed87363 | ||
|
|
28ce878b34 | ||
|
|
be2e19db8d | ||
|
|
785a633115 | ||
|
|
57beefb894 | ||
|
|
787d35e057 | ||
|
|
a5b1d1abb0 | ||
|
|
daa517a11d | ||
|
|
ef7665c7da | ||
|
|
348a557f5c | ||
|
|
26b36ab1c8 | ||
|
|
94faa30882 | ||
|
|
c579b3ac15 | ||
|
|
82bf346ce5 | ||
|
|
67ed9d0d8e | ||
|
|
c0b241e70a | ||
|
|
808acd8289 | ||
|
|
3d39fe994d | ||
|
|
569db46a8b | ||
|
|
9493ee65cf | ||
|
|
ce962f60ff | ||
|
|
922232e943 | ||
|
|
ca587ce962 | ||
|
|
755be6e0e0 | ||
|
|
4a64e5f0a0 | ||
|
|
c458b42821 | ||
|
|
dc298c4f46 | ||
|
|
91f69ed928 | ||
|
|
64c04e2d23 | ||
|
|
ae8077d700 | ||
|
|
50d8e6388e | ||
|
|
af334c7142 | ||
|
|
f2da0bd58a | ||
|
|
cfdea26599 | ||
|
|
a4a6abec72 | ||
|
|
d8dfd6e093 | ||
|
|
5175b2ca15 | ||
|
|
f734772670 | ||
|
|
5eee62f731 | ||
|
|
4f8d76f797 | ||
|
|
bf4acc09fb | ||
|
|
2a3a39d6f7 | ||
|
|
92b1ffd61c | ||
|
|
c4c9316386 | ||
|
|
0509ff8fac | ||
|
|
1dc85e25de | ||
|
|
fb695bbed1 | ||
|
|
3b0465c65c | ||
|
|
43238ecc44 | ||
|
|
821d280f5c | ||
|
|
1da487a972 | ||
|
|
61c53301bf | ||
|
|
61613af1b7 | ||
|
|
ab612a3eca | ||
|
|
f323034eed | ||
|
|
aa35fa3b2b | ||
|
|
f97d24402e | ||
|
|
036776bde8 | ||
|
|
63e48d99ca | ||
|
|
4dd6f54b37 | ||
|
|
a4e02031c6 | ||
|
|
95bdc37411 | ||
|
|
c90b617ad0 | ||
|
|
aecdaa2875 | ||
|
|
7e924c2741 | ||
|
|
241b851c02 | ||
|
|
2ba9949035 | ||
|
|
3d4b76ffc3 | ||
|
|
f7fb0c6c82 | ||
|
|
e5fc734b90 | ||
|
|
10fa659e52 | ||
|
|
0ac315b017 | ||
|
|
bbfbd2daae | ||
|
|
cd2c4d3314 | ||
|
|
37ee7752c2 | ||
|
|
6b11e6b060 | ||
|
|
f650ab7394 | ||
|
|
fead7459d4 | ||
|
|
bbf3970121 | ||
|
|
26fb810840 | ||
|
|
af9074af6e | ||
|
|
4229c2a4fa | ||
|
|
fd58602e6d | ||
|
|
af26397ad7 | ||
|
|
859d01594a | ||
|
|
09a37058e6 | ||
|
|
edc7366b1d | ||
|
|
7306cb335b | ||
|
|
1e13cc4294 | ||
|
|
a88e5a9ec8 | ||
|
|
09d743c603 | ||
|
|
36c1ec528a | ||
|
|
aec9600036 | ||
|
|
6e1306bdd6 | ||
|
|
37f5c95716 | ||
|
|
0d48c541a0 | ||
|
|
8928cd1667 | ||
|
|
780f935fea | ||
|
|
5bf095178d | ||
|
|
e135293b43 | ||
|
|
be7e009909 | ||
|
|
ada8a713c1 | ||
|
|
eb7391dae0 | ||
|
|
8bdbd5e4fe | ||
|
|
137d15ae71 | ||
|
|
8a2ed6f8ff | ||
|
|
7766b43187 | ||
|
|
b9a637fdac | ||
|
|
3096dbc922 | ||
|
|
d6b0bb4378 | ||
|
|
43ef43ba72 | ||
|
|
314037dd06 | ||
|
|
7c4d66bbf5 | ||
|
|
49d28961ef | ||
|
|
9d649daee5 | ||
|
|
7598a47283 | ||
|
|
c9f7230d27 | ||
|
|
716633c6df | ||
|
|
8a215a765f | ||
|
|
e2ff41a0b1 | ||
|
|
d790fae74a | ||
|
|
1a17862f45 | ||
|
|
acd4c36531 | ||
|
|
f623450f08 | ||
|
|
ce681cfb67 | ||
|
|
a0a6523a25 | ||
|
|
8e2fa36d0e | ||
|
|
c953a84c1f | ||
|
|
2b403f0761 | ||
|
|
f954e3b2d7 | ||
|
|
24ee2a206e | ||
|
|
02d165829f | ||
|
|
b4d996bd14 | ||
|
|
60989faa03 | ||
|
|
4aeda8a1a7 | ||
|
|
023a20f263 | ||
|
|
62821ed803 | ||
|
|
903d62ed57 | ||
|
|
adef626b34 | ||
|
|
11a933cc04 | ||
|
|
4b5ce99bb1 | ||
|
|
f8dff77cee | ||
|
|
0a9b08803e | ||
|
|
39a8568663 | ||
|
|
e1502440eb | ||
|
|
5834d032c3 | ||
|
|
0a28a2af61 | ||
|
|
259f56bd26 | ||
|
|
011ad66a3f | ||
|
|
281c72f88d | ||
|
|
1293523ebf | ||
|
|
67e5d34f39 | ||
|
|
8b3dec6116 | ||
|
|
3359f49f0a | ||
|
|
9715cae587 | ||
|
|
4b122bd907 | ||
|
|
edefa36c8a | ||
|
|
e40a352aed | ||
|
|
70267f0623 | ||
|
|
c19ed7f6d5 | ||
|
|
e2fb0f2ee8 | ||
|
|
f9e208a7e0 | ||
|
|
ee2b8a60a2 | ||
|
|
5651f17e96 | ||
|
|
9679b3722a | ||
|
|
6cb736a10d | ||
|
|
ac5c3b421d | ||
|
|
f01fad5fa6 | ||
|
|
110b727cbc | ||
|
|
1dba2debc5 | ||
|
|
8fec73341f | ||
|
|
33c76de397 | ||
|
|
d409eb1126 | ||
|
|
69d53591c8 | ||
|
|
4cf783f07f | ||
|
|
a971c121cc | ||
|
|
b4abfb6aa4 | ||
|
|
8f760081a5 | ||
|
|
c15934f210 | ||
|
|
c7e1bb4463 | ||
|
|
4ac97fc476 | ||
|
|
a5dda01ffd | ||
|
|
9db4937e54 | ||
|
|
3dc50a384e | ||
|
|
ccc48620b8 | ||
|
|
943c3960e1 | ||
|
|
9a84a67555 | ||
|
|
2241e3c7d3 | ||
|
|
03042dae96 | ||
|
|
3df9d067d6 | ||
|
|
c5f3e8b3c2 | ||
|
|
3a3e8c0e10 | ||
|
|
f010bf7eed | ||
|
|
b22d6a77b0 | ||
|
|
f91d9125e4 | ||
|
|
625eedef1e | ||
|
|
61dd33187a | ||
|
|
817640c7d0 | ||
|
|
aa15d5a6df | ||
|
|
60857007bd | ||
|
|
cfd0c5d846 | ||
|
|
71ec88d235 | ||
|
|
902ea583bd | ||
|
|
fb6b54fba0 | ||
|
|
9dfc56987c | ||
|
|
b982a02717 | ||
|
|
15a0d8ee16 | ||
|
|
9ca03132db | ||
|
|
f908ba3ea3 | ||
|
|
79ea09bd91 | ||
|
|
c743a4f1fe | ||
|
|
ccd1fc5c10 | ||
|
|
2f82a64dbe | ||
|
|
71c6eae9c4 | ||
|
|
28894a08cc | ||
|
|
41e366dcc4 | ||
|
|
6ee11a6b2d | ||
|
|
ab82c66f83 | ||
|
|
f94820f45e | ||
|
|
fe03e1ca68 | ||
|
|
44ee6951c9 | ||
|
|
40194f7204 | ||
|
|
40eefef9a2 | ||
|
|
d8ab44ebb5 | ||
|
|
f875539f2e | ||
|
|
d9c2d142cb | ||
|
|
51d5715f04 | ||
|
|
8ad81b1d50 | ||
|
|
6d4083d8a7 | ||
|
|
bb120afea2 | ||
|
|
cebcc20c26 | ||
|
|
0522e787cd | ||
|
|
81e01f1485 | ||
|
|
9b2b93d56f | ||
|
|
133b402e2b | ||
|
|
9a91eab13f | ||
|
|
5f6722b917 | ||
|
|
fdcd4d46ac | ||
|
|
27a72170b8 | ||
|
|
b3021e60ec | ||
|
|
6d8834bd87 | ||
|
|
ab8a9d95d8 | ||
|
|
563ee3f5df | ||
|
|
c3389a7fcf | ||
|
|
e824bbb533 | ||
|
|
7de0e351f3 | ||
|
|
2df5f55390 | ||
|
|
21038cc5ac | ||
|
|
d6749bcd41 | ||
|
|
005c9ccdef | ||
|
|
d47cb53e59 | ||
|
|
adfb3ca4f0 | ||
|
|
88b7e4edda | ||
|
|
e33b57f0fd | ||
|
|
777825b73f | ||
|
|
04ca9b6f9a | ||
|
|
7f3d3eeb9c | ||
|
|
75ac16d43c | ||
|
|
5f1120c718 | ||
|
|
806a0694c6 | ||
|
|
c506ae3242 | ||
|
|
444fa529fb | ||
|
|
62ced3eb04 | ||
|
|
31718f1c4d | ||
|
|
98ab8971b4 | ||
|
|
870c86f9af | ||
|
|
a4f6aabee9 | ||
|
|
5584ad0a10 | ||
|
|
6b4932b4c5 | ||
|
|
7f1cb0aaf3 | ||
|
|
d42a5a48e9 | ||
|
|
8a1b762c35 | ||
|
|
d2d1de41d2 | ||
|
|
d1366af2a0 | ||
|
|
cae4b15bbb | ||
|
|
e41bc64b0c | ||
|
|
3d03ebe487 | ||
|
|
1fcb431d1b | ||
|
|
4f321490af | ||
|
|
04c7469e68 | ||
|
|
98bc0a9309 | ||
|
|
6e537f4cdf | ||
|
|
b121d61852 | ||
|
|
6ba3c3ffbd | ||
|
|
67b3efad4c | ||
|
|
1282e468e3 | ||
|
|
67b2e78a63 | ||
|
|
213c56f945 | ||
|
|
ccde6e4f4b | ||
|
|
2c1c94d24c | ||
|
|
56edb51f36 | ||
|
|
b6ed2c7dd8 | ||
|
|
74c7c5c423 | ||
|
|
314d774bde | ||
|
|
d94091ae4e | ||
|
|
8bf3c4fcd7 | ||
|
|
4358c15432 | ||
|
|
fdf8d649fe | ||
|
|
a4e22ec4b1 | ||
|
|
783bc43547 | ||
|
|
2e6aec175a | ||
|
|
ee41aaa112 | ||
|
|
32e8a8b1b9 | ||
|
|
6b3def230b | ||
|
|
51c46db106 | ||
|
|
64c18179ac | ||
|
|
5ee0b39e07 | ||
|
|
6470319fd6 | ||
|
|
4ca3f3c8ee | ||
|
|
07e2d2d509 | ||
|
|
07d2a143a2 | ||
|
|
968845492f | ||
|
|
c2106b64f9 | ||
|
|
0d73d1d258 | ||
|
|
423d425950 | ||
|
|
7ab0093fec | ||
|
|
56d2757448 | ||
|
|
f5b6037367 | ||
|
|
2c4ce6c8d1 | ||
|
|
f9ccd3628a | ||
|
|
abef2b394e | ||
|
|
97ff6f1de9 | ||
|
|
7fad4435cb | ||
|
|
92a97209ca | ||
|
|
e7c3f083b4 | ||
|
|
7b5961f941 | ||
|
|
85d4b411b5 | ||
|
|
c9ec32aca7 | ||
|
|
13a3013a8e | ||
|
|
0dd3bac855 | ||
|
|
d7a716a5cb | ||
|
|
0bbc9cbe81 | ||
|
|
df766d8d1f | ||
|
|
20a150a228 | ||
|
|
010292a440 | ||
|
|
394dae18e9 | ||
|
|
f1676c52f0 | ||
|
|
05f6f3c79b | ||
|
|
9942b488ea | ||
|
|
5d83f4bf2d | ||
|
|
d9afd914ff | ||
|
|
990d8160f8 | ||
|
|
80c321b66f | ||
|
|
67b45f3e5c | ||
|
|
ca3cee5673 | ||
|
|
ae0d170244 | ||
|
|
9a31cfa938 | ||
|
|
cdbc9c305e | ||
|
|
cdbce13c49 | ||
|
|
0a41ebbcda | ||
|
|
476fe1602b | ||
|
|
2f482e9afc | ||
|
|
d59e6e0691 | ||
|
|
7ec6866f26 | ||
|
|
3686e7facf | ||
|
|
1aba1894ea | ||
|
|
14503c9b8f | ||
|
|
a315668d31 | ||
|
|
db9ea63210 | ||
|
|
51ecbd5b53 | ||
|
|
45ef0d9809 | ||
|
|
356702b50d | ||
|
|
e72ce5376c | ||
|
|
0d35f3fbd2 | ||
|
|
28c22c1eae | ||
|
|
7cf83f65c3 | ||
|
|
4c4f2e720d | ||
|
|
0fa562e6fd | ||
|
|
bcd0f778cf | ||
|
|
401ab9f706 | ||
|
|
8b09a81d5a | ||
|
|
86718cc406 | ||
|
|
ccb72364e1 | ||
|
|
bfd6a59d87 | ||
|
|
af4caa455a | ||
|
|
d511e4a75c | ||
|
|
8fd906223c | ||
|
|
deadb56aaa | ||
|
|
1ff867879c | ||
|
|
f3630dd868 | ||
|
|
39143525c3 | ||
|
|
e8dd1f8f8b | ||
|
|
28e5d2e3f2 | ||
|
|
21def91427 | ||
|
|
bc5d04c662 | ||
|
|
c736227448 | ||
|
|
168285cb64 | ||
|
|
3411f50d29 | ||
|
|
319c14b0e0 | ||
|
|
64c077396f | ||
|
|
65f0d448a1 | ||
|
|
2fdaa464dd | ||
|
|
f86a6a10ac | ||
|
|
08a2438e79 | ||
|
|
60cc47f7ca | ||
|
|
7e4f606492 | ||
|
|
8ff58534d9 | ||
|
|
a4bb184e95 | ||
|
|
940c1f5692 | ||
|
|
0430733b58 | ||
|
|
33136816af | ||
|
|
469a235799 | ||
|
|
2d71c827b3 | ||
|
|
17f9e72a9f | ||
|
|
120aeed56f | ||
|
|
8128c3b2d7 | ||
|
|
4581a33cae | ||
|
|
d43e2af3ae | ||
|
|
0283eb4d85 | ||
|
|
f483ae42a8 | ||
|
|
f974eba465 | ||
|
|
7d7969fe0f | ||
|
|
2a3d7e8362 | ||
|
|
a38c03c4e0 | ||
|
|
342a0c612a | ||
|
|
f1f9970407 | ||
|
|
c83a3e6315 | ||
|
|
fbc65e7e2a | ||
|
|
d9e9407cab | ||
|
|
d0881b76e0 | ||
|
|
61c867b49c | ||
|
|
87de30d257 | ||
|
|
817605417c | ||
|
|
65b018db2a | ||
|
|
addb52e3fa | ||
|
|
c3124ec7c3 | ||
|
|
b1caa6dfdc | ||
|
|
26f28d55d9 | ||
|
|
cb66688529 | ||
|
|
40c61f11be | ||
|
|
9b45c75a5b | ||
|
|
09425c1910 | ||
|
|
591798e98c | ||
|
|
acdd82a680 | ||
|
|
5719ac3209 | ||
|
|
2ac687b0c2 | ||
|
|
a86a249f05 | ||
|
|
e49a7b0bb4 | ||
|
|
e904a7949c | ||
|
|
080d8110df | ||
|
|
d90826e851 | ||
|
|
e495da692b | ||
|
|
52970ef93e | ||
|
|
8f641d117a | ||
|
|
d164ebc7da | ||
|
|
632cc5810d | ||
|
|
e565a6c77f | ||
|
|
c1fe700d7a | ||
|
|
06ee267804 | ||
|
|
aad722c723 | ||
|
|
aefc58b636 | ||
|
|
fdd96507b8 | ||
|
|
2ad87a5ec5 | ||
|
|
b94cdba5af | ||
|
|
725261335c | ||
|
|
5fb0051fc6 | ||
|
|
1247847739 | ||
|
|
18cb4e74d6 | ||
|
|
e07cb7fca9 | ||
|
|
dc54ed46f8 | ||
|
|
0415d86d71 | ||
|
|
b8b95be5ce | ||
|
|
46820f0986 | ||
|
|
dcc022ac7f | ||
|
|
9142f0d633 | ||
|
|
181c72befe | ||
|
|
99f3459978 | ||
|
|
75fbc9679c | ||
|
|
700b7774b1 | ||
|
|
d9f0a9b1ca | ||
|
|
70644ff26d | ||
|
|
bbefcc3bc8 | ||
|
|
09767dbae3 | ||
|
|
57eafa95ba | ||
|
|
f4f28a411e | ||
|
|
f6059ef5c7 | ||
|
|
e3fa4efa95 | ||
|
|
6884a91eb8 | ||
|
|
71ba018a42 | ||
|
|
10f5232ac3 | ||
|
|
78d707484d | ||
|
|
69db66fbbb | ||
|
|
99691cd7ee | ||
|
|
47cef359ca | ||
|
|
046105498f | ||
|
|
4d3ef5dd2a | ||
|
|
8bcd5623bf | ||
|
|
a29b4a3a8e | ||
|
|
dee0fb396b | ||
|
|
b5c707e07f | ||
|
|
8fe35bd1d7 | ||
|
|
6c864c35cd | ||
|
|
f00acf6af1 | ||
|
|
49e1599bc4 | ||
|
|
7311d4b724 | ||
|
|
fa44e348a2 | ||
|
|
8cba02741c | ||
|
|
48d04d5e72 | ||
|
|
7cac25c0e2 | ||
|
|
88b0fa0163 | ||
|
|
3fcef24cc9 | ||
|
|
d9fba6ce6b | ||
|
|
8bc2f0c40e | ||
|
|
21254695d5 | ||
|
|
f063f0a6f4 | ||
|
|
2d847cbcdb | ||
|
|
547e99f526 | ||
|
|
a9794cd2ee | ||
|
|
c651abd8ae | ||
|
|
15781475b6 | ||
|
|
26a28175fd | ||
|
|
aa3680934b | ||
|
|
0b36586ddf | ||
|
|
7b58acac0d | ||
|
|
27bf4eadf9 | ||
|
|
c8d4353888 | ||
|
|
4876ca2643 | ||
|
|
e06a382c94 | ||
|
|
d1a421ca15 | ||
|
|
cd3c8d89d0 | ||
|
|
1f943ccead | ||
|
|
753776fa9a | ||
|
|
9787a2446e | ||
|
|
4cb29d274b | ||
|
|
df55d63f99 | ||
|
|
236e2d48c5 | ||
|
|
30d45d834f | ||
|
|
edf30897f2 | ||
|
|
3d31ebb576 | ||
|
|
d3bac8bcc0 | ||
|
|
a360f80cdf | ||
|
|
0cc7549546 | ||
|
|
283d2743e0 | ||
|
|
b431fa11fa | ||
|
|
648e00867f | ||
|
|
552af7bb6b | ||
|
|
92980f7c79 | ||
|
|
09a563bf73 | ||
|
|
141fa12a20 | ||
|
|
6e0035d4f3 | ||
|
|
97bac4132c | ||
|
|
b23b0280cd | ||
|
|
7ac093a8d0 | ||
|
|
dfc524b957 | ||
|
|
65ba0d348b | ||
|
|
ed07031539 | ||
|
|
93f3690344 | ||
|
|
1341d1356a | ||
|
|
38dcf16c03 | ||
|
|
8696a42959 | ||
|
|
c6fc7db1e9 | ||
|
|
58540aca57 | ||
|
|
b7b75279c2 | ||
|
|
204a35d026 | ||
|
|
fb2841f198 | ||
|
|
5de055c977 | ||
|
|
084659ea3d | ||
|
|
c1a414afab | ||
|
|
a5747034d6 | ||
|
|
fda52fec97 | ||
|
|
e38ec79618 | ||
|
|
1ef125db12 | ||
|
|
b580b640bd | ||
|
|
214bddaca4 | ||
|
|
065d489869 | ||
|
|
46ffefbbb9 | ||
|
|
a19db3bca9 | ||
|
|
2c8d8d9989 | ||
|
|
d52943e31e | ||
|
|
3eababb742 | ||
|
|
8a954d3c20 | ||
|
|
8516901032 | ||
|
|
3f2d246fec | ||
|
|
58fdaa26ca | ||
|
|
7dc1a8790d | ||
|
|
70c9ec1d73 | ||
|
|
2bcbbc96ad | ||
|
|
527d36a159 | ||
|
|
2ce21247ee | ||
|
|
8ea6c406e0 | ||
|
|
e22f50ecd3 | ||
|
|
20dcd98fdf | ||
|
|
bc5708857a | ||
|
|
b9c045ebfb | ||
|
|
c69bd7018e | ||
|
|
078d149175 | ||
|
|
be9f0bd061 | ||
|
|
a4723563f5 | ||
|
|
1fdcd24f28 | ||
|
|
a43480db92 | ||
|
|
e85a072f1c | ||
|
|
bbfa2a4eab | ||
|
|
2f2db4ded8 | ||
|
|
7296a0d2cd | ||
|
|
08e02b6ac0 | ||
|
|
715811d7fd | ||
|
|
c7d6ae6995 | ||
|
|
b1d1396944 | ||
|
|
25a319710e | ||
|
|
796b13dd62 | ||
|
|
8197863ac5 | ||
|
|
89bd164d43 | ||
|
|
80d7061e5f | ||
|
|
c49bac3a09 | ||
|
|
06d53fe801 | ||
|
|
15ba529938 | ||
|
|
83054d0cd1 | ||
|
|
8da486adf2 | ||
|
|
32bc3847fa | ||
|
|
5d763c18c8 | ||
|
|
bd3920cfff | ||
|
|
06d94332b6 | ||
|
|
50614484d8 | ||
|
|
c29d3d8c92 | ||
|
|
26f46af375 | ||
|
|
32b1491dd0 | ||
|
|
51b8a6c80a | ||
|
|
0f63d6d3a0 | ||
|
|
4771b08773 | ||
|
|
9b880101fd | ||
|
|
594806d6e8 | ||
|
|
e9afd4db2f | ||
|
|
b23efe4089 | ||
|
|
e33be41a93 | ||
|
|
33b09df872 | ||
|
|
e9050d0aa0 | ||
|
|
baeb2a33fe | ||
|
|
4ad89acdc7 | ||
|
|
7d87af8f5c | ||
|
|
65c0e84e2a | ||
|
|
7b15d85871 | ||
|
|
ad8ec0f4fd | ||
|
|
2d05d83dd0 | ||
|
|
bd45066b13 | ||
|
|
8ee4274054 | ||
|
|
83a7ed4d6b | ||
|
|
07dbd86ac6 | ||
|
|
0e671d2cc0 | ||
|
|
2d6d3c04ce | ||
|
|
b0148963c7 | ||
|
|
13356950f3 | ||
|
|
629bcb30a7 | ||
|
|
03721fff1c | ||
|
|
2a6911ae3d | ||
|
|
164eddecab | ||
|
|
9eacb38eb9 | ||
|
|
20f5cfb9a7 | ||
|
|
6c6c1cc90a | ||
|
|
a32c099cc1 | ||
|
|
fe2f832e83 | ||
|
|
868746cc23 | ||
|
|
3be7a54284 | ||
|
|
635e1ec8e2 | ||
|
|
a638a35a76 | ||
|
|
8cc33d3418 | ||
|
|
9947f7b967 | ||
|
|
daf5350f41 | ||
|
|
020b9ddb8d | ||
|
|
23aff9497a | ||
|
|
3c119396f3 | ||
|
|
f7c7c47ac0 | ||
|
|
dbe2369bbe | ||
|
|
4e8033d221 | ||
|
|
97a0f87cbd | ||
|
|
bfa2713d43 | ||
|
|
fe5e109751 | ||
|
|
8cc96030b1 | ||
|
|
a2b172ad58 | ||
|
|
e756225d8b | ||
|
|
dd803b604f | ||
|
|
b5c961c8ee | ||
|
|
47cd9d227e | ||
|
|
e2be3aafcd | ||
|
|
015fe76c44 | ||
|
|
44666aec03 | ||
|
|
6a265e4f35 | ||
|
|
12c7316524 | ||
|
|
dcf9741d69 | ||
|
|
63dd1fdd50 | ||
|
|
5aa166bbfd | ||
|
|
34cbf7093e | ||
|
|
159d58949e | ||
|
|
fcf802b7e3 | ||
|
|
92ff6dadb0 | ||
|
|
05fa2f9883 | ||
|
|
71bb8fd784 | ||
|
|
16ffd6dfab | ||
|
|
2661d15910 | ||
|
|
394102bb93 | ||
|
|
3585b12dfd | ||
|
|
423d87d5f1 | ||
|
|
13b13b1104 | ||
|
|
a77e7b96b7 | ||
|
|
d7213c255c | ||
|
|
ddeb1dcdb7 | ||
|
|
221cfa3528 | ||
|
|
d6f6348ff1 | ||
|
|
0c6afdc98e | ||
|
|
02a2148b3f | ||
|
|
36a02268d8 | ||
|
|
450f07f505 | ||
|
|
777eba9fed | ||
|
|
eaa8fa57d1 | ||
|
|
200bf479e1 | ||
|
|
331f409af9 | ||
|
|
ce875a5e63 | ||
|
|
638013f835 | ||
|
|
1de87cbfec | ||
|
|
7f3428b36a | ||
|
|
35595ded47 | ||
|
|
35e9264017 | ||
|
|
02d33c8f83 | ||
|
|
f229ebc3a8 | ||
|
|
0062351f6d | ||
|
|
e86f6798ec | ||
|
|
4f53f7136b | ||
|
|
d80b982dde | ||
|
|
24788aa9af | ||
|
|
9ffae658df | ||
|
|
82ad573cac | ||
|
|
36bf7ad65b | ||
|
|
b30af128c7 | ||
|
|
72c31ae097 | ||
|
|
d2c608021d | ||
|
|
1f36fb2413 | ||
|
|
16a0cbcecf | ||
|
|
e068e246aa | ||
|
|
ec7c77fcf9 | ||
|
|
46a338b874 | ||
|
|
bfee7ff09d | ||
|
|
ce1305d8ae | ||
|
|
aaebf88438 | ||
|
|
dde2c99e36 | ||
|
|
4dc2f3b9b9 | ||
|
|
f30cfffb86 | ||
|
|
ca3eb62ba7 | ||
|
|
c8e55ca4ce | ||
|
|
e4acb25a40 | ||
|
|
c741e10139 | ||
|
|
28d0b35f8e | ||
|
|
f7f09cd9e5 | ||
|
|
501c92c350 | ||
|
|
f021101322 | ||
|
|
369265bc2c | ||
|
|
b1f1e5db1f | ||
|
|
51d32e5afb | ||
|
|
f396e8e482 | ||
|
|
077321731e | ||
|
|
60eb0c6978 | ||
|
|
475f0af78a | ||
|
|
206fa07035 | ||
|
|
aff949714c | ||
|
|
7e834b9ff6 | ||
|
|
19bad26a98 | ||
|
|
7cc7c8d27b | ||
|
|
ae5a8c7cfa | ||
|
|
5004b73210 | ||
|
|
02f613d269 | ||
|
|
439ac0310b | ||
|
|
3e95467819 | ||
|
|
90522cb88b | ||
|
|
af39b01d4a | ||
|
|
73a0a5ff0b | ||
|
|
e157f500bc | ||
|
|
274ee5ed5f | ||
|
|
4cb11ba8c0 | ||
|
|
7b8e775139 | ||
|
|
86a7d26bfd | ||
|
|
84a437772d | ||
|
|
d7c95e2ae0 | ||
|
|
b4f0ef8b43 | ||
|
|
6d30cd7ae4 | ||
|
|
f631236ee7 | ||
|
|
1a58ff5c4c | ||
|
|
73aca913a1 | ||
|
|
24dee0cad6 | ||
|
|
2d2de75372 | ||
|
|
d98982e6fd | ||
|
|
14c12ffb08 | ||
|
|
f260afca11 | ||
|
|
5bcbe25d97 | ||
|
|
2eee366fbd | ||
|
|
85d57ec5e6 | ||
|
|
502c878f82 |
300
.aiassistant/rules/guidelines.md
Normal file
300
.aiassistant/rules/guidelines.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
---
|
||||||
|
apply: by model decision
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||||
|
|
||||||
|
- **Next.js React frontend** `/web`
|
||||||
|
- Pages, components, hooks, lib
|
||||||
|
- **Express Node API server** `/backend/api`
|
||||||
|
- **Shared backend utilities** `/backend/shared`
|
||||||
|
- **Email functions** `/backend/email`
|
||||||
|
- **Database schema** `/backend/supabase`
|
||||||
|
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- **Files shared between frontend and backend** `/common`
|
||||||
|
- Types (User, Profile, etc.) and utilities
|
||||||
|
- Try not to add package dependencies to common
|
||||||
|
- **Android app** `/android`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Both dev and prod environments
|
||||||
|
- Backend on GCP (Google Cloud Platform)
|
||||||
|
- Frontend on Vercel
|
||||||
|
- Database on Supabase (PostgreSQL)
|
||||||
|
- Firebase for authentication and storage
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
### Component Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
|
import {useUser} from 'web/hooks/use-user'
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: User
|
||||||
|
profile: ProfileRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||||
|
<img src={user.avatarUrl} alt={user.name} />
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||||
|
|
||||||
|
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||||
|
`ProfileCard`).
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
**Server-side (getStaticProps):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {api} from 'web/lib/api'
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const profiles = await api('get-profiles', {})
|
||||||
|
return {
|
||||||
|
props: {profiles},
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side - use hooks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||||
|
|
||||||
|
function ProfileList() {
|
||||||
|
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||||
|
|
||||||
|
if (!data) return <Loading / >
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.profiles.map((profile) => (
|
||||||
|
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<button onClick = {refresh} > Refresh < /button>
|
||||||
|
< /div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
**Backend (pg-promise):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend (Supabase client):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return <h1>{t('welcome', 'Welcome to Compass'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
1. Define schema in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'get-user-and-profile'
|
||||||
|
:
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
authed
|
||||||
|
:
|
||||||
|
false,
|
||||||
|
rateLimited
|
||||||
|
:
|
||||||
|
true,
|
||||||
|
props
|
||||||
|
:
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
}),
|
||||||
|
returns
|
||||||
|
:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
as
|
||||||
|
{
|
||||||
|
user: User;
|
||||||
|
profile: ProfileRow | null
|
||||||
|
}
|
||||||
|
,
|
||||||
|
summary: 'Get user and profile data by username',
|
||||||
|
tag
|
||||||
|
:
|
||||||
|
'Users',
|
||||||
|
}
|
||||||
|
,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create handler in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||||
|
const user = await getUserByUsername(username)
|
||||||
|
if (!user) {
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {user, profile}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Register in `backend/api/src/app.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {getUserAndProfile} from './get-user-and-profile'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
'get-user-and-profile': getUserAndProfile,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Options (Interests, Causes, Work)
|
||||||
|
|
||||||
|
Options are stored in separate tables with many-to-many relationships:
|
||||||
|
|
||||||
|
- `interests`, `causes`, `work` - option values
|
||||||
|
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||||
|
|
||||||
|
Fetch in parallel:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||||
|
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jest (unit + integration)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||||
|
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||||
|
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||||
|
|
||||||
|
### Mocking Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
jest.mock('shared/supabase/init')
|
||||||
|
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn(),
|
||||||
|
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||||
|
}
|
||||||
|
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
|
||||||
|
- Create user + profile + options in single database transaction
|
||||||
|
- Return full profile data from creation API
|
||||||
|
- Don't use sleep() hacks - rely on transactional integrity
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIErrors} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `debug()` from `common/logger` for development
|
||||||
|
- Use `log` from `shared/utils` for production
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency"
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` or `log()`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
120
.coderabbit.yaml
Normal file
120
.coderabbit.yaml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Enables IDE autocompletion for this config file
|
||||||
|
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||||
|
|
||||||
|
# Language for CodeRabbit's review comments
|
||||||
|
language: en
|
||||||
|
|
||||||
|
# Enable experimental features (currently not using any specific early_access features)
|
||||||
|
early_access: true
|
||||||
|
|
||||||
|
chat:
|
||||||
|
# CodeRabbit will automatically respond to @coderabbitai mentions in PR comments
|
||||||
|
auto_reply: true
|
||||||
|
|
||||||
|
reviews:
|
||||||
|
auto_review:
|
||||||
|
# Automatically trigger reviews when PRs are opened or updated
|
||||||
|
enabled: true
|
||||||
|
# Skip auto-review if PR title contains these keywords
|
||||||
|
ignore_title_keywords:
|
||||||
|
- "WIP"
|
||||||
|
# Don't auto-review draft PRs
|
||||||
|
drafts: false
|
||||||
|
# Only auto-review PRs targeting these branches
|
||||||
|
base_branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
# Include a high-level summary at the start of each review
|
||||||
|
high_level_summary: true
|
||||||
|
|
||||||
|
# Generate sequence diagrams for complex code flows
|
||||||
|
sequence_diagrams: true
|
||||||
|
|
||||||
|
# Don't include poems in reviews (fun feature, but keeping it professional)
|
||||||
|
poem: false
|
||||||
|
|
||||||
|
# Show review completion status
|
||||||
|
review_status: true
|
||||||
|
|
||||||
|
# Keep the walkthrough section expanded by default
|
||||||
|
collapse_walkthrough: false
|
||||||
|
|
||||||
|
# Include summary of all changed files
|
||||||
|
changed_files_summary: true
|
||||||
|
|
||||||
|
# Don't automatically request changes on the PR (just leave comments)
|
||||||
|
request_changes_workflow: false
|
||||||
|
|
||||||
|
# Pre-merge checks to enforce before merging PRs
|
||||||
|
pre_merge_checks:
|
||||||
|
description:
|
||||||
|
# Validate that PR has a proper description
|
||||||
|
mode: warning # Options: off, warning, error
|
||||||
|
docstrings:
|
||||||
|
# Disable docstring coverage checks (let's assume we don't need them)
|
||||||
|
mode: off
|
||||||
|
|
||||||
|
# Exclude these paths from reviews (build artifacts and dependencies)
|
||||||
|
path_filters:
|
||||||
|
- "!**/node_modules/**" # npm dependencies
|
||||||
|
- "!**/android/**" # Native Android build files
|
||||||
|
- "!**/ios/**" # Native iOS build files
|
||||||
|
- "!**/.expo/**" # Expo build cache
|
||||||
|
- "!**/.expo-shared/**" # Expo shared config
|
||||||
|
- "!**/dist/**" # Build output
|
||||||
|
|
||||||
|
# Custom review instructions for specific file patterns
|
||||||
|
path_instructions:
|
||||||
|
# TypeScript/JavaScript files - main app code
|
||||||
|
- path: "**/*.{ts,tsx,js,jsx}"
|
||||||
|
instructions: |
|
||||||
|
General practices:
|
||||||
|
- Summarize the changes clearly.
|
||||||
|
- Format the summary with bullet points.
|
||||||
|
- Highlight any potential breaking changes for users.
|
||||||
|
- We use early returns to avoid deep nesting.
|
||||||
|
- Ensure all public functions have docstrings.
|
||||||
|
- Flag any hardcoded strings; they should be in the constants file.
|
||||||
|
- Check for edge cases like null values or empty arrays.
|
||||||
|
- Suggest performance optimizations where appropriate.
|
||||||
|
|
||||||
|
Mobile best practices:
|
||||||
|
- Proper use of hooks (useRouter, useFonts, useAssets)
|
||||||
|
- Accessibility: touch targets min 44x44, screen reader support
|
||||||
|
- Safe area handling and platform-specific code (iOS vs Android)
|
||||||
|
- Memory leaks in useEffect and event listeners
|
||||||
|
|
||||||
|
Performance:
|
||||||
|
- Use FlatList/SectionList for lists (never ScrollView with .map)
|
||||||
|
- React.memo, useMemo, useCallback where appropriate
|
||||||
|
|
||||||
|
TypeScript:
|
||||||
|
- Avoid 'any', use explicit types
|
||||||
|
- Prefer 'import type' for type imports
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- No exposed API keys or sensitive data
|
||||||
|
- Use expo-secure-store for sensitive storage
|
||||||
|
- Validate deep linking configurations
|
||||||
|
|
||||||
|
Internationalization:
|
||||||
|
- User-visible strings should be externalized to JSON resource files in common/messages via
|
||||||
|
```
|
||||||
|
const t = useT()
|
||||||
|
const message = t('key', 'english string')
|
||||||
|
```
|
||||||
|
|
||||||
|
- path: "tests/e2e/**/*.ts"
|
||||||
|
instructions: |
|
||||||
|
Playwright E2E test guidelines for this repo:
|
||||||
|
- Page objects live in `tests/e2e/web/pages/`. Each class wraps one page/route, holds only `private readonly` Locators, and exposes action methods.
|
||||||
|
- All tests must use the `app` fixture (type `App`) from `tests/e2e/web/fixtures/base.ts`. Never instantiate page objects directly in a test.
|
||||||
|
- Cross-page flows (actions spanning multiple pages) belong as methods on the `App` class, not as standalone helper functions.
|
||||||
|
- Action methods in page objects must assert `expect(locator).toBeVisible()` before interacting.
|
||||||
|
- Never use `page.waitForTimeout()`. Use Playwright's built-in auto-waiting or `waitForURL` / `waitForSelector`.
|
||||||
|
- No hardcoded credentials in spec files; use `SPEC_CONFIG.ts` or account fixtures.
|
||||||
|
- Test account cleanup must be done in fixture teardown (after `await use(...)`), not in `afterEach` hooks.
|
||||||
|
- File and class names must use PascalCase (e.g., `CompatibilityPage.ts` / `class CompatibilityPage`).
|
||||||
|
- No DB or Firebase calls inside page object classes; those belong in `tests/e2e/utils/`.
|
||||||
|
- Flag any new page object not yet registered in `App`.
|
||||||
5
.coderabbitignore
Normal file
5
.coderabbitignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**/*.md
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
dist/**
|
||||||
|
test/mocks/**
|
||||||
420
.cursor/rules/dev-rules.mdc
Normal file
420
.cursor/rules/dev-rules.mdc
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- next.js react tailwind frontend `/web`
|
||||||
|
- broken down into pages, components, hooks, lib
|
||||||
|
- express node api server `/backend/api`
|
||||||
|
- one off scripts, like migrations `/backend/scripts`
|
||||||
|
- supabase postgres. schema in `/backend/supabase`
|
||||||
|
- supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- files shared between backend directories `/backend/shared`
|
||||||
|
- anything in `/backend` can import from `shared`, but not vice versa
|
||||||
|
- files shared between the frontend and backend in `/common`
|
||||||
|
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- The project has both dev and prod environments.
|
||||||
|
- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform.
|
||||||
|
- Project ID is `compass-130ba`.
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Here's an example component from web in our style:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { isAdminId, isModId } from 'common/envs/constants'
|
||||||
|
import { type Headline } from 'common/news'
|
||||||
|
import { EditNewsButton } from 'web/components/news/edit-news-button'
|
||||||
|
import { Carousel } from 'web/components/widgets/carousel'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page'
|
||||||
|
import { removeEmojis } from 'common/util/string'
|
||||||
|
|
||||||
|
export function HeadlineTabs(props: {
|
||||||
|
headlines: Headline[]
|
||||||
|
currentSlug: string
|
||||||
|
endpoint: DashboardEndpoints
|
||||||
|
hideEmoji?: boolean
|
||||||
|
notSticky?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =
|
||||||
|
props
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'bg-canvas-50 w-full',
|
||||||
|
!notSticky && 'sticky top-0 z-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Carousel labelsParentClassName="gap-px">
|
||||||
|
{headlines.map(({ id, slug, title }) => (
|
||||||
|
<Tab
|
||||||
|
key={id}
|
||||||
|
label={hideEmoji ? removeEmojis(title) : title}
|
||||||
|
href={`/${endpoint}/${slug}`}
|
||||||
|
active={slug === currentSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{user && <Tab label="More" href="/dashboard" />}
|
||||||
|
{user && (isAdminId(user.id) || isModId(user.id)) && (
|
||||||
|
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
|
||||||
|
)}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components.
|
||||||
|
|
||||||
|
It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find.
|
||||||
|
|
||||||
|
Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { api } from 'web/lib/api/api'
|
||||||
|
// More imports...
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
try {
|
||||||
|
const headlines = await api('headlines', {})
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
headlines,
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { props: { headlines: [] }, revalidate: 60 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home(props: { headlines: Headline[] }) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If we are calling the API on the client, prefer using the `useAPIGetter` hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const YourTopicsSection = (props: {
|
||||||
|
user: User
|
||||||
|
className?: string
|
||||||
|
}) => {
|
||||||
|
const { user, className } = props
|
||||||
|
const { data, refresh } = useAPIGetter('get-followed-groups', {
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
const followedGroups = data?.groups ?? []
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This stores the result in memory, and allows you to call refresh() to get an updated version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly.
|
||||||
|
|
||||||
|
Here's the definition of usePersistentInMemoryState:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
|
||||||
|
const [state, setState] = useStateCheckEquality<T>(
|
||||||
|
safeJsonParse(store[key]) ?? initialValue
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedValue = safeJsonParse(store[key]) ?? initialValue
|
||||||
|
setState(storedValue as T)
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
const saveState = useEvent((newState: T | ((prevState: T) => T)) => {
|
||||||
|
setState((prevState) => {
|
||||||
|
const updatedState = isFunction(newState) ? newState(prevState) : newState
|
||||||
|
store[key] = JSON.stringify(updatedState)
|
||||||
|
return updatedState
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return [state, saveState] as const
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useApiSubscription(opts: SubscriptionOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = client
|
||||||
|
if (ws != null) {
|
||||||
|
if (opts.enabled ?? true) {
|
||||||
|
ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||||
|
return () => {
|
||||||
|
ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [opts.enabled, JSON.stringify(opts.topics)])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `use-bets`, we have this hook to get live updates with useApiSubscription:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const useContractBets = (
|
||||||
|
contractId: string,
|
||||||
|
opts?: APIParams<'bets'> & { enabled?: boolean }
|
||||||
|
) => {
|
||||||
|
const { enabled = true, ...apiOptions } = {
|
||||||
|
contractId,
|
||||||
|
...opts,
|
||||||
|
}
|
||||||
|
const optionsKey = JSON.stringify(apiOptions)
|
||||||
|
|
||||||
|
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
|
||||||
|
[],
|
||||||
|
`${optionsKey}-bets`
|
||||||
|
)
|
||||||
|
|
||||||
|
const addBets = (bets: Bet[]) => {
|
||||||
|
setNewBets((currentBets) => {
|
||||||
|
const uniqueBets = sortBy(
|
||||||
|
uniqBy([...currentBets, ...bets], 'id'),
|
||||||
|
'createdTime'
|
||||||
|
)
|
||||||
|
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPageVisible = useIsPageVisible()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPageVisible && enabled) {
|
||||||
|
api('bets', apiOptions).then(addBets)
|
||||||
|
}
|
||||||
|
}, [optionsKey, enabled, isPageVisible])
|
||||||
|
|
||||||
|
useApiSubscription({
|
||||||
|
topics: [`contract/${contractId}/new-bet`],
|
||||||
|
onBroadcast: (msg) => {
|
||||||
|
addBets(msg.data.bets as Bet[])
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return newBets
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function broadcastUpdatedPrivateUser(userId: string) {
|
||||||
|
// don't send private user info because it's private and anyone can listen
|
||||||
|
broadcast(`private-user/${userId}`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
|
||||||
|
broadcast(`user/${user.id}`, { user })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastUpdatedComment(comment: Comment) {
|
||||||
|
broadcast(`user/${comment.onUserId}/comment`, { comment })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We have our scripts in the directory `/backend/scripts`.
|
||||||
|
|
||||||
|
To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env.
|
||||||
|
|
||||||
|
Example from `/backend/scripts/manicode.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { runScript } from 'run-script'
|
||||||
|
|
||||||
|
runScript(async ({ pg }) => {
|
||||||
|
const userPrompt = process.argv[2]
|
||||||
|
await pg.none(...)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Generally scripts should be run by me, especially if they modify backend state or schema.
|
||||||
|
But if you need to run a script, you can use `bun`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||||
|
```
|
||||||
|
|
||||||
|
if that doesn't work, try
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`.
|
||||||
|
|
||||||
|
E.g. Here is a hypothetical bet schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
bet: {
|
||||||
|
method: 'POST',
|
||||||
|
authed: true,
|
||||||
|
returns: {} as CandidateBet & { betId: string },
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
contractId: z.string(),
|
||||||
|
amount: z.number().gte(1),
|
||||||
|
replyToCommentId: z.string().optional(),
|
||||||
|
limitProb: z.number().gte(0.01).lte(0.99).optional(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
// Used for binary and new multiple choice contracts (cpmm-multi-1).
|
||||||
|
outcome: z.enum(['YES', 'NO']).default('YES'),
|
||||||
|
//Multi
|
||||||
|
answerId: z.string().optional(),
|
||||||
|
dryRun: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, we define the bet endpoint in `backend/api/src/place-bet.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const placeBet: APIHandler<'bet'> = async (props, auth) => {
|
||||||
|
const isApi = auth.creds.kind === 'key'
|
||||||
|
return await betsQueue.enqueueFn(
|
||||||
|
() => placeBetMain(props, auth.uid, isApi),
|
||||||
|
[props.contractId, auth.uid]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And finally, you need to register the handler in `backend/api/src/routes.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { placeBet } from './place-bet'
|
||||||
|
...
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
bet: placeBet,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We have two ways to access our postgres database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db } from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
and
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
|
||||||
|
```
|
||||||
|
|
||||||
|
The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend.
|
||||||
|
|
||||||
|
`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this.
|
||||||
|
|
||||||
|
Another example using the direct client:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const getUniqueBettorIds = async (
|
||||||
|
contractId: string,
|
||||||
|
pg: SupabaseDirectClient
|
||||||
|
) => {
|
||||||
|
const res = await pg.manyOrNone(
|
||||||
|
'select distinct user_id from contract_bets where contract_id = $1',
|
||||||
|
[contractId]
|
||||||
|
)
|
||||||
|
return res.map((r) => r.user_id as string)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(you may notice we write sql in lowercase)
|
||||||
|
|
||||||
|
We have a few helper functions for updating and inserting data into the database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
buikInsert,
|
||||||
|
bulkUpdate,
|
||||||
|
bulkUpdateData,
|
||||||
|
bulkUpsert,
|
||||||
|
insert,
|
||||||
|
update,
|
||||||
|
updateData,
|
||||||
|
} from 'shared/supabase/utils'
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// you are encouraged to use tryCatch for these
|
||||||
|
const { data, error } = await tryCatch(
|
||||||
|
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw APIError(500, 'Error creating profile: ' + error.message)
|
||||||
|
|
||||||
|
await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 })
|
||||||
|
|
||||||
|
await updateData(pg, 'private_users', { id: userId, notifications: { ... } })
|
||||||
|
```
|
||||||
|
|
||||||
|
The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including:
|
||||||
|
|
||||||
|
- `select`: Specifies the columns to select
|
||||||
|
- `from`: Specifies the table to query
|
||||||
|
- `where`: Adds WHERE clauses
|
||||||
|
- `orderBy`: Specifies the order of results
|
||||||
|
- `limit`: Limits the number of results
|
||||||
|
- `renderSql`: Combines all parts into a final SQL string
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const query = renderSql(
|
||||||
|
select('distinct user_id'),
|
||||||
|
from('contract_bets'),
|
||||||
|
where('contract_id = ${id}', { id }),
|
||||||
|
orderBy('created_time desc'),
|
||||||
|
limitValue != null && limit(limitValue)
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await pg.manyOrNone(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these functions instead of string concatenation.
|
||||||
23
.env.example
23
.env.example
@@ -1,20 +1,7 @@
|
|||||||
# Rename this file to `.env` and fill in the values.
|
|
||||||
# You already have access to basic local functionality (UI, authentication, database read access).
|
# You already have access to basic local functionality (UI, authentication, database read access).
|
||||||
|
|
||||||
# Optional variables for the backend server functionality (modifying user data, etc.)
|
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
|
||||||
|
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
|
||||||
# For database write access (dev).
|
|
||||||
# A 16-character password with digits and letters.
|
|
||||||
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
|
|
||||||
|
|
||||||
# For Firebase access.
|
|
||||||
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
|
|
||||||
# TODO: find a way to give anyone moderate access to dev firebase.
|
|
||||||
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
|
|
||||||
|
|
||||||
# The URL where your local backend server is running.
|
|
||||||
# You can change the port if needed.
|
|
||||||
NEXT_PUBLIC_API_URL=localhost:8088
|
|
||||||
|
|
||||||
|
|
||||||
# Optional variables for full local functionality
|
# Optional variables for full local functionality
|
||||||
@@ -23,10 +10,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
|
|||||||
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
||||||
GEODB_API_KEY=
|
GEODB_API_KEY=
|
||||||
|
|
||||||
# For analytics like page views, user actions, feature usage, etc.
|
|
||||||
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
|
|
||||||
POSTHOG_KEY=
|
|
||||||
|
|
||||||
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
||||||
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
||||||
RESEND_API_KEY=
|
RESEND_KEY=
|
||||||
|
|||||||
19
.env.test
Normal file
19
.env.test
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Database
|
||||||
|
#DATABASE_URL=postgresql://test_user:test_password@localhost:5433/test_db
|
||||||
|
#DIRECT_URL=postgresql://test_user:test_password@localhost:5433/test_db
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
|
||||||
|
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
|
||||||
|
FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8088
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV=TEST
|
||||||
|
NEXT_PUBLIC_FIREBASE_EMULATOR=true
|
||||||
|
|
||||||
|
# API
|
||||||
|
NODE_ENV=test
|
||||||
|
PORT=8088
|
||||||
|
|
||||||
|
ENVIRONMENT=DEV
|
||||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [CompassConnections] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: CompassMeet # Replace with a single Patreon username
|
||||||
|
open_collective: compass-connection # Replace with a single Open Collective username
|
||||||
|
ko_fi: compassconnections # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: CompassConnections # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
||||||
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Bug description
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: |
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Info
|
||||||
|
description: |
|
||||||
|
- Browser: [e.g. chrome, safari]
|
||||||
|
- Device (if mobile): [e.g. iPhone6]
|
||||||
|
- Build info
|
||||||
|
placeholder: |
|
||||||
|
Build info from `Settings` -> `About`
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
Add any other context about the problem here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing!
|
||||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea or improvement for this project
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what the problem is.
|
||||||
|
placeholder: I'm always frustrated when [...]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Solution
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Alternatives
|
||||||
|
description: |
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing!
|
||||||
25
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Other
|
||||||
|
description: Use this only if no other issue type fits.
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Issue
|
||||||
|
description: |
|
||||||
|
A clear and concise description of the question or issue
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Info
|
||||||
|
description: |
|
||||||
|
- Browser: [e.g. chrome, safari]
|
||||||
|
- Device (if mobile): [e.g. iPhone6]
|
||||||
|
- Build info
|
||||||
|
placeholder: |
|
||||||
|
Build info from `Settings` -> `About`
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing!
|
||||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- Replace [ ] with [X] to check a box -->
|
||||||
|
|
||||||
|
- [ ] Closes #xxxx (Replace xxxx with the GitHub issue number, or delete line).
|
||||||
|
- [ ] Tests added and passed if fixing a bug or adding a new feature.
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
<!-- Describe your changes in detail -->
|
||||||
33
.github/actions/setup/action.yml
vendored
Normal file
33
.github/actions/setup/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Setup
|
||||||
|
description: Checkout, cache and install dependencies
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
web/node_modules
|
||||||
|
backend/api/node_modules
|
||||||
|
backend/shared/node_modules
|
||||||
|
backend/email/node_modules
|
||||||
|
common/node_modules
|
||||||
|
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Post-install
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit == 'true'
|
||||||
|
run: yarn postinstall
|
||||||
|
shell: bash
|
||||||
271
.github/copilot-instructions.md
vendored
Normal file
271
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||||
|
|
||||||
|
- **Next.js React frontend** `/web`
|
||||||
|
- Pages, components, hooks, lib
|
||||||
|
- **Express Node API server** `/backend/api`
|
||||||
|
- **Shared backend utilities** `/backend/shared`
|
||||||
|
- **Email functions** `/backend/email`
|
||||||
|
- **Database schema** `/backend/supabase`
|
||||||
|
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- **Files shared between frontend and backend** `/common`
|
||||||
|
- Types (User, Profile, etc.) and utilities
|
||||||
|
- Try not to add package dependencies to common
|
||||||
|
- **Android app** `/android`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Both dev and prod environments
|
||||||
|
- Backend on GCP (Google Cloud Platform)
|
||||||
|
- Frontend on Vercel
|
||||||
|
- Database on Supabase (PostgreSQL)
|
||||||
|
- Firebase for authentication and storage
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
### Component Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
|
import {useUser} from 'web/hooks/use-user'
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: User
|
||||||
|
profile: ProfileRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||||
|
<img src={user.avatarUrl} alt={user.name} />
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||||
|
|
||||||
|
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||||
|
`ProfileCard`).
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
**Server-side (getStaticProps):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {api} from 'web/lib/api'
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const profiles = await api('get-profiles', {})
|
||||||
|
return {
|
||||||
|
props: {profiles},
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side - use hooks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||||
|
|
||||||
|
function ProfileList() {
|
||||||
|
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||||
|
|
||||||
|
if (!data) return <CompassLoadingIndicator / >
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.profiles.map((profile) => (
|
||||||
|
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<button onClick = {refresh} > Refresh < /button>
|
||||||
|
< /div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
**Backend (pg-promise):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend (Supabase client):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return <h1>{t('welcome', 'Welcome to Compass')}</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
1. Define schema in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'get-user-and-profile': {
|
||||||
|
method: 'GET',
|
||||||
|
authed: false,
|
||||||
|
rateLimited: true,
|
||||||
|
props: z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
}),
|
||||||
|
returns: {} as {user: User; profile: ProfileRow | null},
|
||||||
|
summary: 'Get user and profile data by username',
|
||||||
|
tag: 'Users',
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create handler in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||||
|
const user = await getUserByUsername(username)
|
||||||
|
if (!user) {
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {user, profile}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Register in `backend/api/src/app.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {getUserAndProfile} from './get-user-and-profile'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
'get-user-and-profile': getUserAndProfile,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Options (Interests, Causes, Work)
|
||||||
|
|
||||||
|
Options are stored in separate tables with many-to-many relationships:
|
||||||
|
|
||||||
|
- `interests`, `causes`, `work` - option values
|
||||||
|
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||||
|
|
||||||
|
Fetch in parallel:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||||
|
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jest (unit + integration)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||||
|
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||||
|
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||||
|
|
||||||
|
### Mocking Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
jest.mock('shared/supabase/init')
|
||||||
|
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn(),
|
||||||
|
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||||
|
}
|
||||||
|
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
|
||||||
|
- Create user + profile + options in single database transaction
|
||||||
|
- Return full profile data from creation API
|
||||||
|
- Don't use sleep() hacks - rely on transactional integrity
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `debug()` from `common/logger` for development
|
||||||
|
- Use `log` from `shared/utils` for production
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency"
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` or `log()`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
67
.github/workflows/cd-android-live-update.yml
vendored
Normal file
67
.github/workflows/cd-android-live-update.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: CD Android Live Update
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- 'android/capawesome.json'
|
||||||
|
- '.github/workflows/cd-android-live-update.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
name: Check Version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
changed: ${{ steps.check.outputs.changed }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Read current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
current=$(jq -r '.version' android/capawesome.json)
|
||||||
|
echo "version=$current" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Read previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
if git show HEAD^:android/capawesome.json >/dev/null 2>&1; then
|
||||||
|
previous=$(git show HEAD^:android/capawesome.json | jq -r '.version')
|
||||||
|
else
|
||||||
|
previous="none"
|
||||||
|
fi
|
||||||
|
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versions
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
echo "current=${{ steps.current.outputs.version }}"
|
||||||
|
echo "previous=${{ steps.previous.outputs.version }}"
|
||||||
|
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
|
||||||
|
echo "Version unchanged. Skipping deploy."
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-version
|
||||||
|
if: needs.check-version.outputs.changed == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Deploy Live Update
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||||
|
CAPAWESOME_TOKEN: ${{ secrets.CAPAWESOME_TOKEN }}
|
||||||
|
commitRef: ${{ github.head_ref || github.ref_name }}
|
||||||
|
commitSha: ${{ github.sha }}
|
||||||
|
run: yarn android-live-update
|
||||||
88
.github/workflows/cd-android.yml
vendored
Normal file
88
.github/workflows/cd-android.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'android/app/build.gradle'
|
||||||
|
- '.github/workflows/cd-android.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.version_check.outputs.should_build }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get previous versionCode
|
||||||
|
id: prev_version
|
||||||
|
run: |
|
||||||
|
git checkout HEAD^
|
||||||
|
PREV=$(grep versionCode android/app/build.gradle | awk '{print $2}')
|
||||||
|
echo "prev_version=$PREV" >> $GITHUB_OUTPUT
|
||||||
|
git checkout -
|
||||||
|
|
||||||
|
- name: Get current versionCode
|
||||||
|
id: curr_version
|
||||||
|
run: |
|
||||||
|
CURR=$(grep versionCode android/app/build.gradle | awk '{print $2}')
|
||||||
|
echo "curr_version=$CURR" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versionCodes
|
||||||
|
id: version_check
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.curr_version.outputs.curr_version }}" -gt "${{ steps.prev_version.outputs.prev_version }}" ]; then
|
||||||
|
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "versionCode not increased. Skipping build."
|
||||||
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-version
|
||||||
|
if: needs.check-version.outputs.should_build == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Compile Web App into Android assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||||
|
run: yarn build-sync-android
|
||||||
|
|
||||||
|
- name: Build AAB
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
echo "${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json
|
||||||
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore
|
||||||
|
cp release.keystore app/release.keystore
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew bundleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=release.keystore \
|
||||||
|
-Pandroid.injected.signing.store.password=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||||
|
-Pandroid.injected.signing.key.alias=compass \
|
||||||
|
-Pandroid.injected.signing.key.password=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Upload to Play Store (Internal Track)
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: com.compassconnections.app
|
||||||
|
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
|
||||||
|
track: internal
|
||||||
87
.github/workflows/cd-api.yml
vendored
Normal file
87
.github/workflows/cd-api.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: API Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- 'backend/api/package.json'
|
||||||
|
- '.github/workflows/cd-api.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
name: Check Version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
changed: ${{ steps.check.outputs.changed }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Read current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
current=$(jq -r '.version' backend/api/package.json)
|
||||||
|
echo "version=$current" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Read previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
if git show HEAD^:backend/api/package.json >/dev/null 2>&1; then
|
||||||
|
previous=$(git show HEAD^:backend/api/package.json | jq -r '.version')
|
||||||
|
else
|
||||||
|
previous="none"
|
||||||
|
fi
|
||||||
|
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versions
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
echo "current=${{ steps.current.outputs.version }}"
|
||||||
|
echo "previous=${{ steps.previous.outputs.version }}"
|
||||||
|
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
|
||||||
|
echo "Version unchanged. Skipping deploy."
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-version
|
||||||
|
if: needs.check-version.outputs.changed == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Authenticate to GCP
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||||
|
|
||||||
|
- name: Install gcloud CLI
|
||||||
|
uses: google-github-actions/setup-gcloud@v2
|
||||||
|
with:
|
||||||
|
project_id: compass-130ba
|
||||||
|
|
||||||
|
- name: Configure Docker for Artifact Registry
|
||||||
|
run: gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
|
||||||
|
|
||||||
|
- name: Install Tofu (Terraform)
|
||||||
|
run: |
|
||||||
|
LATEST=https://github.com/opentofu/opentofu/releases/download/v1.10.5/tofu_1.10.5_linux_amd64.zip
|
||||||
|
curl -LO "$LATEST"
|
||||||
|
unzip -o tofu_*_linux_amd64.zip
|
||||||
|
sudo mv tofu /usr/local/bin/
|
||||||
|
rm tofu_*_linux_amd64.zip
|
||||||
|
echo "OpenTofu version: $(tofu version)"
|
||||||
|
cd backend/api
|
||||||
|
tofu init
|
||||||
|
|
||||||
|
- name: Run deploy script
|
||||||
|
run: |
|
||||||
|
chmod +x backend/api/deploy-api.sh
|
||||||
|
backend/api/deploy-api.sh
|
||||||
12
.github/workflows/cd.yml
vendored
12
.github/workflows/cd.yml
vendored
@@ -1,11 +1,13 @@
|
|||||||
name: CD
|
name: GitHub Release
|
||||||
|
|
||||||
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
|
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
|
||||||
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- '.github/workflows/cd.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -15,7 +17,7 @@ jobs:
|
|||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@master
|
uses: actions/checkout@master
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # To fetch all history for tags
|
fetch-depth: 0 # To fetch all history for tags
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -29,4 +31,4 @@ jobs:
|
|||||||
git config --global user.name "github-actions[bot]"
|
git config --global user.name "github-actions[bot]"
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||||
./scripts/release.sh
|
./scripts/release.sh
|
||||||
|
|||||||
68
.github/workflows/ci-e2e.yml
vendored
Normal file
68
.github/workflows/ci-e2e.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Cache Firebase emulators
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/firebase/emulators
|
||||||
|
key: firebase-emulators-${{ hashFiles('firebase.json') }}
|
||||||
|
restore-keys: firebase-emulators-
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: Install Java (for Firebase emulators)
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '21' # Required for firebase-tools@15+
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
# Docker load from cache is actually slower than pulling the images every time with supabase start
|
||||||
|
- name: Start Supabase
|
||||||
|
run: ./scripts/supabase_start.sh
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
env:
|
||||||
|
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
|
||||||
|
FIREBASE_TOKEN: 'dummy' # Suppresses auth warning
|
||||||
|
# or
|
||||||
|
run: |
|
||||||
|
yarn test:e2e
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: tests/reports/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@@ -2,59 +2,52 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
lint:
|
||||||
name: All
|
name: Lint & Typecheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: echo skipping #npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: yarn typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Jest Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
- name: Run Jest tests
|
- name: Run Jest tests
|
||||||
run: npm run test tests/jest
|
|
||||||
|
|
||||||
# - name: Build app
|
|
||||||
# env:
|
|
||||||
# DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
# run: npm run build
|
|
||||||
|
|
||||||
# Optional: Playwright E2E tests
|
|
||||||
- name: Install Playwright deps
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
# npm install @playwright/test
|
|
||||||
# npx playwright install
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_API_URL: localhost:8088
|
NEXT_PUBLIC_FIREBASE_ENV: DEV
|
||||||
NEXT_PUBLIC_FIREBASE_ENV: PROD
|
|
||||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
yarn --cwd=web serve &
|
yarn test:coverage
|
||||||
npx wait-on http://localhost:3000
|
|
||||||
npx playwright test tests/playwright
|
- name: Upload coverage to Codecov
|
||||||
SERVER_PID=$(fuser -k 3000/tcp)
|
uses: codecov/codecov-action@v5
|
||||||
echo $SERVER_PID
|
if: success()
|
||||||
kill $SERVER_PID
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
files: |
|
||||||
|
backend/api/coverage/lcov.info
|
||||||
|
backend/shared/coverage/lcov.info
|
||||||
|
backend/email/coverage/lcov.info
|
||||||
|
common/coverage/lcov.info
|
||||||
|
web/coverage/lcov.info
|
||||||
|
flags: unit
|
||||||
|
fail_ci_if_error: true
|
||||||
|
slug: CompassConnections/Compass
|
||||||
|
|||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -13,6 +13,10 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/tests/reports/playwright-report
|
||||||
|
/tests/e2e/web/.auth/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
@@ -35,7 +39,6 @@ yarn-error.log*
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
.envrc
|
.envrc
|
||||||
supabase/*
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -61,15 +64,17 @@ email-preview
|
|||||||
*.last-run.json
|
*.last-run.json
|
||||||
|
|
||||||
*lock.hcl
|
*lock.hcl
|
||||||
/web/pages/test.tsx
|
/web/pages/_test.tsx
|
||||||
|
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
*.gif
|
*.gif
|
||||||
*.svg
|
*.svg
|
||||||
|
*.ico
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
|
*.webp
|
||||||
*.avi
|
*.avi
|
||||||
*.wmv
|
*.wmv
|
||||||
*.mp3
|
*.mp3
|
||||||
@@ -79,3 +84,23 @@ email-preview
|
|||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.rar
|
*.rar
|
||||||
|
/favicon_color.ico
|
||||||
|
/backend/shared/src/googleApplicationCredentials-dev.json
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.backup
|
||||||
|
*.terraform
|
||||||
|
/backups/firebase/auth/data/
|
||||||
|
/backups/firebase/storage/data/
|
||||||
|
|
||||||
|
android/app/release*
|
||||||
|
icons/
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
test-results
|
||||||
|
/.nyc_output/
|
||||||
|
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
*my-release-key.keystore
|
||||||
|
|
||||||
|
.vscode/settings.json
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
1294
.junie/guidelines.md
Normal file
1294
.junie/guidelines.md
Normal file
File diff suppressed because it is too large
Load Diff
28
.lintstagedrc.mjs
Normal file
28
.lintstagedrc.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export default {
|
||||||
|
'web/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config web/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config web/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'common/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config common/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config common/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'backend/api/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/api/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/api/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'backend/shared/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/shared/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/shared/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'backend/email/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/email/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/email/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'**/*.{json,css,scss,md}': (files) => [`prettier --write ${files.join(' ')}`],
|
||||||
|
}
|
||||||
35
.prettierignore
Normal file
35
.prettierignore
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
lib
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
coverage
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
|
||||||
|
# Database / migrations
|
||||||
|
**/*.sql
|
||||||
|
|
||||||
|
# Config / lock files
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Android / iOS
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
capacitor.config.ts
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
tests/reports
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"plugins": ["prettier-plugin-sql"],
|
"singleAttributePerLine": false,
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": ["prettier-plugin-sql", "prettier-plugin-packagejson"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.sql",
|
"files": "*.sql",
|
||||||
|
|||||||
33
.vscode/launch.json
vendored
Normal file
33
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Current Test",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--inspect-brk",
|
||||||
|
"${workspaceRoot}/node_modules/.bin/jest",
|
||||||
|
"${fileBasename}",
|
||||||
|
"--runInBand"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen"
|
||||||
|
}
|
||||||
|
// {
|
||||||
|
// "type": "node",
|
||||||
|
// "request": "launch",
|
||||||
|
// "name": "Launch Program",
|
||||||
|
// "skipFiles": [
|
||||||
|
// "<node_internals>/**"
|
||||||
|
// ],
|
||||||
|
// "program": "${workspaceFolder}/backend/api/tests/unit/get-profiles.unit.test.ts",
|
||||||
|
// "outFiles": [
|
||||||
|
// "${workspaceFolder}/**/*.js"
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
}
|
||||||
295
.windsurf/rules/compass.md
Normal file
295
.windsurf/rules/compass.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||||
|
|
||||||
|
- **Next.js React frontend** `/web`
|
||||||
|
- Pages, components, hooks, lib
|
||||||
|
- **Express Node API server** `/backend/api`
|
||||||
|
- **Shared backend utilities** `/backend/shared`
|
||||||
|
- **Email functions** `/backend/email`
|
||||||
|
- **Database schema** `/backend/supabase`
|
||||||
|
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- **Files shared between frontend and backend** `/common`
|
||||||
|
- Types (User, Profile, etc.) and utilities
|
||||||
|
- Try not to add package dependencies to common
|
||||||
|
- **Android app** `/android`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Both dev and prod environments
|
||||||
|
- Backend on GCP (Google Cloud Platform)
|
||||||
|
- Frontend on Vercel
|
||||||
|
- Database on Supabase (PostgreSQL)
|
||||||
|
- Firebase for authentication and storage
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
### Component Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
|
import {useUser} from 'web/hooks/use-user'
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: User
|
||||||
|
profile: ProfileRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||||
|
<img src={user.avatarUrl} alt={user.name} />
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||||
|
|
||||||
|
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||||
|
`ProfileCard`).
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
**Server-side (getStaticProps):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {api} from 'web/lib/api'
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const profiles = await api('get-profiles', {})
|
||||||
|
return {
|
||||||
|
props: {profiles},
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side - use hooks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||||
|
|
||||||
|
function ProfileList() {
|
||||||
|
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||||
|
|
||||||
|
if (!data) return <Loading / >
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.profiles.map((profile) => (
|
||||||
|
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<button onClick = {refresh} > Refresh < /button>
|
||||||
|
< /div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
**Backend (pg-promise):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend (Supabase client):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return <h1>{t('welcome', 'Welcome to Compass'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
1. Define schema in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'get-user-and-profile'
|
||||||
|
:
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
authed
|
||||||
|
:
|
||||||
|
false,
|
||||||
|
rateLimited
|
||||||
|
:
|
||||||
|
true,
|
||||||
|
props
|
||||||
|
:
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
}),
|
||||||
|
returns
|
||||||
|
:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
as
|
||||||
|
{
|
||||||
|
user: User;
|
||||||
|
profile: ProfileRow | null
|
||||||
|
}
|
||||||
|
,
|
||||||
|
summary: 'Get user and profile data by username',
|
||||||
|
tag
|
||||||
|
:
|
||||||
|
'Users',
|
||||||
|
}
|
||||||
|
,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create handler in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||||
|
const user = await getUserByUsername(username)
|
||||||
|
if (!user) {
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {user, profile}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Register in `backend/api/src/app.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {getUserAndProfile} from './get-user-and-profile'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
'get-user-and-profile': getUserAndProfile,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Options (Interests, Causes, Work)
|
||||||
|
|
||||||
|
Options are stored in separate tables with many-to-many relationships:
|
||||||
|
|
||||||
|
- `interests`, `causes`, `work` - option values
|
||||||
|
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||||
|
|
||||||
|
Fetch in parallel:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||||
|
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `debug()` from `common/logger` for development
|
||||||
|
- Use `log` from `shared/utils` for production
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jest (unit + integration)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||||
|
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||||
|
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||||
|
|
||||||
|
### Mocking Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
jest.mock('shared/supabase/init')
|
||||||
|
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn(),
|
||||||
|
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||||
|
}
|
||||||
|
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
|
||||||
|
- Create user + profile + options in single database transaction
|
||||||
|
- Return full profile data from creation API
|
||||||
|
- Don't use sleep() hacks - rely on transactional integrity
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency"
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` or `log()`
|
||||||
|
- Don't remove commented code
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
42
.windsurf/rules/next.md
Normal file
42
.windsurf/rules/next.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
trigger: manual
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
const t = useT()
|
||||||
|
t('common.key', 'English translations')
|
||||||
|
```
|
||||||
|
|
||||||
|
Translations should go to the JSON files in `web/messages` (`de.json` and `fr.json`, as of now).
|
||||||
|
|
||||||
|
### Misc coding tips
|
||||||
|
|
||||||
|
We have many useful hooks that should be reused rather than rewriting them again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We prefer using lodash functions instead of reimplementing them with for loops:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {keyBy, uniq} from 'lodash'
|
||||||
|
|
||||||
|
const betsByUserId = keyBy(bets, 'userId')
|
||||||
|
const betIds = uniq(bets, (b) => b.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Instead of Sets, consider using lodash's uniq function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const betIds = uniq([])
|
||||||
|
for (const id of betIds) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
288
.windsurf/workflows/translate.md
Normal file
288
.windsurf/workflows/translate.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
description: Adding Translations to an Existing File (using useT)
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Assistant Workflow — Adding Translations to an Existing File (using `useT`)
|
||||||
|
|
||||||
|
This is **not** about adding a new language.
|
||||||
|
This is about correctly adding new translation keys to a feature or component that already exists.
|
||||||
|
|
||||||
|
Follow this strictly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1️⃣ Identify All User-Facing Strings
|
||||||
|
|
||||||
|
Scan the file and list every:
|
||||||
|
|
||||||
|
- Button label
|
||||||
|
- Title
|
||||||
|
- Placeholder
|
||||||
|
- Tooltip
|
||||||
|
- Toast message
|
||||||
|
- Modal text
|
||||||
|
- Validation error
|
||||||
|
- SEO metadata
|
||||||
|
- Empty state message
|
||||||
|
|
||||||
|
If a string is visible to users, it must be translated.
|
||||||
|
|
||||||
|
Do **not** leave inline English in JSX.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button>Delete account</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button>{t('settings.delete_account', 'Delete account')}</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ Import and Initialize `useT`
|
||||||
|
|
||||||
|
At the top of the file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const t = useT()
|
||||||
|
```
|
||||||
|
|
||||||
|
No exceptions.
|
||||||
|
Do not manually access locale files.
|
||||||
|
|
||||||
|
For the backend, use
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {createT} from 'shared/locale'
|
||||||
|
|
||||||
|
const t = createT(locale)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ Replace Hardcoded Strings
|
||||||
|
|
||||||
|
Wrap every string in:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('namespace.key', 'Default English text')
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('news.seo.description_general', 'All news and code updates for Compass')
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- First argument = stable translation key
|
||||||
|
- Second argument = default English fallback
|
||||||
|
- The English text must exactly match what you want displayed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ Naming Convention for Keys
|
||||||
|
|
||||||
|
Use structured namespaces.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```
|
||||||
|
profile.delete.confirm_title
|
||||||
|
profile.delete.confirm_body
|
||||||
|
settings.notifications.email_label
|
||||||
|
events.create.submit_button
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```
|
||||||
|
delete1
|
||||||
|
buttonText
|
||||||
|
labelNew
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys must be:
|
||||||
|
|
||||||
|
- Hierarchical
|
||||||
|
- Feature-scoped
|
||||||
|
- Predictable
|
||||||
|
- Stable (never rename casually)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ Add the Keys to All Existing Locale Files
|
||||||
|
|
||||||
|
After updating the component:
|
||||||
|
|
||||||
|
1. Open:
|
||||||
|
|
||||||
|
```
|
||||||
|
common/src/messages/fr.json
|
||||||
|
common/src/messages/de.json
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add the new keys to **every language file**
|
||||||
|
|
||||||
|
Keys must be identical across all files.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"confirm_title": "Delete account?",
|
||||||
|
"confirm_body": "This action cannot be undone."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then translate values for non-English files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6️⃣ Use an LLM for Draft Translation (Correctly)
|
||||||
|
|
||||||
|
When translating large additions:
|
||||||
|
|
||||||
|
- Copy only the new JSON section
|
||||||
|
- Ask:
|
||||||
|
|
||||||
|
```
|
||||||
|
Translate the values of the JSON below to French.
|
||||||
|
Keep all keys unchanged.
|
||||||
|
Return valid JSON only.
|
||||||
|
```
|
||||||
|
|
||||||
|
Never let the model modify keys.
|
||||||
|
|
||||||
|
Then manually review.
|
||||||
|
|
||||||
|
LLMs make mistakes:
|
||||||
|
|
||||||
|
- Wrong tone
|
||||||
|
- Cultural mismatch
|
||||||
|
- Broken JSON
|
||||||
|
- Overly long mobile labels
|
||||||
|
|
||||||
|
You must verify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7️⃣ Respect Mobile Constraints
|
||||||
|
|
||||||
|
Certain keys must stay short (< 10 characters):
|
||||||
|
|
||||||
|
```
|
||||||
|
nav.home
|
||||||
|
nav.messages
|
||||||
|
nav.more
|
||||||
|
nav.notifs
|
||||||
|
nav.people
|
||||||
|
```
|
||||||
|
|
||||||
|
If you add navigation items, enforce brevity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8️⃣ Handle Variables Properly
|
||||||
|
|
||||||
|
For dynamic values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('events.count', '{count} events', {count})
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure placeholders match across all languages.
|
||||||
|
|
||||||
|
Do not concatenate strings manually.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'Events: ' + count
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('events.count', '{count} events', {count})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9️⃣ SEO & Metadata
|
||||||
|
|
||||||
|
Even SEO descriptions must use translations:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={t('profile.seo.description', 'View user profiles and connect with like-minded people.')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not hardcode metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10️⃣ Final Verification Checklist
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
- No visible English strings left
|
||||||
|
- All new keys added to all locale files
|
||||||
|
- No missing translations warnings
|
||||||
|
- JSON is valid
|
||||||
|
- Mobile nav labels are short
|
||||||
|
- Variables work in all languages
|
||||||
|
- No duplicate keys
|
||||||
|
- Namespaces are consistent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example — Full Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
export default function DeleteModal() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>{t('profile.delete.confirm_title', 'Delete account?')}</h2>
|
||||||
|
<p>{t('profile.delete.confirm_body', 'This action cannot be undone.')}</p>
|
||||||
|
<button>{t('profile.delete.confirm_button', 'Delete')}</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
When adding translations to a file:
|
||||||
|
|
||||||
|
1. Replace every user-visible string
|
||||||
|
2. Use `useT()`
|
||||||
|
3. Create structured keys
|
||||||
|
4. Add keys to every locale file
|
||||||
|
5. Translate values carefully
|
||||||
|
6. Validate JSON
|
||||||
|
7. Test UI in multiple languages
|
||||||
|
|
||||||
|
If you skip any of these steps, you create future maintenance debt.
|
||||||
|
|
||||||
|
There is no shortcut.
|
||||||
114
AGENTS.md
Normal file
114
AGENTS.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# AGENTS.md - AI Assistant Guidelines for Compass
|
||||||
|
|
||||||
|
This file provides guidance for AI assistants working on the Compass codebase.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections. Built with Next.js, React, Supabase, Firebase, and Google Cloud.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/web # Next.js frontend (React, Tailwind CSS)
|
||||||
|
/backend/api # Express.js REST API
|
||||||
|
/backend/shared # Shared backend utilities
|
||||||
|
/backend/email # Email functions
|
||||||
|
/common # Shared types and utilities between frontend/backend
|
||||||
|
/supabase # Database schema and migrations
|
||||||
|
/android # Android mobile app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
- Use `createSupabaseDirectClient()` for backend SQL queries (pg-promise)
|
||||||
|
- Use Supabase JS client (`db.from('table')`) for frontend queries
|
||||||
|
- Never use string concatenation for SQL - use parameterized queries
|
||||||
|
|
||||||
|
### API Development
|
||||||
|
|
||||||
|
1. Add endpoint schema to `common/src/api/schema.ts`
|
||||||
|
2. Create handler in `backend/api/src/`
|
||||||
|
3. Register in `backend/api/src/app.ts`
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
- Export main component at top of file
|
||||||
|
- Name component same as file (e.g., `profile-card.tsx` → `ProfileCard`)
|
||||||
|
- Use smaller, composable components over large ones
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
|
||||||
|
- Translation files in `common/messages/` (en.json, fr.json, de.json)
|
||||||
|
- Use `useT()` hook: `t('key', 'fallback')`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in package `tests/unit/`
|
||||||
|
- Mock external dependencies (DB, APIs, time)
|
||||||
|
- Use `jest.mock()` at top of test files
|
||||||
|
|
||||||
|
### Profile System
|
||||||
|
|
||||||
|
- Profile fields stored in `profiles` table
|
||||||
|
- Options (interests, causes, work) stored in separate tables with many-to-many relationship
|
||||||
|
- Always fetch profile options in parallel using Promise.all
|
||||||
|
|
||||||
|
### User Registration Flow
|
||||||
|
|
||||||
|
1. Create user + profile + options in single transaction
|
||||||
|
2. Never use sleep() hacks - rely on transactional integrity
|
||||||
|
3. Return full profile data from creation API
|
||||||
|
|
||||||
|
### Important Patterns
|
||||||
|
|
||||||
|
#### Frontend API calls (server-side):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await api('endpoint-name', {props})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend API calls (client-side):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {data} = useAPIGetter('endpoint-name', {props})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Translation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const t = useT()
|
||||||
|
return <div>{t('key', 'Default text')}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a profile field
|
||||||
|
|
||||||
|
1. Add column to `profiles` table via migration
|
||||||
|
2. Add to schema in `common/src/api/schema.ts`
|
||||||
|
3. Update frontend forms/components
|
||||||
|
|
||||||
|
### Adding translations
|
||||||
|
|
||||||
|
1. Add key to `common/messages/en.json`
|
||||||
|
2. Add translations to `fr.json`, `de.json`, etc.
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency" - fix at DB level
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` from `common/logger`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
|||||||
Examples of behavior that contributes to a positive environment for our
|
Examples of behavior that contributes to a positive environment for our
|
||||||
community include:
|
community include:
|
||||||
|
|
||||||
* Demonstrating empathy and kindness toward other people
|
- Demonstrating empathy and kindness toward other people
|
||||||
* Being respectful of differing opinions, viewpoints, and experiences
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
* Giving and gracefully accepting constructive feedback
|
- Giving and gracefully accepting constructive feedback
|
||||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
and learning from the experience
|
and learning from the experience
|
||||||
* Focusing on what is best not just for us as individuals, but for the
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
overall community
|
overall community
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery, and sexual attention or
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
advances of any kind
|
advances of any kind
|
||||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
- Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or email
|
- Publishing others' private information, such as a physical or email
|
||||||
address, without their explicit permission
|
address, without their explicit permission
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
## Enforcement Responsibilities
|
||||||
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
|||||||
### 4. Permanent Ban
|
### 4. Permanent Ban
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of community
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
standards, including sustained inappropriate behavior, harassment of an
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
individual, or aggression toward or disparagement of classes of individuals.
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction within
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
|||||||
534
CONTRIBUTING.md
534
CONTRIBUTING.md
@@ -1,125 +1,493 @@
|
|||||||
# Contributing to This Repository
|
# Contributing to Compass
|
||||||
|
|
||||||
We welcome pull requests, but only if they meet the project's quality and design standards. Follow the process below precisely to avoid wasting time—yours or ours.
|
Thank you for your interest in contributing to Compass! This document provides comprehensive guidelines for contributing
|
||||||
|
to this open-source project.
|
||||||
|
|
||||||
## Prerequisites
|
## Table of Contents
|
||||||
|
|
||||||
- Familiarity with Git and GitHub (basic commands, branching, forking, etc.)
|
- [Code of Conduct](#code-of-conduct)
|
||||||
- A functioning development environment
|
- [Getting Started](#getting-started)
|
||||||
- Node.js, Python, or other relevant runtime/tools installed (check the `README.md`)
|
- [Development Environment](#development-environment)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Standards](#coding-standards)
|
||||||
|
- [Making Changes](#making-changes)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||||
|
- [Commit Message Guidelines](#commit-message-guidelines)
|
||||||
|
- [Documentation](#documentation)
|
||||||
|
- [Questions and Support](#questions-and-support)
|
||||||
|
|
||||||
## Fork & Clone
|
## Code of Conduct
|
||||||
|
|
||||||
1. **Fork the repository** using the GitHub UI.
|
Please read and follow our [Code of Conduct](./CODE_OF_CONDUCT.md). We are committed to providing a welcoming and
|
||||||
2. **Clone your fork** locally:
|
inclusive environment for all contributors.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Before contributing, ensure you have the following installed:
|
||||||
|
|
||||||
|
- **Node.js** 20.x or later
|
||||||
|
- **Yarn** 1.x (classic)
|
||||||
|
- **Git**
|
||||||
|
- **Docker** (optional, for isolated development)
|
||||||
|
|
||||||
|
### Fork and Clone
|
||||||
|
|
||||||
|
1. Fork the [repository](https://github.com/CompassConnections/Compass) on GitHub
|
||||||
|
2. Clone your fork:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/Compass.git
|
git clone https://github.com/<your-username>/Compass.git
|
||||||
cd your-fork
|
cd Compass
|
||||||
|
```
|
||||||
3. **Add the upstream remote**:
|
3. Add the upstream remote:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create a New Branch
|
### Install Dependencies
|
||||||
|
|
||||||
Never work on `main` or `master`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b fix/brief-but-specific-description
|
yarn install --frozen-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a clear, descriptive branch name. Avoid vague names like `patch-1`.
|
## Development Environment
|
||||||
|
|
||||||
## Stay Updated
|
### Running the Development Server
|
||||||
|
|
||||||
Before you start, make sure your fork is up to date:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch upstream
|
yarn dev
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then rebase your feature branch if needed:
|
Visit http://localhost:3000 to see the application.
|
||||||
|
|
||||||
|
### Isolated Development (Recommended)
|
||||||
|
|
||||||
|
For full isolation with local Supabase and Firebase emulators:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout fix/your-feature
|
yarn dev:isolated
|
||||||
git rebase main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Make Atomic Commits
|
Benefits:
|
||||||
|
|
||||||
Each commit should represent a single logical change. Follow this format:
|
- No conflicts with other contributors
|
||||||
|
- Works offline
|
||||||
|
- Faster database queries
|
||||||
|
- Free to reset and reseed data
|
||||||
|
|
||||||
```text
|
Requirements:
|
||||||
type(scope): concise description
|
|
||||||
|
|
||||||
body explaining what and why, if necessary
|
- Docker (~500MB)
|
||||||
|
- Supabase CLI
|
||||||
|
- Java 21+ (for Firebase emulators)
|
||||||
|
- Firebase CLI
|
||||||
|
|
||||||
|
See the [README](./README.md) for detailed setup instructions.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
yarn test:coverage
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
yarn test:watch
|
||||||
|
|
||||||
|
# Run E2E tests
|
||||||
|
yarn test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Linting and Type Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint all packages
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
yarn lint-fix
|
||||||
|
|
||||||
|
# Type check all packages
|
||||||
|
yarn typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This is a Yarn workspaces monorepo with the following packages:
|
||||||
|
|
||||||
|
```
|
||||||
|
Compass/
|
||||||
|
├── web/ # Next.js web application
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── lib/ # Utilities and services
|
||||||
|
│ ├── pages/ # Next.js pages
|
||||||
|
│ └── messages/ # Internationalization files
|
||||||
|
├── backend/
|
||||||
|
│ ├── api/ # Express API server
|
||||||
|
│ ├── shared/ # Shared backend utilities
|
||||||
|
│ ├── email/ # React email templates
|
||||||
|
│ └── scripts/ # Database migration scripts
|
||||||
|
├── common/ # Shared TypeScript types and utilities
|
||||||
|
├── supabase/ # Database migrations and config
|
||||||
|
├── android/ # Capacitor Android app
|
||||||
|
└── docs/ # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| -------- | -------------------------------- |
|
||||||
|
| Frontend | Next.js 16, React 19, TypeScript |
|
||||||
|
| Styling | Tailwind CSS |
|
||||||
|
| Backend | Express.js, Node.js |
|
||||||
|
| Database | PostgreSQL (Supabase) |
|
||||||
|
| Auth | Firebase Auth |
|
||||||
|
| Storage | Firebase Storage |
|
||||||
|
| Mobile | Capacitor (Android) |
|
||||||
|
| Testing | Jest, Playwright |
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- Use strict TypeScript typing
|
||||||
|
- Avoid `any` type; use `unknown` when necessary
|
||||||
|
- Prefer interfaces over types for object shapes
|
||||||
|
- Use `const` assertions where appropriate
|
||||||
|
|
||||||
|
### React Components
|
||||||
|
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Name components after their file name
|
||||||
|
- Export primary component at the top of the file
|
||||||
|
- Use composition over inheritance
|
||||||
|
- Keep components small and focused
|
||||||
|
|
||||||
|
Example component structure:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {useState} from 'react'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
name: string
|
||||||
|
age: number
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({name, age, onSelect, className}: ProfileCardProps) {
|
||||||
|
const [selected, setSelected] = useState(false)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setSelected(!selected)
|
||||||
|
onSelect?.(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('card', selected && 'selected', className)}>
|
||||||
|
<h3>
|
||||||
|
{name}, {age}
|
||||||
|
</h3>
|
||||||
|
<button onClick={handleClick}>Select</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Files**: kebab-case (`profile-card.tsx`)
|
||||||
|
- **Components**: PascalCase (`ProfileCard`)
|
||||||
|
- **Hooks**: camelCase with `use` prefix (`useUserProfile`)
|
||||||
|
- **Constants**: SCREAMING_SNAKE_CASE
|
||||||
|
- **Types/Interfaces**: PascalCase
|
||||||
|
|
||||||
|
### Import Order
|
||||||
|
|
||||||
|
Run `yarn lint-fix` to automatically sort imports:
|
||||||
|
|
||||||
|
1. External libraries (React, Next.js, etc.)
|
||||||
|
2. Internal packages (`common/`, `shared/`)
|
||||||
|
3. Relative imports (`../`, `./`)
|
||||||
|
4. Type imports
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use try-catch for async operations
|
||||||
|
- Create custom error types for API errors
|
||||||
|
- Implement error boundaries for React components
|
||||||
|
- Log errors with appropriate context
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './errors'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('endpoint', params)
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof APIError) {
|
||||||
|
logger.error('API error', {status: err.status, message: err.message})
|
||||||
|
} else {
|
||||||
|
logger.error('Unexpected error', err)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Use semantic HTML elements
|
||||||
|
- Include ARIA labels where appropriate
|
||||||
|
- Ensure keyboard navigation works
|
||||||
|
- Use the `SkipLink` component for main content
|
||||||
|
- Announce dynamic content changes with `useLiveRegion`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useLiveRegion} from 'web/components/live-region'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {announce} = useLiveRegion()
|
||||||
|
|
||||||
|
const handleAction = () => {
|
||||||
|
// Action completed
|
||||||
|
announce('Action successful', 'polite')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Creating a Branch
|
||||||
|
|
||||||
|
Never work directly on `main`. Create a new branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b type/short-description
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch types:
|
||||||
|
|
||||||
|
- `feat/` - New features
|
||||||
|
- `fix/` - Bug fixes
|
||||||
|
- `docs/` - Documentation
|
||||||
|
- `refactor/` - Code refactoring
|
||||||
|
- `test/` - Adding/updating tests
|
||||||
|
- `chore/` - Maintenance tasks
|
||||||
|
|
||||||
|
### Making Commits
|
||||||
|
|
||||||
|
Keep commits atomic and descriptive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat(profiles): add compatibility score display
|
||||||
|
|
||||||
|
- Added compatibility score calculation
|
||||||
|
- Display score on profile cards
|
||||||
|
- Added tests for scoring algorithm"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Commit Message Guidelines](#commit-message-guidelines) for details.
|
||||||
|
|
||||||
|
### Keeping Your Fork Updated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fetch latest from upstream
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# Update main branch
|
||||||
|
git checkout main
|
||||||
|
git merge upstream/main
|
||||||
|
|
||||||
|
# Rebase your feature branch
|
||||||
|
git checkout feat/your-feature
|
||||||
|
git rebase main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
#### Unit Tests
|
||||||
|
|
||||||
|
Place tests in `tests/unit/` within each package:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// web/tests/unit/my-function.test.ts
|
||||||
|
import {myFunction} from '../my-function'
|
||||||
|
|
||||||
|
describe('myFunction', () => {
|
||||||
|
it('should return correct output', () => {
|
||||||
|
expect(myFunction('input')).toBe('expected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Integration Tests
|
||||||
|
|
||||||
|
Place in `tests/integration/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// web/tests/integration/api.test.ts
|
||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import {MyComponent} from '../MyComponent'
|
||||||
|
|
||||||
|
describe('MyComponent', () => {
|
||||||
|
it('renders correctly', () => {
|
||||||
|
render(<MyComponent / >)
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2E Tests
|
||||||
|
|
||||||
|
Place in `tests/e2e/` at the root:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/e2e/web/specs/onboarding.spec.ts
|
||||||
|
import {test, expect} from '@playwright/test'
|
||||||
|
|
||||||
|
test('onboarding flow', async ({page}) => {
|
||||||
|
await page.goto('/signup')
|
||||||
|
await page.fill('[name="email"]', 'test@example.com')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await expect(page).toHaveURL('/onboarding')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests for web
|
||||||
|
yarn workspace web test
|
||||||
|
|
||||||
|
# Run tests matching pattern
|
||||||
|
yarn test --testPathPattern="profile"
|
||||||
|
|
||||||
|
# Run E2E tests
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
Aim for meaningful test coverage. Focus on:
|
||||||
|
|
||||||
|
- Business logic
|
||||||
|
- User interactions
|
||||||
|
- Error handling
|
||||||
|
- Edge cases
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
### Before Submitting
|
||||||
|
|
||||||
|
1. **Run all tests**: `yarn test`
|
||||||
|
2. **Run linter**: `yarn lint`
|
||||||
|
3. **Run type check**: `yarn typecheck`
|
||||||
|
4. **Update documentation** if needed
|
||||||
|
5. **Rebase on main** if necessary
|
||||||
|
|
||||||
|
### Pull Request Format
|
||||||
|
|
||||||
|
**Title**: Clear, descriptive title
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Brief description of changes
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added compatibility score to profile cards
|
||||||
|
- Updated search algorithm for better results
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Added unit tests for scoring algorithm
|
||||||
|
- Tested manually with synthetic data
|
||||||
|
|
||||||
|
## Screenshots (if UI changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows style guidelines
|
||||||
|
- [ ] Tests added/updated and passing
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] No console.log statements (except debugging)
|
||||||
|
- [ ] No debug code left behind
|
||||||
|
|
||||||
|
### Review Process
|
||||||
|
|
||||||
|
1. Maintainers review within 48 hours
|
||||||
|
2. Address feedback promptly
|
||||||
|
3. Do not open new PRs for changes - update existing one
|
||||||
|
4. Squash commits before merging
|
||||||
|
|
||||||
|
## Commit Message Guidelines
|
||||||
|
|
||||||
|
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `docs`: Documentation
|
||||||
|
- `style`: Formatting
|
||||||
|
- `refactor`: Code restructuring
|
||||||
|
- `test`: Tests
|
||||||
|
- `chore`: Maintenance
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
```text
|
```text
|
||||||
fix(api): handle 500 error on invalid payload
|
feat(profiles): add compatibility scoring algorithm
|
||||||
|
fix(api): handle rate limiting gracefully
|
||||||
|
docs(readme): update installation instructions
|
||||||
|
refactor(auth): simplify token refresh logic
|
||||||
|
test(profiles): add unit tests for scoring
|
||||||
```
|
```
|
||||||
|
|
||||||
Types include: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`.
|
## Documentation
|
||||||
|
|
||||||
## Test Everything
|
### Updating Documentation
|
||||||
|
|
||||||
If the project has tests, run them. If it doesn’t, write some. Do **not** submit code that hasn't been tested.
|
- Update relevant README files
|
||||||
|
- Add JSDoc comments to complex functions
|
||||||
|
- Update the `/docs` folder for architectural changes
|
||||||
|
|
||||||
```bash
|
### API Documentation
|
||||||
# Example for Node.js
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
No exceptions. If you don't validate your changes, your PR will be closed.
|
API docs are auto-generated and available at:
|
||||||
|
|
||||||
## Lint & Format
|
- Production: https://api.compassmeet.com
|
||||||
|
- Local: http://localhost:8088 (when running locally)
|
||||||
|
|
||||||
Ensure code matches the project style. If the repo uses a linter or formatter, run them:
|
## Questions and Support
|
||||||
|
|
||||||
```bash
|
- **Discord**: https://discord.gg/8Vd7jzqjun
|
||||||
npm run lint
|
- **Email**: hello@compassmeet.com
|
||||||
npm run format
|
- **GitHub Issues**: For bug reports and feature requests
|
||||||
```
|
|
||||||
|
|
||||||
Or whatever command is defined in the repo.
|
---
|
||||||
|
|
||||||
## Write a Good Pull Request
|
|
||||||
|
|
||||||
When opening a pull request:
|
|
||||||
|
|
||||||
* **Title**: Describe what the PR does, clearly and specifically.
|
|
||||||
* **Description**: Explain the context. Link related issues (use `Fixes #123` if applicable).
|
|
||||||
* **Checklist**:
|
|
||||||
|
|
||||||
* [ ] My code is clean and follows the style guide
|
|
||||||
* [ ] I’ve added or updated tests
|
|
||||||
* [ ] I’ve run all tests and they pass
|
|
||||||
* [ ] I’ve documented my changes (if necessary)
|
|
||||||
|
|
||||||
## Code Review Process
|
|
||||||
|
|
||||||
* PRs are reviewed by maintainers or core contributors.
|
|
||||||
* If feedback is given, respond and push updates. Do **not** open new PRs for changes to an existing one.
|
|
||||||
* PRs that are incomplete, sloppy, or violate the above will be closed.
|
|
||||||
|
|
||||||
## Don't Do This
|
|
||||||
|
|
||||||
* Don’t commit directly to `main`
|
|
||||||
* Don’t submit multiple unrelated changes in a single PR
|
|
||||||
* Don’t ignore CI/test failures
|
|
||||||
* Don’t expect hand-holding—read the docs and the source first
|
|
||||||
|
|
||||||
## Security Issues
|
|
||||||
|
|
||||||
Do **not** open public issues for security vulnerabilities. Email the development team instead.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your code will be licensed under the same license as the rest of the project.
|
|
||||||
|
|
||||||
|
Thank you for contributing to Compass! Together we're building a platform for meaningful connections.
|
||||||
|
|||||||
284
README.md
284
README.md
@@ -1,13 +1,16 @@
|
|||||||
|

|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||||

|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-android.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/ci-e2e.yml)
|
||||||
|
[](https://codecov.io/gh/CompassConnections/Compass)
|
||||||
|
[](https://www.compassmeet.com/stats)
|
||||||
|
|
||||||
# Compass
|
# Compass
|
||||||
|
|
||||||
This repository contains the source code for [Compass](https://compassmeet.com) — an open platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
|
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming
|
||||||
|
deep, authentic 1-on-1 connections with clarity and efficiency.
|
||||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -15,17 +18,50 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
|||||||
- Radically transparent: user base fully searchable
|
- Radically transparent: user base fully searchable
|
||||||
- Free, ad-free, not for profit (supported by donations)
|
- Free, ad-free, not for profit (supported by donations)
|
||||||
- Created, hosted, maintained, and moderated by volunteers
|
- Created, hosted, maintained, and moderated by volunteers
|
||||||
- Open source
|
- Open-source
|
||||||
- Democratically governed
|
- Democratically governed
|
||||||
|
|
||||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
|
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and
|
||||||
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
|
the [FAQ](https://www.compassmeet.com/faq) as well.
|
||||||
|
A detailed description of the early vision is also available in
|
||||||
|
this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass
|
||||||
|
shifted to a more general audience).
|
||||||
|
|
||||||
|
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small
|
||||||
|
donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help
|
||||||
|
our community thrive!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## To Do
|
## To Do
|
||||||
|
|
||||||
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
|
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording.
|
||||||
|
Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The
|
||||||
|
goal is to make the platform better step by step, and every improvement counts. If you see something that could be
|
||||||
|
clearer, smoother, or more engaging, **please jump in**!
|
||||||
|
|
||||||
Here are some examples of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
The complete, official list of tasks is
|
||||||
|
available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are
|
||||||
|
working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue
|
||||||
|
for that task, assign it to yourself as well.
|
||||||
|
|
||||||
|
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do
|
||||||
|
so, use your preferred option:
|
||||||
|
|
||||||
|
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
||||||
|
- Email hello@compassmeet.com
|
||||||
|
- Raise an issue on GitHub
|
||||||
|
|
||||||
|
If you want to add tasks without creating an account, you can simply email
|
||||||
|
|
||||||
|
```
|
||||||
|
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the task title in the email subject and the task description in the email content.
|
||||||
|
|
||||||
|
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start,
|
||||||
|
just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||||
|
|
||||||
- [x] Authentication (user/password and Google Sign In)
|
- [x] Authentication (user/password and Google Sign In)
|
||||||
- [x] Set up PostgreSQL in Production with supabase
|
- [x] Set up PostgreSQL in Production with supabase
|
||||||
@@ -36,35 +72,40 @@ Here are some examples of things that would be very useful. If you want to help
|
|||||||
- [x] Search through most profile variables
|
- [x] Search through most profile variables
|
||||||
- [x] Set up chat / direct messaging
|
- [x] Set up chat / direct messaging
|
||||||
- [x] Set up domain name (compassmeet.com)
|
- [x] Set up domain name (compassmeet.com)
|
||||||
- [ ] Add mobile app (React Native on Android and iOS)
|
- [ ] Cover more than 90% with tests (unit, integration, e2e)
|
||||||
- [ ] Add better onboarding (tooltips, modals, etc.)
|
- [x] Add Android mobile app
|
||||||
|
- [ ] Add iOS mobile app
|
||||||
|
- [x] Add better onboarding (tooltips, modals, etc.)
|
||||||
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
||||||
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
||||||
- [ ] Add calendar integration and scheduling
|
- [ ] Add calendar integration and scheduling
|
||||||
- [ ] Add events (group calls, in-person meetups, etc.)
|
- [x] Add events (group calls, in-person meetups, etc.)
|
||||||
|
|
||||||
#### Secondary To Do
|
#### Secondary To Do
|
||||||
|
|
||||||
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
|
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time
|
||||||
|
contributors.
|
||||||
|
|
||||||
- [x] Clean up learn more page
|
- [x] Clean up learn more page
|
||||||
- [x] Add dark theme
|
- [x] Add dark theme
|
||||||
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
|
- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.)
|
||||||
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
|
- [ ] Add profile fields: conflict style
|
||||||
- [ ] Cover with tests (very important, just the test template and framework are ready)
|
- [ ] Add profile fields: timezone
|
||||||
|
- [ ] Add translations: Italian, Dutch, Hindi, Chinese, etc.
|
||||||
|
- [x] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||||
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
||||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||||
- [ ] Clean up privacy notice (convert to Markdown)
|
- [ ] Clean up privacy notice (convert to Markdown)
|
||||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||||
- [ ] Add email verification
|
- [x] Add email verification
|
||||||
- [ ] Add password reset
|
- [x] Add password reset
|
||||||
- [ ] Add automated welcome email
|
- [x] Add automated welcome email
|
||||||
- [ ] Security audit and penetration testing
|
- [ ] Security audit and penetration testing
|
||||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
- [x] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
- [x] Create settings page (change email, password, delete account, etc.)
|
||||||
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
||||||
- [ ] Improve loading sign (e.g., animation of a compass moving around)
|
- [x] Improve loading sign (e.g., animation of a compass moving around)
|
||||||
- [ ] Show compatibility score in profile page
|
- [x] Show compatibility score in profile page
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
|
|
||||||
@@ -77,91 +118,226 @@ The web app is coded in Typescript using React as front-end. It includes:
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
|
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or
|
||||||
|
contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
|
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo
|
||||||
|
and navigating into it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/<your-username>/Compass.git
|
git clone https://github.com/<your-username>/Compass.git
|
||||||
cd Compass
|
cd Compass
|
||||||
```
|
```
|
||||||
|
|
||||||
Install `opentofu`, `docker`, and `yarn`. Try running this on Linux or macOS for a faster install:
|
Install `yarn` (if not already installed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./setup.sh
|
npm install --global yarn
|
||||||
```
|
```
|
||||||
If it doesn't work, you can install them manually (google how to install `opentofu`, `docker`, and `yarn` for your OS).
|
|
||||||
|
|
||||||
Then, install the dependencies for this project:
|
Then, install the dependencies for this project:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn install
|
yarn install --frozen-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
We can't make the following information public, for security and privacy reasons:
|
|
||||||
- Database, otherwise anyone could access all the user data (including private messages)
|
|
||||||
- Firebase, otherwise anyone could remove users or modify the media files
|
|
||||||
- Email, analytics, and location services, otherwise anyone could use our paid plan
|
|
||||||
|
|
||||||
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
|
|
||||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
|
||||||
|
|
||||||
Most of the code will work out of the box. All you need to do is creating an `.env` file as a copy of `.env.example`:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
If you do need one of the few remaining services, you need to store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
Make sure the tests pass:
|
Make sure the Jest tests pass:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn test tests/jest/
|
yarn test
|
||||||
```
|
```
|
||||||
TODO: make `yarn test` run all the tests, not just the ones in `tests/jest/`.
|
|
||||||
|
If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked.
|
||||||
|
|
||||||
### Running the Development Server
|
### Running the Development Server
|
||||||
|
|
||||||
Start the development server:
|
Start the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
|
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles;
|
||||||
|
you should see a few synthetic profiles.
|
||||||
|
|
||||||
|
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it
|
||||||
|
would be great to improve that though!
|
||||||
|
|
||||||
|
#### Full isolation
|
||||||
|
|
||||||
|
Running `yarn dev:isolated` spins up a local Supabase and Firebase emulator instead of pointing at the shared remote
|
||||||
|
database. This is strongly recommended for day-to-day development:
|
||||||
|
|
||||||
|
- **Freedom** — Reset, wipe, and reseed your local database as many times as you want without affecting other
|
||||||
|
contributors.
|
||||||
|
- **No conflicts** — Multiple contributors can work simultaneously without stepping on each other's data or schema.
|
||||||
|
- **Works offline** — No internet required once services are started locally.
|
||||||
|
- **Faster** — No network latency on every database query.
|
||||||
|
|
||||||
|
However, running in full isolation requires installing several heavy dependencies:
|
||||||
|
|
||||||
|
- **Docker** (~500MB) — runs the Supabase Postgres container
|
||||||
|
- **Supabase CLI** — manages the local Supabase stack (10+ Docker containers, ~1-2GB RAM when running)
|
||||||
|
- **Java 21+** (~300MB) — required by Firebase emulators
|
||||||
|
- **Firebase CLI** — manages the local Firebase emulators
|
||||||
|
|
||||||
|
First startup is slow (30-60s) and the stack uses significant memory. If your machine has less than 8GB RAM, you may
|
||||||
|
notice slowdowns.
|
||||||
|
|
||||||
|
If this feels like too much, you can skip isolation entirely — `yarn dev` works out of the box against the shared remote
|
||||||
|
and is perfectly fine for most contributions, especially UI changes, wording fixes, or anything that doesn't touch the
|
||||||
|
database or authentication.
|
||||||
|
|
||||||
|
###### Setup instructions
|
||||||
|
|
||||||
|
As always, don't hesitate to raise an issue if you run into any problems!
|
||||||
|
|
||||||
|
Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian (native - recommended over snap)
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Log out and back in for group changes to take effect
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install --cask docker
|
||||||
|
# Or download from https://www.docker.com/products/docker-desktop
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Supabase CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify it got installed from `yarn install`
|
||||||
|
npx supabase --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Java 21+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install openjdk-21-jdk
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install openjdk@21
|
||||||
|
|
||||||
|
# Verify (must be 21+)
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
Firebase CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g firebase-tools
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
firebase --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Run in isolation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev:isolated
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000` as usual. Your local database comes preloaded with synthetic test profiles so the app
|
||||||
|
looks and feels like the real thing.
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
Now you can start contributing by making changes and submitting pull requests!
|
Now you can start contributing by making changes and submitting pull requests!
|
||||||
|
|
||||||
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
|
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (
|
||||||
|
GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||||
|
|
||||||
|
- Components tab to see the React component tree and props (you need to install
|
||||||
|
the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||||
|
- Console tab for errors and logs
|
||||||
|
- Network tab to see the requests and responses
|
||||||
|
- Storage tab to see cookies and local storage
|
||||||
|
|
||||||
|
You can also add `console.log()` statements in the code.
|
||||||
|
|
||||||
|
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web
|
||||||
|
components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||||
|
|
||||||
|
##### Resources
|
||||||
|
|
||||||
|
There is a lot of documentation in the [docs](docs) folder and across the repo, namely:
|
||||||
|
|
||||||
|
- [Next.js.md](docs/Next.js.md) for core fundamentals about our web / page-rendering framework.
|
||||||
|
- [knowledge.md](docs/knowledge.md) for general information about the project structure.
|
||||||
|
- [development.md](docs/development.md) for additional instructions, such as adding new profile fields or languages.
|
||||||
|
- [TESTING.md](docs/TESTING.md) for how to write tests.
|
||||||
|
- [PERFORMANCE_OPTIMIZATION.md](docs/PERFORMANCE_OPTIMIZATION.md) for frontend, backend, and database performance best practices.
|
||||||
|
- [DATABASE_CONNECTION_POOLING.md](docs/DATABASE_CONNECTION_POOLING.md) for database connection management and troubleshooting.
|
||||||
|
- [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for resolving common development issues.
|
||||||
|
- [web](web) for the web.
|
||||||
|
- [backend/api](backend/api) for the backend API.
|
||||||
|
- [android](android) for the Android app.
|
||||||
|
|
||||||
|
There are a lot of useful scripts you can use in the [scripts](scripts) folder.
|
||||||
|
|
||||||
### Submission
|
### Submission
|
||||||
|
|
||||||
Add the original repo as upstream for syncing:
|
Add the original repo as upstream for syncing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a new branch for your changes:
|
Create a new branch for your changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b <branch-name>
|
git checkout -b <branch-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Make changes, then stage and commit:
|
Make changes, then stage and commit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Describe your changes"
|
git commit -m "Describe your changes"
|
||||||
```
|
```
|
||||||
|
|
||||||
Push branch to your fork:
|
Push branch to your fork:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push origin <branch-name>
|
git push origin <branch-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `CompassConnections/Compass` main branch.
|
Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `CompassConnections/Compass` main branch.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the
|
||||||
|
following services: email, geolocation.
|
||||||
|
|
||||||
|
We can't make the following information public, for security and privacy reasons:
|
||||||
|
|
||||||
|
- Database, otherwise anyone could access all the user data (including private messages)
|
||||||
|
- Firebase, otherwise anyone could remove users or modify the media files
|
||||||
|
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the
|
||||||
|
bill.
|
||||||
|
|
||||||
|
That's why we separate all those services between production and development environments, so that you can code freely
|
||||||
|
without impacting the functioning of the deployed platform.
|
||||||
|
Contributors should use the default keys for local development. Production uses a separate environment with stricter
|
||||||
|
rules and private keys that are not shared.
|
||||||
|
|
||||||
|
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment
|
||||||
|
variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
|
||||||
|
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform
|
||||||
|
licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have
|
||||||
|
significantly aided in the development of some core features such as direct messaging, prompts, and email notifications.
|
||||||
|
We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially
|
||||||
|
if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
||||||
|
|||||||
116
SECURITY.md
116
SECURITY.md
@@ -4,9 +4,121 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| 1.0.0 | :white_check_mark: |
|
| 1.10.x | :white_check_mark: |
|
||||||
|
| 1.9.x | :white_check_mark: |
|
||||||
|
| < 1.9.0 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Contact the development team at compass.meet.info@gmail.com to report a vulnerability. You should receive updates within a week.
|
If you discover a security vulnerability within Compass, please send an email to hello@compassmeet.com. All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
Please do not publicly disclose the vulnerability until it has been resolved.
|
||||||
|
|
||||||
|
## Security Practices
|
||||||
|
|
||||||
|
Compass takes security seriously and implements several best practices to protect user data and ensure application integrity.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- **Firebase Authentication**: User authentication is handled by Firebase Auth, which provides industry-standard security for user credentials
|
||||||
|
- **JWT Tokens**: Secure token-based authentication for API access
|
||||||
|
- **Role-Based Access Control**: Different permission levels for users, moderators, and administrators
|
||||||
|
- **Session Management**: Secure session handling with automatic timeout
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- **Encryption at Rest**: Sensitive data is encrypted in the database
|
||||||
|
- **Encryption in Transit**: All communications use HTTPS/TLS encryption
|
||||||
|
- **Environment Variables**: Secrets are managed through secure environment variable configuration
|
||||||
|
- **Data Minimization**: Only necessary data is collected and stored
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
- **Zod Validation**: Strong type checking and validation for all API inputs
|
||||||
|
- **Sanitization**: Input sanitization to prevent injection attacks
|
||||||
|
- **Rate Limiting**: Protection against brute force and denial of service attacks
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
- **CORS Configuration**: Restricted cross-origin resource sharing policies
|
||||||
|
- **Rate Limiting**: Per-endpoint rate limiting to prevent abuse
|
||||||
|
- **Authentication Middleware**: All protected endpoints require valid authentication
|
||||||
|
- **Input Validation**: Comprehensive validation of all API inputs
|
||||||
|
|
||||||
|
### Database Security
|
||||||
|
|
||||||
|
- **Row Level Security**: Fine-grained access control at the database level
|
||||||
|
- **Parameterized Queries**: Prevention of SQL injection attacks
|
||||||
|
- **Audit Logging**: Tracking of database access and modifications
|
||||||
|
- **Regular Backups**: Automated database backups for disaster recovery
|
||||||
|
|
||||||
|
### Third-Party Services
|
||||||
|
|
||||||
|
- **Firebase Security Rules**: Strict security rules for Firestore and Storage
|
||||||
|
- **Supabase RLS**: Row-level security policies for PostgreSQL
|
||||||
|
- **Secrets Management**: Secure storage of API keys and credentials
|
||||||
|
|
||||||
|
### Development Practices
|
||||||
|
|
||||||
|
- **Code Reviews**: All changes reviewed by multiple developers
|
||||||
|
- **Automated Testing**: Security-focused tests integrated into CI/CD pipeline
|
||||||
|
- **Dependency Management**: Regular updates and security scanning of dependencies
|
||||||
|
- **Security Audits**: Periodic security assessments and penetration testing
|
||||||
|
|
||||||
|
## Common Security Issues and Resolutions
|
||||||
|
|
||||||
|
### XSS Prevention
|
||||||
|
|
||||||
|
- **Content Security Policy**: Strict CSP headers to prevent cross-site scripting
|
||||||
|
- **Input Sanitization**: All user-generated content is sanitized before display
|
||||||
|
- **Output Encoding**: Proper encoding of user data in HTML contexts
|
||||||
|
|
||||||
|
### CSRF Protection
|
||||||
|
|
||||||
|
- **SameSite Cookies**: CSRF protection through SameSite cookie attributes
|
||||||
|
- **Anti-Forgery Tokens**: Token-based protection for state-changing operations
|
||||||
|
|
||||||
|
### Injection Attacks
|
||||||
|
|
||||||
|
- **SQL Injection**: Parameterized queries and prepared statements
|
||||||
|
- **Command Injection**: Input validation and sanitization
|
||||||
|
- **Script Injection**: Content Security Policy and input filtering
|
||||||
|
|
||||||
|
## Incident Response
|
||||||
|
|
||||||
|
In the event of a security incident:
|
||||||
|
|
||||||
|
1. **Immediate Containment**: Isolate affected systems
|
||||||
|
2. **Investigation**: Determine scope and impact of breach
|
||||||
|
3. **Remediation**: Apply fixes and security patches
|
||||||
|
4. **Notification**: Inform affected users and stakeholders
|
||||||
|
5. **Review**: Post-incident analysis and process improvement
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
|
||||||
|
Compass aims to comply with relevant data protection regulations:
|
||||||
|
|
||||||
|
- **GDPR**: General Data Protection Regulation compliance
|
||||||
|
- **CCPA**: California Consumer Privacy Act compliance
|
||||||
|
- **Data Retention**: Clear policies for data retention and deletion
|
||||||
|
|
||||||
|
## Third-Party Security
|
||||||
|
|
||||||
|
We regularly audit third-party services for:
|
||||||
|
|
||||||
|
- Security certifications and compliance
|
||||||
|
- Regular security updates and patches
|
||||||
|
- Data handling and privacy practices
|
||||||
|
- Incident response procedures
|
||||||
|
|
||||||
|
## Security Contact
|
||||||
|
|
||||||
|
For security-related inquiries, contact:
|
||||||
|
|
||||||
|
- Email: hello@compassmeet.com
|
||||||
|
- Response Time: Within 24 hours for critical issues
|
||||||
|
- Disclosure Policy: Coordinated disclosure with 90-day timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last Updated: March 2026_
|
||||||
|
|||||||
0
android/.aiexclude
Normal file
0
android/.aiexclude
Normal file
102
android/.gitignore
vendored
Normal file
102
android/.gitignore
vendored
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
|
/app/release/
|
||||||
531
android/README.md
Normal file
531
android/README.md
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
# Compass Android WebView App
|
||||||
|
|
||||||
|
This folder contains the source code for the Android application of Compass.
|
||||||
|
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor
|
||||||
|
WebView** for Android. In the future it may contain native code as well.
|
||||||
|
|
||||||
|
This document describes how to:
|
||||||
|
|
||||||
|
1. Build and run the web frontend and backend locally
|
||||||
|
2. Sync and build the Android WebView wrapper
|
||||||
|
3. Debug, sign, and publish the APK
|
||||||
|
4. Enable Google Sign-In and push notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
|
||||||
|
The app is a Capacitor Android project that loads the local Next.js assets inside a WebView.
|
||||||
|
|
||||||
|
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (
|
||||||
|
`http://10.0.2.2:8088`).
|
||||||
|
|
||||||
|
Firebase handles authentication and push notifications.
|
||||||
|
Google Sign-In is supported natively in the WebView via the Capacitor Social Login plugin.
|
||||||
|
|
||||||
|
Project Structure
|
||||||
|
|
||||||
|
- `app/src/main/java/com/compass/app`: Contains the Java/Kotlin source code for the Android application.
|
||||||
|
- `app/src/main/res`: Contains the resources for the application, such as layouts, strings, and images.
|
||||||
|
- `app/build.gradle`: The Gradle build file for the Android application module.
|
||||||
|
- `build.gradle`: The top-level Gradle build file for the project.
|
||||||
|
- `AndroidManifest.xml`: The manifest file that describes essential information about the application.
|
||||||
|
|
||||||
|
### **Why Local Is the Default**
|
||||||
|
|
||||||
|
- **Performance:** Local assets load instantly, without network latency.
|
||||||
|
- **Reliability:** Works offline or in poor connectivity environments.
|
||||||
|
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesn’t depend on a remote
|
||||||
|
site (for security, review, and performance reasons).
|
||||||
|
- **Version consistency:** The web bundle is versioned with the app, ensuring no breaking updates outside your control.
|
||||||
|
|
||||||
|
When Remote (No Local Assets) Is sometimes Used
|
||||||
|
Loading from a **remote URL** (e.g. `https://compassmeet.com`) is **less common**, but seen in a few cases:
|
||||||
|
|
||||||
|
- **Internal enterprise apps** where the WebView just wraps an existing web portal.
|
||||||
|
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too
|
||||||
|
slow.
|
||||||
|
- To leverage the low latency of ISR and SSR.
|
||||||
|
However, this approach requires:
|
||||||
|
- Careful handling of **CORS**, **SSL**, and **login/session** persistence.
|
||||||
|
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless there’s
|
||||||
|
meaningful native integration).
|
||||||
|
|
||||||
|
**A middle ground we use:**
|
||||||
|
|
||||||
|
- The app ships with **local assets** for core functionality.
|
||||||
|
- The app **fetches remote content or updates** (e.g., via Capacitor Live Updates, Ionic Appflow).
|
||||||
|
|
||||||
|
## 2. Prerequisites
|
||||||
|
|
||||||
|
### Required Software
|
||||||
|
|
||||||
|
| Tool | Version | Purpose |
|
||||||
|
|----------------|---------|------------------------------------|
|
||||||
|
| Node.js | 22+ | For building frontend/backend |
|
||||||
|
| yarn | latest | Package manager |
|
||||||
|
| Java | 21 | Required for Android Gradle plugin |
|
||||||
|
| Android Studio | latest | For building and signing APKs |
|
||||||
|
| Capacitor CLI | latest | Android bridge |
|
||||||
|
| OpenJDK | 21 | JDK for Gradle |
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install openjdk-21-jdk
|
||||||
|
sudo update-alternatives --config java
|
||||||
|
# Select Java 21
|
||||||
|
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
||||||
|
java -version
|
||||||
|
javac -version
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Build and Run the Web App
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
yarn build-web-view
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local mode
|
||||||
|
|
||||||
|
If you want the webview to load from your local web version of Compass, run the web app.
|
||||||
|
|
||||||
|
In root directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||||
|
yarn dev # or prod
|
||||||
|
```
|
||||||
|
|
||||||
|
* Runs Next.js frontend at `http://localhost:3000`
|
||||||
|
* Runs backend at `http://10.0.2.2:8088`
|
||||||
|
|
||||||
|
### Deployed mode
|
||||||
|
|
||||||
|
If you want the webview to load from the deployed web version of Compass (like at compassmeet.com), no web app to
|
||||||
|
run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Android WebView App Setup
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
cd android
|
||||||
|
./gradlew clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Sync web files and native plugins with Android, for offline fallback. In root:
|
||||||
|
|
||||||
|
```
|
||||||
|
export NEXT_PUBLIC_LOCAL_ANDROID=1 # if running your local web Compass
|
||||||
|
yarn build-web-view # if you made changes to web app
|
||||||
|
npx cap sync android
|
||||||
|
```
|
||||||
|
|
||||||
|
### Load from site
|
||||||
|
|
||||||
|
During local development, open Android Studio project and run the app on an emulator or your physical device.
|
||||||
|
|
||||||
|
To use an emulator:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx cap open android
|
||||||
|
```
|
||||||
|
|
||||||
|
To use a physical device for the local web version, you need your mobile and computer to be on the same network / Wi-Fi
|
||||||
|
and point the URL (`LOCAL_BACKEND_DOMAIN` in the code) to your computer IP address (for example, `192.168.1.3:3000`).
|
||||||
|
You also need to set
|
||||||
|
|
||||||
|
```
|
||||||
|
export NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then adb install the app your phone (or simply run it from Android Studio on your phone) and the app should be loading
|
||||||
|
content directly from the local code on your computer. You can make changes in the code and it will refresh instantly on
|
||||||
|
the phone.
|
||||||
|
|
||||||
|
Building the Application:
|
||||||
|
|
||||||
|
1. Open Android Studio.
|
||||||
|
2. Click on "Open an existing Android Studio project".
|
||||||
|
3. Navigate to the `android` folder in this repository and select it.
|
||||||
|
4. Wait for Android Studio to index the project and download any necessary dependencies.
|
||||||
|
5. Connect your Android device via USB or set up an Android emulator.
|
||||||
|
6. Click on the "Run" button (green play button) in Android Studio to build and run the application.
|
||||||
|
7. Select your device or emulator and click "OK".
|
||||||
|
8. The application should now build and launch on your device or emulator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Building the APK
|
||||||
|
|
||||||
|
### From Android Studio
|
||||||
|
|
||||||
|
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the
|
||||||
|
prompts.
|
||||||
|
- Make sure to test the application thoroughly on different devices and Android versions to ensure compatibility.
|
||||||
|
|
||||||
|
### Debug build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
```
|
||||||
|
android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install on emulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release build (signed)
|
||||||
|
|
||||||
|
1. Generate a release keystore:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkey -v -keystore release-key.keystore -alias compass \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000
|
||||||
|
```
|
||||||
|
2. Add signing config to `android/app/build.gradle`
|
||||||
|
3. Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release on App Stores
|
||||||
|
|
||||||
|
To release on the app stores, you need to submit the .aab files, which are not signed, instead of APK. Google or Apple
|
||||||
|
will then sign it with their own key.
|
||||||
|
|
||||||
|
However, it's recommended to use the GitHub Action for better version control and automation. See section below:
|
||||||
|
`Deploy to Play Store`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Debugging
|
||||||
|
|
||||||
|
Client logs from the emulator on Chrome can be accessed at:
|
||||||
|
|
||||||
|
```
|
||||||
|
chrome://inspect/#devices
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
|
||||||
|
|
||||||
|
Java/Kotlin logs can be accessed via Android Studio's Logcat.
|
||||||
|
|
||||||
|
```
|
||||||
|
adb logcat | grep CompassApp
|
||||||
|
adb logcat | grep com.compassconnections.app
|
||||||
|
adb logcat | grep Capacitor
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also add this inside `MainActivity.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
webView.setWebChromeClient(new WebChromeClient() {
|
||||||
|
@Override
|
||||||
|
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
|
||||||
|
Log.d("WebView", consoleMessage.message());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Deploy to Play Store
|
||||||
|
|
||||||
|
The best way to deploy to the Play Store is to use the GitHub Action defined
|
||||||
|
in [cd-android.yml](../.github/workflows/cd-android.yml). You
|
||||||
|
increase the version in `android/app/build.gradle`, commit to the main branch and it will automatically build the
|
||||||
|
release APK and upload it to the Play Store.
|
||||||
|
|
||||||
|
To deploy manually, follow these steps:
|
||||||
|
|
||||||
|
1. Sign the release APK or AAB.
|
||||||
|
2. Verify package name matches Firebase settings (`com.compassconnections.app`).
|
||||||
|
3. Upload to Google Play Console.
|
||||||
|
4. Add Privacy Policy and content rating (one time).
|
||||||
|
5. Submit for review. It takes around an hour for it to be approved and appear in the store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Common Issues
|
||||||
|
|
||||||
|
| Problem | Cause | Fix |
|
||||||
|
|--------------------------------------|----------------------------------------|---------------------------------------------------------------------|
|
||||||
|
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
|
||||||
|
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
|
||||||
|
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
|
||||||
|
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Local Development Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||||
|
yarn dev # or prod
|
||||||
|
|
||||||
|
# Terminal 2: start frontend
|
||||||
|
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||||
|
yarn build-web-view # if you made changes to web app
|
||||||
|
npx cap sync android
|
||||||
|
# Run on emulator or device
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Deployment Workflow
|
||||||
|
|
||||||
|
To deploy manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build web app for production and Sync assets to Android
|
||||||
|
yarn build-sync-android
|
||||||
|
|
||||||
|
# Build signed release APK in Android Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
But prefer using the GitHub Action, see `Deploy to Play Store`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live Updates
|
||||||
|
|
||||||
|
Note: As of early 2026, we don't use the live update anymore because the free plan is too limited for our use case. To
|
||||||
|
update the android app, we need to stick to the normal release process on the app stores.
|
||||||
|
|
||||||
|
To avoid releasing to the app stores after every code update in the web pages, we build the new bundle and store it in
|
||||||
|
Capawesome Cloud (an alternative to Ionic). To add a new update, increment the version number
|
||||||
|
in [capawesome.json](capawesome.json) and push to main (or make a PR to main). A GitHub Action will automatically build
|
||||||
|
the new bundle and push it to Capawesome.
|
||||||
|
|
||||||
|
You can also do so locally if you have admin access. First, you need to do this one-time setup:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install -g @capawesome/cli@latest
|
||||||
|
npx @capawesome/cli login
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run this to build your local assets and push them to Capawesome. Once done, each mobile app user will receive a
|
||||||
|
notice that there is a new update available, which they can approve to download.
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn android:live-update
|
||||||
|
```
|
||||||
|
|
||||||
|
That's all. So you should run the lines above every time you want your web updates pushed to main (which essentially
|
||||||
|
updates the web app) to update the mobile app as well.
|
||||||
|
There is a limit of 100 monthly active user per month, though. So we may need to pay or create our custom limit as we
|
||||||
|
scale. Next plan is $9 / month and allows 1000 MAUs.
|
||||||
|
|
||||||
|
- ∞ Live Updates
|
||||||
|
- 100 Monthly Active Users
|
||||||
|
- 500 MB of Storage (around 10 MB per update, but we just delete the previous ones)
|
||||||
|
- 5 GB of Bandwidth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Resources
|
||||||
|
|
||||||
|
* [Capacitor Docs](https://capacitorjs.com/docs)
|
||||||
|
* [Firebase Android Setup](https://firebase.google.com/docs/android/setup)
|
||||||
|
* [FCM HTTP API](https://firebase.google.com/docs/cloud-messaging/send-message)
|
||||||
|
* [Next.js Deployment](https://nextjs.org/docs/deployment)
|
||||||
|
|
||||||
|
# Useful Commands
|
||||||
|
|
||||||
|
- To build the project: `./gradlew assembleDebug`
|
||||||
|
- To run unit tests: `./gradlew test`
|
||||||
|
- To run instrumentation tests: `./gradlew connectedAndroidTest`
|
||||||
|
- To clean the project: `./gradlew clean`
|
||||||
|
- To install dependencies: Open Android Studio and it will handle dependencies automatically.
|
||||||
|
- To update dependencies: Modify the `build.gradle` files and sync the project in Android Studio.
|
||||||
|
- To generate a signed APK: Use the "Generate Signed Bundle / APK..." option in Android Studio.
|
||||||
|
- To lint the project: `./gradlew lint`
|
||||||
|
- To check for updates to the Android Gradle Plugin: `./gradlew dependencyUpdates`
|
||||||
|
- To run the application on a connected device or emulator: `./gradlew installDebug`
|
||||||
|
- To view the project structure: Use the "Project" view in Android Studio.
|
||||||
|
- To analyze the APK: `./gradlew analyzeRelease`
|
||||||
|
- To run ProGuard/R8: `./gradlew minifyRelease`
|
||||||
|
- To generate documentation: `./gradlew javadoc`
|
||||||
|
|
||||||
|
# One time setups
|
||||||
|
|
||||||
|
Was already done for Compass, so you only need to do the steps below if you create a project separated from Compass.
|
||||||
|
|
||||||
|
## Configure Firebase
|
||||||
|
|
||||||
|
### In Firebase Console
|
||||||
|
|
||||||
|
1. Add a **Web app** → obtain `firebaseConfig`
|
||||||
|
2. Add an **Android app**
|
||||||
|
|
||||||
|
* Package name: `com.compassconnections.app`
|
||||||
|
* Add your SHA-1 and SHA-256 fingerprints (see below)
|
||||||
|
* Download `google-services.json` and put it in:
|
||||||
|
|
||||||
|
```
|
||||||
|
android/app/google-services.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### To get SHA-1 for debug keystore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -list -v \
|
||||||
|
-keystore ~/.android/debug.keystore \
|
||||||
|
-alias androiddebugkey \
|
||||||
|
-storepass android \
|
||||||
|
-keypass android
|
||||||
|
```
|
||||||
|
|
||||||
|
Add both SHA-1 and SHA-256 to Firebase.
|
||||||
|
|
||||||
|
## 7. Google Sign-In (Web + Native)
|
||||||
|
|
||||||
|
In Firebase Console:
|
||||||
|
|
||||||
|
* Enable **Google** provider under *Authentication → Sign-in method*
|
||||||
|
* Add your **Android SHA-1**
|
||||||
|
* Add your **Web OAuth client ID**
|
||||||
|
|
||||||
|
In your code:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {googleNativeLogin} from 'web/lib/service/android-push'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Push Notifications (FCM)
|
||||||
|
|
||||||
|
### Setup FCM
|
||||||
|
|
||||||
|
* Add Firebase Cloud Messaging to your project
|
||||||
|
* Include `google-services.json` under `android/app/`
|
||||||
|
* Add in `android/build.gradle`:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
classpath 'com.google.gms:google-services:4.3.15'
|
||||||
|
```
|
||||||
|
* Add at the bottom of `android/app/build.gradle`:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test notification
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const message = {
|
||||||
|
notification: {
|
||||||
|
title: "Test Notification",
|
||||||
|
body: "Hello from Firebase Admin SDK"
|
||||||
|
},
|
||||||
|
token: "..."
|
||||||
|
};
|
||||||
|
initAdmin()
|
||||||
|
await admin.messaging().send(message)
|
||||||
|
.then(response => console.log("Successfully sent message:", response))
|
||||||
|
.catch(error => console.error("Error sending message:", error));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deep link / custom scheme
|
||||||
|
|
||||||
|
A **custom scheme** is a URL protocol that your app owns.
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
com.compassconnections.app://auth
|
||||||
|
```
|
||||||
|
|
||||||
|
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data. It's
|
||||||
|
useful to open links in the app instead of the browser. For example, if there's a link to Compass on Discord and we
|
||||||
|
click on it on a mobile device that has the app, we want the link to open in the app instead of the browser.
|
||||||
|
|
||||||
|
You register this scheme in your `AndroidManifest.xml` so Android knows which app handles it.
|
||||||
|
|
||||||
|
## Automatic Workflow for App Release
|
||||||
|
|
||||||
|
Below is a **minimal, production-ready GitHub Actions setup** that:
|
||||||
|
|
||||||
|
* Builds on push to `main`
|
||||||
|
* Checks if `versionCode` increased
|
||||||
|
* Only builds if it did
|
||||||
|
* Signs the AAB
|
||||||
|
* Uploads to Google Play (internal track)
|
||||||
|
|
||||||
|
#### A. Create Play Console API access
|
||||||
|
|
||||||
|
1. Go to google cloud console. Create service account without selecting any specific permission or roles. Just copy
|
||||||
|
paste the email address and generate a JSON key.
|
||||||
|
2. Go to **Google Play Console**
|
||||||
|
3. Invite user, enter the service account email address.
|
||||||
|
5. Give it:
|
||||||
|
* Release Manager role
|
||||||
|
|
||||||
|
You will store the JSON key in GitHub Secrets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### B. Prepare Keystore
|
||||||
|
|
||||||
|
If you already sign locally, you have a `.jks` or `.keystore` file.
|
||||||
|
|
||||||
|
Base64 encode it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 my-release-key.keystore
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### C. GitHub Secrets
|
||||||
|
|
||||||
|
In your GitHub repo:
|
||||||
|
|
||||||
|
Settings → Secrets and variables → Actions → New repository secret
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```
|
||||||
|
ANDROID_KEYSTORE_BASE64
|
||||||
|
ANDROID_KEYSTORE_PASSWORD
|
||||||
|
ANDROID_KEY_PASSWORD
|
||||||
|
PLAY_SERVICE_ACCOUNT_JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
For the JSON:
|
||||||
|
|
||||||
|
* Paste full raw JSON (not base64)
|
||||||
|
|
||||||
|
#### GitHub Actions YAML
|
||||||
|
|
||||||
|
We compare:
|
||||||
|
|
||||||
|
* `versionCode` in current commit
|
||||||
|
* `versionCode` in previous commit
|
||||||
|
|
||||||
|
If not increased → skip build.
|
||||||
|
|
||||||
|
We extract from `app/build.gradle` using grep.
|
||||||
|
|
||||||
|
See `.github/workflows/android-release.yml` for all details.
|
||||||
3
android/app/.gitignore
vendored
Normal file
3
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
|
/google-services.json
|
||||||
79
android/app/build.gradle
Normal file
79
android/app/build.gradle
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.compassconnections.app"
|
||||||
|
compileSdk 36
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.compassconnections.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 84
|
||||||
|
versionName "1.17.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
buildConfigField "boolean", "ENABLE_WEBVIEW_DEBUG", "false"
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
buildConfigField "boolean", "ENABLE_WEBVIEW_DEBUG", "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
|
// Import the Firebase BoM
|
||||||
|
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||||
|
// TODO: Add the dependencies for Firebase products you want to use
|
||||||
|
// When using the BoM, don't specify versions in Firebase dependencies
|
||||||
|
implementation 'com.google.firebase:firebase-analytics'
|
||||||
|
// Add the dependencies for any other desired Firebase products
|
||||||
|
// https://firebase.google.com/docs/android/setup#available-libraries
|
||||||
|
|
||||||
|
implementation 'com.google.android.gms:play-services-auth:21.5.1'
|
||||||
|
implementation 'com.google.firebase:firebase-auth:24.0.1'
|
||||||
|
|
||||||
|
implementation 'com.google.android.play:app-update:2.1.0'
|
||||||
|
implementation 'com.google.android.play:app-update-ktx:2.1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
24
android/app/capacitor.build.gradle
Normal file
24
android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-keyboard')
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
implementation project(':capawesome-capacitor-live-update')
|
||||||
|
implementation project(':capgo-capacitor-social-login')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
21
android/app/proguard-rules.pro
vendored
Normal file
21
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
86
android/app/src/main/AndroidManifest.xml
Normal file
86
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.1" encoding="utf-8" ?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
<data android:host="compassmeet.com" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
<data android:host="www.compassmeet.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- <intent-filter>-->
|
||||||
|
<!-- <action android:name="openapp" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
|
||||||
|
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
|
|
||||||
|
<!-- <intent-filter android:autoVerify="true">-->
|
||||||
|
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
<!-- <data android:scheme="com.compassconnections.app" android:host="auth" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- <service-->
|
||||||
|
<!-- android:name=".MyMessagingService"-->
|
||||||
|
<!-- android:exported="false">-->
|
||||||
|
<!-- <intent-filter>-->
|
||||||
|
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
|
<!--<!– <meta-data–>-->
|
||||||
|
<!--<!– android:name="com.google.firebase.messaging.default_notification_channel_id"–>-->
|
||||||
|
<!--<!– android:value="@string/default_notification_channel_id" />–>-->
|
||||||
|
<!-- </service>-->
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
|
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
||||||
|
|
||||||
|
<!-- Firebase Cloud Messaging -->
|
||||||
|
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature" />
|
||||||
|
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE" />
|
||||||
|
<!-- Old, can be removed ?-->
|
||||||
|
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package com.compassconnections.app;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.ContentResolver;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.MediaScannerConnection;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.JavascriptInterface;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
|
import androidx.annotation.RequiresApi;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin;
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
import com.getcapacitor.BridgeWebViewClient;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginHandle;
|
||||||
|
import com.google.android.play.core.appupdate.AppUpdateInfo;
|
||||||
|
import com.google.android.play.core.appupdate.AppUpdateManager;
|
||||||
|
import com.google.android.play.core.appupdate.AppUpdateManagerFactory;
|
||||||
|
import com.google.android.play.core.appupdate.AppUpdateOptions;
|
||||||
|
import com.google.android.play.core.install.model.AppUpdateType;
|
||||||
|
import com.google.android.play.core.install.model.UpdateAvailability;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import ee.forgr.capacitor.social.login.GoogleProvider;
|
||||||
|
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
|
||||||
|
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||||
|
|
||||||
|
private String pendingDeepLink = null;
|
||||||
|
|
||||||
|
// Declare this at class level
|
||||||
|
private final ActivityResultLauncher<String> requestPermissionLauncher =
|
||||||
|
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
|
||||||
|
if (isGranted) {
|
||||||
|
Log.i("CompassApp", "Permission granted");
|
||||||
|
// Permission granted – you can show notifications
|
||||||
|
} else {
|
||||||
|
Log.i("CompassApp", "Permission denied");
|
||||||
|
// Permission denied – handle gracefully
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private void askNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // API 33
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
// Permission not yet granted; request it
|
||||||
|
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebAppInterface {
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
public WebAppInterface(Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public String getPendingDeepLink() {
|
||||||
|
String link = pendingDeepLink;
|
||||||
|
pendingDeepLink = null; // consume it
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void downloadFile(String filename, String content) {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// Android 10+ (API 29+) - Use MediaStore
|
||||||
|
downloadFileModern(filename, content);
|
||||||
|
} else {
|
||||||
|
// Android 9 and below - Use legacy method
|
||||||
|
downloadFileLegacy(filename, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
runOnUiThread(() ->
|
||||||
|
Toast.makeText(MainActivity.this, "File downloaded: " + filename, Toast.LENGTH_SHORT).show()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.e("CompassApp", "Failed to download file", e);
|
||||||
|
runOnUiThread(() ->
|
||||||
|
Toast.makeText(MainActivity.this, "Download failed: " + e.getMessage(), Toast.LENGTH_SHORT).show()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Android 10+ (Scoped Storage)
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||||
|
private void downloadFileModern(String filename, String content) throws IOException {
|
||||||
|
ContentResolver resolver = getContentResolver();
|
||||||
|
ContentValues contentValues = new ContentValues();
|
||||||
|
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
|
||||||
|
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(filename));
|
||||||
|
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||||
|
|
||||||
|
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
|
||||||
|
if (uri != null) {
|
||||||
|
try (OutputStream outputStream = resolver.openOutputStream(uri)) {
|
||||||
|
if (outputStream != null) {
|
||||||
|
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Android 9 and below
|
||||||
|
private void downloadFileLegacy(String filename, String content) throws IOException {
|
||||||
|
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||||
|
if (!downloadsDir.exists()) {
|
||||||
|
downloadsDir.mkdirs();
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = getUniqueFile(downloadsDir, filename);
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(file)) {
|
||||||
|
fos.write(content.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File getUniqueFile(File directory, String filename) {
|
||||||
|
File file = new File(directory, filename);
|
||||||
|
if (!file.exists()) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add number suffix if file exists
|
||||||
|
String name = filename.substring(0, filename.lastIndexOf("."));
|
||||||
|
String extension = filename.substring(filename.lastIndexOf("."));
|
||||||
|
|
||||||
|
int counter = 1;
|
||||||
|
while (file.exists()) {
|
||||||
|
file = new File(directory, name + "(" + counter + ")" + extension);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to determine MIME type
|
||||||
|
private String getMimeType(String filename) {
|
||||||
|
String extension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
return switch (extension) {
|
||||||
|
case "txt" -> "text/plain";
|
||||||
|
case "pdf" -> "application/pdf";
|
||||||
|
case "json" -> "application/json";
|
||||||
|
case "csv" -> "text/csv";
|
||||||
|
case "html" -> "text/html";
|
||||||
|
case "jpg", "jpeg" -> "image/jpeg";
|
||||||
|
case "png" -> "image/png";
|
||||||
|
default -> "application/octet-stream";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
|
||||||
|
String endpoint = intent.getStringExtra("endpoint");
|
||||||
|
Log.i("CompassApp", "onNewIntent called with endpoint: " + endpoint);
|
||||||
|
if (endpoint != null) {
|
||||||
|
Log.i("CompassApp", "redirecting to endpoint: " + endpoint);
|
||||||
|
try {
|
||||||
|
String payload = new JSONObject().put("endpoint", endpoint).toString();
|
||||||
|
Log.i("CompassApp", "Handling notif click: " + payload);
|
||||||
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("handleAppLink(" + payload + ");", null));
|
||||||
|
} catch (JSONException e) {
|
||||||
|
Log.i("CompassApp", "Failed to encode JSON payload", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
handleDeepLink(data.toString());
|
||||||
|
} else {
|
||||||
|
Log.i("CompassApp", "No relevant data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
Log.i("CompassApp", "onCreate called");
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
WebView webView = this.bridge.getWebView();
|
||||||
|
webView.setWebViewClient(new BridgeWebViewClient(this.bridge));
|
||||||
|
|
||||||
|
if (BuildConfig.ENABLE_WEBVIEW_DEBUG) {
|
||||||
|
WebView.setWebContentsDebuggingEnabled(true);
|
||||||
|
}
|
||||||
|
// Set a recognizable User-Agent (always reliable)
|
||||||
|
WebSettings settings = webView.getSettings();
|
||||||
|
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
|
||||||
|
|
||||||
|
settings.setJavaScriptEnabled(true);
|
||||||
|
webView.addJavascriptInterface(new WebAppInterface(this), "AndroidBridge");
|
||||||
|
|
||||||
|
registerPlugin(PushNotificationsPlugin.class);
|
||||||
|
// Initialize the Bridge with Push Notifications plugin
|
||||||
|
// this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
|
||||||
|
// add(com.getcapacitor.plugin.PushNotifications.class);
|
||||||
|
// }});
|
||||||
|
|
||||||
|
askNotificationPermission();
|
||||||
|
|
||||||
|
appUpdateManager = AppUpdateManagerFactory.create(this);
|
||||||
|
checkForUpdates();
|
||||||
|
|
||||||
|
Uri data = getIntent().getData();
|
||||||
|
if (data != null) {
|
||||||
|
pendingDeepLink = data.toString();
|
||||||
|
} else {
|
||||||
|
// Check for notification endpoint when app is opened from cold start via notification click
|
||||||
|
String endpoint = getIntent().getStringExtra("endpoint");
|
||||||
|
if (endpoint != null) {
|
||||||
|
Log.i("CompassApp", "onCreate found endpoint from notification: " + endpoint);
|
||||||
|
pendingDeepLink = endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDeepLink(String url) {
|
||||||
|
try {
|
||||||
|
String path = new URL(url).getPath();
|
||||||
|
String payload = new JSONObject().put("url", url).put("endpoint", path).toString();
|
||||||
|
Log.i("CompassApp", "Handling deep link: " + url);
|
||||||
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("handleAppLink(" + payload + ");", null));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e("CompassApp", "Failed to handle deep link for " + url, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
|
||||||
|
if (requestCode >= GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MIN && requestCode < GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MAX) {
|
||||||
|
PluginHandle pluginHandle = getBridge().getPlugin("SocialLogin");
|
||||||
|
if (pluginHandle == null) {
|
||||||
|
Log.i("CompassApp", "SocialLogin login handle is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Plugin plugin = pluginHandle.getInstance();
|
||||||
|
if (!(plugin instanceof SocialLoginPlugin)) {
|
||||||
|
Log.i("CompassApp", "SocialLogin plugin instance is not SocialLoginPlugin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i("CompassApp", "handleGoogleLoginIntent");
|
||||||
|
((SocialLoginPlugin) plugin).handleGoogleLoginIntent(requestCode, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function will never be called, leave it empty
|
||||||
|
@Override
|
||||||
|
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int UPDATE_REQUEST_CODE = 500;
|
||||||
|
private AppUpdateManager appUpdateManager;
|
||||||
|
private static final String TAG = "MainActivity";
|
||||||
|
|
||||||
|
private void checkForUpdates() {
|
||||||
|
appUpdateManager.getAppUpdateInfo()
|
||||||
|
.addOnSuccessListener(appUpdateInfo -> {
|
||||||
|
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
|
||||||
|
if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) {
|
||||||
|
startImmediateUpdate(appUpdateInfo);
|
||||||
|
} else if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
|
||||||
|
startFlexibleUpdate(appUpdateInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addOnFailureListener(exception -> {
|
||||||
|
// Handle error - log it
|
||||||
|
Log.e(TAG, "Failed to check For Updates", exception);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startImmediateUpdate(AppUpdateInfo appUpdateInfo) {
|
||||||
|
AppUpdateOptions options = AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build();
|
||||||
|
|
||||||
|
appUpdateManager.startUpdateFlow(appUpdateInfo, this, options)
|
||||||
|
.addOnSuccessListener(result -> {
|
||||||
|
Log.i(TAG, "Immediate update started successfully");
|
||||||
|
})
|
||||||
|
.addOnFailureListener(exception -> {
|
||||||
|
Log.e(TAG, "Failed to start immediate update", exception);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startFlexibleUpdate(AppUpdateInfo appUpdateInfo) {
|
||||||
|
AppUpdateOptions options = AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build();
|
||||||
|
|
||||||
|
appUpdateManager.startUpdateFlow(appUpdateInfo, this, options)
|
||||||
|
.addOnSuccessListener(result -> {
|
||||||
|
Log.i(TAG, "Flexible update started successfully");
|
||||||
|
})
|
||||||
|
.addOnFailureListener(exception -> {
|
||||||
|
Log.e(TAG, "Failed to start flexible update", exception);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
// Check if an immediate update was interrupted
|
||||||
|
appUpdateManager.getAppUpdateInfo().addOnSuccessListener(appUpdateInfo -> {
|
||||||
|
if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
|
||||||
|
startImmediateUpdate(appUpdateInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
appUpdateManager = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
12
android/app/src/main/res/layout/activity_main.xml
Normal file
12
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/strings.xml
Normal file
7
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Compass</string>
|
||||||
|
<string name="title_activity_main">Compass</string>
|
||||||
|
<string name="package_name">com.compassconnections.app</string>
|
||||||
|
<string name="custom_url_scheme">com.compassconnections.app</string>
|
||||||
|
</resources>
|
||||||
25
android/app/src/main/res/values/styles.xml
Normal file
25
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<item name="android:windowTranslucentStatus">true</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:windowTranslucentStatus">true</item>
|
||||||
|
<item name="android:windowTranslucentNavigation">true</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
android/app/src/main/res/xml/file_paths.xml
Normal file
5
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
android/build.gradle
Normal file
29
android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
21
android/capacitor.settings.gradle
Normal file
21
android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-keyboard'
|
||||||
|
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||||
|
|
||||||
|
include ':capacitor-push-notifications'
|
||||||
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-status-bar'
|
||||||
|
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||||
|
|
||||||
|
include ':capawesome-capacitor-live-update'
|
||||||
|
project(':capawesome-capacitor-live-update').projectDir = new File('../node_modules/@capawesome/capacitor-live-update/android')
|
||||||
|
|
||||||
|
include ':capgo-capacitor-social-login'
|
||||||
|
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
|
||||||
3
android/capawesome.json
Normal file
3
android/capawesome.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": 24
|
||||||
|
}
|
||||||
22
android/gradle.properties
Normal file
22
android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
252
android/gradlew
vendored
Executable file
252
android/gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
android/gradlew.bat
vendored
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
5
android/settings.gradle
Normal file
5
android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
16
android/variables.gradle
Normal file
16
android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 23
|
||||||
|
compileSdkVersion = 35
|
||||||
|
targetSdkVersion = 35
|
||||||
|
androidxActivityVersion = '1.9.2'
|
||||||
|
androidxAppCompatVersion = '1.7.0'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
|
androidxCoreVersion = '1.15.0'
|
||||||
|
androidxFragmentVersion = '1.8.4'
|
||||||
|
coreSplashScreenVersion = '1.0.1'
|
||||||
|
androidxWebkitVersion = '1.12.1'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.2.1'
|
||||||
|
androidxEspressoCoreVersion = '3.6.1'
|
||||||
|
cordovaAndroidVersion = '10.1.1'
|
||||||
|
}
|
||||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
34
backend/api/.prettierignore
Normal file
34
backend/api/.prettierignore
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
lib
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
coverage
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
|
||||||
|
# Database / migrations
|
||||||
|
**/*.sql
|
||||||
|
|
||||||
|
# Config / lock files
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Android / iOS
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
capacitor.config.ts
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
tests/reports
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
coverage
|
||||||
@@ -14,6 +14,9 @@ RUN yarn install --frozen-lockfile --production && \
|
|||||||
yarn cache clean --force && \
|
yarn cache clean --force && \
|
||||||
rm -rf /usr/local/share/.cache/yarn
|
rm -rf /usr/local/share/.cache/yarn
|
||||||
|
|
||||||
|
# Show installed packages
|
||||||
|
RUN npm list || true
|
||||||
|
|
||||||
# Copy over typescript payload
|
# Copy over typescript payload
|
||||||
COPY dist ./
|
COPY dist ./
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,67 @@
|
|||||||
# Backend API
|
# Backend API
|
||||||
|
|
||||||
This is the code for the API running at https://api.compassmeet.com.
|
Express.js REST API for Compass, running at https://api.compassmeet.com.
|
||||||
It runs in a docker inside a Google Cloud virtual machine.
|
|
||||||
|
|
||||||
### Requirements
|
## Overview
|
||||||
|
|
||||||
You must have the `gcloud` CLI.
|
The API handles:
|
||||||
|
|
||||||
|
- User authentication and management
|
||||||
|
- Profile CRUD operations
|
||||||
|
- Search and filtering
|
||||||
|
- Messaging
|
||||||
|
- Notifications
|
||||||
|
- Compatibility scoring
|
||||||
|
- Events management
|
||||||
|
- WebSocket connections for real-time features
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node.js 20+
|
||||||
|
- **Framework**: Express.js 5.0
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Database**: PostgreSQL (via Supabase)
|
||||||
|
- **ORM**: pg-promise
|
||||||
|
- **Validation**: Zod
|
||||||
|
- **WebSocket**: ws library
|
||||||
|
- **API Docs**: Swagger/OpenAPI
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/api/
|
||||||
|
├── src/
|
||||||
|
│ ├── app.ts # Express app setup
|
||||||
|
│ ├── routes.ts # Route definitions
|
||||||
|
│ ├── test.ts # Test utilities
|
||||||
|
│ ├── get-*.ts # GET endpoints
|
||||||
|
│ ├── create-*.ts # POST endpoints
|
||||||
|
│ ├── update-*.ts # PUT/PATCH endpoints
|
||||||
|
│ ├── delete-*.ts # DELETE endpoints
|
||||||
|
│ └── helpers/ # Shared utilities
|
||||||
|
├── tests/
|
||||||
|
│ └── unit/ # Unit tests
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20.x or later
|
||||||
|
- Yarn
|
||||||
|
- Access to Supabase project (for database)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From root directory
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
You must also have the `gcloud` CLI.
|
||||||
|
|
||||||
On macOS:
|
On macOS:
|
||||||
|
|
||||||
@@ -27,6 +83,363 @@ gcloud auth login
|
|||||||
gcloud config set project YOUR_PROJECT_ID
|
gcloud config set project YOUR_PROJECT_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You also need `opentofu` and `docker`. Try running this (from root) on Linux or macOS for a faster install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./script/setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If it doesn't work, you can install them manually (google how to install `opentofu` and `docker` for your OS).
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all services (web + API)
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# Run API only (from backend/api)
|
||||||
|
cd backend/api
|
||||||
|
yarn serve
|
||||||
|
```
|
||||||
|
|
||||||
|
The API runs on http://localhost:8088 when running locally with the full stack.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
yarn test --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check lint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Fix issues
|
||||||
|
yarn lint-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | -------------- | --------------- |
|
||||||
|
| POST | `/create-user` | Create new user |
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ------------ | ------------------- |
|
||||||
|
| GET | `/get-me` | Get current user |
|
||||||
|
| PUT | `/update-me` | Update current user |
|
||||||
|
| DELETE | `/delete-me` | Delete account |
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ----------------- | ------------------ |
|
||||||
|
| GET | `/get-profiles` | List profiles |
|
||||||
|
| GET | `/get-profile` | Get single profile |
|
||||||
|
| POST | `/create-profile` | Create profile |
|
||||||
|
| PUT | `/update-profile` | Update profile |
|
||||||
|
| DELETE | `/delete-profile` | Delete profile |
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ------------------------------ | -------------- |
|
||||||
|
| GET | `/get-private-messages` | Get messages |
|
||||||
|
| POST | `/create-private-user-message` | Send message |
|
||||||
|
| PUT | `/edit-message` | Edit message |
|
||||||
|
| DELETE | `/delete-message` | Delete message |
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ----------------------- | ------------------ |
|
||||||
|
| GET | `/get-notifications` | List notifications |
|
||||||
|
| PUT | `/update-notif-setting` | Update settings |
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ------------------ | ------------------ |
|
||||||
|
| GET | `/search-users` | Search users |
|
||||||
|
| GET | `/search-location` | Search by location |
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ------------------------------ | ----------------------- |
|
||||||
|
| GET | `/get-compatibility-questions` | List questions |
|
||||||
|
| POST | `/set-compatibility-answers` | Submit answers |
|
||||||
|
| GET | `/compatible-profiles` | Get compatible profiles |
|
||||||
|
|
||||||
|
## Writing Endpoints
|
||||||
|
|
||||||
|
### 1. Define Schema
|
||||||
|
|
||||||
|
Add endpoint definition in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const endpoints = {
|
||||||
|
myEndpoint: {
|
||||||
|
method: 'POST',
|
||||||
|
authed: true,
|
||||||
|
returns: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
data: z.any(),
|
||||||
|
}),
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
userId: z.string(),
|
||||||
|
option: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Implement Handler
|
||||||
|
|
||||||
|
Create handler file in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {z} from 'zod'
|
||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const myEndpoint: APIHandler<'myEndpoint'> = async (props, auth) => {
|
||||||
|
const {userId, option} = props
|
||||||
|
|
||||||
|
// Implementation
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {userId},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register Route
|
||||||
|
|
||||||
|
Add to `routes.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {myEndpoint} from './my-endpoint'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
myEndpoint,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Authenticated Endpoints
|
||||||
|
|
||||||
|
Use the `authed: true` schema property. The auth object is passed to the handler:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const getProfile: APIHandler<'get-profile'> = async (props, auth) => {
|
||||||
|
// auth.uid - user ID
|
||||||
|
// auth.creds - credentials type
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Types
|
||||||
|
|
||||||
|
- `firebase` - Firebase Auth token
|
||||||
|
- `session` - Session-based auth
|
||||||
|
|
||||||
|
## Database Access
|
||||||
|
|
||||||
|
### Using pg-promise
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const result = await pg.oneOrNone<User>('SELECT * FROM users WHERE id = $1', [userId])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Supabase Client
|
||||||
|
|
||||||
|
But this works only in the front-end.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data, error} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
The API includes built-in rate limiting:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const myEndpoint: APIHandler<'myEndpoint'> = withRateLimit(
|
||||||
|
async (props, auth) => {
|
||||||
|
// Handler implementation
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'my-endpoint',
|
||||||
|
limit: 100,
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Use `APIError` for consistent error responses:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIError(404, 'User not found')
|
||||||
|
throw APIError(400, 'Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
Error codes:
|
||||||
|
|
||||||
|
- `400` - Bad Request
|
||||||
|
- `401` - Unauthorized
|
||||||
|
- `403` - Forbidden
|
||||||
|
- `404` - Not Found
|
||||||
|
- `429` - Too Many Requests
|
||||||
|
- `500` - Internal Server Error
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
WebSocket connections are handled for real-time features:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe to updates
|
||||||
|
ws.subscribe('user/123', (data) => {
|
||||||
|
console.log('User updated:', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unsubscribe
|
||||||
|
ws.unsubscribe('user/123', callback)
|
||||||
|
```
|
||||||
|
|
||||||
|
Available topics:
|
||||||
|
|
||||||
|
- `user/{userId}` - User updates
|
||||||
|
- `private-user/{userId}` - Private user updates
|
||||||
|
- `message/{channelId}` - New messages
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Use the shared logger:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {log} from 'shared/monitoring/log'
|
||||||
|
|
||||||
|
log.info('Processing request', {userId: auth.uid})
|
||||||
|
log.error('Failed to process', error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
Deployments are automated via GitHub Actions. Push to main triggers deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Increment version
|
||||||
|
# Update package.json version
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore: bump version"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/api
|
||||||
|
./deploy-api.sh prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Access
|
||||||
|
|
||||||
|
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
|
||||||
|
files, debug, etc.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into production server
|
||||||
|
cd backend/api
|
||||||
|
./ssh-api.sh prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands on server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u konlet-startup --no-pager -ef # View logs
|
||||||
|
sudo docker logs -f $(sudo docker ps -alq) # Container logs
|
||||||
|
docker exec -it $(sudo docker ps -alq) sh # Shell access
|
||||||
|
docker run -it --rm $(docker images -q | head -n 1) sh
|
||||||
|
docker rmi -f $(docker images -aq)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required secrets (set in Google Cloud Secrets Manager):
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| ---------------------- | ---------------------------- |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
|
| `FIREBASE_PROJECT_ID` | Firebase project ID |
|
||||||
|
| `FIREBASE_PRIVATE_KEY` | Firebase private key |
|
||||||
|
| `SUPABASE_SERVICE_KEY` | Supabase service role key |
|
||||||
|
| `JWT_SECRET` | JWT signing secret |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Writing Unit Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/unit/my-endpoint.unit.test.ts
|
||||||
|
import {myEndpoint} from '../my-endpoint'
|
||||||
|
|
||||||
|
describe('myEndpoint', () => {
|
||||||
|
it('should return success', async () => {
|
||||||
|
const result = await myEndpoint({userId: '123'}, mockAuth)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocking Database
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn().mockResolvedValue({id: '123'}),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Full API docs available at:
|
||||||
|
|
||||||
|
- Production: https://api.compassmeet.com
|
||||||
|
- Local: http://localhost:8088 (when running)
|
||||||
|
|
||||||
|
Docs are generated from route definitions in `app.ts`.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Main README](../../README.md)
|
||||||
|
- [Contributing Guide](../../CONTRIBUTING.md)
|
||||||
|
- [Shared Backend Utils](../shared/README.md)
|
||||||
|
- [Database Migrations](../../supabase)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
This section is only for the people who are creating a server from scratch, for instance for a forked project.
|
This section is only for the people who are creating a server from scratch, for instance for a forked project.
|
||||||
@@ -54,6 +467,9 @@ gcloud projects add-iam-policy-binding compass-130ba \
|
|||||||
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
||||||
--role="roles/secretmanager.secretAccessor"
|
--role="roles/secretmanager.secretAccessor"
|
||||||
gcloud run services list
|
gcloud run services list
|
||||||
|
gcloud compute backend-services update api-backend \
|
||||||
|
--global \
|
||||||
|
--timeout=600s
|
||||||
```
|
```
|
||||||
|
|
||||||
Set up the saved search notifications job:
|
Set up the saved search notifications job:
|
||||||
@@ -70,10 +486,34 @@ gcloud scheduler jobs create http daily-saved-search-notifications \
|
|||||||
|
|
||||||
View it [here](https://console.cloud.google.com/cloudscheduler).
|
View it [here](https://console.cloud.google.com/cloudscheduler).
|
||||||
|
|
||||||
|
##### API Deploy CD
|
||||||
|
|
||||||
|
```shell
|
||||||
|
gcloud iam service-accounts create ci-deployer \
|
||||||
|
--display-name="CI Deployer"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/artifactregistry.writer"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/storage.objectAdmin"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/storage.admin"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/compute.admin"
|
||||||
|
gcloud iam service-accounts add-iam-policy-binding \
|
||||||
|
253367029065-compute@developer.gserviceaccount.com \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/iam.serviceAccountUser"
|
||||||
|
gcloud iam service-accounts keys create keyfile.json --iam-account=ci-deployer@compass-130ba.iam.gserviceaccount.com
|
||||||
|
```
|
||||||
|
|
||||||
##### DNS
|
##### DNS
|
||||||
|
|
||||||
* After deployment, Terraform assigns a static external IP to this resource.
|
- After deployment, Terraform assigns a static external IP to this resource.
|
||||||
* You can get it manually:
|
- You can get it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gcloud compute addresses describe api-lb-ip-2 --global --format="get(address)"
|
gcloud compute addresses describe api-lb-ip-2 --global --format="get(address)"
|
||||||
@@ -87,11 +527,11 @@ Since Vercel manages your domain (`compassmeet.com`):
|
|||||||
3. Add an **A record** for your API subdomain:
|
3. Add an **A record** for your API subdomain:
|
||||||
|
|
||||||
| Type | Name | Value | TTL |
|
| Type | Name | Value | TTL |
|
||||||
|------|------|--------------|-------|
|
| ---- | ---- | ------------ | ----- |
|
||||||
| A | api | 34.123.45.67 | 600 s |
|
| A | api | 34.123.45.67 | 600 s |
|
||||||
|
|
||||||
* `Name` is just the subdomain: `api` → `api.compassmeet.com`.
|
- `Name` is just the subdomain: `api` → `api.compassmeet.com`.
|
||||||
* `Value` is the **external IP of the LB** from step 1.
|
- `Value` is the **external IP of the LB** from step 1.
|
||||||
|
|
||||||
Verify connectivity
|
Verify connectivity
|
||||||
From your local machine:
|
From your local machine:
|
||||||
@@ -102,8 +542,8 @@ ping -c 3 api.compassmeet.com
|
|||||||
curl -I https://api.compassmeet.com
|
curl -I https://api.compassmeet.com
|
||||||
```
|
```
|
||||||
|
|
||||||
* `nslookup` should return the LB IP (`34.123.45.67`).
|
- `nslookup` should return the LB IP (`34.123.45.67`).
|
||||||
* `curl -I` should return `200 OK` from your service.
|
- `curl -I` should return `200 OK` from your service.
|
||||||
|
|
||||||
If SSL isn’t ready (may take 15 mins), check LB logs:
|
If SSL isn’t ready (may take 15 mins), check LB logs:
|
||||||
|
|
||||||
@@ -120,43 +560,3 @@ in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secr
|
|||||||
can access them.
|
can access them.
|
||||||
|
|
||||||
For Compass, the name of the secrets are in [secrets.ts](../../common/src/secrets.ts).
|
For Compass, the name of the secrets are in [secrets.ts](../../common/src/secrets.ts).
|
||||||
|
|
||||||
### Run Locally
|
|
||||||
|
|
||||||
In root directory, run the local api with hot reload, along with all the other backend and web code.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./run_local.sh prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy
|
|
||||||
|
|
||||||
Run in this directory to deploy your code to the server.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy-api.sh prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### Connect to the server
|
|
||||||
|
|
||||||
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
|
|
||||||
files, debug, etc.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ssh-api.sh prod
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful commands once inside the server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo journalctl -u konlet-startup --no-pager -efb
|
|
||||||
sudo docker logs -f $(sudo docker ps -alq)
|
|
||||||
docker exec -it $(sudo docker ps -alq) sh
|
|
||||||
docker run -it --rm $(docker images -q | head -n 1) sh
|
|
||||||
docker rmi -f $(docker images -aq)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
|
|
||||||
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!
|
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ PROJECT="compass-130ba"
|
|||||||
SERVICE_NAME="api"
|
SERVICE_NAME="api"
|
||||||
|
|
||||||
GIT_REVISION=$(git rev-parse --short HEAD)
|
GIT_REVISION=$(git rev-parse --short HEAD)
|
||||||
|
GIT_COMMIT_DATE=$(git log -1 --format=%ci)
|
||||||
|
GIT_COMMIT_AUTHOR=$(git log -1 --format='%an')
|
||||||
|
GIT_COMMIT_MESSAGE=$(git log -1 --format='%s')
|
||||||
|
echo "Git commit message: ${GIT_COMMIT_MESSAGE}"
|
||||||
|
|
||||||
|
cat > metadata.json << EOF
|
||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"revision": "${GIT_REVISION}",
|
||||||
|
"commitDate": "${GIT_COMMIT_DATE}",
|
||||||
|
"author": "${GIT_COMMIT_AUTHOR}",
|
||||||
|
"message": "${GIT_COMMIT_MESSAGE}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
TIMESTAMP=$(date +"%s")
|
TIMESTAMP=$(date +"%s")
|
||||||
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
|
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
|
||||||
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
|
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
|
||||||
@@ -33,7 +49,7 @@ echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))
|
|||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin us-west1-docker.pkg.dev
|
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin us-west1-docker.pkg.dev
|
||||||
docker build . --tag ${IMAGE_URL} --platform linux/amd64
|
docker build . --tag ${IMAGE_URL} --platform linux/amd64 --progress=plain
|
||||||
echo "docker push ${IMAGE_URL}"
|
echo "docker push ${IMAGE_URL}"
|
||||||
docker push ${IMAGE_URL}
|
docker push ${IMAGE_URL}
|
||||||
|
|
||||||
@@ -41,16 +57,16 @@ export TF_VAR_image_url=$IMAGE_URL
|
|||||||
export TF_VAR_env=$ENV
|
export TF_VAR_env=$ENV
|
||||||
tofu apply -auto-approve
|
tofu apply -auto-approve
|
||||||
|
|
||||||
INSTANCE_NAME=$(gcloud compute instances list \
|
#INSTANCE_NAME=$(gcloud compute instances list \
|
||||||
--filter="zone:(us-west1-c)" \
|
# --filter="zone:(us-west1-c)" \
|
||||||
--sort-by="~creationTimestamp" \
|
# --sort-by="~creationTimestamp" \
|
||||||
--format="value(name)" \
|
# --format="value(name)" \
|
||||||
--limit=1)
|
# --limit=1)
|
||||||
SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
|
#SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
|
||||||
--zone us-west1-c \
|
# --zone us-west1-c \
|
||||||
--format="value(serviceAccounts.email)")
|
# --format="value(serviceAccounts.email)")
|
||||||
gcloud projects add-iam-policy-binding ${PROJECT} \
|
#gcloud projects add-iam-policy-binding ${PROJECT} \
|
||||||
--member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
|
# --member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
|
||||||
--role="roles/artifactregistry.reader"
|
# --role="roles/artifactregistry.reader"
|
||||||
|
|
||||||
echo "✅ Deployment complete! Image: ${IMAGE_URL}"
|
echo "✅ Deployment complete! Image: ${IMAGE_URL}"
|
||||||
|
|||||||
31
backend/api/dist_copy.sh
Executable file
31
backend/api/dist_copy.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
rsync -a --delete ../../common/lib/ dist/common/lib
|
||||||
|
rsync -a --delete ../../common/messages/ dist/common/messages/
|
||||||
|
|
||||||
|
rsync -a --delete ../shared/lib/ dist/backend/shared/lib
|
||||||
|
|
||||||
|
rsync -a --delete ../email/lib/ dist/backend/email/lib
|
||||||
|
|
||||||
|
rsync -a --delete ./lib/* dist/backend/api/lib
|
||||||
|
cp package.json dist/backend/api
|
||||||
|
cp metadata.json dist
|
||||||
|
cp metadata.json dist/backend/api
|
||||||
|
|
||||||
|
cp ../../yarn.lock dist
|
||||||
|
|
||||||
|
# Installing from backend/api/package.json is not enough
|
||||||
|
# Need to install the deps from all the workspaces used in the back end
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const deps = ['../api', '../shared', '../email', '../../common']
|
||||||
|
.map(p => require('./' + p + '/package.json').dependencies || {})
|
||||||
|
.reduce((acc, d) => ({ ...acc, ...d }), {});
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
pkg.dependencies = { ...deps, ...pkg.dependencies };
|
||||||
|
fs.writeFileSync('./dist/package.json', JSON.stringify(pkg, null, 2));
|
||||||
|
"
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "api",
|
name: 'api',
|
||||||
script: "node",
|
script: 'node',
|
||||||
args: "--dns-result-order=ipv4first backend/api/lib/serve.js",
|
args: '--dns-result-order=ipv4first backend/api/lib/serve.js',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: 'production',
|
||||||
NODE_PATH: "/usr/src/app/node_modules", // <- ensures Node finds tsconfig-paths
|
NODE_PATH: '/usr/src/app/node_modules', // <- ensures Node finds tsconfig-paths
|
||||||
PORT: 80,
|
PORT: 80,
|
||||||
},
|
},
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: "fork",
|
exec_mode: 'fork',
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
watch: false,
|
watch: false,
|
||||||
// 4 GB on the box, give 3 GB to the JS heap
|
// 4 GB on the box, give 3 GB to the JS heap
|
||||||
node_args: "--max-old-space-size=3072",
|
node_args: '--max-old-space-size=3072',
|
||||||
max_memory_restart: "3500M"
|
max_memory_restart: '3500M',
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
}
|
||||||
|
|||||||
76
backend/api/eslint.config.mjs
Normal file
76
backend/api/eslint.config.mjs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import lodash from 'eslint-plugin-lodash'
|
||||||
|
import unusedImports from 'eslint-plugin-unused-imports'
|
||||||
|
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
lodash,
|
||||||
|
'unused-imports': unusedImports,
|
||||||
|
'simple-import-sort': simpleImportSort,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json', './tsconfig.test.json', 'tsconfig.eslint.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
process: 'readonly',
|
||||||
|
console: 'readonly',
|
||||||
|
module: 'readonly',
|
||||||
|
require: 'readonly',
|
||||||
|
__dirname: 'readonly',
|
||||||
|
__filename: 'readonly',
|
||||||
|
Buffer: 'readonly',
|
||||||
|
global: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearInterval: 'readonly',
|
||||||
|
setImmediate: 'readonly',
|
||||||
|
clearImmediate: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
URLSearchParams: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
WebSocket: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-function-type': 'error',
|
||||||
|
'@typescript-eslint/no-wrapper-object-types': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-extra-semi': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'unused-imports/no-unused-imports': 'warn',
|
||||||
|
'no-constant-condition': 'off',
|
||||||
|
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
|
||||||
|
'lodash/import-scope': [2, 'member'],
|
||||||
|
'simple-import-sort/imports': 'error',
|
||||||
|
'simple-import-sort/exports': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'dist',
|
||||||
|
'lib',
|
||||||
|
'coverage',
|
||||||
|
'eslint.config.mjs',
|
||||||
|
'jest.config.ts',
|
||||||
|
'ecosystem.config.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
eslintConfigPrettier,
|
||||||
|
)
|
||||||
29
backend/api/jest.config.ts
Normal file
29
backend/api/jest.config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
|
||||||
|
rootDir: '.',
|
||||||
|
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.spec.ts'],
|
||||||
|
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^api/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^shared/(.*)$': '<rootDir>/../shared/src/$1',
|
||||||
|
'^common/(.*)$': '<rootDir>/../../common/src/$1',
|
||||||
|
'^email/(.*)$': '<rootDir>/../email/emails/$1',
|
||||||
|
},
|
||||||
|
|
||||||
|
moduleFileExtensions: ['tsx', 'ts', 'js', 'json'],
|
||||||
|
clearMocks: true,
|
||||||
|
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/tsconfig.test.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
|
||||||
|
silent: true,
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ variable "env" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
locals {
|
locals {
|
||||||
project = "compass-130ba"
|
project = "compass-130ba"
|
||||||
region = "us-west1"
|
region = "us-west1"
|
||||||
zone = "us-west1-b"
|
zone = "us-west1-b"
|
||||||
service_name = "api"
|
service_name = "api"
|
||||||
machine_type = "e2-small"
|
machine_type = "e2-small"
|
||||||
@@ -55,7 +55,7 @@ resource "google_storage_bucket" "public_storage" {
|
|||||||
|
|
||||||
# static IPs
|
# static IPs
|
||||||
resource "google_compute_global_address" "api_lb_ip" {
|
resource "google_compute_global_address" "api_lb_ip" {
|
||||||
name = "api-lb-ip-2"
|
name = "api-lb-ip-2"
|
||||||
address_type = "EXTERNAL"
|
address_type = "EXTERNAL"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ resource "google_compute_instance_template" "api_template" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
network_interface {
|
network_interface {
|
||||||
network = "default"
|
network = "default"
|
||||||
subnetwork = "default"
|
subnetwork = "default"
|
||||||
access_config {
|
access_config {
|
||||||
network_tier = "PREMIUM"
|
network_tier = "PREMIUM"
|
||||||
@@ -105,6 +105,7 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
EOF
|
EOF
|
||||||
|
google-logging-enabled = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle {
|
lifecycle {
|
||||||
@@ -116,12 +117,12 @@ EOF
|
|||||||
resource "google_compute_region_instance_group_manager" "api_group" {
|
resource "google_compute_region_instance_group_manager" "api_group" {
|
||||||
name = "${local.service_name}-group"
|
name = "${local.service_name}-group"
|
||||||
base_instance_name = "${local.service_name}-group"
|
base_instance_name = "${local.service_name}-group"
|
||||||
region = local.region
|
region = local.region
|
||||||
target_size = 1
|
target_size = 1
|
||||||
|
|
||||||
version {
|
version {
|
||||||
instance_template = google_compute_instance_template.api_template.id
|
instance_template = google_compute_instance_template.api_template.id
|
||||||
name = "primary"
|
name = "primary"
|
||||||
}
|
}
|
||||||
|
|
||||||
update_policy {
|
update_policy {
|
||||||
@@ -185,29 +186,29 @@ resource "google_compute_url_map" "api_url_map" {
|
|||||||
path_matcher {
|
path_matcher {
|
||||||
name = "allpaths"
|
name = "allpaths"
|
||||||
default_service = google_compute_backend_service.api_backend.self_link
|
default_service = google_compute_backend_service.api_backend.self_link
|
||||||
|
#
|
||||||
# Priority 0: passthrough /v0/* requests
|
# # Priority 0: passthrough /v0/* requests
|
||||||
route_rules {
|
# route_rules {
|
||||||
priority = 1
|
# priority = 1
|
||||||
match_rules {
|
# match_rules {
|
||||||
prefix_match = "/v0"
|
# prefix_match = "/v0"
|
||||||
}
|
# }
|
||||||
service = google_compute_backend_service.api_backend.self_link
|
# service = google_compute_backend_service.api_backend.self_link
|
||||||
}
|
# }
|
||||||
|
#
|
||||||
# Priority 1: rewrite everything else to /v0
|
# # Priority 1: rewrite everything else to /v0
|
||||||
route_rules {
|
# route_rules {
|
||||||
priority = 2
|
# priority = 2
|
||||||
match_rules {
|
# match_rules {
|
||||||
prefix_match = "/"
|
# prefix_match = "/"
|
||||||
}
|
# }
|
||||||
route_action {
|
# route_action {
|
||||||
url_rewrite {
|
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||||
path_prefix_rewrite = "/v0/"
|
# path_prefix_rewrite = "/v0/"
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
service = google_compute_backend_service.api_backend.self_link
|
# service = google_compute_backend_service.api_backend.self_link
|
||||||
}
|
# }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,10 +268,10 @@ resource "google_compute_firewall" "allow_health_check" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resource "google_compute_firewall" "default_allow_https" {
|
resource "google_compute_firewall" "default_allow_https" {
|
||||||
name = "default-allow-http"
|
name = "default-allow-http"
|
||||||
network = "default"
|
network = "default"
|
||||||
priority = 1000
|
priority = 1000
|
||||||
direction = "INGRESS"
|
direction = "INGRESS"
|
||||||
|
|
||||||
allow {
|
allow {
|
||||||
protocol = "tcp"
|
protocol = "tcp"
|
||||||
|
|||||||
8
backend/api/metadata.json
Normal file
8
backend/api/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"revision": "c085e8f",
|
||||||
|
"commitDate": "2026-02-22 21:51:08 +0100",
|
||||||
|
"author": "MartinBraquet",
|
||||||
|
"message": "Add guidelines for adding translations to existing files"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"openapi": "3.0.0",
|
|
||||||
"info": {
|
|
||||||
"title": "Compass API",
|
|
||||||
"version": "1.0.0"
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/health": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Health",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/get-profiles": {
|
|
||||||
"get": {
|
|
||||||
"summary": "List profiles",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +1,66 @@
|
|||||||
{
|
{
|
||||||
"name": "@compass/api",
|
"name": "@compass/api",
|
||||||
"description": "Backend API endpoints",
|
"version": "1.31.0",
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"description": "Backend API endpoints",
|
||||||
|
"main": "src/serve.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
|
|
||||||
"watch:serve": "nodemon -r tsconfig-paths/register --watch lib --ignore 'lib/**/*.map' src/serve.ts",
|
|
||||||
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
|
||||||
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
||||||
"build:fast": "yarn compile && yarn dist:copy",
|
"build:fast": "yarn compile && yarn dist:copy",
|
||||||
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias)",
|
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
|
||||||
|
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
|
||||||
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
||||||
|
"dev": "yarn watch:serve",
|
||||||
"dist": "yarn dist:clean && yarn dist:copy",
|
"dist": "yarn dist:clean && yarn dist:copy",
|
||||||
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
||||||
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp openapi.json dist",
|
"dist:copy": "./dist_copy.sh",
|
||||||
"watch": "tsc -w",
|
"lint": "npx eslint . --max-warnings 0",
|
||||||
"verify": "yarn --cwd=../.. verify",
|
"lint-fix": "npx eslint . --fix",
|
||||||
"verify:dir": "npx eslint . --max-warnings 0",
|
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
||||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
|
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev",
|
||||||
|
"test": "jest --config jest.config.ts",
|
||||||
|
"test:coverage": "jest --config jest.config.ts --coverage",
|
||||||
|
"typecheck": "yarn build && npx tsc --noEmit",
|
||||||
|
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
|
||||||
|
"watch:serve": "tsx watch src/serve.ts"
|
||||||
},
|
},
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"main": "src/serve.ts",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/monitoring": "4.0.0",
|
"@google-cloud/monitoring": "4.0.0",
|
||||||
"@google-cloud/secret-manager": "4.2.1",
|
"@google-cloud/secret-manager": "4.2.1",
|
||||||
"@react-email/components": "0.0.33",
|
"@mozilla/readability": "0.6.0",
|
||||||
"@supabase/supabase-js": "2.38.5",
|
"@sentry/node": "10.41.0",
|
||||||
"@tiptap/core": "2.3.2",
|
"@tiptap/core": "2.10.4",
|
||||||
"@tiptap/extension-blockquote": "2.3.2",
|
|
||||||
"@tiptap/extension-bold": "2.3.2",
|
|
||||||
"@tiptap/extension-bubble-menu": "2.3.2",
|
|
||||||
"@tiptap/extension-floating-menu": "2.3.2",
|
|
||||||
"@tiptap/extension-image": "2.3.2",
|
|
||||||
"@tiptap/extension-link": "2.3.2",
|
|
||||||
"@tiptap/extension-mention": "2.3.2",
|
|
||||||
"@tiptap/html": "2.3.2",
|
|
||||||
"@tiptap/pm": "2.3.2",
|
|
||||||
"@tiptap/starter-kit": "2.3.2",
|
|
||||||
"@tiptap/suggestion": "2.3.2",
|
|
||||||
"colors": "1.4.0",
|
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dayjs": "1.11.4",
|
"dayjs": "1.11.19",
|
||||||
"express": "4.18.1",
|
"express": "5.0.0",
|
||||||
"firebase-admin": "13.5.0",
|
"firebase-admin": "13.5.0",
|
||||||
"gcp-metadata": "6.1.0",
|
"gcp-metadata": "6.1.0",
|
||||||
|
"jsdom": "29.0.1",
|
||||||
"jsonwebtoken": "9.0.0",
|
"jsonwebtoken": "9.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.23",
|
||||||
"pg-promise": "11.4.1",
|
"marked": "17.0.5",
|
||||||
|
"openapi-types": "12.1.3",
|
||||||
|
"pg-promise": "12.6.1",
|
||||||
"posthog-node": "4.11.0",
|
"posthog-node": "4.11.0",
|
||||||
"resend": "4.1.2",
|
|
||||||
"string-similarity": "4.0.4",
|
|
||||||
"swagger-jsdoc": "6.2.8",
|
"swagger-jsdoc": "6.2.8",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"twitter-api-v2": "1.15.0",
|
"twitter-api-v2": "1.15.0",
|
||||||
"ws": "8.17.0",
|
"web-push": "3.6.7",
|
||||||
"react": "18.2.0",
|
"ws": "8.17.1",
|
||||||
"react-dom": "18.2.0",
|
"zod": "^3.25"
|
||||||
"zod": "3.21.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/react": "18.3.5",
|
"@types/jsdom": "28.0.1",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
|
"@types/lodash": "^4.17.0",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@types/ws": "8.5.10"
|
"@types/ws": "8.5.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,110 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import {hrtime} from 'node:process'
|
||||||
|
|
||||||
|
import {contact} from 'api/contact'
|
||||||
|
import {createVote} from 'api/create-vote'
|
||||||
|
import {deleteMessage} from 'api/delete-message'
|
||||||
|
import {editMessage} from 'api/edit-message'
|
||||||
|
import {getChannelMemberships} from 'api/get-channel-memberships'
|
||||||
|
import {getLastSeenChannelTime, setChannelLastSeenTime} from 'api/get-channel-seen-time'
|
||||||
|
import {getHiddenProfiles} from 'api/get-hidden-profiles'
|
||||||
|
import {getLastMessages} from 'api/get-last-messages'
|
||||||
|
import {getMessagesCountEndpoint} from 'api/get-messages-count'
|
||||||
|
import {getOptionsEndpoint} from 'api/get-options'
|
||||||
|
import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions'
|
||||||
|
import {getChannelMessagesEndpoint} from 'api/get-private-messages'
|
||||||
|
import {getUser} from 'api/get-user'
|
||||||
|
import {hideProfile} from 'api/hide-profile'
|
||||||
|
import {reactToMessage} from 'api/react-to-message'
|
||||||
|
import {saveSubscription} from 'api/save-subscription'
|
||||||
|
import {saveSubscriptionMobile} from 'api/save-subscription-mobile'
|
||||||
|
import {sendSearchNotifications} from 'api/send-search-notifications'
|
||||||
|
import {localSendTestEmail} from 'api/test'
|
||||||
|
import {unhideProfile} from 'api/unhide-profile'
|
||||||
|
import {updateCompatibilityQuestionPin} from 'api/update-compatibility-question-pin'
|
||||||
|
import {updateConnectionInterests} from 'api/update-connection-interests'
|
||||||
|
import {updateOptions} from 'api/update-options'
|
||||||
|
import {vote} from 'api/vote'
|
||||||
import {API, type APIPath} from 'common/api/schema'
|
import {API, type APIPath} from 'common/api/schema'
|
||||||
import {APIError, pathWithPrefix} from 'common/api/utils'
|
import {APIError, APIErrors, pathWithPrefix} from 'common/api/utils'
|
||||||
|
import {sendDiscordMessage} from 'common/discord/core'
|
||||||
|
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||||
|
import {IS_LOCAL} from 'common/hosting/constants'
|
||||||
|
import {filterDefined} from 'common/util/array'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
|
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
|
||||||
import {hrtime} from 'node:process'
|
import {OpenAPIV3} from 'openapi-types'
|
||||||
import {withMonitoringContext} from 'shared/monitoring/context'
|
import {withMonitoringContext} from 'shared/monitoring/context'
|
||||||
import {log} from 'shared/monitoring/log'
|
import {log} from 'shared/monitoring/log'
|
||||||
import {metrics} from 'shared/monitoring/metrics'
|
import {metrics} from 'shared/monitoring/metrics'
|
||||||
|
import swaggerUi from 'swagger-ui-express'
|
||||||
|
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from 'zod'
|
||||||
|
|
||||||
|
import {git} from './../metadata.json'
|
||||||
|
import {version as pkgVersion} from './../package.json'
|
||||||
import {banUser} from './ban-user'
|
import {banUser} from './ban-user'
|
||||||
import {blockUser, unblockUser} from './block-user'
|
import {blockUser, unblockUser} from './block-user'
|
||||||
|
import {cancelEvent} from './cancel-event'
|
||||||
|
import {cancelRsvp} from './cancel-rsvp'
|
||||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||||
|
import {createBookmarkedSearch} from './create-bookmarked-search'
|
||||||
import {createComment} from './create-comment'
|
import {createComment} from './create-comment'
|
||||||
import {createCompatibilityQuestion} from './create-compatibility-question'
|
import {createCompatibilityQuestion} from './create-compatibility-question'
|
||||||
|
import {createEvent} from './create-event'
|
||||||
|
import {createPrivateUserMessage} from './create-private-user-message'
|
||||||
|
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
|
||||||
import {createProfile} from './create-profile'
|
import {createProfile} from './create-profile'
|
||||||
import {createUser} from './create-user'
|
import {createUser} from './create-user'
|
||||||
|
import {createUserAndProfile} from './create-user-and-profile'
|
||||||
|
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
|
||||||
|
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
|
||||||
|
import {deleteMe} from './delete-me'
|
||||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||||
|
import {getConnectionInterestsEndpoint} from './get-connection-interests'
|
||||||
|
import {getCurrentPrivateUser} from './get-current-private-user'
|
||||||
|
import {getEvents} from './get-events'
|
||||||
import {getLikesAndShips} from './get-likes-and-ships'
|
import {getLikesAndShips} from './get-likes-and-ships'
|
||||||
|
import {getMe} from './get-me'
|
||||||
|
import {getNotifications} from './get-notifications'
|
||||||
import {getProfileAnswers} from './get-profile-answers'
|
import {getProfileAnswers} from './get-profile-answers'
|
||||||
import {getProfiles} from './get-profiles'
|
import {getProfiles} from './get-profiles'
|
||||||
import {getSupabaseToken} from './get-supabase-token'
|
import {getSupabaseToken} from './get-supabase-token'
|
||||||
import {getDisplayUser, getUser} from './get-user'
|
import {getUserAndProfileHandler} from './get-user-and-profile'
|
||||||
import {getMe} from './get-me'
|
import {getUserDataExport} from './get-user-data-export'
|
||||||
import {hasFreeLike} from './has-free-like'
|
import {hasFreeLike} from './has-free-like'
|
||||||
import {health} from './health'
|
import {health} from './health'
|
||||||
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
|
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
|
||||||
import {hideComment} from './hide-comment'
|
import {hideComment} from './hide-comment'
|
||||||
|
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
|
||||||
import {likeProfile} from './like-profile'
|
import {likeProfile} from './like-profile'
|
||||||
|
import {llmExtractProfileEndpoint} from './llm-extract-profile'
|
||||||
import {markAllNotifsRead} from './mark-all-notifications-read'
|
import {markAllNotifsRead} from './mark-all-notifications-read'
|
||||||
import {removePinnedPhoto} from './remove-pinned-photo'
|
import {removePinnedPhoto} from './remove-pinned-photo'
|
||||||
import {report} from './report'
|
import {report} from './report'
|
||||||
import {searchLocation} from './search-location'
|
import {rsvpEvent} from './rsvp-event'
|
||||||
|
import {searchLocationEndpoint} from './search-location'
|
||||||
import {searchNearCity} from './search-near-city'
|
import {searchNearCity} from './search-near-city'
|
||||||
|
import {searchUsers} from './search-users'
|
||||||
|
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||||
|
import {setLastOnlineTime} from './set-last-online-time'
|
||||||
import {shipProfiles} from './ship-profiles'
|
import {shipProfiles} from './ship-profiles'
|
||||||
import {starProfile} from './star-profile'
|
import {starProfile} from './star-profile'
|
||||||
import {updateProfile} from './update-profile'
|
import {stats} from './stats'
|
||||||
|
import {updateEvent} from './update-event'
|
||||||
import {updateMe} from './update-me'
|
import {updateMe} from './update-me'
|
||||||
import {deleteMe} from './delete-me'
|
|
||||||
import {getCurrentPrivateUser} from './get-current-private-user'
|
|
||||||
import {createPrivateUserMessage} from './create-private-user-message'
|
|
||||||
import {
|
|
||||||
getChannelMemberships,
|
|
||||||
getChannelMessages,
|
|
||||||
getLastSeenChannelTime,
|
|
||||||
setChannelLastSeenTime,
|
|
||||||
} from 'api/get-private-messages'
|
|
||||||
import {searchUsers} from './search-users'
|
|
||||||
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
|
|
||||||
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
|
|
||||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
|
||||||
import {getNotifications} from './get-notifications'
|
|
||||||
import {updateNotifSettings} from './update-notif-setting'
|
import {updateNotifSettings} from './update-notif-setting'
|
||||||
import swaggerUi from "swagger-ui-express"
|
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||||
import * as fs from "fs"
|
import {updateProfileEndpoint} from './update-profile'
|
||||||
import {sendSearchNotifications} from "api/send-search-notifications";
|
import {updateUserLocale} from './update-user-locale'
|
||||||
|
import {validateUsernameEndpoint} from './validate-username'
|
||||||
|
|
||||||
|
// const corsOptions: CorsOptions = {
|
||||||
|
// origin: ['*'], // Only allow requests from this domain
|
||||||
|
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
// allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
// credentials: true, // if you use cookies or auth headers
|
||||||
|
// };
|
||||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||||
|
|
||||||
function cacheController(policy?: string): RequestHandler {
|
function cacheController(policy?: string): RequestHandler {
|
||||||
@@ -65,15 +116,15 @@ function cacheController(policy?: string): RequestHandler {
|
|||||||
|
|
||||||
const requestMonitoring: RequestHandler = (req, _res, next) => {
|
const requestMonitoring: RequestHandler = (req, _res, next) => {
|
||||||
const traceContext = req.get('X-Cloud-Trace-Context')
|
const traceContext = req.get('X-Cloud-Trace-Context')
|
||||||
const traceId = traceContext
|
const traceId = traceContext ? traceContext.split('/')[0] : crypto.randomUUID()
|
||||||
? traceContext.split('/')[0]
|
|
||||||
: crypto.randomUUID()
|
|
||||||
const context = {endpoint: req.path, traceId}
|
const context = {endpoint: req.path, traceId}
|
||||||
withMonitoringContext(context, () => {
|
withMonitoringContext(context, () => {
|
||||||
const startTs = hrtime.bigint()
|
const startTs = hrtime.bigint()
|
||||||
log(`${req.method} ${req.url}`)
|
log(`${req.method} ${req.url}`)
|
||||||
metrics.inc('http/request_count', {endpoint: req.path})
|
metrics.inc('http/request_count', {endpoint: req.path})
|
||||||
next()
|
next()
|
||||||
|
// There's a bug worth flagging in that middleware. The timing/cleanup code after next() won't work as you expect:
|
||||||
|
// next() is synchronous — it just hands off to the next middleware. The response hasn't been sent by the time endTs is captured. To measure actual latency you'd want to hook into res.on('finish', ...)
|
||||||
const endTs = hrtime.bigint()
|
const endTs = hrtime.bigint()
|
||||||
const latencyMs = Number(endTs - startTs) / 1e6
|
const latencyMs = Number(endTs - startTs) / 1e6
|
||||||
metrics.push('http/request_latency', latencyMs, {endpoint: req.path})
|
metrics.push('http/request_latency', latencyMs, {endpoint: req.path})
|
||||||
@@ -84,16 +135,16 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
if (error instanceof APIError) {
|
if (error instanceof APIError) {
|
||||||
log.info(error)
|
log.info(error)
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
const output: { [k: string]: unknown } = {message: error.message}
|
res.status(error.code).json(error.toJSON())
|
||||||
if (error.details != null) {
|
|
||||||
output.details = error.details
|
|
||||||
}
|
|
||||||
res.status(error.code).json(output)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.error(error)
|
log.error(error)
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({message: error.stack, error})
|
const apiError = APIErrors.internalServerError(error.message || 'Internal server error', {
|
||||||
|
originalError: error.toString(),
|
||||||
|
context: 'Unhandled exception in request processing',
|
||||||
|
})
|
||||||
|
res.status(500).json(apiError.toJSON())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,79 +152,526 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
export const app = express()
|
export const app = express()
|
||||||
app.use(requestMonitoring)
|
app.use(requestMonitoring)
|
||||||
|
|
||||||
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
|
const schemaCache = new WeakMap<ZodTypeAny, any>()
|
||||||
swaggerDocument.info = {
|
|
||||||
...swaggerDocument.info,
|
export function zodToOpenApiSchema(zodObj: ZodTypeAny): any {
|
||||||
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
|
if (schemaCache.has(zodObj)) {
|
||||||
version: "1.0.0",
|
return schemaCache.get(zodObj)
|
||||||
contact: {
|
|
||||||
name: "Compass",
|
|
||||||
email: "compass.meet.info@gmail.com",
|
|
||||||
url: "https://compassmeet.com"
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const rootPath = pathWithPrefix("/")
|
const def: any = (zodObj as any)._def
|
||||||
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
const typeName = def.typeName as ZodFirstPartyTypeKind
|
||||||
app.use(rootPath, swaggerUi.serve)
|
|
||||||
|
|
||||||
app.options('*', allowCorsUnrestricted)
|
// Placeholder so recursive references can point here
|
||||||
|
const placeholder: any = {}
|
||||||
|
schemaCache.set(zodObj, placeholder)
|
||||||
|
|
||||||
const handlers: { [k in APIPath]: APIHandler<k> } = {
|
let schema: any
|
||||||
health: health,
|
|
||||||
'get-supabase-token': getSupabaseToken,
|
switch (typeName) {
|
||||||
'get-notifications': getNotifications,
|
case 'ZodString':
|
||||||
'mark-all-notifs-read': markAllNotifsRead,
|
schema = {type: 'string'}
|
||||||
'user/:username': getUser,
|
break
|
||||||
'user/:username/lite': getDisplayUser,
|
case 'ZodNumber':
|
||||||
'user/by-id/:id': getUser,
|
schema = {type: 'number'}
|
||||||
'user/by-id/:id/lite': getDisplayUser,
|
break
|
||||||
'user/by-id/:id/block': blockUser,
|
case 'ZodBoolean':
|
||||||
'user/by-id/:id/unblock': unblockUser,
|
schema = {type: 'boolean'}
|
||||||
'search-users': searchUsers,
|
break
|
||||||
|
case 'ZodEnum':
|
||||||
|
schema = {type: 'string', enum: def.values}
|
||||||
|
break
|
||||||
|
case 'ZodArray':
|
||||||
|
schema = {type: 'array', items: zodToOpenApiSchema(def.type)}
|
||||||
|
break
|
||||||
|
case 'ZodObject': {
|
||||||
|
const shape = def.shape()
|
||||||
|
const properties: Record<string, any> = {}
|
||||||
|
const required: string[] = []
|
||||||
|
|
||||||
|
for (const key in shape) {
|
||||||
|
const child = shape[key]
|
||||||
|
properties[key] = zodToOpenApiSchema(child)
|
||||||
|
if (!child.isOptional()) required.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
...(required.length ? {required} : {}),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ZodRecord':
|
||||||
|
schema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: zodToOpenApiSchema(def.valueType),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ZodIntersection': {
|
||||||
|
const left = zodToOpenApiSchema(def.left)
|
||||||
|
const right = zodToOpenApiSchema(def.right)
|
||||||
|
schema = {allOf: [left, right]}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ZodLazy':
|
||||||
|
schema = {type: 'object', description: 'Lazy schema - details omitted'}
|
||||||
|
break
|
||||||
|
case 'ZodUnion':
|
||||||
|
schema = {
|
||||||
|
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
schema = {type: 'string'} // fallback for unhandled
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(placeholder, schema)
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTag(route: string) {
|
||||||
|
let tag = 'General'
|
||||||
|
if (route.includes('user') || route.includes('profile')) tag = 'Profiles'
|
||||||
|
if (route.includes('auth') || route.includes('login') || route === 'me') tag = 'Authentication'
|
||||||
|
if (route.includes('search') || route.includes('location')) tag = 'Search'
|
||||||
|
if (route.includes('message') || route.includes('channel')) tag = 'Messaging'
|
||||||
|
if (route.includes('compatibility') || route.includes('question')) tag = 'Compatibility'
|
||||||
|
if (
|
||||||
|
route.includes('like') ||
|
||||||
|
route.includes('ship') ||
|
||||||
|
route.includes('star') ||
|
||||||
|
route.includes('block')
|
||||||
|
)
|
||||||
|
tag = 'Relations'
|
||||||
|
if (route.includes('event') || route.includes('rsvp')) tag = 'Events'
|
||||||
|
if (route.includes('notification') || route.includes('notif')) tag = 'Notifications'
|
||||||
|
if (route.includes('comment')) tag = 'Comments'
|
||||||
|
if (route.includes('report') || route.includes('ban')) tag = 'Moderation'
|
||||||
|
if (route.includes('option') || route.includes('locale')) tag = 'Utilities'
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSwaggerPaths(api: typeof API) {
|
||||||
|
const paths: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const [route, config] of Object.entries(api)) {
|
||||||
|
const pathKey = '/' + route.replace(/_/g, '-') // optional: convert underscores to dashes
|
||||||
|
const method = config.method.toLowerCase()
|
||||||
|
const summary = (config as any).summary ?? route
|
||||||
|
|
||||||
|
const tag = (config as any).tag ?? inferTag(route)
|
||||||
|
|
||||||
|
const operation: any = {
|
||||||
|
summary,
|
||||||
|
description: (config as any).description ?? '',
|
||||||
|
tags: [tag],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Success',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {type: 'object'},
|
||||||
|
example: (config as any).exampleResponse ?? {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Bad Request - Invalid input or malformed request',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {type: 'string'},
|
||||||
|
details: {type: 'object'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Unauthorized - Authentication required',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {type: 'string'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: 'Forbidden - Insufficient permissions',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {type: 'string'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Not Found - Resource does not exist',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {type: 'string'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: 'Internal Server Error - Something went wrong on our end',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: {type: 'string'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include props in request body for POST/PUT
|
||||||
|
if (config.props && ['post', 'put', 'patch'].includes(method)) {
|
||||||
|
operation.requestBody = {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zodToOpenApiSchema(config.props),
|
||||||
|
example: (config as any).exampleRequest ?? {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include props as query parameters for GET/DELETE
|
||||||
|
if (config.props && ['get', 'delete'].includes(method)) {
|
||||||
|
const shape = (config.props as z.ZodObject<any>)._def.shape()
|
||||||
|
operation.parameters = Object.entries(shape).map(([key, zodType]) => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
ZodString: 'string',
|
||||||
|
ZodNumber: 'number',
|
||||||
|
ZodBoolean: 'boolean',
|
||||||
|
ZodArray: 'array',
|
||||||
|
}
|
||||||
|
const t = zodType as z.ZodTypeAny // assert type to ZodTypeAny
|
||||||
|
const typeName = t._def.typeName
|
||||||
|
return {
|
||||||
|
name: key,
|
||||||
|
in: 'query',
|
||||||
|
description: (config as any).paramDescriptions?.[key] ?? '',
|
||||||
|
required: !(t.isOptional ?? false),
|
||||||
|
schema: {type: typeMap[typeName] ?? 'string'},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
paths[pathKey] = {
|
||||||
|
[method]: operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.authed) {
|
||||||
|
operation.security = [{BearerAuth: []}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'API_KEY'
|
||||||
|
|
||||||
|
const swaggerDocument: OpenAPIV3.Document = {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'Compass API',
|
||||||
|
description: `
|
||||||
|
Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Our API provides programmatic access to core platform features including user profiles, messaging, compatibility scoring, and community features.
|
||||||
|
|
||||||
|
## Access Tiers
|
||||||
|
|
||||||
|
### Tier 1 — Public Access (no authentication required)
|
||||||
|
Some endpoints are publicly accessible without authentication, such as events and server health. These are marked as **public** in the endpoint documentation.
|
||||||
|
|
||||||
|
### Tier 2 — User Access (Firebase authentication required)
|
||||||
|
Most endpoints require a valid Firebase JWT token. This gives you access to your own user data, profile, messages, and all interactive features.
|
||||||
|
|
||||||
|
To obtain a token:
|
||||||
|
|
||||||
|
**In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):**
|
||||||
|
\`\`\`js
|
||||||
|
const db = await new Promise((res, rej) => {
|
||||||
|
const req = indexedDB.open('firebaseLocalStorageDb')
|
||||||
|
req.onsuccess = () => res(req.result)
|
||||||
|
req.onerror = rej
|
||||||
|
})
|
||||||
|
const req = db.transaction('firebaseLocalStorage', 'readonly').objectStore('firebaseLocalStorage').getAll()
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const id_token = req.result[0].value.stsTokenManager.accessToken
|
||||||
|
console.log('YOUR_FIREBASE_JWT_TOKEN is the string below:')
|
||||||
|
console.log(id_token)
|
||||||
|
copy(id_token)
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**For testing (REST):**
|
||||||
|
\`\`\`bash
|
||||||
|
curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${apiKey}' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
--data '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}'
|
||||||
|
# Use the returned idToken as your Bearer token
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Tokens expire after **1 hour**. Refresh by calling \`getIdToken(true)\`.
|
||||||
|
|
||||||
|
Pass the token in the Authorization header for all authenticated requests:
|
||||||
|
\`\`\`
|
||||||
|
Authorization: Bearer YOUR_FIREBASE_JWT_TOKEN
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
In the API docs, authenticate through the green button at the bottom right of this section.
|
||||||
|
|
||||||
|
**Don't have an account?** [Register on Compass](${DEPLOYED_WEB_URL}/register) to get started.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
API requests are subject to rate limiting to ensure fair usage and platform stability. Exceeding limits will result in a \`429 Too Many Requests\` response. Rate limits are applied per authenticated user. Unauthenticated requests are limited by IP.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
This documentation reflects API version ${pkgVersion}. Endpoints marked as **deprecated** will include a \`Sunset\` header indicating when they will be removed, and a \`Link\` header pointing to the replacement endpoint. Breaking changes are avoided where possible.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All API responses follow a consistent error format:
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"message": "Human-readable error description",
|
||||||
|
"details": { /* Optional additional context */ }
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Common HTTP status codes:
|
||||||
|
- \`200\` Success
|
||||||
|
- \`400\` Bad Request — invalid or missing input
|
||||||
|
- \`401\` Unauthorized — missing or expired token
|
||||||
|
- \`403\` Forbidden — valid token but insufficient permissions
|
||||||
|
- \`404\` Not Found — resource does not exist
|
||||||
|
- \`429\` Too Many Requests — rate limit exceeded
|
||||||
|
- \`500\` Internal Server Error
|
||||||
|
|
||||||
|
## Open Source
|
||||||
|
|
||||||
|
Compass is open source. Contributions, bug reports, and feature requests are welcome on [GitHub](https://github.com/CompassConnections/Compass).
|
||||||
|
|
||||||
|
## Git Information
|
||||||
|
|
||||||
|
Commit: ${git.revision} (${git.commitDate})`,
|
||||||
|
version: pkgVersion,
|
||||||
|
contact: {
|
||||||
|
name: 'Compass Team',
|
||||||
|
email: 'hello@compassmeet.com',
|
||||||
|
url: 'https://compassmeet.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paths: generateSwaggerPaths(API),
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
BearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'Firebase JWT token obtained through authentication',
|
||||||
|
},
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'x-api-key',
|
||||||
|
description: 'API key for internal/non-user endpoints',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: filterDefined([
|
||||||
|
{
|
||||||
|
name: 'General',
|
||||||
|
description: 'General endpoints and health checks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Authentication',
|
||||||
|
description: 'User authentication and account management endpoints',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Users',
|
||||||
|
description: 'User accounts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Profiles',
|
||||||
|
description: 'User profile creation, retrieval, updating, and deletion',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Search',
|
||||||
|
description: 'User discovery and search functionality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Messages',
|
||||||
|
description: 'Direct messaging between users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Compatibility',
|
||||||
|
description: 'Compatibility questions, answers, and scoring',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Relations',
|
||||||
|
description: 'User relationships (likes, ships, blocks, comments)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Notifications',
|
||||||
|
description: 'User notifications and preferences',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Events',
|
||||||
|
description: 'Community events and RSVP management',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Votes',
|
||||||
|
description: 'Voting system for user content and polls',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Moderation',
|
||||||
|
description: 'Report system and user moderation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin',
|
||||||
|
description: 'Administrative functions including user bans and moderation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Contact',
|
||||||
|
description: 'Contact form and support requests',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Utilities',
|
||||||
|
description: 'Helper functions and utilities',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Internal',
|
||||||
|
description: 'Internal API endpoints for system operations',
|
||||||
|
},
|
||||||
|
IS_LOCAL && {
|
||||||
|
name: 'Local',
|
||||||
|
description: 'Local development and testing endpoints',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as OpenAPIV3.Document
|
||||||
|
|
||||||
|
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
|
||||||
|
// May not be necessary
|
||||||
|
// app.options('*', allowCorsUnrestricted)
|
||||||
|
|
||||||
|
const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||||
'ban-user': banUser,
|
'ban-user': banUser,
|
||||||
report: report,
|
|
||||||
'create-user': createUser,
|
|
||||||
'create-profile': createProfile,
|
|
||||||
me: getMe,
|
|
||||||
'me/private': getCurrentPrivateUser,
|
|
||||||
'me/update': updateMe,
|
|
||||||
'update-notif-settings': updateNotifSettings,
|
|
||||||
'me/delete': deleteMe,
|
|
||||||
'update-profile': updateProfile,
|
|
||||||
'like-profile': likeProfile,
|
|
||||||
'ship-profiles': shipProfiles,
|
|
||||||
'get-likes-and-ships': getLikesAndShips,
|
|
||||||
'has-free-like': hasFreeLike,
|
|
||||||
'star-profile': starProfile,
|
|
||||||
'get-profiles': getProfiles,
|
|
||||||
'get-profile-answers': getProfileAnswers,
|
|
||||||
'get-compatibility-questions': getCompatibilityQuestions,
|
|
||||||
'remove-pinned-photo': removePinnedPhoto,
|
|
||||||
'create-comment': createComment,
|
|
||||||
'hide-comment': hideComment,
|
|
||||||
'create-compatibility-question': createCompatibilityQuestion,
|
|
||||||
'compatible-profiles': getCompatibleProfilesHandler,
|
'compatible-profiles': getCompatibleProfilesHandler,
|
||||||
'search-location': searchLocation,
|
contact: contact,
|
||||||
'search-near-city': searchNearCity,
|
'create-bookmarked-search': createBookmarkedSearch,
|
||||||
|
'create-comment': createComment,
|
||||||
|
'create-compatibility-question': createCompatibilityQuestion,
|
||||||
'create-private-user-message': createPrivateUserMessage,
|
'create-private-user-message': createPrivateUserMessage,
|
||||||
'create-private-user-message-channel': createPrivateUserMessageChannel,
|
'create-private-user-message-channel': createPrivateUserMessageChannel,
|
||||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
'create-profile': createProfile,
|
||||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
'create-user': createUser,
|
||||||
|
'create-user-and-profile': createUserAndProfile,
|
||||||
|
'create-vote': createVote,
|
||||||
|
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||||
|
'delete-compatibility-answer': deleteCompatibilityAnswer,
|
||||||
|
'delete-message': deleteMessage,
|
||||||
|
'edit-message': editMessage,
|
||||||
'get-channel-memberships': getChannelMemberships,
|
'get-channel-memberships': getChannelMemberships,
|
||||||
'get-channel-messages': getChannelMessages,
|
'get-channel-messages': getChannelMessagesEndpoint,
|
||||||
'get-channel-seen-time': getLastSeenChannelTime,
|
'get-channel-seen-time': getLastSeenChannelTime,
|
||||||
|
'get-last-messages': getLastMessages,
|
||||||
|
'get-compatibility-questions': getCompatibilityQuestions,
|
||||||
|
'get-likes-and-ships': getLikesAndShips,
|
||||||
|
'get-messages-count': getMessagesCountEndpoint,
|
||||||
|
'get-notifications': getNotifications,
|
||||||
|
'get-options': getOptionsEndpoint,
|
||||||
|
'get-profile-answers': getProfileAnswers,
|
||||||
|
'get-profiles': getProfiles,
|
||||||
|
'get-supabase-token': getSupabaseToken,
|
||||||
|
'has-free-like': hasFreeLike,
|
||||||
|
'hide-comment': hideComment,
|
||||||
|
'hide-profile': hideProfile,
|
||||||
|
'unhide-profile': unhideProfile,
|
||||||
|
'get-hidden-profiles': getHiddenProfiles,
|
||||||
|
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||||
|
'like-profile': likeProfile,
|
||||||
|
'mark-all-notifs-read': markAllNotifsRead,
|
||||||
|
'me/delete': deleteMe,
|
||||||
|
'me/data': getUserDataExport,
|
||||||
|
'me/private': getCurrentPrivateUser,
|
||||||
|
'me/update': updateMe,
|
||||||
|
'react-to-message': reactToMessage,
|
||||||
|
'remove-pinned-photo': removePinnedPhoto,
|
||||||
|
'save-subscription': saveSubscription,
|
||||||
|
'save-subscription-mobile': saveSubscriptionMobile,
|
||||||
|
'search-location': searchLocationEndpoint,
|
||||||
|
'search-near-city': searchNearCity,
|
||||||
|
'search-users': searchUsers,
|
||||||
'set-channel-seen-time': setChannelLastSeenTime,
|
'set-channel-seen-time': setChannelLastSeenTime,
|
||||||
|
'set-compatibility-answer': setCompatibilityAnswer,
|
||||||
|
'set-last-online-time': setLastOnlineTime,
|
||||||
|
'ship-profiles': shipProfiles,
|
||||||
|
'star-profile': starProfile,
|
||||||
|
'update-notif-settings': updateNotifSettings,
|
||||||
|
'update-options': updateOptions,
|
||||||
|
'update-user-locale': updateUserLocale,
|
||||||
|
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||||
|
'update-profile': updateProfileEndpoint,
|
||||||
|
'update-compatibility-question-pin': updateCompatibilityQuestionPin,
|
||||||
|
'get-pinned-compatibility-questions': getPinnedCompatibilityQuestions,
|
||||||
|
'get-connection-interests': getConnectionInterestsEndpoint,
|
||||||
|
'update-connection-interest': updateConnectionInterests,
|
||||||
|
'user/by-id/:id': getUser,
|
||||||
|
'user/by-id/:id/block': blockUser,
|
||||||
|
'user/by-id/:id/unblock': unblockUser,
|
||||||
|
vote: vote,
|
||||||
|
'validate-username': validateUsernameEndpoint,
|
||||||
|
'llm-extract-profile': llmExtractProfileEndpoint,
|
||||||
|
// 'user/:username': getUser,
|
||||||
|
// 'user/:username/lite': getDisplayUser,
|
||||||
|
// 'user/by-id/:id/lite': getDisplayUser,
|
||||||
|
'cancel-event': cancelEvent,
|
||||||
|
'cancel-rsvp': cancelRsvp,
|
||||||
|
'create-event': createEvent,
|
||||||
|
'get-events': getEvents,
|
||||||
|
'rsvp-event': rsvpEvent,
|
||||||
|
'update-event': updateEvent,
|
||||||
|
health: health,
|
||||||
|
stats: stats,
|
||||||
|
me: getMe,
|
||||||
|
'get-user-and-profile': getUserAndProfileHandler,
|
||||||
|
report: report,
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(handlers).forEach(([path, handler]) => {
|
Object.entries(handlers).forEach(([path, handler]) => {
|
||||||
const api = API[path as APIPath]
|
const api = API[path as APIPath]
|
||||||
const cache = cacheController((api as any).cache)
|
const cache = cacheController((api as any).cache)
|
||||||
const url = pathWithPrefix('/' + path as APIPath)
|
const url = pathWithPrefix(('/' + path) as APIPath)
|
||||||
|
|
||||||
const apiRoute = [
|
const apiRoute = [
|
||||||
url,
|
url,
|
||||||
express.json(),
|
express.json({limit: '1mb'}),
|
||||||
allowCorsUnrestricted,
|
allowCorsUnrestricted,
|
||||||
cache,
|
cache,
|
||||||
typedEndpoint(path as any, handler as any),
|
typedEndpoint(path as any, handler as any),
|
||||||
@@ -191,26 +689,130 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
|
||||||
|
|
||||||
// Internal Endpoints
|
// Internal Endpoints
|
||||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) => {
|
||||||
async (req, res) => {
|
const apiKey = req.header('x-api-key')
|
||||||
const apiKey = req.header("x-api-key");
|
if (apiKey !== process.env.COMPASS_API_KEY) {
|
||||||
if (apiKey !== process.env.COMPASS_API_KEY) {
|
return res.status(401).json({error: 'Unauthorized'})
|
||||||
return res.status(401).json({error: "Unauthorized"});
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendSearchNotifications()
|
||||||
|
return res.status(200).json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send notifications:', err)
|
||||||
|
await sendDiscordMessage(
|
||||||
|
'Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...',
|
||||||
|
'health',
|
||||||
|
)
|
||||||
|
return res.status(500).json({error: 'Internal server error'})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const responses = {
|
||||||
|
200: {
|
||||||
|
description: 'Request successful',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
status: {type: 'string', example: 'success'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: 'Unauthorized (e.g., invalid or missing API key)',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
error: {type: 'string', example: 'Unauthorized'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: 'Internal server error during request processing',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
error: {type: 'string', example: 'Internal server error'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
swaggerDocument.paths['/internal/send-search-notifications'] = {
|
||||||
|
post: {
|
||||||
|
summary: 'Trigger daily search notifications',
|
||||||
|
description:
|
||||||
|
'Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.',
|
||||||
|
tags: ['Internal'],
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
ApiKeyAuth: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestBody: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
responses: responses,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Local Endpoints
|
||||||
|
if (IS_LOCAL) {
|
||||||
|
app.post(pathWithPrefix('/local/send-test-email'), async (req, res) => {
|
||||||
|
if (!IS_LOCAL) {
|
||||||
|
return res.status(401).json({error: 'Unauthorized'})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendSearchNotifications()
|
const result = await localSendTestEmail()
|
||||||
return res.status(200).json(result)
|
return res.status(200).json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to send notifications:", err);
|
return res.status(500).json({error: err})
|
||||||
return res.status(500).json({error: "Internal server error"});
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
swaggerDocument.paths['/local/send-test-email'] = {
|
||||||
|
post: {
|
||||||
|
summary: 'Send a test email',
|
||||||
|
description: 'Local endpoint to send a test email.',
|
||||||
|
tags: ['Local'],
|
||||||
|
requestBody: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
responses: responses,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = pathWithPrefix('/')
|
||||||
|
app.get(
|
||||||
|
rootPath,
|
||||||
|
swaggerUi.setup(swaggerDocument, {
|
||||||
|
customSiteTitle: 'Compass API Docs',
|
||||||
|
customCssUrl: '/swagger.css',
|
||||||
|
customJs: `
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'viewport';
|
||||||
|
meta.content = 'width=device-width, initial-scale=1';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app.use(rootPath, swaggerUi.serve)
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')))
|
||||||
|
|
||||||
app.use(allowCorsUnrestricted, (req, res) => {
|
app.use(allowCorsUnrestricted, (req, res) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|||||||
37
backend/api/src/auth-google.ts
Normal file
37
backend/api/src/auth-google.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
// import {GOOGLE_CLIENT_ID} from "common/constants";
|
||||||
|
// import {REDIRECT_URI} from "common/envs/constants";
|
||||||
|
//
|
||||||
|
// export const authGoogle: APIHandler<'auth-google'> = async (
|
||||||
|
// {code},
|
||||||
|
// _auth
|
||||||
|
// ) => {
|
||||||
|
// console.log('Google Auth Codes:', code)
|
||||||
|
// if (!code) return {success: false, result: {}}
|
||||||
|
//
|
||||||
|
// const body = {
|
||||||
|
// client_id: GOOGLE_CLIENT_ID,
|
||||||
|
// client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
// code: code as string,
|
||||||
|
// grant_type: 'authorization_code',
|
||||||
|
// redirect_uri: REDIRECT_URI,
|
||||||
|
// };
|
||||||
|
// console.log('Body:', body)
|
||||||
|
// const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
// body: new URLSearchParams(body),
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const tokens = await tokenRes.json();
|
||||||
|
// if (tokens.error) {
|
||||||
|
// console.error('Google token error:', tokens);
|
||||||
|
// throw APIErrors.badRequest('Google token error: ' + JSON.stringify(tokens))
|
||||||
|
// }
|
||||||
|
// console.log('Google Tokens:', tokens);
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: true,
|
||||||
|
// result: {tokens},
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { trackPublicEvent } from 'shared/analytics'
|
import {isAdminId} from 'common/envs/constants'
|
||||||
import { throwErrorIfNotMod } from 'shared/helpers/auth'
|
import {trackPublicEvent} from 'shared/analytics'
|
||||||
import { isAdminId } from 'common/envs/constants'
|
import {throwErrorIfNotMod} from 'shared/helpers/auth'
|
||||||
import { log } from 'shared/utils'
|
import {updateUser} from 'shared/supabase/users'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {log} from 'shared/utils'
|
||||||
import { updateUser } from 'shared/supabase/users'
|
|
||||||
|
|
||||||
export const banUser: APIHandler<'ban-user'> = async (body, auth) => {
|
export const banUser: APIHandler<'ban-user'> = async (body, auth) => {
|
||||||
const { userId, unban } = body
|
const {userId, unban} = body
|
||||||
const db = createSupabaseDirectClient()
|
|
||||||
await throwErrorIfNotMod(auth.uid)
|
await throwErrorIfNotMod(auth.uid)
|
||||||
if (isAdminId(userId)) throw new APIError(403, 'Cannot ban admin')
|
if (isAdminId(userId)) throw APIErrors.forbidden('Cannot ban admin')
|
||||||
await trackPublicEvent(auth.uid, 'ban user', {
|
await trackPublicEvent(auth.uid, 'ban user', {
|
||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
await updateUser(db, userId, {
|
await updateUser(userId, {isBannedFromPosting: !unban})
|
||||||
isBannedFromPosting: !unban,
|
|
||||||
})
|
|
||||||
log('updated user')
|
log('updated user')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { FieldVal } from 'shared/supabase/utils'
|
import {updatePrivateUser} from 'shared/supabase/users'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {FieldVal} from 'shared/supabase/utils'
|
||||||
import { updatePrivateUser } from 'shared/supabase/users'
|
|
||||||
|
|
||||||
export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
|
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||||
{ id },
|
|
||||||
auth
|
export const blockUser: APIHandler<'user/by-id/:id/block'> = async ({id}, auth) => {
|
||||||
) => {
|
if (auth.uid === id) throw APIErrors.badRequest('You cannot block yourself')
|
||||||
if (auth.uid === id) throw new APIError(400, 'You cannot block yourself')
|
|
||||||
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
await pg.tx(async (tx) => {
|
await pg.tx(async (tx) => {
|
||||||
@@ -20,10 +18,7 @@ export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async (
|
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async ({id}, auth) => {
|
||||||
{ id },
|
|
||||||
auth
|
|
||||||
) => {
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
await pg.tx(async (tx) => {
|
await pg.tx(async (tx) => {
|
||||||
await updatePrivateUser(tx, auth.uid, {
|
await updatePrivateUser(tx, auth.uid, {
|
||||||
|
|||||||
46
backend/api/src/cancel-event.ts
Normal file
46
backend/api/src/cancel-event.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {update} from 'shared/supabase/utils'
|
||||||
|
|
||||||
|
export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Check if event exists and user is the creator
|
||||||
|
const event = await pg.oneOrNone<{
|
||||||
|
id: string
|
||||||
|
creator_id: string
|
||||||
|
status: string
|
||||||
|
}>(
|
||||||
|
`SELECT id, creator_id, status
|
||||||
|
FROM events
|
||||||
|
WHERE id = $1`,
|
||||||
|
[body.eventId],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw APIErrors.notFound('Event not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.creator_id !== auth.uid) {
|
||||||
|
throw APIErrors.forbidden('Only the event creator can cancel this event')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'cancelled') {
|
||||||
|
throw APIErrors.badRequest('Event is already cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update event status to cancelled
|
||||||
|
const {error} = await tryCatch(
|
||||||
|
update(pg, 'events', 'id', {
|
||||||
|
status: 'cancelled',
|
||||||
|
id: body.eventId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw APIErrors.internalServerError('Failed to cancel event: ' + error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {success: true}
|
||||||
|
}
|
||||||
38
backend/api/src/cancel-rsvp.ts
Normal file
38
backend/api/src/cancel-rsvp.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Check if RSVP exists
|
||||||
|
const rsvp = await pg.oneOrNone<{
|
||||||
|
id: string
|
||||||
|
}>(
|
||||||
|
`SELECT id
|
||||||
|
FROM events_participants
|
||||||
|
WHERE event_id = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[body.eventId, auth.uid],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rsvp) {
|
||||||
|
throw APIErrors.notFound('RSVP not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the RSVP
|
||||||
|
const {error} = await tryCatch(
|
||||||
|
pg.none(
|
||||||
|
`DELETE
|
||||||
|
FROM events_participants
|
||||||
|
WHERE id = $1`,
|
||||||
|
[rsvp.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw APIErrors.internalServerError('Failed to cancel RSVP: ' + error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {success: true}
|
||||||
|
}
|
||||||
@@ -1,61 +1,27 @@
|
|||||||
import { groupBy, sortBy } from 'lodash'
|
import {type APIHandler} from 'api/helpers/endpoint'
|
||||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
|
||||||
import {
|
|
||||||
getProfile,
|
|
||||||
getCompatibilityAnswers,
|
|
||||||
getGenderCompatibleProfiles,
|
|
||||||
} from 'shared/love/supabase'
|
|
||||||
import { log } from 'shared/utils'
|
|
||||||
|
|
||||||
export const getCompatibleProfilesHandler: APIHandler<
|
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
|
||||||
'compatible-profiles'
|
|
||||||
> = async (props) => {
|
|
||||||
return getCompatibleProfiles(props.userId)
|
return getCompatibleProfiles(props.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCompatibleProfiles = async (userId: string) => {
|
export const getCompatibleProfiles = async (userId: string) => {
|
||||||
const profile = await getProfile(userId)
|
const pg = createSupabaseDirectClient()
|
||||||
|
const scores = await pg.map(
|
||||||
log('got profile', {
|
`select *
|
||||||
id: profile?.id,
|
from compatibility_scores
|
||||||
userId: profile?.user_id,
|
where score is not null
|
||||||
username: profile?.user?.username,
|
and (user_id_1 = $1 or user_id_2 = $1)`,
|
||||||
})
|
[userId],
|
||||||
|
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const,
|
||||||
if (!profile) throw new APIError(404, 'Profile not found')
|
|
||||||
|
|
||||||
const profiles = await getGenderCompatibleProfiles(profile)
|
|
||||||
|
|
||||||
const profileAnswers = await getCompatibilityAnswers([
|
|
||||||
userId,
|
|
||||||
...profiles.map((l) => l.user_id),
|
|
||||||
])
|
|
||||||
log('got profile answers ' + profileAnswers.length)
|
|
||||||
|
|
||||||
const answersByUserId = groupBy(profileAnswers, 'creator_id')
|
|
||||||
const profileCompatibilityScores = Object.fromEntries(
|
|
||||||
profiles.map(
|
|
||||||
(l) =>
|
|
||||||
[
|
|
||||||
l.user_id,
|
|
||||||
getCompatibilityScore(
|
|
||||||
answersByUserId[profile.user_id] ?? [],
|
|
||||||
answersByUserId[l.user_id] ?? []
|
|
||||||
),
|
|
||||||
] as const
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedCompatibleProfiles = sortBy(
|
const profileCompatibilityScores = Object.fromEntries(scores)
|
||||||
profiles,
|
|
||||||
(l) => profileCompatibilityScores[l.user_id].score
|
// console.log('scores', profileCompatibilityScores)
|
||||||
).reverse()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
profile,
|
|
||||||
compatibleProfiles: sortedCompatibleProfiles,
|
|
||||||
profileCompatibilityScores,
|
profileCompatibilityScores,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
backend/api/src/contact.ts
Normal file
44
backend/api/src/contact.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {sendDiscordMessage} from 'common/discord/core'
|
||||||
|
import {jsonToMarkdown} from 'common/md'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
|
||||||
|
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
// Stores a contact message into the `contact` table
|
||||||
|
// Web sends TipTap JSON in `content`; we store it as string in `description`.
|
||||||
|
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
|
||||||
|
export const contact: APIHandler<'contact'> = async ({content, userId}, _auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const {error} = await tryCatch(
|
||||||
|
insert(pg, 'contact', {
|
||||||
|
user_id: userId,
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw APIErrors.internalServerError('Failed to submit contact message')
|
||||||
|
|
||||||
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
let user = null
|
||||||
|
if (userId) {
|
||||||
|
user = await pg.oneOrNone(` select name from users where id = $1 `, [userId])
|
||||||
|
}
|
||||||
|
const md = jsonToMarkdown(content)
|
||||||
|
const tile = user ? `New message from ${user.name}` : 'New message'
|
||||||
|
const message: string = `**${tile}**\n${md}`
|
||||||
|
await sendDiscordMessage(message, 'contact')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord contact', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/api/src/create-bookmarked-search.ts
Normal file
24
backend/api/src/create-bookmarked-search.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = async (
|
||||||
|
props,
|
||||||
|
auth,
|
||||||
|
) => {
|
||||||
|
const creator_id = auth.uid
|
||||||
|
const {search_filters, location = null, search_name = null} = props
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const inserted = await pg.one(
|
||||||
|
`
|
||||||
|
INSERT INTO bookmarked_searches (creator_id, search_filters, location, search_name)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[creator_id, search_filters, location, search_name],
|
||||||
|
)
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
@@ -1,35 +1,28 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {type JSONContent} from '@tiptap/core'
|
||||||
import { type JSONContent } from '@tiptap/core'
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { getPrivateUser, getUser } from 'shared/utils'
|
import {Notification} from 'common/notifications'
|
||||||
import {
|
import {convertComment} from 'common/supabase/comment'
|
||||||
createSupabaseDirectClient,
|
import {type Row} from 'common/supabase/utils'
|
||||||
SupabaseDirectClient,
|
import {User} from 'common/user'
|
||||||
} from 'shared/supabase/init'
|
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
import {richTextToString} from 'common/util/parse'
|
||||||
import { Notification } from 'common/notifications'
|
|
||||||
import { insertNotificationToSupabase } from 'shared/supabase/notifications'
|
|
||||||
import { User } from 'common/user'
|
|
||||||
import { richTextToString } from 'common/util/parse'
|
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import { sendNewEndorsementEmail } from 'email/functions/helpers'
|
import {sendNewEndorsementEmail} from 'email/functions/helpers'
|
||||||
import { type Row } from 'common/supabase/utils'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
|
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||||
import { convertComment } from 'common/supabase/comment'
|
import {getPrivateUser, getUser} from 'shared/utils'
|
||||||
|
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
|
||||||
|
|
||||||
export const MAX_COMMENT_JSON_LENGTH = 20000
|
export const MAX_COMMENT_JSON_LENGTH = 20000
|
||||||
|
|
||||||
export const createComment: APIHandler<'create-comment'> = async (
|
export const createComment: APIHandler<'create-comment'> = async (
|
||||||
{ userId, content: submittedContent, replyToCommentId },
|
{userId, content: submittedContent, replyToCommentId},
|
||||||
auth
|
auth,
|
||||||
) => {
|
) => {
|
||||||
const { creator, content } = await validateComment(
|
const {creator, content} = await validateComment(userId, auth.uid, submittedContent)
|
||||||
userId,
|
|
||||||
auth.uid,
|
|
||||||
submittedContent
|
|
||||||
)
|
|
||||||
|
|
||||||
const onUser = await getUser(userId)
|
const onUser = await getUser(userId)
|
||||||
if (!onUser) throw new APIError(404, 'User not found')
|
if (!onUser) throw APIErrors.notFound('User not found')
|
||||||
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const comment = await pg.one<Row<'profile_comments'>>(
|
const comment = await pg.one<Row<'profile_comments'>>(
|
||||||
@@ -43,7 +36,7 @@ export const createComment: APIHandler<'create-comment'> = async (
|
|||||||
userId,
|
userId,
|
||||||
content,
|
content,
|
||||||
replyToCommentId,
|
replyToCommentId,
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
if (onUser.id !== creator.id)
|
if (onUser.id !== creator.id)
|
||||||
await createNewCommentOnProfileNotification(
|
await createNewCommentOnProfileNotification(
|
||||||
@@ -51,37 +44,31 @@ export const createComment: APIHandler<'create-comment'> = async (
|
|||||||
creator,
|
creator,
|
||||||
richTextToString(content),
|
richTextToString(content),
|
||||||
comment.id,
|
comment.id,
|
||||||
pg
|
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcastUpdatedComment(convertComment(comment))
|
broadcastUpdatedComment(convertComment(comment))
|
||||||
|
|
||||||
return { status: 'success' }
|
return {status: 'success'}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateComment = async (
|
const validateComment = async (userId: string, creatorId: string, content: JSONContent) => {
|
||||||
userId: string,
|
|
||||||
creatorId: string,
|
|
||||||
content: JSONContent
|
|
||||||
) => {
|
|
||||||
const creator = await getUser(creatorId)
|
const creator = await getUser(creatorId)
|
||||||
|
|
||||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||||
|
|
||||||
const otherUser = await getPrivateUser(userId)
|
const otherUser = await getPrivateUser(userId)
|
||||||
if (!otherUser) throw new APIError(404, 'Other user not found')
|
if (!otherUser) throw APIErrors.notFound('Other user not found')
|
||||||
if (otherUser.blockedUserIds.includes(creatorId)) {
|
if (otherUser.blockedUserIds.includes(creatorId)) {
|
||||||
throw new APIError(404, 'User has blocked you')
|
throw APIErrors.notFound('User has blocked you')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||||
throw new APIError(
|
throw APIErrors.badRequest(
|
||||||
400,
|
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`,
|
||||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return { content, creator }
|
return {content, creator}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createNewCommentOnProfileNotification = async (
|
const createNewCommentOnProfileNotification = async (
|
||||||
@@ -89,14 +76,15 @@ const createNewCommentOnProfileNotification = async (
|
|||||||
creator: User,
|
creator: User,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
commentId: number,
|
commentId: number,
|
||||||
pg: SupabaseDirectClient
|
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(onUser.id)
|
const privateUser = await getPrivateUser(onUser.id)
|
||||||
if (!privateUser) return
|
if (!privateUser) return
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const reason = 'new_endorsement'
|
const reason = 'new_endorsement'
|
||||||
const { sendToBrowser, sendToMobile, sendToEmail } =
|
const {sendToBrowser, sendToMobile, sendToEmail} = getNotificationDestinationsForUser(
|
||||||
getNotificationDestinationsForUser(privateUser, reason)
|
privateUser,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id,
|
id,
|
||||||
userId: privateUser.id,
|
userId: privateUser.id,
|
||||||
@@ -113,7 +101,7 @@ const createNewCommentOnProfileNotification = async (
|
|||||||
sourceSlug: onUser.username,
|
sourceSlug: onUser.username,
|
||||||
}
|
}
|
||||||
if (sendToBrowser) {
|
if (sendToBrowser) {
|
||||||
await insertNotificationToSupabase(notification, pg)
|
await insertNotificationToSupabase(notification)
|
||||||
}
|
}
|
||||||
if (sendToMobile) {
|
if (sendToMobile) {
|
||||||
// await createPushNotification(
|
// await createPushNotification(
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
import { getUser } from 'shared/utils'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { APIHandler, APIError } from './helpers/endpoint'
|
import {insert} from 'shared/supabase/utils'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import {getUser} from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
|
||||||
|
|
||||||
export const createCompatibilityQuestion: APIHandler<
|
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||||
'create-compatibility-question'
|
|
||||||
> = async ({ question, options }, auth) => {
|
export const createCompatibilityQuestion: APIHandler<'create-compatibility-question'> = async (
|
||||||
|
{question, options},
|
||||||
|
auth,
|
||||||
|
) => {
|
||||||
const creator = await getUser(auth.uid)
|
const creator = await getUser(auth.uid)
|
||||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||||
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const { data, error } = await tryCatch(
|
const {data, error} = await tryCatch(
|
||||||
insert(pg, 'love_questions', {
|
insert(pg, 'compatibility_prompts', {
|
||||||
creator_id: creator.id,
|
creator_id: creator.id,
|
||||||
question,
|
question,
|
||||||
answer_type: 'compatibility_multiple_choice',
|
answer_type: 'compatibility_multiple_choice',
|
||||||
multiple_choice_options: options,
|
multiple_choice_options: options,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error) throw new APIError(401, 'Error creating question')
|
if (error) throw APIErrors.internalServerError('Error creating question')
|
||||||
|
|
||||||
return { question: data }
|
return {question: data}
|
||||||
}
|
}
|
||||||
|
|||||||
67
backend/api/src/create-event.ts
Normal file
67
backend/api/src/create-event.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
|
import {sendDiscordMessage} from 'common/discord/core'
|
||||||
|
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
|
||||||
|
export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Validate location
|
||||||
|
if (body.locationType === 'in_person' && !body.locationAddress) {
|
||||||
|
throw APIErrors.badRequest('In-person events require a location address')
|
||||||
|
}
|
||||||
|
if (body.locationType === 'online' && !body.locationUrl) {
|
||||||
|
throw APIErrors.badRequest('Online events require a location URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
const startTime = new Date(body.eventStartTime)
|
||||||
|
if (startTime < new Date()) {
|
||||||
|
throw APIErrors.badRequest('Event start time must be in the future')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.eventEndTime) {
|
||||||
|
const endTime = new Date(body.eventEndTime)
|
||||||
|
if (endTime <= startTime) {
|
||||||
|
throw APIErrors.badRequest('Event end time must be after start time')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data, error} = await tryCatch(
|
||||||
|
insert(pg, 'events', {
|
||||||
|
creator_id: auth.uid,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
location_type: body.locationType,
|
||||||
|
location_address: body.locationAddress,
|
||||||
|
location_url: body.locationUrl,
|
||||||
|
event_start_time: body.eventStartTime,
|
||||||
|
event_end_time: body.eventEndTime,
|
||||||
|
max_participants: body.maxParticipants,
|
||||||
|
}), // consider using convertObjectToSQLRow() to convert snake case to camel case
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw APIErrors.internalServerError('Failed to create event: ' + error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
const user = await pg.oneOrNone(`select name from users where id = $1 `, [auth.uid])
|
||||||
|
const message: string = `${user.name} created a new [event](${DEPLOYED_WEB_URL}/events)!\n**${body.title}**\n${body.description}\nStart: ${body.eventStartTime.replace('T', ' @ ').replace('.000Z', ' UTC')}`
|
||||||
|
await sendDiscordMessage(message, 'general')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord event', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
success: true,
|
||||||
|
event: data,
|
||||||
|
},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
|
}
|
||||||
209
backend/api/src/create-notification.ts
Normal file
209
backend/api/src/create-notification.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import {ANDROID_APP_URL} from 'common/constants'
|
||||||
|
import {Notification} from 'common/notifications'
|
||||||
|
import {Row} from 'common/supabase/utils'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {
|
||||||
|
createBulkNotification,
|
||||||
|
insertNotificationToSupabase,
|
||||||
|
NotificationTemplateTranslation,
|
||||||
|
} from 'shared/supabase/notifications'
|
||||||
|
|
||||||
|
const COMPASS_LOGO_URL =
|
||||||
|
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185'
|
||||||
|
|
||||||
|
export const createAndroidReleaseNotifications = async () => {
|
||||||
|
const createdTime = Date.now()
|
||||||
|
const id = `android-release-${createdTime}`
|
||||||
|
const notification: Notification = {
|
||||||
|
id,
|
||||||
|
userId: 'todo',
|
||||||
|
createdTime: createdTime,
|
||||||
|
isSeen: false,
|
||||||
|
sourceType: 'info',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceSlug: ANDROID_APP_URL,
|
||||||
|
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||||
|
title: 'Android App Released on Google Play',
|
||||||
|
sourceText:
|
||||||
|
'The Compass Android app is now publicly available on Google Play! Download it today to stay connected on the go.',
|
||||||
|
}
|
||||||
|
return await createNotifications(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAndroidTestNotifications = async () => {
|
||||||
|
const createdTime = Date.now()
|
||||||
|
const id = `android-test-${createdTime}`
|
||||||
|
const notification: Notification = {
|
||||||
|
id,
|
||||||
|
userId: 'todo',
|
||||||
|
createdTime: createdTime,
|
||||||
|
isSeen: false,
|
||||||
|
sourceType: 'info',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceSlug: '/contact',
|
||||||
|
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||||
|
title: 'Android App Ready for Review — Help Us Unlock the Google Play Release',
|
||||||
|
sourceText:
|
||||||
|
'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Play–registered email address so we can add you as a tester and complete the review process.',
|
||||||
|
}
|
||||||
|
return await createNotifications(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createShareNotifications = async () => {
|
||||||
|
const createdTime = Date.now()
|
||||||
|
const id = `share-${createdTime}`
|
||||||
|
const notification: Notification = {
|
||||||
|
id,
|
||||||
|
userId: 'todo',
|
||||||
|
createdTime: createdTime,
|
||||||
|
isSeen: false,
|
||||||
|
sourceType: 'info',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceSlug: '/contact',
|
||||||
|
sourceUserAvatarUrl:
|
||||||
|
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445',
|
||||||
|
title: 'Give us tips to reach more people',
|
||||||
|
sourceText: '250 members already! Tell us where and how we can best share Compass.',
|
||||||
|
}
|
||||||
|
return await createNotifications(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVoteNotifications = async () => {
|
||||||
|
const createdTime = Date.now()
|
||||||
|
const id = `vote-${createdTime}`
|
||||||
|
const notification: Notification = {
|
||||||
|
id,
|
||||||
|
userId: 'todo',
|
||||||
|
createdTime: createdTime,
|
||||||
|
isSeen: false,
|
||||||
|
sourceType: 'info',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceSlug: '/vote',
|
||||||
|
sourceUserAvatarUrl:
|
||||||
|
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fvote-icon-design-free-vector.jpg?alt=media&token=f70b6d14-0511-49b2-830d-e7cabf7bb751',
|
||||||
|
title: 'New Proposals & Votes Page',
|
||||||
|
sourceText: "Create proposals and vote on other people's suggestions!",
|
||||||
|
}
|
||||||
|
return await createNotifications(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNotifications = async (notification: Notification) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select * from users'))
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching users', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!users) {
|
||||||
|
console.error('No users found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
await createNotification(user, notification, pg)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create notification', e, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNotification = async (
|
||||||
|
user: Row<'users'>,
|
||||||
|
notification: Notification,
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
) => {
|
||||||
|
notification.userId = user.id
|
||||||
|
console.log('notification', user.username)
|
||||||
|
return await insertNotificationToSupabase(notification, pg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send "Events now available" notification to all users
|
||||||
|
* Uses the new template-based system for efficient bulk notifications
|
||||||
|
*/
|
||||||
|
export const createEventsAvailableNotifications = async () => {
|
||||||
|
// Create template and bulk notifications using the new system
|
||||||
|
const {templateId, count} = await createBulkNotification({
|
||||||
|
sourceType: 'info',
|
||||||
|
title: 'New Events Page',
|
||||||
|
sourceText:
|
||||||
|
'You can now create and join events on Compass! Meet up with other members online or in person for workshops, social events, etc.',
|
||||||
|
sourceSlug: '/events',
|
||||||
|
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Created events notification template ${templateId} for ${count} users`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
templateId,
|
||||||
|
userCount: count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSomeNotifications = async () => {
|
||||||
|
const translations: Omit<NotificationTemplateTranslation, 'template_id' | 'created_time'>[] = [
|
||||||
|
// French translation
|
||||||
|
{
|
||||||
|
locale: 'fr',
|
||||||
|
title: 'Bonjour',
|
||||||
|
source_text: "C'est une notif",
|
||||||
|
},
|
||||||
|
// German translation
|
||||||
|
{
|
||||||
|
locale: 'de',
|
||||||
|
title: 'Halo',
|
||||||
|
source_text: 'Dis das',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create template with translations
|
||||||
|
const {templateId, count} = await createBulkNotification(
|
||||||
|
{
|
||||||
|
sourceType: 'hello',
|
||||||
|
title: 'Hello world',
|
||||||
|
sourceText: 'This is a notification',
|
||||||
|
sourceSlug: '/settings',
|
||||||
|
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
},
|
||||||
|
translations,
|
||||||
|
)
|
||||||
|
console.log(`Created some notification template ${templateId} for ${count} users`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInterestIndicatorNotifications = async () => {
|
||||||
|
const translations: Omit<NotificationTemplateTranslation, 'template_id' | 'created_time'>[] = [
|
||||||
|
// French translation
|
||||||
|
{
|
||||||
|
locale: 'fr',
|
||||||
|
title: 'Nouveau : Signaux d’intérêt privés',
|
||||||
|
source_text:
|
||||||
|
'Vous pouvez désormais exprimer votre intérêt en privé à la fin de chaque profil. L’autre personne n’est informée que si l’intérêt est réciproque.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create template with translations
|
||||||
|
const {templateId, count} = await createBulkNotification(
|
||||||
|
{
|
||||||
|
sourceType: 'info',
|
||||||
|
title: 'New: Private interest signals',
|
||||||
|
sourceText:
|
||||||
|
'You can now express interest privately at the end of each profile. The other person is only notified if it’s mutual.',
|
||||||
|
sourceSlug: '/',
|
||||||
|
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
},
|
||||||
|
translations,
|
||||||
|
)
|
||||||
|
console.log(`Created some notification template ${templateId} for ${count} users`)
|
||||||
|
}
|
||||||
@@ -1,52 +1,71 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {getConnectionInterests} from 'api/get-connection-interests'
|
||||||
import { filterDefined } from 'common/util/array'
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { uniq } from 'lodash'
|
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {filterDefined} from 'common/util/array'
|
||||||
import { addUsersToPrivateMessageChannel } from 'api/junk-drawer/private-messages'
|
import * as admin from 'firebase-admin'
|
||||||
import { getPrivateUser, getUser } from 'shared/utils'
|
import {uniq} from 'lodash'
|
||||||
|
import {getProfile} from 'shared/profiles/supabase'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {getPrivateUser, getUser} from 'shared/utils'
|
||||||
|
|
||||||
export const createPrivateUserMessageChannel: APIHandler<
|
export const createPrivateUserMessageChannel: APIHandler<
|
||||||
'create-private-user-message-channel'
|
'create-private-user-message-channel'
|
||||||
> = async (body, auth) => {
|
> = async (body, auth) => {
|
||||||
|
// Do not use auth.creds.data as its info can be staled. It comes from a client token, which refreshes hourly or so
|
||||||
|
const user = await admin.auth().getUser(auth.uid)
|
||||||
|
// console.log(JSON.stringify(user, null, 2))
|
||||||
|
if (!user?.emailVerified) {
|
||||||
|
throw APIErrors.forbidden('You must verify your email to contact people.')
|
||||||
|
}
|
||||||
|
|
||||||
const userIds = uniq(body.userIds.concat(auth.uid))
|
const userIds = uniq(body.userIds.concat(auth.uid))
|
||||||
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const creatorId = auth.uid
|
const creatorId = auth.uid
|
||||||
|
|
||||||
const creator = await getUser(creatorId)
|
const creator = await getUser(creatorId)
|
||||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||||
const toPrivateUsers = filterDefined(
|
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
|
||||||
await Promise.all(userIds.map((id) => getPrivateUser(id)))
|
|
||||||
)
|
|
||||||
|
|
||||||
if (toPrivateUsers.length !== userIds.length)
|
if (toPrivateUsers.length !== userIds.length)
|
||||||
throw new APIError(
|
throw APIErrors.notFound(
|
||||||
404,
|
|
||||||
`Private user ${userIds.find(
|
`Private user ${userIds.find(
|
||||||
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid)
|
(uid) => !toPrivateUsers.map((p: any) => p.id).includes(uid),
|
||||||
)} not found`
|
)} not found`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
toPrivateUsers.some((user) =>
|
toPrivateUsers.some((user: any) =>
|
||||||
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId))
|
user.blockedUserIds.some((blockedId: string) => userIds.includes(blockedId)),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new APIError(
|
throw APIErrors.forbidden('One of the users has blocked another user in the list')
|
||||||
403,
|
}
|
||||||
'One of the users has blocked another user in the list'
|
|
||||||
)
|
for (const u of toPrivateUsers) {
|
||||||
|
const p = await getProfile(u.id)
|
||||||
|
if (p && !p.allow_direct_messaging) {
|
||||||
|
const {interests, targetInterests} = await getConnectionInterests(
|
||||||
|
{targetUserId: u.id},
|
||||||
|
auth.uid,
|
||||||
|
)
|
||||||
|
const matches = interests.filter((interest: string[]) => targetInterests.includes(interest))
|
||||||
|
if (matches.length > 0) continue
|
||||||
|
const failedUser = await getUser(u.id)
|
||||||
|
throw APIErrors.forbidden(`${failedUser?.username} has disabled direct messaging`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentChannel = await pg.oneOrNone(
|
const currentChannel = await pg.oneOrNone(
|
||||||
`
|
`
|
||||||
select channel_id from private_user_message_channel_members
|
select channel_id
|
||||||
group by channel_id
|
from private_user_message_channel_members
|
||||||
having array_agg(user_id::text) @> array[$1]::text[]
|
group by channel_id
|
||||||
and array_agg(user_id::text) <@ array[$1]::text[]
|
having array_agg(user_id::text) @> array [$1]::text[]
|
||||||
`,
|
and array_agg(user_id::text) <@ array [$1]::text[]
|
||||||
[userIds]
|
`,
|
||||||
|
[userIds],
|
||||||
)
|
)
|
||||||
if (currentChannel)
|
if (currentChannel)
|
||||||
return {
|
return {
|
||||||
@@ -55,17 +74,19 @@ export const createPrivateUserMessageChannel: APIHandler<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channel = await pg.one(
|
const channel = await pg.one(
|
||||||
`insert into private_user_message_channels default values returning id`
|
`insert into private_user_message_channels default
|
||||||
|
values
|
||||||
|
returning id`,
|
||||||
)
|
)
|
||||||
|
|
||||||
await pg.none(
|
await pg.none(
|
||||||
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
||||||
values ($1, $2, 'creator', 'joined')
|
values ($1, $2, 'creator', 'joined')
|
||||||
`,
|
`,
|
||||||
[channel.id, creatorId]
|
[channel.id, creatorId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const memberIds = userIds.filter((id) => id !== creatorId)
|
const memberIds = userIds.filter((id) => id !== creatorId)
|
||||||
await addUsersToPrivateMessageChannel(memberIds, channel.id, pg)
|
await addUsersToPrivateMessageChannel(memberIds, channel.id, pg)
|
||||||
return { status: 'success', channelId: Number(channel.id) }
|
return {status: 'success', channelId: Number(channel.id)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||||
import { getUser } from 'shared/utils'
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||||
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
|
import {getUser} from 'shared/utils'
|
||||||
|
|
||||||
export const createPrivateUserMessage: APIHandler<
|
export const createPrivateUserMessage: APIHandler<'create-private-user-message'> = async (
|
||||||
'create-private-user-message'
|
body,
|
||||||
> = async (body, auth) => {
|
auth,
|
||||||
const { content, channelId } = body
|
) => {
|
||||||
|
const {content, channelId} = body
|
||||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||||
throw new APIError(
|
throw APIErrors.badRequest(`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`)
|
||||||
400,
|
|
||||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const pg = createSupabaseDirectClient()
|
|
||||||
const creator = await getUser(auth.uid)
|
const creator = await getUser(auth.uid)
|
||||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||||
return await createPrivateUserMessageMain(
|
|
||||||
creator,
|
const pg = createSupabaseDirectClient()
|
||||||
channelId,
|
return await createPrivateUserMessageMain(creator, channelId, content, pg, 'private')
|
||||||
content,
|
|
||||||
pg,
|
|
||||||
'private'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,90 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {sendDiscordMessage} from 'common/discord/core'
|
||||||
import { log, getUser } from 'shared/utils'
|
import {debug} from 'common/logger'
|
||||||
import { HOUR_MS } from 'common/util/time'
|
import {jsonToMarkdown} from 'common/md'
|
||||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
import {trimStrings} from 'common/parsing'
|
||||||
import { track } from 'shared/analytics'
|
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
|
||||||
import { updateUser } from 'shared/supabase/users'
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import {track} from 'shared/analytics'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {updateUserData} from 'shared/supabase/users'
|
||||||
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {getUser, log} from 'shared/utils'
|
||||||
|
|
||||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const { data: existingUser } = await tryCatch(
|
const {data: existingProfile} = await tryCatch(
|
||||||
pg.oneOrNone<{ id: string }>('select id from profiles where user_id = $1', [
|
pg.oneOrNone<{id: string}>('select id from profiles where user_id = $1', [auth.uid]),
|
||||||
auth.uid,
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
if (existingUser) {
|
if (existingProfile) {
|
||||||
throw new APIError(400, 'User already exists')
|
throw APIErrors.badRequest('Profile already exists')
|
||||||
}
|
}
|
||||||
|
|
||||||
await removePinnedUrlFromPhotoUrls(body)
|
await removePinnedUrlFromPhotoUrls(body)
|
||||||
|
trimStrings(body)
|
||||||
|
|
||||||
const user = await getUser(auth.uid)
|
const user = await getUser(auth.uid)
|
||||||
if (!user) throw new APIError(401, 'Your account was not found')
|
if (!user) throw APIErrors.unauthorized('Your account was not found')
|
||||||
if (user.createdTime > Date.now() - HOUR_MS) {
|
if (user.createdTime > Date.now() - HOUR_MS) {
|
||||||
// If they just signed up, set their avatar to be their pinned photo
|
// If they just signed up, set their avatar to be their pinned photo
|
||||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
updateUserData(pg, auth.uid, {avatarUrl: body.pinned_url || undefined})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('body', body)
|
debug('body', body)
|
||||||
|
|
||||||
const { data, error } = await tryCatch(
|
const {data, error} = await tryCatch(insert(pg, 'profiles', {user_id: auth.uid, ...body}))
|
||||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
log.error('Error creating user: ' + error.message)
|
log.error('Error creating user: ' + error.message)
|
||||||
throw new APIError(500, 'Error creating user')
|
throw APIErrors.internalServerError('Error creating user')
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Created user', data)
|
log('Created profile', data)
|
||||||
await track(user.id, 'create profile', { username: user.username })
|
|
||||||
|
|
||||||
return data
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to track create profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Let the user fill in the optional form with all their info and pictures before notifying discord of their arrival.
|
||||||
|
// So we can sse their full profile as soon as we get the notif on discord. And that allows OG to pull their pic for the link preview.
|
||||||
|
// Regardless, you need to wait for at least 5 seconds that the profile is fully in the db—otherwise ISR may cache "profile not created yet"
|
||||||
|
await sleep(10 * MINUTE_MS)
|
||||||
|
let message: string = `[**${user.name}**](https://compassmeet.com/${user.username}) just created a profile`
|
||||||
|
if (body.bio) {
|
||||||
|
const bioText = jsonToMarkdown(body.bio)
|
||||||
|
if (bioText) message += `\n${bioText}`
|
||||||
|
}
|
||||||
|
await sendDiscordMessage(message, 'members')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord new profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nProfiles = await pg.one<number>(`SELECT count(*) FROM profiles`, [], (r) =>
|
||||||
|
Number(r.count),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isMilestone = (n: number) => {
|
||||||
|
return (
|
||||||
|
[15, 20, 30, 40].includes(n) || // early milestones
|
||||||
|
n % 50 === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug(nProfiles, isMilestone(nProfiles))
|
||||||
|
if (isMilestone(nProfiles)) {
|
||||||
|
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord user milestone', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: data,
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
218
backend/api/src/create-user-and-profile.ts
Normal file
218
backend/api/src/create-user-and-profile.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
|
||||||
|
import {setProfileOptions} from 'api/update-options'
|
||||||
|
import {APIErrors} from 'common/api/utils'
|
||||||
|
import {defaultLocale} from 'common/constants'
|
||||||
|
import {sendDiscordMessage} from 'common/discord/core'
|
||||||
|
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||||
|
import {debug} from 'common/logger'
|
||||||
|
import {trimStrings} from 'common/parsing'
|
||||||
|
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||||
|
import {PrivateUser} from 'common/user'
|
||||||
|
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||||
|
import {cleanDisplayName} from 'common/util/clean-username'
|
||||||
|
import {removeUndefinedProps} from 'common/util/object'
|
||||||
|
import {sendWelcomeEmail} from 'email/functions/helpers'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import {getIp, track} from 'shared/analytics'
|
||||||
|
import {getBucket} from 'shared/firebase-utils'
|
||||||
|
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||||
|
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {getUserByUsername, log} from 'shared/utils'
|
||||||
|
|
||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {validateUsername} from './validate-username'
|
||||||
|
|
||||||
|
export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async (
|
||||||
|
props,
|
||||||
|
auth,
|
||||||
|
req,
|
||||||
|
) => {
|
||||||
|
trimStrings(props)
|
||||||
|
const {
|
||||||
|
deviceToken,
|
||||||
|
locale = defaultLocale,
|
||||||
|
username,
|
||||||
|
name,
|
||||||
|
profile,
|
||||||
|
interests,
|
||||||
|
causes,
|
||||||
|
work,
|
||||||
|
} = props
|
||||||
|
await removePinnedUrlFromPhotoUrls(profile)
|
||||||
|
|
||||||
|
// const host = req.get('referer')
|
||||||
|
|
||||||
|
const ip = getIp(req)
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const cleanName = cleanDisplayName(name || 'User')
|
||||||
|
|
||||||
|
const fbUser = await admin.auth().getUser(auth.uid)
|
||||||
|
const email = fbUser.email
|
||||||
|
|
||||||
|
const bucket = getBucket()
|
||||||
|
const avatarUrl = profile.pinned_url ?? (await generateAvatarUrl(auth.uid, cleanName, bucket))
|
||||||
|
|
||||||
|
let finalUsername = username
|
||||||
|
const validation = await validateUsername(username)
|
||||||
|
if (validation.suggestedUsername) {
|
||||||
|
finalUsername = validation.suggestedUsername
|
||||||
|
} else if (!validation.valid) {
|
||||||
|
throw APIErrors.badRequest(validation.message || 'Invalid username', {
|
||||||
|
field: 'username',
|
||||||
|
resolution:
|
||||||
|
'Usernames must be 3–25 characters and contain only letters, numbers, or underscores.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pg.tx() call wraps several database operations in a single atomic transaction,
|
||||||
|
// ensuring they either all succeed or all fail together.
|
||||||
|
const {user, privateUser, newProfileRow} = await pg.tx(async (tx) => {
|
||||||
|
const existingUser = await tx.oneOrNone('select id from users where id = $1', [auth.uid])
|
||||||
|
if (existingUser) {
|
||||||
|
const existingProfile = await tx.oneOrNone('select id from profiles where user_id = $1', [
|
||||||
|
auth.uid,
|
||||||
|
])
|
||||||
|
if (existingProfile) {
|
||||||
|
throw APIErrors.conflict('An account for this user already exists', {
|
||||||
|
resolution:
|
||||||
|
'If you already have an account, try logging in. If you believe this is a mistake, contact support.',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await pg.none('DELETE FROM users WHERE id = $1', [auth.uid])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameNameUser = await getUserByUsername(finalUsername, tx)
|
||||||
|
if (sameNameUser) {
|
||||||
|
throw APIErrors.conflict('Username is already taken', {
|
||||||
|
field: 'username',
|
||||||
|
resolution: 'Please choose a different username.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateUserData: PrivateUser = {
|
||||||
|
id: auth.uid,
|
||||||
|
email,
|
||||||
|
locale,
|
||||||
|
initialIpAddress: ip,
|
||||||
|
initialDeviceToken: deviceToken,
|
||||||
|
notificationPreferences: getDefaultNotificationPreferences(),
|
||||||
|
blockedUserIds: [],
|
||||||
|
blockedByUserIds: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUserRow = await insert(tx, 'users', {
|
||||||
|
id: auth.uid,
|
||||||
|
name: cleanName,
|
||||||
|
username: finalUsername,
|
||||||
|
avatar_url: avatarUrl,
|
||||||
|
is_banned_from_posting: Boolean(
|
||||||
|
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||||
|
(ip && bannedIpAddresses.includes(ip)),
|
||||||
|
),
|
||||||
|
data: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newPrivateUserRow = await insert(tx, 'private_users', {
|
||||||
|
id: privateUserData.id,
|
||||||
|
data: privateUserData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileData = removeUndefinedProps(profile)
|
||||||
|
|
||||||
|
const newProfileRow = await insert(tx, 'profiles', {
|
||||||
|
user_id: auth.uid,
|
||||||
|
...profileData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileId = newProfileRow.id
|
||||||
|
|
||||||
|
await setProfileOptions(tx, profileId, auth.uid, 'interests', interests)
|
||||||
|
await setProfileOptions(tx, profileId, auth.uid, 'causes', causes)
|
||||||
|
await setProfileOptions(tx, profileId, auth.uid, 'work', work)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: convertUser(newUserRow),
|
||||||
|
privateUser: convertPrivateUser(newPrivateUserRow),
|
||||||
|
newProfileRow,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
log('created user and profile', {username: user.username, firebaseId: auth.uid})
|
||||||
|
|
||||||
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to track create profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendWelcomeEmail(user, privateUser)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sendWelcomeEmail', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setLastOnlineTimeUser(auth.uid)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set last online time', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const message: string = `[**${user.name}**](${DEPLOYED_WEB_URL}/${user.username}) just created a profile`
|
||||||
|
await sendDiscordMessage(message, 'members')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord new profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nProfiles = await pg.one<number>(`SELECT count(*) FROM profiles`, [], (r) =>
|
||||||
|
Number(r.count),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isMilestone = (n: number) => {
|
||||||
|
return (
|
||||||
|
[15, 20, 30, 40].includes(n) || // early milestones
|
||||||
|
n % 50 === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug(nProfiles, isMilestone(nProfiles))
|
||||||
|
if (isMilestone(nProfiles)) {
|
||||||
|
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord user milestone', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
// include everything the frontend needs
|
||||||
|
user,
|
||||||
|
privateUser,
|
||||||
|
profile: {
|
||||||
|
...newProfileRow,
|
||||||
|
interests: interests ?? [],
|
||||||
|
causes: causes ?? [],
|
||||||
|
work: work ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannedDeviceTokens = [
|
||||||
|
'fa807d664415',
|
||||||
|
'dcf208a11839',
|
||||||
|
'bbf18707c15d',
|
||||||
|
'4c2d15a6cc0c',
|
||||||
|
'0da6b4ea79d3',
|
||||||
|
]
|
||||||
|
const bannedIpAddresses: string[] = [
|
||||||
|
'24.176.214.250',
|
||||||
|
'2607:fb90:bd95:dbcd:ac39:6c97:4e35:3fed',
|
||||||
|
'2607:fb91:389:ddd0:ac39:8397:4e57:f060',
|
||||||
|
'2607:fb90:ed9a:4c8f:ac39:cf57:4edd:4027',
|
||||||
|
'2607:fb90:bd36:517a:ac39:6c91:812c:6328',
|
||||||
|
]
|
||||||
@@ -1,49 +1,51 @@
|
|||||||
|
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
|
||||||
|
import {defaultLocale} from 'common/constants'
|
||||||
|
import {RESERVED_PATHS} from 'common/envs/constants'
|
||||||
|
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||||
|
import {PrivateUser} from 'common/user'
|
||||||
|
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||||
|
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
|
||||||
|
import {removeUndefinedProps} from 'common/util/object'
|
||||||
|
import {randomString} from 'common/util/random'
|
||||||
|
import {sendWelcomeEmail} from 'email/functions/helpers'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { PrivateUser } from 'common/user'
|
import {getIp, track} from 'shared/analytics'
|
||||||
import { randomString } from 'common/util/random'
|
import {getBucket} from 'shared/firebase-utils'
|
||||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||||
import { getIp, track } from 'shared/analytics'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {insert} from 'shared/supabase/utils'
|
||||||
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
|
||||||
import { generateAvatarUrl } from 'shared/helpers/generate-and-update-avatar-urls'
|
|
||||||
import { getStorage } from 'firebase-admin/storage'
|
|
||||||
import { DEV_CONFIG } from 'common/envs/dev'
|
|
||||||
import { PROD_CONFIG } from 'common/envs/prod'
|
|
||||||
import { RESERVED_PATHS } from 'common/envs/constants'
|
|
||||||
import { log, isProd, getUser, getUserByUsername } from 'shared/utils'
|
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
|
||||||
import { insert } from 'shared/supabase/utils'
|
|
||||||
import { convertPrivateUser, convertUser } from 'common/supabase/users'
|
|
||||||
|
|
||||||
export const createUser: APIHandler<'create-user'> = async (
|
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||||
props,
|
|
||||||
auth,
|
|
||||||
req
|
|
||||||
) => {
|
|
||||||
const { deviceToken: preDeviceToken } = props
|
|
||||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
|
||||||
|
|
||||||
const testUserAKAEmailPasswordUser =
|
/**
|
||||||
firebaseUser.providerData[0].providerId === 'password'
|
* Create User API Handler
|
||||||
|
*
|
||||||
// if (
|
* Creates a new user account with associated profile and private user data.
|
||||||
// testUserAKAEmailPasswordUser &&
|
* This endpoint is called after Firebase authentication to initialize
|
||||||
// adminToken !== process.env.TEST_CREATE_USER_KEY
|
* the user's presence in the Compass database.
|
||||||
// ) {
|
*
|
||||||
// throw new APIError(
|
* Process:
|
||||||
// 401,
|
* 1. Validates Firebase authentication token
|
||||||
// 'Must use correct TEST_CREATE_USER_KEY to create user with email/password'
|
* 2. Creates user record in users table
|
||||||
// )
|
* 3. Creates private user record in private_users table
|
||||||
// }
|
* 4. Generates default profile data
|
||||||
|
* 5. Sends welcome email asynchronously
|
||||||
|
* 6. Tracks user creation event
|
||||||
|
*
|
||||||
|
* @param props - Request parameters including device token and locale
|
||||||
|
* @param auth - Authenticated user information from Firebase
|
||||||
|
* @param req - Express request object for accessing headers/IP
|
||||||
|
* @returns User and private user objects with continuation function for async tasks
|
||||||
|
* @throws {APIError} 403 if user already exists or username is taken
|
||||||
|
*/
|
||||||
|
export const createUser: APIHandler<'create-user'> = async (props, auth, req) => {
|
||||||
|
const {deviceToken, locale = defaultLocale} = props
|
||||||
|
|
||||||
const host = req.get('referer')
|
const host = req.get('referer')
|
||||||
log(`Create user from: ${host}`)
|
log(`Create user from: ${host}, ${props}`)
|
||||||
|
|
||||||
const ip = getIp(req)
|
const ip = getIp(req)
|
||||||
const deviceToken = testUserAKAEmailPasswordUser
|
|
||||||
? randomString() + randomString()
|
|
||||||
: preDeviceToken
|
|
||||||
|
|
||||||
const fbUser = await admin.auth().getUser(auth.uid)
|
const fbUser = await admin.auth().getUser(auth.uid)
|
||||||
const email = fbUser.email
|
const email = fbUser.email
|
||||||
@@ -52,10 +54,8 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
|
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
|
||||||
const name = cleanDisplayName(rawName)
|
const name = cleanDisplayName(rawName)
|
||||||
|
|
||||||
const bucket = getStorage().bucket(getStorageBucketId())
|
const bucket = getBucket()
|
||||||
const avatarUrl = fbUser.photoURL
|
const avatarUrl = fbUser.photoURL ?? (await generateAvatarUrl(auth.uid, name, bucket))
|
||||||
? fbUser.photoURL
|
|
||||||
: await generateAvatarUrl(auth.uid, name, bucket)
|
|
||||||
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
@@ -63,31 +63,37 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
|
|
||||||
// Check username case-insensitive
|
// Check username case-insensitive
|
||||||
const dupes = await pg.one<number>(
|
const dupes = await pg.one<number>(
|
||||||
`select count(*) from users where username ilike $1`,
|
`select count(*)
|
||||||
|
from users
|
||||||
|
where username ilike $1`,
|
||||||
[username],
|
[username],
|
||||||
(r) => r.count
|
(r) => r.count,
|
||||||
)
|
)
|
||||||
const usernameExists = dupes > 0
|
const usernameExists = dupes > 0
|
||||||
const isReservedName = RESERVED_PATHS.includes(username)
|
const isReservedName = RESERVED_PATHS.has(username)
|
||||||
if (usernameExists || isReservedName) username += randomString(4)
|
if (usernameExists || isReservedName) username += randomString(4)
|
||||||
|
|
||||||
const { user, privateUser } = await pg.tx(async (tx) => {
|
const {user, privateUser} = await pg.tx(async (tx) => {
|
||||||
const preexistingUser = await getUser(auth.uid, tx)
|
const preexistingUser = await getUser(auth.uid, tx)
|
||||||
if (preexistingUser)
|
if (preexistingUser)
|
||||||
throw new APIError(403, 'User already exists', {
|
throw APIErrors.forbidden('An account for this user already exists', {
|
||||||
userId: auth.uid,
|
field: 'userId',
|
||||||
|
context: `User with ID ${auth.uid} already exists`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check exact username to avoid problems with duplicate requests
|
// Check exact username to avoid problems with duplicate requests
|
||||||
const sameNameUser = await getUserByUsername(username, tx)
|
const sameNameUser = await getUserByUsername(username, tx)
|
||||||
if (sameNameUser)
|
if (sameNameUser)
|
||||||
throw new APIError(403, 'Username already taken', { username })
|
throw APIErrors.conflict('Username is already taken', {
|
||||||
|
field: 'username',
|
||||||
|
context: `Username "${username}" is already taken`,
|
||||||
|
})
|
||||||
|
|
||||||
const user = removeUndefinedProps({
|
const user = removeUndefinedProps({
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
isBannedFromPosting: Boolean(
|
is_banned_from_posting: Boolean(
|
||||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||||
(ip && bannedIpAddresses.includes(ip))
|
(ip && bannedIpAddresses.includes(ip)),
|
||||||
),
|
),
|
||||||
link: {},
|
link: {},
|
||||||
})
|
})
|
||||||
@@ -95,6 +101,7 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
const privateUser: PrivateUser = {
|
const privateUser: PrivateUser = {
|
||||||
id: auth.uid,
|
id: auth.uid,
|
||||||
email,
|
email,
|
||||||
|
locale,
|
||||||
initialIpAddress: ip,
|
initialIpAddress: ip,
|
||||||
initialDeviceToken: deviceToken,
|
initialDeviceToken: deviceToken,
|
||||||
notificationPreferences: getDefaultNotificationPreferences(),
|
notificationPreferences: getDefaultNotificationPreferences(),
|
||||||
@@ -120,10 +127,24 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
log('created user ', { username: user.username, firebaseId: auth.uid })
|
log('created user ', {username: user.username, firebaseId: auth.uid})
|
||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await track(auth.uid, 'create profile', { username: user.username })
|
try {
|
||||||
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to track create profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendWelcomeEmail(user, privateUser)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sendWelcomeEmail', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setLastOnlineTimeUser(auth.uid)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set last online time', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -135,12 +156,6 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStorageBucketId() {
|
|
||||||
return isProd()
|
|
||||||
? PROD_CONFIG.firebaseConfig.storageBucket
|
|
||||||
: DEV_CONFIG.firebaseConfig.storageBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically ban users with these device tokens or ip addresses.
|
// Automatically ban users with these device tokens or ip addresses.
|
||||||
const bannedDeviceTokens = [
|
const bannedDeviceTokens = [
|
||||||
'fa807d664415',
|
'fa807d664415',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user